Component Lab
AIJun 10, 2026

AI 인라인 Diff 에디터

글을 바로 고치는 대신, AI가 제안한 수정안을 자리에서 diff로 보여 준다. 어디가 어떻게 바뀌는지 확인하고 한 번에 받아들이거나 되돌린다.

zustand storeCodeMirror 6codemirror-essentials-react
appeal-letter.md
환자분은 작년부터 허리가 계속 아팠습니다.
여러 병원을 다녔지만 잘 낫지 않았습니다.
검사하니 디스크가 있다고 했습니다.
주사 치료도 받았는데 효과가 없었습니다.
그래서 수술이 꼭 필요하다고 생각합니다.
승인을 부탁드립니다.

AI가 문장을 고쳐 줄 때 결과를 그대로 덮어쓰면, 무엇이 어떻게 바뀌었는지 알 수 없다. 그래서 제안을 에디터 안에서 inline diff로 보여 주고, 한 건씩 확인해 적용하거나 되돌리게 만들었다. 제안은 한 번에 여러 개가 떠 있을 수 있어서, 정작 손이 많이 간 곳은 화면이 아니라 이 제안들을 관리하는 쪽이었다.

만들다 보니 CodeMirror 쪽 로직 — 라인에 위젯을 꽂고(useCmeLineWidget), 영역에 클래스를 입히고(useCmeInjectClassName), 선택을 추적하고(useCmeSelection), 원문 위에 제안을 끼워 diff로 보여 주는(useCmeLineReplace) 일 — 을 한 화면에만 묶어 두기는 아까웠다. 그래서 기능별 훅으로 쪼개 각각 테스트를 붙이고, 하나의 패키지로 묶어 npm에 배포했다(@sung-yeop/codemirror-essentials-react). 저장소는 turborepo + pnpm 모노레포로 두고, 빌드는 tsup·배포는 changesets로 돌린다. 전체 구조는 react-query 같은 오픈소스 모노레포를 참고해 잡았다. 그래서 이 데모도 흉내가 아니라 그 패키지를 그대로 설치해 쓴다.

대기 중인 제안들은 zustand store의 reviewResponse 배열 하나가 단일 진실원이다. setReviewResponse(배치 교체)·addReviewResponse(단건 추가)·acceptReview/rejectReview(필터로 제거)가 이 배열만 바꾸고, 에디터도 우측 큐 패널도 같은 store를 구독한다. 우측 패널은 그 store를 그대로 비추는 인스펙터라, 적용하거나 되돌릴 때 큐에서 항목이 빠지는 게 보인다(footer를 호버하면 store 구현도 볼 수 있다).

reviews가 바뀌면 effect가 processed Set과 비교해 새로 들어온 것만 골라 에디터에 diff 데코레이션과 accept/reject 위젯을 주입한다. 그래서 “AI 전체 검토”로 여러 건을 한꺼번에 올려도 각자의 자리에 동시에 뜬다. 앞선 제안이 줄을 밀어내므로, 위젯은 고정 라인이 아니라 metadata.insertedTo로 계산한 현재 라인에 붙인다. 이 누적 오프셋 보정이 다중 pending의 핵심이다.

제안은 원문 아래에 실제 텍스트로 삽입하되, 원문 줄에는 review-original(−·빨강), 제안 줄에는 review-suggested(+·초록) 데코레이션을 입힌다. 그대로 두면 줄 번호가 어긋나 보이니, 가린 원문 라인은 번호를 생략하고 보이는 라인만 1부터 다시 세는 커스텀 gutter를 달았다. diff 뷰어처럼 읽히게 하는 디테일이다.

에디터, inline diff, accept/reject, store 큐, 라인 번호 재계산은 전부 실제로 동작한다 — 원래 이의신청서 에디터에 쓰던 패키지와 구조 그대로다. 다만 AI 응답만은 실제 LLM 호출 대신, 줄별로 미리 다듬어 둔 문장을 제안으로 흘려 보낸다.