logo
Nostrss
Published on

diff 알고리즘을 안 짜고 diff 뷰어를 만들기 — PegDiff 와 Monaco 의 경계

Authors

Pegman brand retirement

pegboard.me

6편·7편에서 무거운 유틸 — 브라우저 안의 ffmpeg, 온디바이스 LLM — 을 다뤘다. 이번엔 결이 다른 도구다. PegDiff — 두 텍스트(혹은 코드)의 차이를 비교하는 diff 뷰어다.

PegDiff 는 변환기류 유틸이 아니라 앱형 페그다. 데이터를 localStorage 에 보존하고, 반복 방문을 전제로 하는 작은 앱 — PegNote·PegCode 와 같은 apps 카테고리(ADR-0012)에 속한다. 그래서 이 글은 "diff 가 어떻게 동작하는가"보다 "diff 알고리즘을 짜지 않기로 한 결정" 과 그 결정이 만든 코드의 형태에 관한 이야기다.

가장 먼저 정한 것 — diff 를 직접 짜지 않는다

diff 뷰어를 만든다고 하면 보통 두 갈래를 떠올린다.

  1. Myers diff 같은 알고리즘을 직접 구현한다.
  2. diff·diff-match-patch 같은 라이브러리를 가져다 줄/단어 단위로 비교한 뒤, 그 결과를 내가 직접 렌더링한다.

PegDiff 는 둘 다 아니다. 비교 알고리즘도, 그 결과의 렌더링도 전부 MonacoDiffEditor 에 통째로 위임했다. PegCode(브라우저 JS/TS 플레이그라운드)에서 이미 Monaco 를 self-host 하고 있었고(ADR-0014), 같은 엔진이 좌우 2단·인라인 1단 diff 렌더링, 줄·단어 단위 하이라이트, 가로 스크롤 동기화, 접기/펴기를 전부 내장하고 있다.

에디터 컴포넌트 자체는 이게 전부다.

<DiffEditor
  original={initial.left}
  modified={initial.right}
  language={language}
  theme={isDark ? 'vs-dark' : 'light'}
  options={options}
  onMount={handleMount}
  keepCurrentOriginalModel
  keepCurrentModifiedModel
/>

original(왼쪽 원본)과 modified(오른쪽 수정본) 두 문자열을 넘기면, 그 사이의 diff 는 Monaco 가 알아서 계산하고 그린다. 내가 줄 단위 LCS 를 굴리거나, 단어 단위 하이라이트를 색칠하거나, 2단 레이아웃을 잡는 코드는 한 줄도 없다.

types.ts 의 첫 주석이 이 입장을 못박아 둔다.

The diffing itself is delegated to Monaco's diff editor (ADR-0014); these types only describe the state PegDiff persists and the prefs that drive Monaco's view.

그럼 내가 짠 코드는 뭔가

diff 를 위임하고 나면, 남는 질문은 "diff 바깥에 무엇이 필요한가" 다. 그게 PegDiff 의 실제 코드 표면이다.

  • 변경 청크 사이를 점프하는 네비게이션 (diff-nav.ts)
  • 추가/삭제 줄 수 통계 (stats.ts)
  • 두 패널 + 옵션의 localStorage 영속 (storage.ts)
  • 첫 방문 시딩 온보딩 (seed.ts, initial-state.ts)
  • Monaco 위젯을 React 생명주기 안에서 깔끔하게 마운트·언마운트하기 (peg-diff-editor.tsx)

마지막 두 개가 이 글의 war story 다. 먼저 Monaco 를 왜 PegDiff 에만 썼는지부터 짚는다.

Monaco 는 PegDiff·PegCode 에만 — 범위 한정 예외

페그보드의 공통 코드 에디터 표면(변환기·dummy-text 등)은 CodeMirror 6 로 표준화돼 있다. ADR-0008 은 그때 Monaco 를 명시적으로 거부했다 — ≈100개 광고 페이지가 공유하는 번들에 Monaco 를 얹으면 성능이 죽고 공유 번들이 오염되니까.

