Skip to main content
← 블로그

처음 설계가 과했다 — 개선 제안 시스템 단순화와 위치 정보

VauDium ·

Trigger 네 종류, signal spec 추상화, 모든 record 재평가. 그렇게 잘 설계한 줄 알았는데 사용해 보니 무한 cycle을 만드는 시스템이었다. 처음부터 다시 줄이고, 그 다음에 정확도를 더한 하루.

처음 설계가 과했다

Record를 완료할 때 그 description이 template의 description과 다르면, 그 차이를 template에 흘려보낼 제안으로 만든다. 한 줄로 적을 수 있는 기능이지만 처음에 깐 구조는 그렇지 않았어요.

  • Trigger 네 종류: RECORD_COMPLETED, RECORD_UNCOMPLETED, TEMPLATE_FIELD_CHANGED, RECORD_DELETED
  • ImproveSignalSpec dataclass: triggers, monitored fields, needs_records, record_state_filter, compute_from_template, compute_from_record, is_dismissed_match, apply
  • REGISTRY: signal type → spec lookup
  • Orchestrator는 trigger 발생 시 전체 완료 record를 다시 돌면서 모든 signal을 재계산

깔끔해 보이는 추상화였습니다. 새 signal type 추가가 쉽다 는 가독성 좋은 패턴이었고요. 그런데 사용해 보니 망가졌습니다.

무한 cycle

사용자가 한 시나리오에서 발견했어요:

  1. Record에서 description 텍스트 변경
  2. Template에서 그 변경을 apply
  3. Apply 후 옛 record들의 컨텐츠가 또 다른 Suggestion으로 표시됨

화면엔 “하하하” 라는 한 줄을 빼는 동일한 카드가 다섯 장 떠 있었고, 마지막에 다른 변경이 하나 더. 다섯 장이 다 똑같았어요.

원인을 따라가 보면 단순했습니다. TEMPLATE_FIELD_CHANGED trigger로 apply 후 orchestrator가 또 돌고, 모든 완료된 record 를 다시 평가하면서 각자가 갖고 있던 옛 컨텐츠 기준의 hunk를 새 PENDING으로 만들어 냅니다. Apply하면 또 같은 일이 일어나고. 옛 record들로부터의 역제안 이 끝없이 다시 쌓이는 cycle이었어요.

사용자 한 마디가 결정적이었어요 — “Suggestion을 왜 자동 생성하지? 왜 이렇게 간단한 문제를 어렵게 풀지?”

맞는 말이었습니다. 그리고 한 줄 더: “Record가 COMPLETED될 때 line-by-line diff하고 line number랑 다른 내용 저장하고 그걸 보여 주면 되는 거 아닌가?”

줄이기

설계를 다시 깎았습니다.

  • Trigger 하나만: RECORD_COMPLETED. record가 그 자리에서 완료될 때 그 record의 hunk만 계산
  • TEMPLATE_FIELD_CHANGED 호출 모두 제거 — apply가 trigger를 fire하지 않음. 그러니 cycle도 없음
  • RECORD_UNCOMPLETED는 그 record가 만든 PENDING을 정리만 (재계산 X)
  • RECORD_DELETED도 정리만
  • Orchestrator의 record 전체 iterate: 사라짐. 단일 record만
  • ImproveSignalSpec dataclass / REGISTRY / ImproveTrigger enum: 다 제거
  • 그 자리에 PER_RECORD_SIGNALS, PER_TEMPLATE_SIGNALS, APPLY_HANDLERS dispatch dict 세 개

코드 250+ 줄이 빠졌어요. 같은 일을 더 적은 줄로, 덜 정확하게가 아니라 더 정확하게 합니다.

Apply 시 한 가지를 추가했어요. 같은 source line(current_value)에 대해 여러 record가 다른 alternative를 제안한 경우, 사용자가 하나를 고르면 나머지 alt들은 자동 정리. “하나 골랐으면 같은 source의 다른 alt는 무관하다” 라는 자연스러운 규칙입니다.

위치 정보로 정확도

여기서 한 단계 더 — 사용자가 짚었어요:

“current_value 기준이면 같은 의미가 여러 줄 연속으로 나오는 경우는 어떻게 그룹핑이 돼?”

Template description 안에 같은 텍스트 “운동 30분” 이 두 군데 있고, 어느 record가 그걸 “달리기 30분” 으로 바꾸자고 제안하면 — 어느 occurrence를 가리키는지가 정해져야 하는데 현재 구조는 그걸 모릅니다. description.indexOf(currentValue) 로 첫 번째 occurrence만 다루는 게 한계.

해결은 위치 정보 저장. difflib의 opcode가 이미 (i1, i2, j1, j2) 라인 인덱스를 주니까, i1current_line_start로 같이 저장:

  • Dedupe key에 line_start 포함 → 같은 내용 다른 위치 = 별도 PENDING
  • Apply 시 lines[ls:ls+len(cv)] 를 verify한 뒤 정확한 위치 치환
  • 클라이언트 렌더링도 line_start 기반 그룹핑으로 변경

여기서 또 사용자가 던졌어요:

“변경으로 line이 추가되거나 감소하는 것에 대해서도 대응할 수 있어?”

예. 한 hunk를 apply하면 description의 총 라인 수가 바뀝니다. 가령 1줄을 3줄로 바꾸면 +2. 그 에 있던 PENDING들의 current_line_start는 다 2씩 밀려야 맞고, 그 범위와 겹치는 PENDING은 원본이 사라졌으니 stale.

apply 핸들러 안에서 처리:

  • 겹치는 PENDING 일괄 삭제: current_line_start ∈ [applied_start, applied_end)
  • 뒤쪽 PENDING shift: current_line_start ≥ applied_end 인 것들에 $inc: {current_line_start: delta}

stale 필터도 line-aware하게 — enrich 시점에 lines[ls:ls+cvLines].join("") === cv 가 참일 때만 노출. 위치가 drift되어 verify가 깨지면 자동으로 숨김.

통합 diff 뷰

UI도 통째로 다시 짰어요. 처음엔 카드 한 장당 한 hunk (red + green + 버튼). 이제는 GitHub PR 스타일 통합 diff:

변경 안 된 줄
- 빼고 싶은 줄
+ 대안 A    [>][X]
+ 대안 B    [>][X]
+ 대안 C    [>][X]
변경 안 된 줄
변경 안 된 줄
+ 끼워 넣을 줄    [>][X]

같은 source line에 대한 여러 alt가 red 한 번 + green 여러 줄로 묶이고, 각 alt마다 apply/dismiss 버튼이 inline. 변경 안 된 라인도 context로 같이 노출되니 어디서 어떻게 바뀌는지가 한눈에 들어와요.

current_line_start가 있으면 위치별 그룹핑이 가능하고, 클라이언트 verify (실제 라인이 cv와 일치하는지) 가 stale 자동 숨김까지 처리합니다.

회고

처음 설계는 미래의 확장성을 너무 일찍 잡으려고 한 형태였어요. Trigger 네 종, signal spec 추상화, registry — 새 signal 종류 추가가 쉽다 는 가독성 패턴이었는데, 실은 지금 필요한 동작 이 아니라 나중을 위한 모양 만 챙긴 거였어요.

사용자가 그걸 정확히 잡았습니다. “왜 자동 생성하지?” — 자동 생성을 한 번에 한 record 단위로만 좁히면 cycle이 없고, “왜 복잡한 방법을 쓰지?” — abstraction 두 개 빼면 그냥 dispatch dict 두 개가 끝.

그리고 단순해진 뒤에는 정확도 를 더할 수 있었어요. 위치 정보, drift 보정, verify. 처음의 과설계 안에서 이런 것들을 추가했다면 더 복잡해졌을 텐데, 단순한 코어 위에선 자연스럽게 얹혔습니다.

새 기능을 만들 때 추상화부터 잡지 말 것. 두 가지 signal이 있다고 spec dataclass 만들지 말고, 그냥 두 함수로 시작. 세 번째가 나타날 때까지 그대로 두고, 세 번째에서 패턴이 명확해지면 그때 추출해도 늦지 않더라고요. 오늘은 그걸 다시 배운 하루였어요.