기억하지 못하는 리뷰어 — AI 코딩 에이전트는 왜 이미 설명한 코드를 계속 지적하는가

기억하지 못하는 리뷰어 — AI 코딩 에이전트는 왜 이미 설명한 코드를 계속 지적하는가

· #dev · #llm · English

같은 지적이 두 번째로 돌아왔다.

새 버그가 아니다. 똑같은 지적이었다. 나는 이미 PR 설명에 적어뒀다. 코드는 멀쩡했고, 나는 그걸 증명할 수 있었다. 리뷰어는 내 설명을 읽고, 수긍하고, 승인했다. 그리고 다음 PR이 같은 함수를 건드리자, 거의 똑같은 문장으로 그 지적이 다시 올라왔다.

처음엔 고집처럼 느껴졌다. 아니었다. 그냥 산수였다.

AI 리뷰어는 모든 PR을 처음부터 시작한다. 지난번에 네가 뭐라고 답했는지 기억하지 못한다. 그에게는 “지난번”이라는 게 없기 때문이다. 그는 변경분(diff)과, 그 변경분 옆에 놓인 맥락만 읽는다. 네 논리가 PR 설명이나 슬랙 스레드나 네 머릿속에 있었다면, 그 자리에는 이제 아무것도 없다.

그러니까 지적이 반복되는 게 아니다. 매번 처음으로 다시 제기되는 것이다.

이렇게 보고 나면 해법은 명확하고, 조금 겸허해진다. 맥락을 리뷰어가 실제로 보는 곳으로 옮겨야 한다. 그곳은 코드다.

거기로 가기 전에, 아직 이런 식으로 일하지 않는 독자를 위해 잠깐 돌아가자. 이슈와 브랜치와 PR을 매일 다루고 있다면 다음 절은 건너뛰어도 된다.

이 글이 전제하는 워크플로우

아래 내용은 GitHub에서 일하는 꽤 표준적인 방식을 전제한다. 이 방식엔 GitHub Flow라는 이름이 있고, 일부러 단순하게 설계돼 있다. 예전의 무겁던 브랜치 모델을 대부분의 팀에서 밀어냈고, 혼자든 서른 명 팀이든 똑같이 작동한다.

저장소 하나, 오래 사는 브랜치 하나. main은 언제나 배포 가능한 상태다. 실험적인 것은 여기 두지 않는다.

작업은 이슈에서 시작한다. 코드를 짜기 전에, 무엇을 바꿀지 설명하는 Issue를 연다. 여기에 번호가 붙는다. #1234. 한 줄짜리 수정에는 이게 요식행위처럼 느껴진다. 어떤 코드가 왜 지금 이 모양인지 알아야 하는 순간이 오면, 그 느낌은 사라진다.

이슈마다 브랜치를 만든다. main에서 갈라져 나와 거기서 작업한다. 핵심은 격식이 아니다. 브랜치 하나가 주제 하나만 담아야, 변경분을 읽을 수 있고 되돌릴 수 있다는 것이다. 관련 없는 변경 두 개가 한 브랜치에 섞이면, 아무도 정직하게 리뷰할 수 없는 변경분이 나온다.

푸시하면 PR이 열린다. 정확히는 자동으로 열리지 않는다. 브랜치를 푸시하면 GitHub이 “Compare & pull request” 버튼을 띄운다. 누르는 건 네 몫이다. PR은 변경이 눈에 보이게 되는 자리다. 추가된 줄과 삭제된 줄이 나란히 놓인다.

리뷰하고, 그다음 병합한다. 사람이 코멘트하고, 에이전트가 코멘트하고, 너는 고치거나 반박한다. 병합은 리뷰가 끝난 뒤에만 한다. 그러면 브랜치는 사라지고 main은 일관된 한 걸음만큼 전진한다.

이게 전부다. 다섯 단계, 값을 하는 만큼만의 격식.

번호가 실이다

부끄러울 만큼 늦게 깨달은 부분이 여기다.

그 이슈 번호 #1234는 티켓에 붙은 이름표가 아니다. GitHub에서 이건 살아 있는 참조다. 커밋 메시지에 #1234를 쓰면 그 이슈로 연결된다. PR 본문에 Closes #1234라고 쓰면 PR이 병합될 때 이슈가 자동으로 닫힌다. 이슈와 브랜치와 PR과 병합이 번호 하나로 꿰어진다.

그러니까 번호는 이미 네 워크플로우 전체를 관통한다. 대개 멈추는 곳은 코드 그 자체뿐이다.

바로 그곳으로 계속 이어져야 한다.

/* 반복자 안전성 (검토 완료, #1234): delete()는 노드 링크만 교체하고
 * 삭제된 노드만 해제한다. 미리 확보한 커서는 항상 살아남는다. */