그 거부 사유는 "전 페이지가 공유하는 에디터" 맥락의 판단이다. PegDiff 는 단일 라우트고, next/dynamic 으로 ssr:false lazy-load 되어 다른 페이지를 건드리지 않는다.

// peg-diff-tool.tsx
const PegDiffEditor = dynamic(
  async () => (await import('./peg-diff-editor')).PegDiffEditor,
  { ssr: false, loading: () => /* ... */ },
);

Monaco 는 브라우저 전용이고 무겁다. ssr:false 라서 서버에서 렌더되지 않고, 이 라우트를 실제로 열 때만 청크가 내려온다. 그래서 ADR-0014 는 "PegCode 한 라우트에 한해 Monaco 채택" 이라는 범위 한정 예외였고, PegDiff 가 그 예외의 두 번째 수혜자가 됐다. 에디터 파일 맨 위 한 줄이 그 공유를 가리킨다.

// Self-host Monaco (ADR-0014); shared with PegCode — see configureSelfHostedMonaco.
configureSelfHostedMonaco();

self-host 인 이유는 @monaco-editor/react 의 기본 CDN 로더가 제3자 네트워크 의존이기 때문이다. 페그보드의 "아무것도 기기를 떠나지 않는다" 입장과 충돌하므로, vs 에셋을 public/ 에 스테이징하고 로더 경로를 로컬로 박았다. 언어 목록도 그 self-host 번들에 이미 들어 있는 basic-languages id 들만 골랐다 — 사용자가 언어를 바꿔도 추가 에셋을 안 받는다.

// types.ts
export const DIFF_LANGUAGES = [
  'plaintext', 'javascript', 'typescript', 'json', 'html',
  'css', 'markdown', 'xml', 'yaml', 'sql', 'python',
] as const;

diff 바깥의 순수 코드 — 네비게이션과 통계

Monaco 가 diff 를 계산하면, 그 결과를 editor.getLineChanges() 로 꺼낼 수 있다. ILineChange[] — 각 변경이 "원본 몇 줄몇 줄"과 "수정본 몇 줄몇 줄"으로 표현된 배열이다. PegDiff 의 순수 로직은 전부 이 배열을 받아 숫자로 접는 일만 한다.

핵심 설계 결정 하나: 이 산술을 에디터에서 떼어내 순수 함수로 두는 것. Monaco 를 부팅하지 않고도 단위 테스트가 되도록, diff-nav.ts·stats.ts 는 Monaco 타입에 의존하지 않고 필요한 필드만 추린 LineChangeLike 인터페이스만 받는다.

// stats.ts
export interface LineChangeLike {
  originalStartLineNumber: number;
  originalEndLineNumber: number;
  modifiedStartLineNumber: number;
  modifiedEndLineNumber: number;
}

통계는 줄 단위

툴바 오른쪽의 +3 −2 표시는 summarizeLineChanges 가 만든다. Monaco 의 컨벤션에서 *EndLineNumber0 이면 그쪽엔 해당 변경의 줄이 없다는 뜻이다 — 순수 삽입은 originalEndLineNumber === 0, 순수 삭제는 modifiedEndLineNumber === 0. 이 규약만 알면 추가/삭제 줄 수는 뺄셈 한 번이다.

// stats.ts
export function summarizeLineChanges(changes) {
  let added = 0;
  let removed = 0;
  for (const change of changes) {
    if (change.originalEndLineNumber > 0) {
      removed += change.originalEndLineNumber - change.originalStartLineNumber + 1;
    }
    if (change.modifiedEndLineNumber > 0) {
      added += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1;
    }
  }
  return { added, removed };
}

여기서 분명히 해둘 게 있다. 이 통계는 줄 단위다. 화면에서 보이는 한 줄 안에서 바뀐 단어만 색칠되는 단어/문자 단위 하이라이트는 내가 짠 게 아니라 Monaco 가 그려주는 것이다. 나는 그 단어 단위 diff 를 계산하지도, 세지도 않는다 — +N −M 은 어디까지나 "줄이 몇 개 늘고 줄었나"다. seed.ts 의 주석이 그 두 granularity 의 경계를 정확히 가리킨다.

