AI slop 덜어내기 2편: 코드 리뷰 스킬 gimi(기미)
1편에서는 화면의 AI 티를 덜어내는 suta(수타)를 다뤘습니다. 두 번째는 코드 리뷰를 다루는 gimi(기미)입니다. 1편과 같은 순서로 왜 만들었는지, 어떻게 설계하고 구현했는지, 결과가 어땠는지 적습니다.
왜: diff 안에서 끝나는 AI 리뷰
AI에게 코드 리뷰를 맡기면 리뷰가 diff(이번 변경에서 바뀐 줄) 안에서 끝나는 게 불편했습니다.
증상은 대체로 이렇습니다. 바뀐 줄만 보면 멀쩡한 변경입니다. 그런데 그 함수를 쓰는 다른 화면이 깨집니다. 티켓에 적힌 요구사항과는 반대로 구현돼 있습니다. 지운 줄은 예전에 버그를 고치려고 일부러 넣은 줄입니다.
AI가 쓴 코드와 테스트에도 티가 납니다. mock이 돌려준 값을 그대로 단언하는 테스트, 실패하자 기대값을 실제값으로 고친 테스트, catch {}로 삼킨 에러, as any가 그렇습니다.
제가 직접 리뷰할 때 잡는 결함은 대개 diff 밖에 있었습니다. 다만 "AI 리뷰는 diff만 본다"는 제가 겪고 세운 가설이었습니다. 다른 리뷰 도구를 재서 확인한 사실이 아닙니다.
스킬 두 개로 시작한 설계
gimi는 10월 19일에 설계를 시작했습니다. 이름은 기미(氣味)상궁에서 가져왔습니다. 임금의 수라를 먼저 맛보듯, 코드를 올리기 전에 먼저 맛본다는 뜻입니다. 같은 소리의 기미(幾微)는 낌새라는 뜻이기도 합니다.
처음 설계는 스킬 두 개였습니다. 리뷰하는 gimi-review와, AI 티가 나는 테스트를 막고 끝내기 전에 테스트를 실제로 돌리게 하는 gimi-test입니다. 테스트 패턴 16개와 끝내기 전 검사 스크립트까지 만들었습니다.
코드 리뷰 하나로 좁힌 결정
10월 30일에 범위를 좁혔습니다. 그때 한 말은 이랬습니다.
"테스트는 다 제거하고 오로지 pr리뷰쪽을 개선하는걸로하자."
테스트 스킬과 거기 딸린 결정을 모두 취소하고 설계 문서에서 지웠습니다. 테스트 금지선만, PR에 든 테스트 코드를 볼 때 쓰는 리뷰 규칙으로 남겼습니다. 그래서 지금 gimi는 테스트를 써 주거나 돌려 주지 않습니다. 리뷰만 합니다.
설계: 맥락 수집과 판단의 분리
suta에서 가져온 장치
출발점은 suta였습니다. "AI가 흔히 하는 것 / 대신 / 근거"로 적는 금지선 표, 문서보다 먼저 쓰는 평가, 스킬 없이 그리고 스킬로 돌려 비교하는 벤치마크를 그대로 가져왔습니다. 진 결과도 기록한다는 원칙과, 검사가 깨끗해도 끝난 것이 아니라는 한계 절도 함께 가져왔습니다.
거기에 gimi만의 원칙을 하나 더했습니다.
모으는 건 스크립트, 판단은 에이전트.
"diff 밖도 봐라"라고 문서에 적는 것으로 끝낼 수도 있었습니다. 하지만 수집을 에이전트 재량에 맡기면 같은 문제가 다시 생길 거라고 봤습니다. 스크립트가 모으면 결과가 매번 같아서 테스트를 먼저 쓸 수 있습니다. 평가에서 결함을 놓쳤을 때 못 모아서 놓쳤는지, 모았는데 판단을 못 했는지도 가를 수 있습니다.
리뷰가 먼저 모으는 맥락
스크립트가 모을 맥락은 제가 리뷰하면서 기존 리뷰가 놓친다고 꼽은 것으로 정했습니다.
- 영향 범위: 바뀐 함수·타입·API 응답을 이 변경이 고치지 않은 곳에서 쓰는가
- 의도: 티켓과 PR에 적힌 요구사항대로 됐는가
- 변경 이력: 바꾸거나 지운 줄을 예전에 왜 넣었는가
나중에 PR의 CI 결과를 더해 네 가지가 됐습니다.
근거와 실패 조건을 적는 지적
지적에는 조건을 걸었습니다. 어떤 입력이나 상태에서 무엇이 잘못되는지(상황)와 근거 위치를 댈 수 없는 지적은 보고하지 않습니다. 근거 위치는 사용처의 파일:줄, 티켓의 문장, 커밋 해시 같은 것입니다. 근거 없는 "잠재적 문제"는 소음이라고 봤습니다.
단계는 고쳐야 함, 고치면 좋음, 확인 질문 셋입니다. 맥락 리뷰는 의도를 모를 때가 많습니다. 티켓과 구현이 다르거나 예전 코드를 되돌렸을 때는 단정하지 않고 확인 질문으로 묻게 했습니다.
README에 실은 설명용 예시는 이런 모양입니다.
[고쳐야 함] 영향 범위
주문 상태 형태가 바뀌었는데 배지는 예전 형태를 읽는다
바뀐 곳 src/api/order.ts:42
status: string → { code, label }
근거 src/components/OrderBadge.tsx:12
status === 'PAID' (이 PR에서 안 바뀜)
상황 결제 완료 주문 → 배지가 항상 '알 수 없음'
생긴 때 이번 변경
변경 전부터 있던 결함은 이 PR을 막을 이유가 아니어서 "고쳐야 함"으로 올리지 않습니다.
스크립트 하나와 세 겹의 규칙
에이전트는 SKILL.md와 스크립트가 낸 요약을 읽습니다. 규칙은 세 겹으로 두었습니다. AI가 쓴 코드·테스트·리뷰에 흔히 보이는 티를 막는 공통 금지선, 분야 프리셋(커머스 29개, 국내 전자상거래 법령 14개), 리뷰하는 저장소 안의 팀 규칙 파일입니다. 프리셋은 설정에서 켜야 쓰입니다.
결과는 채팅 요약과 리뷰 기록 파일로 남기고, PR에 코멘트를 달지는 않습니다. 기록 파일은 규칙을 늘리는 근거가 됩니다. 규칙에 걸리지 않은 같은 종류의 지적이 리뷰 세 건에 쌓이면 팀 규칙 후보로 올리고, 사람이 승인해야 규칙이 됩니다. 규칙은 팀만 알고, 자동으로 늘어난 규칙은 오탐을 만든다고 봤기 때문입니다.
다섯 단계의 리뷰 절차
리뷰는 다섯 단계로 돕니다.
- 범위를 정합니다. PR 번호가 있으면 그 PR, 없으면 현재 브랜치와 기본 브랜치의 갈림점부터 마지막 커밋까지입니다.
- 맥락을 모읍니다. 스크립트가 바뀐 파일, 의도, CI 결과, 이력, 영향 범위, 계약을 모아 요약합니다.
- 규칙과 대조합니다. 이 프로젝트에 맞는 규칙만 골라 diff에 닿는 것을 읽습니다.
- 근거가 있는 지적만 씁니다. diff 밖 맥락은 지적이 있든 없든 확인합니다.
- 보고합니다. 채팅 첫 줄에 결론을 쓰고, 전체는 리뷰 기록 파일에 남깁니다.
5단계에는 이런 기준이 있습니다.
"머지해도 돼?"에는 고쳐야 함·확인 질문·건너뛴 맥락이 모두 없을 때만 "막을 것 없음"이라고 답한다.
이 기준은 처음 설계에 없었고, 11월에 채팅 보고 형식을 정하면서 넣었습니다.
구현: 맥락 수집 스크립트와 평가 도구
맥락을 모으는 스크립트
스크립트는 의존성 없는 .mjs로 썼습니다. 제 기기의 Node가 22.12라서 suta의 .ts 스크립트(22.18 이상 필요)가 돌지 않았습니다. 같은 일을 겪지 않으려고 Node 22면 도는 형식을 골랐습니다.
바뀐 함수와 타입의 사용처는 리뷰하는 프로젝트에 설치된 TypeScript의 참조 찾기로 찾습니다. 이름만 같은 다른 심볼과 구분하기 위해서입니다. 쿼리 키나 경로처럼 타입으로 이어지지 않는 연결은 문자열 검색으로 찾고, 프로젝트에 TypeScript가 없으면 문자열 검색만 하면서 "정확도 낮음"을 붙입니다.
요구사항은 GitHub CLI(gh)로 PR과 이슈 본문을 읽어 모읍니다. 이력은 바꾸거나 지운 줄의 blame과 파일마다 최근 커밋 10개를 봅니다. 나중에는 PR의 CI 결과도 모으게 했습니다. 실패한 체크가 있으면 에이전트가 그 로그를 열고, 이 변경 때문임을 확인했을 때만 지적의 근거로 씁니다.
남의 PR을 읽는 도구의 보안
gimi는 테스트를 직접 돌리지 않습니다. 돌리면 남의 PR 코드가 제 기기에서 실행되기 때문입니다.
같은 문제가 사용처 찾기에도 있었습니다. 사용처를 찾으려고 리뷰하는 프로젝트의 typescript를 불러오는데, 남의 PR을 체크아웃한 상태라면 그 PR이 커밋한 node_modules의 코드가 실행될 수 있었습니다. 이제 node_modules에 git이 추적하는 파일이 하나라도 있으면 typescript를 불러오지 않고 문자열 검색으로 내려갑니다.
커밋 메시지에도 문제가 있었습니다. 메시지에 구분 문자를 넣으면 모은 맥락에 가짜 커밋을 만들고 요약에 가짜 줄을 넣을 수 있었습니다. 지금은 커밋 기록을 메시지에 들어갈 수 없는 NUL로 나눠 읽습니다.
재서 남긴 금지선
공통 금지선은 적어 놓기만 하지 않고 쟀습니다. 같은 요청과 같은 PR을 gimi 없이, 그리고 gimi로 2번씩 돌려 금지선의 줄마다 몇 번 나왔는지 셌습니다. gimi 없이 한 번 이상 나온 줄만 표에 남겼습니다. 일부는 이렇습니다.
| 금지선 | gimi 없이 | gimi로 |
|---|---|---|
review/repeated-finding 같은 문제를 위치마다 따로 지적하지 않는다 | 24번 중 3번 | 24번 중 1번 |
review/diff-only diff 안에서만 판단하지 않는다 | 24번 중 1번 | 24번 중 0번 |
review/guessed-intent 티켓을 읽지 않고 PR 제목으로 의도를 짐작하지 않는다 | 24번 중 1번 | 24번 중 0번 |
test/real-wait 실제 시간을 기다리거나 고정하지 않은 현재 시각에 기대지 않는다 | 24번 중 1번 | 24번 중 0번 |
code/single-use-abstraction 한 번 쓰는 헬퍼·래퍼·설정 객체를 만들지 않는다 | 24번 중 4번 | 24번 중 6번 |
숫자가 작습니다. 제가 문제라고 봤던 review/diff-only는 24번 중 1번 나왔고, 마지막 줄은 gimi로 돌렸을 때 오히려 더 나왔습니다.
결함을 심은 예제 PR
리뷰가 나아졌는지 재려면 정답이 있는 PR이 필요했습니다. 그래서 예제 앱을 만들었습니다. suta 데모의 테마를 이어 국수집 주문 앱입니다. 여기에 결함을 심은 PR과 결함 없는 PR을 만들고 정답 위치를 적어 두었습니다.
PR마다 gimi와 Claude Code 내장 /code-review를 같은 모델, 같은 지시로 돌립니다. 이슈 본문은 가짜 gh가 두 리뷰어에게 똑같이 줍니다. 채점은 기계가 합니다. 같은 파일에서 정답 줄의 ±3줄 안을 지적했으면 찾은 것으로 셉니다.
평가 도구를 만들면서도 걸린 일이 있었습니다. 결함 브랜치 이름을 defect/COM-024로 지었더니 스크립트가 브랜치 이름을 티켓 번호로 읽었습니다. 그래서 평가용 저장소는 이름이 중립인 폴더에 만들고, 브랜치 이름은 feature/pr로 통일했습니다.
결과: 내장 리뷰와의 비교
스크립트가 놓친 첫 시험
10월 26일, 결함 하나로 처음 돌려 봤습니다. 서버가 주문 응답에서 canCancel과 canReturn을 지웠는데, 화면은 여전히 그 값으로 취소·반품 버튼을 보이는 결함입니다. diff에는 서버 파일만 있습니다. gimi가 찾으려던 경우입니다.
리뷰는 결함을 찾았습니다. 그런데 찾은 건 스크립트가 아니었습니다. 모델 없이 점검해 보니 정답과 근거 4곳 가운데 맥락 묶음에 든 것은 1곳뿐이었습니다. 화면의 사용처는 에이전트가 직접 검색해서 찾은 것이었습니다. 스크립트는 바뀐 함수의 사용처는 찾았지만, 응답에서 지운 필드를 누가 읽는지는 찾지 못했습니다.
지운 필드 찾기는 다음 날 더했습니다. 수집과 판단을 나눠 둔 덕에 어느 쪽이 놓쳤는지 바로 알 수 있었습니다.
찾은 결함 수는 같았던 첫 평가
같은 날 결함 PR 15개와 결함 없는 PR 2개를 리뷰어마다 2번씩 돌렸습니다.
/code-review | gimi | |
|---|---|---|
| 찾은 결함(위치 기준) | 30/30 | 30/30 |
| 정답 밖 지적 | 52 | 15 |
| 결함 없는 PR의 지적 | 18 | 1 |
| 리뷰 한 번 평균 | $0.17 · 36초 | $0.31 · 51초 |
둘 다 전부 찾았습니다. "AI 리뷰는 diff만 봐서 놓친다"는 가설로 시작했는데, 내장 리뷰는 제가 심은 결함을 하나도 놓치지 않았습니다. 차이가 난 곳은 찾은 수가 아니라 심은 결함이 아닌 곳을 짚은 지적의 수였습니다. 그리고 gimi는 돈과 시간이 더 들었습니다.
처음으로 갈린 평가
다음 날 결함을 다르게 만들어 봤습니다. 요구사항을 PR 본문이 아니라 이슈 본문에만 적은 결함 4개입니다. 그중 하나는 이렇습니다. 이슈 #41은 "품절 상품은 숨기지 않고 메뉴 맨 아래로 보낸다"를 요구하는데, PR은 품절 상품을 걸러 냅니다.
// web/src/pages/MenuPage.tsx
- {products.map((product) => (
+ {products
+ .filter((product) => !product.soldOut)
+ .map((product) => (
채점표는 이번에도 같았습니다. 둘 다 8번 중 8번을 찾았습니다.
그런데 출력을 읽어 보니 달랐습니다. gimi는 8번 모두 이슈 번호와 요구사항 문장을 인용했습니다. /code-review의 출력에는 이슈 번호는 나오는데 이슈 본문의 내용은 한 번도 나오지 않았습니다. 이슈를 열지 않고 코드의 단서로 뜻을 짐작하고 있었습니다. 요구사항과 어긋난다고 짚은 리뷰를 다시 세니 gimi가 8번 중 8번, /code-review가 8번 중 5번이었습니다.
채점표가 같았던 이유도 찾았습니다. /code-review는 위 사례에서 두 번 모두 정답 줄 근처의 다른 문제, 걸러 낸 뒤 목록이 비는 경우를 지적했습니다. 줄이 맞으니 "찾음"으로 세어진 겁니다. 위치로만 채점하면 같은 줄의 다른 지적도 정답이 됩니다.
다만 8 대 5는 기계 채점이 아니라 제가 출력을 읽고 센 숫자입니다. 결함도 4개뿐입니다.
결함 32개 평가와 정답 밖 지적
10월 31일에는 새로 만든 결함 PR 32개를 리뷰어마다 한 번씩 돌렸습니다.
/code-review | gimi | |
|---|---|---|
| 찾은 결함(위치 기준) | 32/32 | 31/32 |
| 정답 밖 지적 | 55 | 19 |
| 리뷰 한 번 평균 | $0.31 · 45초 | $0.41 · 57초 |
이번에는 gimi가 하나를 놓쳤습니다. 열어 보니 내용은 맞고 줄 번호가 4줄 어긋난 지적이었습니다. 위치 채점이 이번에는 반대로 작동한 겁니다.
정답 밖 지적은 55개와 19개로 갈렸습니다. 하지만 이 숫자로 gimi의 오탐이 적다고 말할 수는 없습니다. 그 55개가 틀린 지적인지 저는 아직 읽고 판정하지 않았습니다. 제가 정답을 빠짐없이 적었다고 할 수도 없습니다. 심지 않은 진짜 결함을 짚은 것일 수도 있습니다.
이 두 가지를 겪고 평가를 고쳤습니다. 위치 점수와 따로, 사람이 내용을 읽고 판정하는 양식을 더했습니다. 판정하지 않은 항목은 미검토로 남기고, 판정한 것만 셉니다.
효과를 확인하지 못한 개선
그 뒤로 판단 기준을 고쳤습니다. 처음 규칙은 "근거는 diff 밖이어야 한다"였는데, 이대로면 바뀐 함수 안의 계산 실수는 근거가 diff 안이라는 이유로 보고하지 못합니다. 근거는 diff 안팎 어디든 되고, 대신 어떤 입력에서 무엇이 잘못되는지를 쓸 수 없으면 보고하지 않는 것으로 바꿨습니다. 사용처를 판단하는 방법과, 지적을 쓰기 전에 줄 번호를 다시 확인하는 절차도 더했습니다.
11월 10일에 같은 결함 32개를 다시 돌렸습니다. 찾은 수는 31/32로 그대로였습니다. 더한 지침이 결함을 더 찾게 한다는 근거는 없습니다. 리뷰 한 번의 평균만 $0.41·57초에서 $0.46·65초로 늘었습니다.
기대와 다르게 돈 사례도 나왔습니다. gh가 없어 이슈를 읽지 못하게 만든 사례에서, gimi는 읽지 못했다고 적어 놓고도 "이 PR을 막을 문제는 없습니다"라고 했습니다. 요구사항을 못 읽었는데 문제없다고 답한 겁니다. 읽지 못한 이슈가 있으면 확인 질문으로 묻도록 고쳤고, 고친 뒤 2번 돌려 2번 모두 확인 질문이 나왔습니다. 모으지 못한 맥락은 "건너뜀"으로 이유와 함께 적고, "사용처 없음"(찾았는데 없음)과 구분하게 했습니다.
남은 한계
- 측정은 모두 예제 앱 한 곳에서, 제가 심은 결함으로 한 것입니다. 실제 저장소에서 잰 결과는 아직 없습니다.
- 평가는 설정 없이 돌려서 분야 프리셋이 꺼져 있었습니다. 프리셋 규칙의 효과는 재지 못했습니다.
- 바뀐 함수와 타입의 사용처는 TypeScript와 JavaScript 파일에서만 찾습니다.
- 스킬과 규칙, 지적 형식이 한국어입니다.
- 내장 리뷰보다 리뷰 한 번의 비용과 시간이 더 듭니다.
마치며
gimi는 지금 0.3.0입니다. Claude Code에서는 대화창에 이렇게 입력합니다.
/plugin marketplace add Guksu/gimi
/plugin install gimi@gimi
Codex는 터미널에서 설치합니다.
codex plugin marketplace add Guksu/gimi
codex plugin add gimi@gimi
Node.js 22 이상이 필요하고, PR·이슈 본문을 읽으려면 gh에 로그인해 둡니다. 설치한 뒤 리뷰할 저장소에서 이렇게 요청합니다.
gimi-review로 지금 브랜치를 리뷰해줘. 바뀐 API를 쓰는 화면이 깨지지 않는지, 이슈의 요구사항대로 됐는지도 봐줘.
찾은 결함 수로는 gimi가 내장 리뷰를 이기지 못했습니다. 차이가 난 곳은 요구사항이 이슈에만 적힌 경우였고, 그마저 결함 4개로 본 결과입니다. 다음에는 예제 앱이 아니라 실제 저장소의 PR에서, 지적마다 맞음·틀림을 표시한 기록으로 보려 합니다.
AI 리뷰를 쓰고 있다면, 리뷰가 "문제없음"이라고 할 때 무엇을 보고 그렇게 말했는지 한 번 물어보길 권합니다. 바뀐 함수를 쓰는 곳을 열어 봤는지, 이슈 본문을 읽었는지만 확인해도 리뷰를 어디까지 믿을지 정하기가 쉬워질지도 모르겠습니다.
써 보시고 불편한 점은 이슈로 알려주세요. 요청한 것과 실제로 일어난 일을 함께 적어 주시면 됩니다.
다음 글에서는 문서와 글의 AI 티를 덜어내는 geulppal(글빨)을 다룹니다.
- 저장소: github.com/Guksu/gimi
- 대표 사례 3개: docs/cases.md
- 측정 방법과 전체 결과: evals/review/README.md