이제 근거는 사라지는 PR 설명에 있지 않다. 코드 옆에 있고, 그 근거를 만든 논의를 가리킨다. 사람이든 에이전트든, 이 함수를 읽는 누구나 이유를 함께 읽는다.

이제 주석에는 두 번째 독자가 있다

우리는 모두 다음 사람을 위해 주석을 쓰라고 배웠다. 보통은 6개월 뒤의 너 자신이라는 식으로 표현된다. 맞는 말이고, 조금 감상적이기도 하다.

이제 두 번째 독자가 생겼다. 그리고 이 독자에게는 아주 구체적인 한계가 있다. 그는 코드를 읽고, 그 외엔 거의 아무것도 읽지 않는다. 네 PR 설명도 아니고. 설계 문서도 아니고. 네가 논쟁을 매듭지은 슬랙 스레드도 아니고. 팀원 모두가 당연히 안다고 네가 확신하는 그것도 아니다.

이 한 가지 한계가 주석을 어디에 둘지를 결정한다. 쓸지 말지가 아니라, 어디에 둘지를.

이 한계는 주석이 담아야 할 내용의 기준도 높인다. “안전함”은 이유가 아니다. “의도된 것”도 이유가 아니다. 이유는 메커니즘을 지목한다. 어떤 불변조건이 성립하는지, 누가 그 잠금을 두고 경합하는지, 왜 그 분기가 이 대상에서는 죽은 코드인지.

그리고 여기엔 소리 내어 짚어둘 함정이 하나 딸려 온다.

주석은 음소거 버튼이 아니다

솔깃한 실패 방식은 주석을 지적을 사라지게 하는 도구로 취급하는 것이다. 지적된 줄 근처에 그럴듯한 걸 써두면, 리뷰어가 조용해지고, 그대로 내보낸다.

이건 통하지 않고, 통해서도 안 된다. 좋은 리뷰는 주석의 존재가 아니라 근거를 검증한다. 코드가 실제로 하는 일과 맞지 않는 근거는, 주석이 있든 없든 다시 지적된다. 지어낸 티켓 번호는 반박이 아니라 형식일 뿐이다.

여기서 이 규칙 전체에서 가장 재미없는 규칙이 나온다. 근거 주석은 주장이고, 주장은 썩는다. 주석이 의존하는 잠금이나 null 처리를 네가 바꾸면, 그 주석은 이제 거짓말을 하고 있다. 낡은 안전 주장은 아예 없느니만 못하다. 코드가 막 움직인 바로 그 자리에서 리뷰를 침묵시키기 때문이다.

예상하지 못했던 부분

나는 에이전트가 같은 말을 반복하는 걸 막으려고 이 주석들을 쓰기 시작했다. 동기는 그게 전부였다. 솔직히 말하면, 살짝 짜증 섞인 동기였다.

실제로 벌어진 일은, 코드베이스가 사람이 읽기 쉬워졌다는 것이다.

에이전트에게 필요한 주석과 사람에게 필요한 주석이, 알고 보니 같은 주석이었다. 왜 이 분기는 여기서 도달 불가능한지. 이 함수가 null에 대해 무엇을 약속하는지. 내가 이 자료구조를 순회하는 동안 누가 또 건드리는지. 아무도 이걸 적어두지 않았다. 지금 그 코드를 다루는 사람은 모두 이미 알고 있었으니까. 그 지식은 실재했지만, 어디에도 있지 않았다.

AI 리뷰어는 그 암묵적 맥락이 없어서, 묻는다. 매번. 그리고 이건 네 코드베이스가 얼마나 누군가의 머릿속에만 존재하는지를 재는 꽤 정밀한 측정기가 된다.

주석은 에이전트를 위한 것이었다. 알고 보니 우리에게도 진작 필요했던 것이었다.

그 파일

위의 습관들은 저장소 루트의 AGENTS.md 하나에 모아뒀다. 규칙 아홉 개와 푸시 전 점검 목록이고, 특정 도구나 언어에 종속되지 않는다. 저장소에서 일하는 모든 코딩 에이전트가 이걸 읽는다. 코드를 처음 보는 사람도 마찬가지다.

아래에 붙여둔다. 감상하라고 둔 게 아니라, 복사해서 네 상황에 맞게 고치라고 둔 것이다.

원문 파일: AGENTS.md 내려받기

부록 — AGENTS.md: 의도를 적어야 리뷰가 수렴한다

이 저장소를 수정하는 모든 코딩 에이전트(Cursor, Claude Code, Codex 등)를 위한 지침이다. 특정 도구나 언어에 종속되지 않는다.

한 줄 원칙: 머릿속에 있는 맥락을 코드 옆으로 옮겨라.

“이 분기는 우리 환경에서 실행되지 않는다.” “이 함수는 null이 들어와도 안전하다.” “이건 일부러 뺐다.”