The edited phrasing of the line that changes (word-level highlight).

단어 단위는 Monaco 의 몫, 줄 단위 카운트는 내 몫. 이 경계를 흐리지 않는 게 중요하다.

네비게이션은 커서 기준 이다

툴바의 ▲ 3 / 7 ▼ — 변경 청크 사이를 점프하고 현재 위치를 보여주는 부분이다. 순진하게 짜면 "현재 인덱스 ± 1" 이지만, diff-nav.ts 는 그렇게 하지 않았다. 사용자가 버튼을 누르든, 텍스트 중간을 클릭하든, 스크롤하든 — 기준은 항상 수정본 에디터의 커서 줄이다.

// diff-nav.ts — '다음'은 커서보다 엄격히 아래인 첫 변경(없으면 처음으로 순환)
export function nextDiffIndex(changes, cursorLine, direction) {
  if (changes.length === 0) return -1;
  if (direction === 'next') {
    for (let i = 0; i < changes.length; i++) {
      if (changeAnchorLine(changes[i]) > cursorLine) return i;
    }
    return 0; // wrap
  }
  for (let i = changes.length - 1; i >= 0; i--) {
    if (changeAnchorLine(changes[i]) < cursorLine) return i;
  }
  return changes.length - 1; // wrap
}

currentIndex ± 1 이 아니라 커서 상대 로 짠 이유는 주석에 그대로 있다 — 커서가 첫 변경보다 위에 있거나, 사용자가 청크와 청크 사이 빈 줄을 클릭했을 때도 "다음 변경"이 올바르게 잡혀야 하기 때문이다. 그래서 3 / 7 표시(diffIndexAtLine)도 커서 위치에서 매번 다시 유도된다. 이 덕에 버튼·클릭·스크롤 어느 경로로 움직여도 표시가 흔들리지 않는다.

순수 삭제 청크의 앵커는 살짝 까다롭다. 삭제는 수정본 쪽에 줄이 없으니(modifiedEndLineNumber === 0), modifiedStartLineNumber삭제가 일어난 자리 바로 위 줄 — 를 앵커로 쓴다.

export function changeAnchorLine(change: LineChangeLike): number {
  return Math.max(1, change.modifiedStartLineNumber);
}

War story 1 — 언마운트할 때 Monaco 가 던지는 예외

여기서부터가 진짜 시간을 먹은 부분이다. PegDiff 페이지를 떠날 때 Monaco 가 예외를 던졌다.

TextModel got disposed before DiffEditorWidget model got reset

원인은 @monaco-editor/react 의 기본 cleanup 순서다. 이 래퍼는 언마운트 때 두 개의 TextModel(원본·수정본)을 dispose 하는데, 그게 위젯이 자기 모델을 reset 하기 전에 일어난다. 위젯은 아직 살아서 방금 사라진 모델을 참조하고 있다가 터진다.

해결은 두 단계였다.

먼저 keepCurrentOriginalModel·keepCurrentModifiedModel 을 줘서 래퍼의 자동 dispose 경로를 막는다. 그러면 이번엔 모델이 영영 안 치워져 orphan 으로 샌다. 그래서 직접 치우되 — 순서를 뒤집는다. 마운트 시점에 모델 핸들을 잡아 두고, 언마운트 effect 에서 먼저 위젯에서 모델을 떼어낸 다음(setModel(null)) 그제서야 dispose 한다.

// 마운트: 모델 핸들 캡처
modelsRef.current = editor.getModel();

// 언마운트: 떼어내고(setModel(null)) → 그 다음 dispose
useEffect(() => {
  return () => {
    try {
      diffRef.current?.setModel(null); // onWillDispose 리스너 제거
    } catch {
      // 위젯이 이미 dispose됨 — 떼어낼 것 없음
    }
    modelsRef.current?.original.dispose();
    modelsRef.current?.modified.dispose();
    modelsRef.current = null;
  };
}, []);

