# Diffle의 텍스트 앵커: Coding Agent가 코드를 고친 뒤 리뷰 댓글은 어디를 가리킬까?

> Canonical: https://vxi.me/ko/posts/diffle-git-diff-comment-anchoring/
> Locale: ko-kr
> Published: 2026-10-13
> Updated: 2026-10-13
> Markdown: https://vxi.me/ko/posts/diffle-git-diff-comment-anchoring.md

# Diffle의 텍스트 앵커: Coding Agent가 코드를 고친 뒤 리뷰 댓글은 어디를 가리킬까?

Coding Agent가 수정한 코드의 한 줄에 리뷰를 남겼다고 해 보자. “여기 조건이 잘못됐으니 고쳐 줘.” 에이전트는 조건을 고친 뒤 함수까지 나누고 검사를 몇 줄 추가했다. 다시 diff를 열어 보면, 원래 18번째 줄에 남긴 댓글은 여전히 내가 지적한 코드를 가리키고 있을까?

[Diffle](https://github.com/moritzwilksch/diffle)에 관심을 갖게 된 이유가 바로 이 문제다. 로컬 Git diff를 살펴보며 줄 단위로 댓글을 남기고, 이를 에이전트에게 전달하거나 GitHub Pending Review에 추가할 수 있는 도구다. 하지만 에이전트가 여러 차례 코드를 바꾸는데, 댓글이 이전 줄 번호에만 의존할 수는 없다.

이번에는 커밋 [`a69274d`](https://github.com/moritzwilksch/diffle/tree/a69274d512a588dd86714bef103c8dbc2cadb2cf)로 고정한 Diffle 0.2.4 소스를 읽었다. 접근 방식은 의외로 단순하다. **댓글이 가리킨 원문을 기억하고, 완전히 같은 텍스트를 찾는다. 후보가 여러 개면 옛 줄 번호와 가장 가까운 곳을 고른다. 현재 diff에 표시되지 않는 위치라면 stale 상태로 처리한다.** 평범한 줄 이동에는 꽤 잘 맞는 방법이다. 그렇다면 동일한 코드가 두 군데 있다면? 이 선택의 한계를 보기 위해 Node.js 내장 테스트 기능만 이용한 독립 모델에서 네 가지 사례를 실제 실행했다. Diffle CLI나 브라우저를 실행한 제품 평가가 아니라, 고정된 소스 분석과 별도의 합성 모델 테스트다.

## 댓글은 18이라는 숫자만 기억하지 않는다

Diffle은 댓글을 작업 트리의 소스 파일에 쓰지 않고 저장소의 `<git-dir>/diffle/` 아래에 저장한다. [`CommentStore.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts)에서는 스레드와 앵커를 `comments.json`에 보관한다. 줄 단위 앵커에는 파일 경로, diff의 어느 쪽인지, 원래 시작과 끝 줄 번호, 그리고 선택했던 코드 문자열 `quoted`가 들어 있다.

[`anchor.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts)의 `relocate()`는 이 인용문과 줄 단위로 완전히 일치하는 곳을 찾는다. 후보가 여럿일 때 위치를 결정하는 부분은 다음과 같다.

```ts
const start = i + 1;
const dist = Math.abs(start - anchor.startLine);
if (dist < bestDist) {
  best = start;
  bestDist = dist;
  if (dist === 0) break;
}
```

예를 들어 18번째 줄의 코드가 내용은 그대로 둔 채 26번째 줄로 이동했다면 댓글도 따라갈 수 있다. 비교식이 `dist < bestDist`이므로 거리가 같은 경우에는 먼저 발견한 후보가 남는다.

그렇다고 문자를 찾기만 하면 끝나는 것은 아니다. [`CommentStore.relocateAll()`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts)는 비교하는 쪽과 현재 보이는 diff hunk까지 확인한다. 인용문이 삭제됐거나, 대상 쪽이 없거나, 찾은 위치가 이번에 표시되는 hunk 밖이라면 줄 댓글은 `stale` 상태가 될 수 있다. 파일 전체에 남긴 댓글은 그 파일이 이번 리뷰에 남아 있는지를 기준으로 판단한다.

이 보호 장치는 마음에 든다. 같은 문자열을 찾았다고 해서 정말 같은 의미의 코드라고 장담할 수는 없으니까.

## 18번째 줄과 똑같이 생긴 코드가 또 있다면

처음 상황으로 돌아가 보자. 실제 저장소에는 `return false;`가 여러 번 나오거나, 여러 분기에서 `logger.info('done');`을 호출하기도 한다. 댓글을 단 곳은 마지막 예외 처리 분기인데 리팩터링으로 그 분기가 멀리 이동하고, 예전 위치 가까이 같은 문장이 남아 있다면 어떨까? 텍스트와 줄 번호만으로는 원래 어느 분기를 지적했는지 알아낼 수 없다.

이 문제만 따로 보기 위해 독립 모델에서는 그 문장을 `repeat();`로 바꿨다. 실제 실행한 네 테스트 중 가장 중요한 예다. 전체 실행 코드는 글 끝의 접기 영역에 남겨 두고, 여기서는 핵심 검증 부분만 보자.

```js
const contents = [
  'header();',   // 1번째 줄
  'repeat();',   // 2번째 줄: 옛 위치에 더 가까운 동일 코드
  'other();',    // 3번째 줄: 원래 댓글 위치
  'new();',
  'changed();',
  'gap();',
  'repeat();',   // 7번째 줄: 가정한 의미상의 대상
].join('\n') + '\n';

const r = relocateModel(
  { startLine: 3, quoted: 'repeat();' }, contents
);
assert.deepEqual(r, { startLine: 2, endLine: 2 });
assert.notEqual(r.startLine, 7);
```

댓글의 원래 위치는 3번째 줄이다. 수정 뒤 같은 `repeat();`가 2번째와 7번째 줄에 있다. 옛 위치에서의 거리는 각각 1줄과 4줄이다. 모델은 2번째 줄을 선택했다. 하지만 이 테스트에서 내가 ‘원래 추적하고 싶었던 의미상의 대상’으로 가정한 것은 7번째 줄이다.

**가장 가깝다고 같은 코드인 것은 아니다.** 7번째 줄이 진짜 대상이라는 설정은 테스트를 만든 사람의 가정이다. 문자열 매칭 함수 자체가 코드의 의미를 이해한 것은 아니다. 실제 제품에는 파일 경로, 비교 방향, hunk, 스레드 상태에 대한 검사도 있으므로 이 결과를 Diffle UI에서 댓글이 잘못 이동했다는 실측 버그로 해석해서는 안 된다.

다른 세 사례까지 포함해 독립 모델이 반환한 결과는 다음과 같다.

| 합성 테스트 | 실제 결과 | 확인한 점 |
| --- | --- | --- |
| 유일한 인용 텍스트가 이동 | 4번째 줄로 이동 | 일반적인 줄 이동을 따라갈 수 있다 |
| 인용 텍스트가 삭제됨 | `null` 반환 | 없는 위치에 억지로 댓글을 붙이지 않는다 |
| 같은 문장이 2번째와 7번째 줄에 존재 | 2번째 줄 선택 | 거리는 의미상의 동일성을 보장하지 않는다 |
| 텍스트는 4번째 줄, 표시된 hunk는 1~2번째 줄 | `false` 반환 | 원문을 찾아도 현재 diff 밖일 수 있다 |

실행 날짜는 2026년 10월 12일, 환경은 Node.js `v22.16.0`이었다. **4개 테스트가 모두 통과했고 종료 코드는 0, stderr는 비어 있었다.** `node:test`와 `node:assert/strict`라는 Node 내장 모듈만 사용했으며 Diffle npm 패키지는 로드하지 않았다. 따라서 패키지 자체의 Node ≥24 요구 사항은 이 독립 테스트에 적용되지 않는다. 실행 명령은 `TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs`이다. 네 테스트의 전체 코드와 원본 TAP 출력은 끝부분에서 펼쳐 볼 수 있다.

같은 문자열을 더 잘 구분하려면 앞뒤 문맥, 함수 범위, 문맥 지문(context fingerprint)을 추가 단서로 고려할 수 있다. 다만 리팩터링 과정에서 그런 단서도 바뀐다. 정보를 하나 더 저장한다고 해결되는 문제가 아니라 설계상의 절충이다. 고정한 Diffle 0.2.4 매처는 이런 추가적인 의미 분석을 구현하지 않았다.

## Agent에게 댓글을 주는 것과 GitHub Review를 보내는 것은 다르다

로컬 리뷰가 끝나면 [`format.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/format.ts)가 파일 경로, 줄 번호, 인용한 코드와 댓글 본문을 에이전트용 프롬프트로 묶어 준다. `suggestion` 코드 펜스는 `ORIGINAL` / `SUGGESTED` 형태로 바뀌고 오래된 댓글에는 `stale` 표시가 붙는다. “아까 그 부분 다시 고쳐 줘”라고 말하는 것보다 훨씬 구체적이다. 다음 수정에서 무엇을 확인해야 하는지 남기 때문이다.

하지만 GitHub PR에 전달하는 단계에는 별도의 제약이 있다. [`review.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/github/review.ts)는 아직 커밋하지 않은 worktree 차이를 GitHub 댓글의 앵커로 사용하지 못하도록 한다.

```ts
if (snap.newSha === 'worktree')
  throw new GithubError('GitHub cannot anchor comments to the worktree');
```

당연한 제약이다. 내 컴퓨터에서만 존재하는 수정 사항이라면 동료가 PR을 열어도 같은 diff를 볼 수 없다. 존재하지 않는 ‘유령 줄 번호’에 댓글을 걸어 봐야 혼란만 생긴다.

PR에 커밋된 diff와 비교 내용이 맞으면 Diffle은 `stale` 스레드를 건너뛰고 적격 댓글을 **Pending Review**에 추가한다. 최종 Submit review는 사람이 해야 한다. [공식 README](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md)에도 도구가 사용자를 대신해 리뷰를 제출하지 않는다고 적혀 있다. 이런 역할 분리는 좋은 선택이라고 본다. 에이전트는 코드를 고치고 도구는 피드백을 정리할 수 있다. 그래도 팀에 실제로 전달되는 댓글은 내가 마지막으로 읽어 보고 싶다.

## 일상 작업에 넣기 전에

먼저 민감한 자료가 없는 임시 Git 저장소에서 시작하겠다. 작업 트리 diff만 보고 불필요한 언어 서버는 끈다. 공식 명령과 옵션을 조합하면 다음과 같다. 이 명령은 **이번에 실제 Diffle을 실행한 기록은 아니다**.

```sh
diffle working --no-lsp
```

고정 버전 [`package.json`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/package.json)은 npm 패키지에 Node ≥24를 요구하지만 프로젝트에는 Node가 필요 없는 독립 실행 파일도 있다. `--no-lsp`를 넣은 이유도 있다. 자동으로 발견된 언어 서버는 현재 사용자 권한으로 저장소 파일을 읽을 수 있다. diff 한 번 보려는 일에 전부 실행할 필요는 없다.

이전에 [OpenQodex의 pre-push 리뷰 게이트](/posts/openqodex-prepush-review-fail-open/)를 살펴볼 때는 불완전한 리뷰가 코드 변경을 그대로 통과시키는지가 관심사였다. Diffle에서는 더 앞 단계가 문제다. 사람이 남긴 피드백이 에이전트의 다음 수정 후에도 올바른 코드를 가리킬 수 있는가. 생성한 테스트가 버그를 실제로 잡아내는지는 [테스트와 Mutation Testing](/posts/ai-generated-tests-mutation-testing/)에서 다룬 다른 질문이다.

다음에 “18번째 줄이 잘못됐어”라고 적더라도 수정된 전체 diff를 다시 확인할 생각이다. 댓글의 위치는 도구가 찾아 줄 수 있다. 그 위치가 내가 검토하려던 로직인지는 줄 번호만으로 정할 수 없다.

<details>
<summary>펼쳐 보기: 실제 실행 가능한 Node.js 테스트 4개 전체 코드</summary>

```js
/**
 * Independent source-adapted synthetic model; consult verification-results.json for actual execution status.
 * An independent stdlib model of the source-visible anchor decisions in:
 * https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts
 * https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/hunks.ts
 * This does NOT import or run Diffle and cannot establish CLI/UI behavior.
 */
import test from 'node:test';
import assert from 'node:assert/strict';

function lines(s) {
  const a = s.split('\n');
  if (a.at(-1) === '') a.pop();
  return a;
}
function relocateModel({ startLine, quoted }, contents) {
  const haystack = lines(contents);
  const needle = lines(quoted);
  if (needle.length === 0) return { startLine, endLine: startLine };
  const candidates = [];
  for (let i = 0; i + needle.length <= haystack.length; i++) {
    if (needle.every((l, j) => haystack[i + j] === l)) candidates.push(i + 1);
  }
  if (candidates.length === 0) return null;
  const winner = candidates.reduce((best, p) =>
    Math.abs(p - startLine) < Math.abs(best - startLine) ? p : best);
  return { startLine: winner, endLine: winner + needle.length - 1 };
}
function isShownModel(ranges, a) {
  return ranges.some(([start, end]) => a.startLine >= start && a.endLine <= end);
}
test('unique quote moves', () => {
  assert.deepEqual(relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\ntarget();\n'),{startLine:4,endLine:4});
});
test('deleted quote has no text match', () => {
  assert.equal(relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\n'),null);
});
test('duplicate quote can select a nearer occurrence than the intended one', () => {
  const contents = [
    'header();',       // line 1
    'repeat();',       // line 2: lexical duplicate, nearer
    'other();',        // line 3: original anchor position before edit
    'new();',
    'changed();',
    'gap();',
    'repeat();',       // line 7: hypothetical semantic target after refactor
  ].join('\n') + '\n';
  const r = relocateModel({startLine:3,quoted:'repeat();'}, contents);
  assert.deepEqual(r,{startLine:2,endLine:2});
  assert.notEqual(r.startLine,7); // heuristic ambiguity, NOT an observed Diffle UI defect
});
test('a matching line beyond shown hunks is not visible', () => {
  const r = relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\ntarget();\n');
  assert.equal(isShownModel([[1,2]],r),false);
});
```

</details>

<details>
<summary>펼쳐 보기: 2026년 10월 12일의 원본 TAP 출력</summary>

```text
TAP version 13
# Subtest: unique quote moves
ok 1 - unique quote moves
  ---
  duration_ms: 7.325889
  type: 'test'
  ...
# Subtest: deleted quote has no text match
ok 2 - deleted quote has no text match
  ---
  duration_ms: 0.916348
  type: 'test'
  ...
# Subtest: duplicate quote can select a nearer occurrence than the intended one
ok 3 - duplicate quote can select a nearer occurrence than the intended one
  ---
  duration_ms: 4.069714
  type: 'test'
  ...
# Subtest: a matching line beyond shown hunks is not visible
ok 4 - a matching line beyond shown hunks is not visible
  ---
  duration_ms: 2.261519
  type: 'test'
  ...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1124.334836
```

</details>