이런 판단이 머릿속이나 PR 설명에만 있으면 리뷰는 매번 다시 묻는다. AI 리뷰는 모든 PR마다 똑같이 묻는 끈질긴 주니어다. 해법은 논쟁이 아니라 주석이다.

AI 리뷰는 티켓 번호가 붙은 근거 주석을 읽고, 근거가 타당하면 같은 지적을 반복하지 않는다. 아래 습관은 지적을 한 번에 영구히 닫기 위한 것이다.


1. 반박은 PR 설명이 아니라 코드 주석에 쓴다

PR 설명은 휘발성이다. 다음 리뷰는 그것을 읽지 못하고, 같은 지적이 되살아난다. 이유는 코드 옆에 티켓 번호와 함께 남긴다.

/* 반복자 안전성 (검토 완료, #1234): delete()는 노드의 링크만 교체하고
 * 삭제된 노드만 해제한다. 미리 확보한 커서는 항상 살아남는다. */

주의할 점이 있다. 리뷰는 주석의 존재가 아니라 근거의 타당성을 검증한다. 코드와 맞지 않는 근거는 주석이 있어도 다시 지적된다. 주석은 음소거 버튼이 아니다.

티켓 번호는 실재하는 티켓이어야 한다. 지어낸 번호는 반박이 아니라 형식일 뿐이다.

2. 맨몸 TODO를 남기지 않는다

하지 않기로 결정했다면 이유를 쓴다. 진짜로 미룬 것이라면 티켓 번호를 붙인다.

나쁨:  /* TODO: 로깅과 알람 추가 */

좋음:  /* 텔레메트리 의도적 생략 (#1234): 삭제는 이미
        * 변경 알림 이벤트로 관측된다. */

좋음:  /* TODO(#4001): 알람 인프라가 들어오면 속도 제한 알람 추가 */

3. 동시성 주석은 “왜”와 “누가 경합하는지”를 쓴다

리뷰어가 추측하지 않고 잠금(lock)을 검증할 수 있어야 한다.

/* 수신 경로는 데몬·타이머·알림 처리기가 쓰기 잠금으로 변경하는
 * 자료구조를 순회한다. 순회는 읽기 잠금으로 잡고,
 * 판정은 복사본에서 수행한다. */

4. 불변조건은 주석이 아니라 기계에 맡긴다

주석은 사람에게 알린다. 단언(assertion)은 미래의 위반자를 멈춘다.

가정의 종류 맡길 곳
구조체 배치·크기 컴파일 타임 단언
잠금 규율 공용 접근자의 런타임 단언
데이터 불변조건 테스트

5. 함수의 계약은 정의부에 한 줄로 쓴다

null 허용 여부, 반환 규약, 소유권 이전 여부를 정의된 자리에 밝힌다. 그래야 호출부와 호출부의 리뷰가 추측하거나 되묻지 않는다.

/* null 허용: 입력이 null이면 null을 반환한다.
 * 정리(teardown) 이후에 호출해도 안전하다. */

6. 경계의 의미를 이름에 담는다

end는 배타적(exclusive)으로 읽힌다. 마지막 원소를 포함한다면 last로 부른다.

이름은 계약이다. 이름과 실제가 어긋나는 지점에서 off-by-one 오류가 산다. 이름을 바꿀 수 없다면 호출부에 규약을 주석으로 남긴다.

7. 빌드·설정 조건은 함수 머리에 쓴다

리뷰도 사람도 설정 파일까지 읽지 않는다. 특정 플래그에서만 빌드되거나 현재 대상에서 실행되지 않는 코드라면 맨 위에 밝힌다.

/* FEATURE_X가 켜진 경우에만 빌드된다.
 * 현재 대상에서는 꺼져 있다(여기서는 죽은 코드). */

8. 인스턴스가 아니라 클래스를 고친다

리뷰가 어떤 패턴의 사례 하나를 찾으면, 같은 파일과 모듈에서 형제 사례를 검색해 같은 커밋에서 함께 고친다. 그러지 않으면 다음 리뷰가 다음 형제를 찾고, 이 과정이 끝없이 반복된다.

안전 래퍼를 새로 추가했다면 모든 호출부의 계약을 같은 커밋에서 점검한다.

9. 근거 주석은 주장이다. 계속 참으로 유지한다

근거 주석은 작성 시점에만 참이고, 설명 대상 코드가 바뀌면 썩는다. 그런 주석이 의존하는 동작(잠금 방식, null 처리, 자원 해제 시점)을 수정했다면 같은 커밋에서 주석을 재검증한다.

낡은 안전 주장은 없느니만 못하다. 코드가 막 움직인 바로 그 자리에서 리뷰를 침묵시키기 때문이다.


푸시 전 점검