setModel(null) 이 위젯의 onWillDispose 리스너를 먼저 제거하고, 그 다음 모델을 dispose 하니 위젯이 죽은 모델을 참조할 틈이 없다. try/catch 는 위젯이 이미 dispose 된 경우에도 안전하게 만드는 가드다. 정적 export 사이트라 이런 페이지 이탈은 항상 클라이언트에서 일어나고, 게다가 페그보드엔 모든 유틸 페이지를 열어 console 에러 하나만 나도 실패하는 스모크 게이트(utility-smoke.spec.ts)가 있다. 이 throw 하나가 그대로 빌드 게이트 실패였다.

War story 2 — controlled 컴포넌트로 만들면 커서가 튄다

두 번째 함정. 두 패널의 텍스트와 옵션을 localStorage 에 autosave 하려면, 값이 바뀔 때마다 저장 트리거가 필요하다. React 의 본능은 "그럼 controlled 컴포넌트로 만들고 value 를 state 에 묶자" 다.

그런데 Monaco 의 diff 에디터는 그렇게 하면 안 된다. original prop 이 바뀔 때마다 가드 없는 setValue 로 원본 패널을 통째로 다시 적용하는데, 그게 커서를 맨 앞으로 튕긴다. 사용자가 입력하는 중에 매 글자마다 캐럿이 점프하는 셈이다.

그래서 controlled 로 안 만들었다. 대신 모델 편집 이벤트rev 카운터를 하나 두고, autosave effect 는 그 숫자만 바라본다.

const [rev, setRev] = useState(0);

const handleMount: DiffOnMount = (editor) => {
  const bump = () => setRev((value) => value + 1);
  editor.getOriginalEditor().onDidChangeModelContent(bump);
  editor.getModifiedEditor().onDidChangeModelContent(bump);
  // ...
};

// rev(또는 옵션)가 바뀌면 디바운스 후 저장
useEffect(() => {
  if (skipNextSaveRef.current) {
    skipNextSaveRef.current = false;
    return; // 마운트 직후 1회 저장 건너뜀
  }
  const id = window.setTimeout(() => {
    const model = diffRef.current?.getModel();
    saveStored({
      left: model?.original.getValue() ?? initial.left,
      right: model?.modified.getValue() ?? initial.right,
      view, ignoreWhitespace, language,
    });
  }, SAVE_DEBOUNCE_MS);
  return () => window.clearTimeout(id);
}, [rev, view, ignoreWhitespace, language, initial.left, initial.right]);

rev 는 "뭔가 바뀌었다"는 신호일 뿐, 저장할 실제 텍스트는 저장 시점에 모델에서 getValue() 로 직접 읽는다. 에디터는 uncontrolled 로 두고, React 는 곁에서 "바뀌었으니 곧 저장해"만 한다. 커서는 Monaco 안에서만 산다.

skipNextSaveRef 는 작지만 중요한 가드다. 마운트 직후 첫 effect 실행에서 한 번 저장을 건너뛴다 — 안 그러면 돌아온 사용자의 저장된 텍스트 위에 인메모리 시드가 덮어써질 수 있다. 다음 절의 시딩과 직접 엮인 가드다.

첫 방문엔 샘플이 깔려 있다 — Peg Apps 의 온보딩

앱형 페그는 표준 유틸 페이지 골격(UtilityHero·learn 본문 블록)의 예외다(ADR-0012). 변환기류는 검색 일회성 유입으로 먹고살아서 본문에 SEO/광고 블록이 필요하지만, 앱형 페그는 반복 방문·리텐션으로 먹고산다. 그래서 PegDiff 의 페이지 본문엔 hero 도, 사용법 설명 블록도 없다. 장르 키워드는 <head> 메타데이터와 화면에 안 보이는 sr-only H1 하나로만 앵커한다.

// page.tsx
<PageShell utilitySlug="peg-diff">
  {/* Peg Apps 예외(ADR-0012): hero/learn 본문 없음. SEO heading은 시각적으로 숨김 */}
  <h1 className="sr-only">{t('srHeading')}</h1>
  <PegDiffTool />
</PageShell>

그럼 사용법은 어떻게 알려주나? 본문 설명 블록을 없앤 대신 in-place 시딩으로 대체했다. 첫 방문(저장된 상태가 없을 때)에만, diff 가 한눈에 보이는 두 개의 샘플 텍스트를 패널에 미리 깔아 둔다. 빈 화면 대신, 이미 비교가 일어나고 있는 화면으로 시작하는 것이다.

// seed.ts — 왼쪽=원본, 오른쪽=수정본
export function buildSeed(s: SeedStrings): SeedBuffers {
  const left = [s.unchanged, s.before, s.removed].join('\n') + '\n';
  const right = [s.unchanged, s.after, s.added].join('\n') + '\n';
  return { left, right };
}

세 줄짜리 샘플이 한 화면에서 세 가지를 동시에 가르친다 — (a) 양쪽 패널 다 편집 가능하고, (b) 일치하는 줄(unchanged)은 제자리에 머물고, (c) 한 줄 안에서 바뀐 단어(beforeafter)는 단어 단위로 칠해지며, 삭제(removed)와 추가(added)는 각각 표시된다는 것.

시작 상태를 정하는 규칙은 순수 함수 하나로 떼어 두어 단위 테스트가 된다. 우선순위는 단순하다 — 저장된 상태 > 첫 방문 시드.

// initial-state.ts
export function resolveInitialState({ stored, seed }) {
  if (stored) return stored;            // 돌아온 방문자: 저장된 그대로 복원
  return { left: seed.left, right: seed.right, ...SEED_PREFS }; // 첫 방문: 시드
}

시드는 절대 사용자의 텍스트를 덮어쓰지 않는다. 저장된 상태가 있으면 시드는 쳐다보지도 않고, 앞서 본 skipNextSaveRef 가드가 인메모리 시드를 저장소로 흘려보내는 것도 막는다. 사용자가 직접 지운 뒤엔 시드가 복원되지도 않는다 — 한 번 비운 화면은 비운 채로 둔다.

정리

  • PegDiff 는 두 텍스트/코드를 비교하는 diff 뷰어. 앱형 페그(ADR-0012)라 hero·learn 본문이 없고, 도구 그 자체가 페이지다.
  • diff 알고리즘을 한 줄도 안 짰다. diff·diff-match-patch 같은 라이브러리도 안 썼다. 비교·렌더링·단어 단위 하이라이트는 전부 Monaco 의 DiffEditor 에 위임. 내가 짠 코드는 전부 diff 바깥에 있다.
  • Monaco 는 PegDiff·PegCode 두 라우트에만(ADR-0014). ssr:false lazy-load 로 격리, self-host 로 제3자 CDN 의존 제거. 공유 코드 표면은 CodeMirror 6 유지.
  • 통계는 줄 단위(stats.ts), 단어 단위 하이라이트는 Monaco 의 몫 — 두 granularity 를 섞지 않는다.
  • 네비게이션(diff-nav.ts)은 커서 상대 로 짜서, 버튼·클릭·스크롤 어느 경로로 움직여도 n / total 이 안 흔들린다. 순수 함수라 Monaco 부팅 없이 테스트.
  • War story 1: 언마운트 시 TextModel got disposed before DiffEditorWidget model got resetkeepCurrent*Model 로 자동 dispose 차단 + setModel(null) 먼저, dispose 나중.
  • War story 2: controlled 로 묶으면 Monaco 의 가드 없는 setValue 가 커서를 튕긴다 → uncontrolled 로 두고 rev 카운터로 autosave 트리거, 텍스트는 저장 시점에 getValue().
  • 첫 방문 in-place 시딩 — 빈 화면 대신 diff 가 보이는 샘플로 시작. 저장된 상태 > 시드, 사용자 텍스트는 절대 안 덮어쓴다.

diff 뷰어를 만들면서 정작 diff 코드는 한 줄도 안 짰다는 게 이 도구의 요약이다. 무엇을 직접 짜지 않을지 를 먼저 정하면, 남는 진짜 일 — 위젯의 생명주기, 커서를 안 튕기는 영속, 한눈에 보이는 온보딩 — 이 또렷해진다.