PeekCart 는 혼자 개발하는 이커머스 백엔드 프로젝트입니다. 작업은 Claude Code 와 함께 하고 계획과 코드 변경은 별도의 AI 검토자에게 한 번 더 보여 줬습니다. 이 과정을 반복할 수 있게 명령 문서와 shell 함수, 검사 스크립트를 만들었습니다. 이 글에서는 그 묶음을 하네스라고 부릅니다. 명령은 계획, 구현, 제출 세 단계로 나뉘고 각 규칙을 왜 정했는지는 별도의 근거 문서에 모아 뒀습니다.
하네스가 커지면서 작은 작업에도 같은 절차가 붙었습니다. 간단하게 구현할 수 있는 기능도 계획서를 쓰고 검토를 받고 다시 검토하는 순서를 따라야 했고 그 과정에서 불필요한 토큰과 시간을 쓰는 경우가 많았습니다. 혼자 개발할 때 가장 큰 장점은 소통 비용이 없어서 빠르게 진행할 수 있다는 점인데 그 장점이 사라지고 절차가 먼저인 개발 환경이 돼 있었습니다.
그래서 절차를 줄이기로 했습니다. 다만 검토를 그냥 빼면 검토가 실제로 찾아 주던 결함도 같이 놓칩니다. 이 글은 다음 질문에 답하려고 합니다.
AI 검토를 더하는 대신, 어떤 지적을 지금 처리하고 언제 멈출지 어떻게 정할까?
글에 나오는 수치의 출처는 다섯 가지입니다. 개발하면서 남긴 검토 기록과 작업 기록, 커밋 메시지의 값이 있고 그 기록을 이번에 다시 센 값, 이번에 현재 코드를 합성 입력으로 실행한 실험 결과, CI 에 남은 로그, 이번 조사보다 앞서 한 조사에서 넘겨받은 결과가 있습니다. 절차를 줄이기 전과 후의 토큰, 시간, 품질을 비교한 측정은 없습니다. 아래에서 각 수치가 어디서 왔는지 함께 적겠습니다. 이 글의 검토 규칙은 Claude Code 명령으로 작업할 때의 절차이고, 지금 쓰는 다른 실행 경로에는 별도 검토 단계가 없습니다(8장 끝).
1. 두 번째 검토는 무엇을 찾았을까요?
검토는 OpenAI 의 코딩 에이전트 CLI 에 맡겼습니다. 이 글에서는 이 도구를 검토 모델이라고 부릅니다. 계획서나 diff 를 넘기면 지적 목록을 JSON 으로 돌려주고 지적마다 severity 를 붙입니다. 이 프로젝트에서는 P0 를 머지를 막는 지적으로, P1 과 P2 를 그 아래 등급으로 썼습니다.
검토를 줄이기 전에 먼저 환불 실행 기능을 살펴봤습니다. 결제 서비스가 PG 사에 환불을 요청하고 그 결과를 환불 원장에 확정하는 기능이었습니다. 실행자가 중간에 죽으면 다른 실행자가 같은 환불을 이어받아야 하므로, 누가 지금 그 환불을 처리하는지를 원장 행에서 claim 으로 표시했습니다.
첫 번째 diff 검토는 diff 를 세 조각으로 나눠 조각마다 검토 모델을 한 번씩 불렀고, 중복을 뺀 지적 10건(P1 7건, P2 3건)이 나와 모두 반영했습니다. 그중 하나가 오래된 실행자와 새 실행자가 둘 다 결과를 확정할 수 있다는 지적이었고 원장에 generation 이라는 fencing token 을 두고 claim 할 때마다 값을 올리도록 고쳤습니다. 다른 하나는 수동 종결이 CLAIMED 상태의 환불까지 종결한다는 지적이었고 수동 종결을 UNRESOLVED 상태에만 허용하도록 고쳤습니다.
그 뒤 두 번째 검토를 돌렸습니다. 기존 절차는 수정한 코드의 diff 를 다시 떠서 검토 모델에 넘기는 방식이었고 이 회차의 입력은 main 코드 1,776줄이었습니다. 반영한 수정 목록만 묻는 질문 형식은 이후에 도입했습니다. 기존 구현 단계의 예산은 검토 모델 호출 3회 미만이었는데 첫 회차의 세 조각이 이미 3회를 써서 예산을 넘겨도 된다는 승인을 받고 돌렸습니다. 지적 8건(P1 5건, P2 3건)이 나왔고 그중 두 건은 첫 번째 검토의 수정이 만든 결함이었습니다.
generation을 잠금 없는 SELECT 로 읽은 뒤 비교했습니다. 읽기와 비교 사이에 다른 실행자가 다시 claim 할 수 있었습니다. 확정과 수동 종결 경로가findByOrderIdForUpdate로 행을 잠근 뒤 비교하도록 바꿨습니다.- 결과를 확인하지 못한
UNRESOLVED환불을 재처리용으로 claim 해도 상태가UNRESOLVED로 남았습니다. 그래서 처리 중인 환불을 수동으로 실패 종결할 수 있었고 PG 에서는 환불이 성공했는데 원장은 실패로 남는 불일치가 생길 수 있었습니다. claim 할 때CLAIMED로 바꾸고 상태 전이에UNRESOLVED에서CLAIMED로 가는 경로를 추가했습니다.
두 건 모두 재검토를 하지 않았다면 그대로 남았을 결함입니다. 이런 결함 때문에 수정 뒤의 확인은 빼기 어렵습니다.
나머지 여섯 건은 다음과 같았습니다.
| 지적 | 반영 |
|---|---|
마이그레이션의 NULL 가드가 CREATE TABLE 뒤에 있어 실패하면 남은 객체 때문에 재배포가 막힘 | 가드를 모든 DDL 앞으로 이동 |
환불 원장의 payment_key 길이 200 과 결제 테이블의 255 가 달라 INSERT IGNORE 가 잘린 키를 넣음 | 255 로 통일 |
| 최악 호출 시간 계산이 timeout 을 시도당 한 번만 셈. 실제로는 connect 와 read 양쪽에 걸림 | 시도당 2배로 계산 |
| lock 시간 검증이 배치 1회분만 봄. 스케줄러는 실행 한 번에 여러 배치를 반복함 | 실행 전체 기준으로 검증식 확대 |
| 조회만 한 확정이 시도 횟수를 0 으로 덮어씀 | 누적으로 변경하고 회귀 테스트 추가 |
오류 코드 없는 4xx 응답이 failureCode 없이 실패 회신을 만듦 | HTTP_<status> 대체값과 UNKNOWN_PERMANENT_FAILURE 방어 추가 |
여덟 건은 모두 그 자리에서 반영했습니다. 여섯 건 가운데 틀린 지적은 없었습니다. 그렇다고 여섯 건이 모두 앞선 수정과 무관했다고 말할 근거도 부족합니다. NULL 가드는 첫 번째 회차에서 추가한 가드의 위치 문제였고 lock 검증은 첫 번째 회차에서 도입한 배치 반복 설정과 이어져 있습니다. 시도 횟수와 timeout 계산도 어느 회차에서 처음 생긴 문제인지 확정하지 못했습니다. PR 의 커밋이 기능 단위로 묶여 있어서 두 검토 사이의 작업 트리가 따로 남지 않았기 때문입니다.
이 사례로 수정을 다시 확인하는 일이 필요하다는 것은 알 수 있습니다. 남는 질문은 그 확인에서 나온 지적을 모두 같은 방식으로 처리해야 하는가입니다.
2. 맞는 지적과 지금 고칠 지적은 어떻게 나눌까요?
기존 절차에서 지적을 처리하는 선택지는 반영과 기각 둘이었습니다. 기각은 지적이 사실과 다를 때 쓰는 선택이므로 맞는 지적은 사실상 반영할 수밖에 없었습니다.
세 번째 선택지로 이월을 추가했습니다.
| 처리 | 뜻 | 남기는 것 |
|---|---|---|
| 반영 | 이번 변경에서 고친다 | 코드나 계획서 변경 |
| 이월 | 지적은 맞지만 이번 범위가 아니다 | PR 본문에 재검토 조건과 함께 기록 |
| 기각 | 지적이 사실과 다르다 | 사유를 적고, 근거를 다시 확인한 뒤 확정 |
이월하면 지적이 맞다는 것을 인정하면서 이번 작업의 범위를 유지할 수 있습니다. 대신 언제 다시 볼지를 적어야 합니다. 구조적이거나 반복되는 문제는 프로젝트의 작업 목록에 별도 행으로 올리고 한두 건의 국소적인 문제는 PR 본문의 이월 절에만 남기도록 했습니다.
severity 별 기본값도 정했습니다. P0 는 반영이 기본이고 예외에는 사유가 필요합니다. P1 은 기본값이 없어서 항목마다 반영, 이월, 기각 중 하나를 고르고 한 줄 사유를 씁니다. P2 는 이월이 기본이고 같은 변경에서 고칠 이유가 있으면 반영합니다. 1장처럼 맞는 P1 을 한꺼번에 반영하던 습관이 작업을 키운 주된 경로라고 봤기 때문입니다. 이 경로가 얼마나 컸는지를 따로 잰 것은 아닙니다.
이렇게 나누면 지적마다 세 가지를 따로 묻게 됩니다. 지적이 사실인가, 이번 작업이 만든 문제인가, 지금 고치지 않으면 무엇이 깨지는가. 사실이 아니면 기각입니다. 사실이고 이번 작업이 만들었으며 미루면 깨지는 것이 있으면 반영합니다. 사실이지만 이번 작업 밖의 문제이거나 미뤄도 깨지는 것이 없으면 이월 후보가 됩니다. 환불 사례의 두 결함은 첫 회차의 수정이 만들었고 미루면 원장이 PG 결과와 어긋날 수 있어 반영에 해당합니다. 결제 키 길이처럼 첫 회차의 수정과는 관계가 없어 보이는 지적도 있습니다. 이번 작업이 새로 만든 테이블의 데이터 호환 문제라면 이번 작업의 문제이고, 마이그레이션이 배포되기 전에 고치는 비용이 더 적을 수 있습니다.
이월의 효과를 측정하지는 못했습니다. 이월한 지적 가운데 몇 건이 이후에 해소됐는지, 이월 때문에 놓친 결함이 있었는지는 따로 세지 않았습니다.
3. 재검토는 무엇을 묻고 언제 멈출까요?
수정 목록만 확인합니다
기존 재검토는 수정한 코드의 diff 를 다시 넘기면서 첫 번째 회차의 수정이 만든 새 결함을 찾으라고 물었습니다. 어떤 수정을 확인할지 목록이 없고 새 제안을 막는 조건도 없어서 회차마다 새 지적이 나올 수 있었습니다. 새로 나온 P1 을 반영하면 바뀐 코드가 늘고 다음 회차는 그 코드를 다시 지적할 수 있습니다.
두 번째 검토의 질문을 반영한 수정 목록에 묶고, 목록 밖의 발견은 낮은 등급으로 표시만 하게 했습니다. 프로젝트의 공통 검토 규칙에 있는 프롬프트를 그대로 옮기면 다음과 같습니다.
[리뷰 대상] 라운드 1에서 반영한 아래 N개 항목만 검사하라.
1. <원 지적> → <무엇을 고쳤는지>
2. ...
[질문] 각 항목에 대해 두 가지만 답하라.
(a) 그 수정이 원 지적을 실제로 해소했는가
(b) 그 수정이 기존 동작이나 다른 항목을 깼는가
[금지] 새로운 개선 제안을 하지 마라. 위 두 질문 밖의 발견은 severity 를 P2 로 두고
scope 를 out_of_scope 로 표시만 하라. 본문에서 논하지 마라.
전체를 다시 탐색하는 검토와 수정을 확인하는 검토는 답해야 할 질문의 수가 다릅니다. 앞의 것은 대상이 정해져 있지 않고 뒤의 것은 첫 번째 회차에서 반영한 항목 수만큼입니다. 환불 사례의 두 결함은 (b) 질문, 즉 수정이 다른 동작을 깼는가에 해당하는 종류입니다. 그래서 질문을 좁혀도 그런 결함은 대상에 남을 것으로 봅니다. 다만 환불 사례의 두 번째 검토는 diff 전체를 열린 질문으로 본 회차였으므로, 좁힌 질문으로 같은 결함을 찾았을지는 확인하지 못했습니다.
상한과 종료 조건을 예산으로 정합니다
검토 상한은 3회에서 2회로 줄였습니다. 종료 조건은 “P1 이 0건”으로 두지 않았습니다. P1 을 반영할 때마다 다음 회차가 지적할 코드가 늘어나므로, 그 조건을 두면 루프가 스스로 만든 지적이 다시 종료를 미룹니다. 규칙은 이렇습니다. 첫 번째 회차에서 모든 지적의 처리를 정하고 두 번째 회차에서 P0 가 0건이면 끝냅니다. 두 번째 회차에서 P0 가 나와 한 번 더 돌려야 하는 경우처럼 2회를 넘겨야 할 사정이 있으면 사용자에게 명시적으로 확인합니다.
두 번째 회차에서는 P0 만 반영하고 P1 과 P2 는 모두 이월하도록 했습니다. 두 번째 회차를 돌리는 조건도 좁혔습니다. 작업은 건드리는 모듈 수, 다른 곳이 참조하는 계약의 변경, 되돌릴 때 작업 밖까지 고쳐야 하는지에 따라 S, M, L 세 등급으로 나눕니다(6장). 가장 넓은 L 등급의 diff 검토에서 첫 번째 회차에 P0 를 실제로 수정한 경우에만 두 번째 회차를 돌립니다.
이 규칙을 환불 사례에 그대로 대입하면 문제가 보입니다. 첫 번째 회차의 10건은 모두 P1 과 P2 였으므로 이 작업을 L 로 판정했더라도 지금 규칙으로는 두 번째 검토가 실행되지 않습니다. 실행되더라도 두 결함은 P1 이어서 이월 대상입니다. 이월하면 원장과 PG 결과가 어긋날 수 있는 결함을 다음 작업으로 미뤘을 것입니다. 이 규칙을 만들 때 환불 사례를 근거로 들었습니다. 그런데 규칙은 바로 그 사례의 결함을 찾지 못하는 방향으로 동작합니다. 목록 밖의 발견을 P2 로 표시하게 한 프롬프트도 같은 방향입니다. 결제 키 길이 같은 지적은 두 번째 회차에서 나오면 자동으로 이월 쪽으로 가므로, 이 규칙은 2장에서 말한 배포 전에 고치는 쪽이 싼 경우를 구분하지 않습니다.
이 반례는 규칙 문장을 과거 기록에 대입해 본 결과이고 실험으로 확인한 것은 없습니다. 그래도 상한과 severity 규칙을 안전 장치로 볼 수는 없습니다. 이 규칙은 검토 비용의 상한을 정할 뿐입니다. 정해진 회차를 마쳤다는 사실만으로 중요한 결함이 없다고 말할 수는 없습니다. 규칙은 P1 에도 사유를 적고 반영하는 길을 열어 두지만 언제 그래야 하는지는 정하지 않습니다. 제 생각으로는 데이터 정합성이나 금전 처리처럼 되돌리기 어려운 변경이라면 P1 이라도 지금 반영할 이유를 적고 반영하거나, 상한을 넘긴다는 확인을 받고 한 번 더 돌려야 합니다. 2회라는 상한이 이 프로젝트에 가장 알맞은 값이라는 근거도 없습니다.
4. 규칙의 근거로 쓴 숫자는 무엇을 셌을까요?
규칙을 바꾸면서 근거로 몇 가지 숫자를 적었습니다. 이번에 그 숫자를 다시 계산했는데 일부는 처음 설명과 다른 것을 세고 있었습니다.
442건과 307건은 같은 집합이 아닙니다
규칙을 바꿀 때 근거로 든 숫자는 기각률 307건 중 1건(0.3%)과 P0 비율 442건 중 9건(2.0%)이었습니다. 두 숫자를 나란히 놓으면 전체 지적 대부분을 그 자리에서 구현했다고 읽힙니다. 이 장에서 다시 세는 근거 숫자는 모두 근거 문서에 지금도 정정 전 그대로 남아 있습니다.
원래 집계 스크립트는 찾지 못했습니다. 대신 규칙을 바꾸기 직전 커밋에서 계획서 폴더 바로 아래에 있던 검토 기록 18개 파일을 대상으로, 기록된 값을 정확히 다시 만드는 정규식을 찾았습니다. 다음은 이번에 재구성한 추출식입니다.
severity = r'항목: (\d+)건 \(P0:(\d+), P1:(\d+), P2:(\d+)\)'
disposition = r'반영 (\d+)건 / 기각 (\d+)건'
round_mentions = r'라운드 (\d+)'
첫 번째 식에 맞는 줄은 56개이고 합하면 442건, P0 9건, P1 297건, P2 136건입니다. 두 번째 식에 맞는 줄은 41개이고 합하면 반영 306건, 기각 1건입니다. 재구성한 식으로 보면 두 숫자는 서로 다른 표기 형식으로 쓴 줄을 각각 더한 값으로 재현됩니다. 지적과 그 처리 결과를 항목 단위로 연결하지 않았고 중복도 빼지 않았습니다. 442에서 307을 뺀 135건을 처리되지 않은 지적으로 읽어서도 안 됩니다.
표기가 조금만 달라도 빠집니다. 1장의 환불 기록이 그 예입니다. 같은 검토 기록 파일에서 요약 줄 셋을 발췌하면 다음과 같습니다.
- 항목: 17건 (P0:0, P1:13, P2:4) — **전량 반영**
- 항목: 23건(중복 포함) → **중복 제거 10건** (P1 7 · P2 3) — **전량 반영**
- 항목: **8건** (P1 5 · P2 3) — **전량 반영**
첫 줄의 계획 검토는 첫 번째 식에 들어가지만 두 diff 검토는 형식이 달라 들어가지 않습니다. 세 줄 모두 처리 결과를 “전량 반영”으로 썼기 때문에 두 번째 식에는 한 줄도 들어가지 않습니다. 다른 추출식으로 반영 365건, 기각 6건 같은 값도 만들 수 있었지만 그것도 정답 통계로 쓰지는 않았습니다.
그래서 이 숫자로는 “기각률 0.3%“를 말할 수 없습니다. 맞는 지적을 대부분 반영해 왔다는 인상은 개별 검토 기록과 이어지지만 그 비율을 계산하려면 검토 실행, 지적, 중복, 처리 결과를 같은 식별자로 이은 기록이 필요합니다.
”세 번째 검토 20회”는 완료 횟수가 아닙니다
상한을 3회에서 2회로 줄인 근거는 18개 계획서에서 세 번째 검토에 도달한 것이 20회였고 모두 끝까지 정리되지 않았거나 상한으로 종료했다는 설명이었습니다. 세 번째 정규식으로 회차 번호가 3인 줄의 수를 세면 20이 나와서, 이 값도 같은 방식으로 셌다고 봅니다.
그 20줄을 읽어 보면 성격이 섞여 있습니다. “(상한)“으로 끝난 회차, “미실행 (usage limit)”, “실행 불가”, 사용량 한도로 첫 시도가 실패한 뒤 다시 돌린 회차의 제목과 “(재실행)” 소제목, 세 번째 검토를 돌리지 못했다는 본문 문장이 함께 들어 있습니다. 한 번의 회차가 제목과 본문에서 두 번 세어지기도 합니다. 20은 문자열이 나온 횟수이고 성공한 세 번째 검토의 횟수로 확정할 수 없습니다. 끝까지 정리되지 않은 종료, 상한 도달, 실행 실패, 사용자 중단도 서로 다른 결과인데 이 숫자는 그 구분을 담지 않습니다.
진행 기록에 남은 “끝까지 정리되지 않은 종료 7회”도 원래 집계 코드와 분류표가 없어서 다시 계산하지 못했습니다. 검토를 반복할수록 일이 늘어난 개별 사례는 기록에 있습니다. 하지만 상한을 줄인 근거로 내세운 숫자는 그 사례보다 강한 주장을 하고 있었습니다.
분할 검토의 “24건 중 10건”
diff 가 길 때는 코드, 테스트, 설정의 세 조각으로 나눠 따로 검토하던 절차도 있었습니다. 내부 인증 토큰을 도입한 작업에서 세 조각의 지적을 합하면 24건(10건, 5건, 9건)이었고 그중 10건은 다른 조각에 있는 파일을 “패치에 없다”고 지적한 것이어서 기각했습니다. 전체 빌드와 테스트가 통과한 것과 파일별 조각 소속이 그 근거였습니다. 실제 결함은 중복을 빼고 7건이었습니다.
그런데 같은 기록에서 기각한 지적의 번호를 열거한 목록에는 11개가 있습니다. 총 10건과 맞지 않고 조각별 원본 출력이 남아 있지 않아서 정확한 10건을 복원하지 못했습니다. 조각만 본 검토 모델이 조각 밖의 파일을 누락으로 지적한다는 현상 자체는 기록과 예시로 확인됩니다. 다만 “10건”은 정확한 값으로 쓰기 어렵습니다.
5. 지적 0건과 검토 실패는 어떻게 구분할까요?
검토를 언제 멈출지 정하려면 그 전에 검토 결과를 실제로 받았는지 알아야 합니다. “지적이 없어서 멈춘다”와 “결과가 없어서 멈췄다”가 같은 모습으로 보이면, 상한과 종료 조건은 엉뚱한 근거 위에서 동작합니다.
282개 중 44개가 빈 파일이었습니다
검토 결과를 표로 보여 주는 렌더러를 만들기 전에, 로컬에 쌓여 있던 검토 모델의 출력 JSON 파일을 조사했습니다. 조사를 시작할 때 파일은 282개였고 그중 238개는 items 배열을 가진 정상 JSON, 44개는 0바이트 빈 파일이었습니다. 파일 수 기준으로 약 15.6% 입니다.
정상 JSON 에서 items 가 빈 배열이면 검토 모델이 결과를 냈고 지적이 0건이라는 뜻입니다. 0바이트 파일은 결과를 받지 못한 상태입니다. 기존 절차는 P0 와 P1 이 0건이면 자동 통과로 처리했고 표를 손으로 만들면 두 경우가 모두 0건으로 보일 수 있었습니다.
이 숫자를 정하는 과정에서도 집계가 한 번 틀렸습니다. 처음에는 크기 필터(find -size +2c)로 고른 파일 집합에 jq 를 돌려 파일 239개(그중 items: null 1개)를 얻었는데 빈 파일 44개와 더하면 283개가 돼 전체 282개와 맞지 않았습니다. 전체 파일을 직접 도는 회귀 검사로 238개, 44개, null 0개로 바로잡았습니다. 그 뒤 같은 작업 중에 검토 모델을 새로 호출하면서 파일이 늘었고 이후 기록에 나오는 빈 파일 45개는 늘어난 집합의 값입니다. 처음의 44개와 더하지 않습니다.
같은 jq 집계에서 지적은 1,267건(P0 45건, P1 787건, P2 435건)이었습니다. 이 값은 계획 검토와 diff 검토가 섞여 있고 실행별 중복도 확인하지 못했습니다. 4장의 442건과 모집단이 달라서 두 P0 비율을 비교할 수 없습니다.
실패 원인은 한 가지가 아니었습니다
처음에는 빈 파일의 원인을 모두 사용량 한도로 설명했습니다. stderr 파일 두 개를 표본으로 본 결과였습니다. 이후 빈 파일 전체의 stderr 를 분류했는데, 이때는 새 호출이 더해져 빈 파일이 45개였고 그중 1개는 새 호출에서 생긴 schema 오류(뒤의 400 오류)였습니다. 그 1개를 뺀 44개는 사용량 한도 9건, MCP transport 오류 9건, OAuth 갱신 실패 5건, 모델 capacity 1건, 기타 20건이었습니다.
이 분류도 엄밀한 원인 분석은 아닙니다. 사용량 한도와 schema 오류를 뺀 파일에서는 ERROR 나 error 가 들어간 줄 가운데 마지막 줄을 원인으로 뽑았는데 stderr 의 마지막 줄이 닫는 중괄호이거나 검토 대상 diff 에서 그대로 출력된 오류 문구인 경우가 있었습니다. 한 stderr 에 OAuth 로그와 다른 오류가 함께 남은 경우도 있어서 문자열 하나를 원인 하나로 읽을 수 없습니다. 원래 JSON 과 stderr 파일이 지금은 남아 있지 않아 파일별로 다시 검산하지 못했습니다. 9, 9, 5, 1, 20 은 그 시점의 분류 기록이고 지금 서비스의 실패율로 읽으면 안 됩니다.
렌더러의 stderr 인용 방식도 이 경험으로 바꿨습니다. 마지막 줄을 가져오는 대신 알려진 오류 패턴을 먼저 찾고, 없을 때만 마지막 줄을 씁니다.
종료 코드를 셋으로 나눴습니다
렌더러는 결과 파일 하나를 받아 종료 코드로 상태를 구분합니다.
| exit | 뜻 |
|---|---|
| 0 | 정상 처리. 지적이 있는 결과와 지적 0건을 모두 포함 |
| 1 | 사용법 오류. 인자가 없거나 파일이 없음 |
| 2 | 검토 실패. 빈 파일, JSON 파싱 실패, items 가 배열이 아님 |
exit 0 은 “정상적으로 읽었다”는 뜻입니다. “지적이 없다”로 읽으면 안 됩니다. 아래 실험에서 렌더러가 “(자동 통과)“라고 출력하는 경우도 exit 0 가운데 지적이 0건인 경우뿐입니다. 지적이 있는지는 출력된 목록을 읽어야 알 수 있습니다. 실패를 판정하는 부분은 다음과 같습니다. 현재 렌더러에서 해당 줄만 발췌했습니다.
[ -s "$JSON" ] || fail_review '빈 응답 (0바이트)'
jq -e . "$JSON" >/dev/null 2>&1 || fail_review 'JSON 파싱 실패'
jq -e '(.items | type) == "array"' "$JSON" >/dev/null 2>&1 \
|| fail_review 'items 가 배열이 아님 (null / 객체 / 누락)'
이 검사가 보장하는 범위는 items 가 배열이라는 데까지입니다. 이번 조사에서 현재 렌더러에 합성 fixture 를 넣어 실행해 봤습니다. 입력은 이번에 만든 것이고 과거 출력에서 가져오지 않았습니다. 결과를 입력별로 다시 배열하면 다음과 같습니다.
정상, 빈 items exit 0 지적 0건 (자동 통과)
정상, P1 항목 1개 exit 0 지적 1건
0바이트 파일 exit 2 리뷰 실패
JSON 파싱 불가 exit 2 리뷰 실패
items: null/false/{}/문자열/누락 exit 2 리뷰 실패
items: [{}] exit 0 총 1건, P0/P1/P2 모두 0
severity: "P9" exit 0 총 1건, P0/P1/P2 모두 0
{"items":[]} exit 0 지적 0건 (자동 통과)
items: [null] exit 1 Python AttributeError
외형이 깨진 입력은 모두 검토 실패로 분류됩니다. 배열 안의 항목은 검사하지 않습니다. 필수 필드가 하나도 없는 항목이나 정의에 없는 severity 는 정상 결과로 처리되고, 총 1건인데 severity 별 합은 0건인 표가 나옵니다. run_id 와 summary 가 없는 {"items":[]} 도 자동 통과입니다. 배열에 null 을 넣으면 렌더러 안의 Python 코드가 예외를 내고 exit 1 로 끝나는데 이 코드는 사용법 오류와 같고 문서가 정한 실패 코드 2 와 다릅니다. 이 결과를 받은 쪽은 검토 실패를 사용법 오류로 읽을 수 있습니다.
렌더러의 self-test 는 이번 로컬 실행과 CI 에 남은 로그에서 모두 34개 통과, 0개 실패, 1개 건너뜀이었습니다. 건너뛴 하나는 로컬의 실제 검토 이력을 다시 돌리는 회귀 검사인데, CI 에는 그 이력이 없어서 항상 건너뜁니다. 이번 로컬 실행도 이력이 없는 임시 경로에서 돌려서 같은 항목을 건너뛰었습니다. self-test 통과는 정의된 34개 경우가 통과했다는 뜻입니다. 위의 항목 내부 입력은 그 34개에 들어 있지 않습니다.
렌더러 스크립트의 머리 주석은 아직 고치지 않았습니다. 계획서에서는 실패 파일 수와 원인을 모두 바로잡았지만 주석에는 정정 전 값인 45개(16%)와 “전부 usage limit” 설명이 그대로입니다.
검토 상태를 네 가지로 적습니다
종료 코드를 나눈 것과 함께, 작업 기록에 남기는 검토 상태도 넷으로 나눴습니다.
| 상태 | 뜻 | 미충족 항목인가 |
|---|---|---|
| 수행(N회) | 검토를 돌렸고 결과를 받음. 지적 0건 포함 | 아니오 |
| 해당 없음 | 그 등급의 절차에 없는 단계 | 아니오 |
| 의도적 생략 | 대상이지만 사용자가 호출을 승인하지 않았거나 검토를 끄는 스위치(환경변수, 파일, 계획서 설정)로 끔 | 아니오 |
| 미결 | 돌리려 했으나 실패했거나 중단됨 | 예 |
앞의 셋은 정상적인 종료이고 미결만 완료 보고의 미충족 항목에 오릅니다. 검토가 실패했으면 “P0 와 P1 이 0건”이라고 쓰지 않습니다. 반대로 일부러 건너뛴 검토를 실패처럼 미결로 적지도 않습니다.
다만 렌더러가 exit 2 를 내면 미결로 적으라는 규칙은 명령 문서 속 문장입니다. 렌더러를 단독으로 실행했을 때 exit 2 가 나오는 것은 확인했지만 에이전트가 그 결과를 언제나 미결로 기록한다는 것까지 실행 검사로 확인하지는 않았습니다. exit 1 을 받았을 때의 처리는 정하지 않았습니다.
이월 결정의 전제가 틀렸던 경우
렌더러를 만드는 중에 이월 결정을 한 번 되돌렸습니다. 검토 모델을 부를 때는 출력 JSON 의 형식을 정한 schema 를 함께 넘깁니다. 과거 출력 1,267건 모두에서 지적의 범위를 담는 scope 와 권장 처리를 담는 suggested_disposition 필드가 비어 있었습니다. 처음에는 출력 schema 의 required 를 고치는 일이 검토 모델의 출력 계약을 바꾸는 작업이어서 범위가 커진다고 보고 이월했습니다. 렌더러를 먼저 만들고, 새 검토를 몇 번 돌려 필드가 계속 비는지 확인한 뒤 고친다는 재검토 조건도 달았습니다.
그런데 이 작업의 diff 를 검토받으려고 검토 모델을 호출하자, API 가 schema 를 400 invalid_json_schema 로 거부했습니다. 서버 오류는 required 에 scope 가 빠졌다고 알렸습니다. 렌더러는 이 실패를 검토 실패로 분류했고 그래서 문제가 드러났습니다. 이월은 “검토는 정상적으로 돌고 있다”는 전제 위에서 내린 결정이었는데, 이 시점에는 그 schema 로 보내는 검토가 하나도 돌지 않고 있었습니다. 과거 출력은 정상 JSON 으로 남아 있으므로 거부는 그 사이 어느 시점부터 시작된 것으로 보이고, 언제부터인지는 확인하지 못했습니다. required 수정을 같은 작업에 포함했고 출력 계약을 바꾸는 작업이 됐으므로 등급도 M 에서 L 로 올렸습니다.
이 오류는 그 시점의 API 가 그 schema 에 낸 응답입니다. 지금의 API 계약을 설명하는 근거는 아닙니다. 과거 1,267건에서 두 필드가 비어 있던 이유를 400 오류와 같은 원인으로 확정한 것도 아닙니다. 이 사례는 이월에 붙인 조건이 전제까지 포함해야 한다는 점을 보여 줍니다.
6. 작업마다 검토를 얼마나 쓸까요?
작은 작업이 빨라지려면 작업마다 검토에 쓸 비용을 다르게 정해야 합니다. 계획 단계에서는 diff 크기를 아직 모르므로 변경 줄 수 대신 영향 범위로 등급을 나눴습니다.
- 건드리는 모듈이 2개 이상인가
- 다른 곳에서 참조하는 계약(공개 API, 이벤트 schema, DB schema, 설정 키, 공유 모듈)이 바뀌는가
- 되돌리려면 이 작업 밖의 파일도 같이 고쳐야 하는가
해당하는 것이 없으면 S, 하나면 M, 둘 이상이면 L 이고 애매하면 높은 쪽을 고릅니다. 등급별 절차는 다음과 같이 나눴습니다.
| S | M | L | |
|---|---|---|---|
| 계획서 | 10줄 안쪽 최소형 | 명제, 작업 항목, 검증 방법 | 전체 형식 |
| 계획 검토 | 없음 | 없음 | 1회 |
| diff 검토 | 없음 | 1회 | 1회와 조건부 재검토 |
S 등급에서도 두 가지는 남겼습니다. 계획서를 아예 없애는 안도 있었지만 구현과 제출 명령이 계획서가 있다는 전제로 동작하고 등급을 적을 자리도 사라지기 때문에 최소형으로 남겼습니다. 계획의 전제를 현재 코드로 확인하는 단계도 모든 등급에서 생략하지 않습니다. 이미 있는 구성요소를 다시 만드는 계획을 막는 단계라서 작업 크기와 무관하다고 봤습니다. 전제 확인 결과를 S 는 말로 보고하고 M 과 L 은 표로 남깁니다.
사후 대조가 입증하는 범위
이후 등급이 적힌 계획 25개(S 8개, M 11개, L 6개)를 실제 PR 과 다시 대조했습니다. 계획서 안의 첫 PR 링크가 그 계획의 PR 이 아닌 경우가 여섯 건 있었고 PR 링크가 아예 없는 계획서도 8개 있어서 PR 의 head 브랜치 이름으로 짝을 지었습니다. 세 기준을 실제 diff 에 다시 적용하자 25개 모두 원래 등급과 같았고 작업 중에 등급을 다시 정한 2건은 모두 올리는 방향이었습니다. 이번 조사에서도 25개의 PR 짝과 docs/ 를 뺀 변경 줄 수를 다시 확인했습니다.
이 결과로 말할 수 있는 범위는 좁습니다. 사후 대조 대상 기간에는 별도 모델 검토가 대부분 꺼져 있었습니다. 사용자가 검토를 끄는 스위치 파일(5장)을 만든 뒤로, 25개 가운데 실제로 별도 검토를 받은 계획은 2개뿐입니다. 그래서 “등급에 따라 검토 횟수를 나눈 것이 품질과 비용을 잘 배분했는가”는 대조 대상에서 빠졌고, 남은 질문은 “세 기준을 실제 diff 에 다시 적용해도 같은 등급이 나오는가” 하나였습니다. 등급을 매긴 쪽과 다시 판정한 쪽도 같은 에이전트였습니다. 25개 일치는 이번 대조에서 불일치를 찾지 못했다는 결과까지만 보여 줍니다.
변경 줄 수와 영향 범위가 따로 움직인다는 점은 이 표에서 확인됩니다. M 등급은 변경이 0줄에서 617줄까지, L 등급은 115줄에서 1,228줄까지 걸쳐 있어 두 범위가 겹칩니다. 서비스 하나의 테스트를 617줄 바꾼 작업은 M 이었고 k8s base 설정과 overlay 를 함께 바꾼 115줄 작업은 L 이었습니다. GitHub 브랜치 보호 설정을 바꾼 작업은 저장소 안의 코드 변경이 0줄이지만 필수 체크라는 계약을 바꾸므로 M 이었습니다. 그래서 줄 수 threshold 로 등급을 자동 판정하는 검사는 만들지 않았습니다. 다만 영향 범위를 스크립트로 판정하는 것이 원리상 불가능하다는 데까지 확인한 것은 아닙니다. 앱 모듈이 없는 CI, k8s, 하네스 작업에서 “모듈”을 어떻게 셀지도 정의가 모호하게 남아 있습니다.
7. 절차를 줄이면 무엇이 같이 사라질까요?
상태 기록 장치를 걷어 냈습니다
검토 규칙을 바꾸기 전에 먼저 줄인 것은 명령마다 붙어 있던 상태 기록 장치였습니다. 계획과 구현 명령에는 lock 디렉터리, state.json, gate 이벤트 TSV, metrics TSV, 실행 결과를 나중에 확정하는 pending_run 을 다루는 12단계 상태 머신이 있었습니다. 이를 걷어 내고 진행 상황은 계획서의 체크박스와 검토 기록 파일, git 이력으로 남기도록 바꿨습니다. diff 를 세 조각으로 나눠 검토하던 절차도 이때 없앴습니다.
이 결정을 설명한 커밋 메시지에는 상태 머신이 “실행당 Bash 왕복 10~15회를 쓰면서 결함 발견은 0건”이었다고 적었습니다. 이 두 값은 원래 계측 파일이나 실행별 호출 기록이 남아 있지 않아 다시 확인하지 못했습니다. 결함을 찾은 기록이 없다는 것이 그 장치가 효과가 없었다는 증명도 아닙니다. 예를 들어 lock 이 동시 실행을 막은 경우는 결함 발견으로 기록되지 않습니다.
순서에서 생긴 문제도 있었습니다. 계획과 구현 명령에서 state.json 을 먼저 없앴는데 제출 명령은 여전히 그 파일을 필수로 요구했습니다. 그래서 정상적인 흐름에서도 제출 첫 단계가 state 파일이 없으니 구현 명령부터 끝내라는 안내로 멈췄습니다. 한 작업에서는 해당 단계를 건너뛰고 진행했습니다. 제출 명령의 11단계 상태 머신은 그 뒤에 따로 걷어 냈습니다. 마지막으로 이 축소로 쓰이지 않게 된 공용 shell 함수를 정리하면서 함수 정의가 61개에서 10개로 줄었습니다. 이번에 두 시점의 함수 정의 이름 집합을 비교해 51개가 빠진 것을 다시 계산했습니다. 이 값은 정의 개수의 차이이고 파일 수나 기능 수와는 다릅니다.
진행 상태를 git 과 GitHub 에서 읽습니다
state.json 이 사라진 뒤 제출 명령은 중단된 작업을 어디서 재개할지 git 과 GitHub CLI(gh)의 결과로 정합니다. 현재 함수에서 해당 부분을 발췌하면 다음과 같습니다.
if [ -z "$(git ls-remote --heads origin "$branch" 2>/dev/null)" ]; then
printf '6\n원격에 브랜치가 없다 (미push)\n'; return 0
fi
local pr
pr="$(gh pr list --head "$branch" --state open --json url -q '.[0].url' 2>/dev/null)"
if [ -z "$pr" ]; then
printf '7\n열린 PR 이 없다\n'; return 0
fi
커밋되지 않은 변경이 있으면 3, 원격 브랜치가 없으면 6, 열린 PR 이 없으면 7, 작업 목록에 PR 링크가 없으면 8, 모두 있으면 0 을 냅니다. 이번 조사에서 git 과 gh 를 대신하는 stub 함수로 다섯 정상 상태를 만들어 보니 각각 3, 6, 7, 8, 0 이 나왔습니다.
같은 stub 으로 git ls-remote 가 exit 128 로 실패하는 경우와 gh 가 exit 1 로 실패하는 경우도 만들었습니다. 결과는 각각 6 과 7 이었습니다. stderr 를 버리고 출력이 비었는지만 보기 때문에, 원격 조회가 실패한 것과 브랜치나 PR 이 없는 것이 같은 결과가 됩니다. 이 결과를 받은 에이전트는 이미 push 한 브랜치를 다시 push 하거나 PR 을 새로 만들려고 할 수 있습니다. 이 실험은 셸 함수 stub 으로 판정 함수의 입력만 바꾼 것이고, 실제 네트워크 장애를 일으키거나 그 뒤의 push 와 PR 생성을 실행해 보지는 않았습니다.
이 축소에서 보존된 것과 사라진 것을 나누면 다음과 같습니다.
| 보존 | 사라짐 |
|---|---|
| 변경 내용과 커밋 이력(git) | 같은 작업의 동시 실행을 막던 lock |
| 원격 브랜치와 PR 의 존재(조회가 성공했을 때) | 조회 실패와 부재의 구분 |
| 계획서 체크박스의 진행 상황 | 실행별 상태 기록과 metrics |
| 검토 결과와 처리 기록(검토 기록 파일) | 실행 결과를 나중에 확정하던 단계 |
git 이력은 변경을 보존하지만 같은 작업을 두 세션에서 동시에 실행하는 것을 막지는 않습니다. 상태를 다른 정보에서 읽도록 바꾸면 그 정보를 읽는 조회가 성공했는지도 따로 확인해야 합니다.
문서 크기는 무엇을 보여 줄까요?
명령 문서에서는 규칙만 남기고 규칙의 이유와 사건 이력을 별도 근거 문서로 옮겼습니다. 명령 문서는 호출할 때마다 컨텍스트에 실리므로 그 분량을 줄이려는 목적이었습니다. 이후 계획과 구현 명령에 각각 있던 모델 검토 규칙을 공통 문서 하나로 합쳤습니다.
| 변경 | 비교한 파일 | 변경 전 | 변경 후 |
|---|---|---|---|
| 근거 분리 | 계획, 구현, 제출 명령 3개 | 46,556B | 43,219B (약 7.17% 감소) |
| 근거 분리 | 위 3개와 새 근거 문서 | 46,556B | 50,248B |
| 공통 규칙 분리 | 계획, 구현 명령 2개 | 29,995B | 20,561B (약 31.45% 감소) |
| 공통 규칙 분리 | 위 2개와 새 공통 규칙 문서 | 29,995B | 27,583B (약 8.04% 감소) |
모두 UTF-8 파일 크기이고 두 변경은 비교한 파일 범위가 다릅니다. 공통 규칙 분리에서는 근거 문서도 446B 늘었는데 표에는 넣지 않았습니다. 새로 만든 문서를 넣으면 근거 분리는 전체 크기가 오히려 늘었습니다. 공통 규칙 문서는 검토 모델을 호출할 때 읽고 근거 문서는 규칙을 바꿀 때 읽으므로 실제로 모델에 들어간 입력이 얼마나 줄었는지는 이 숫자로 알 수 없습니다. 토큰, 시간, 비용으로 환산하지 않았습니다.
옮기면서 내용이 빠지지 않았는지도 확인했습니다. 두 변경에서 헤딩 목록은 그대로였고 호출 허가와 실패 처리 규칙, severity 기본값, 이월의 정의, 두 번째 검토의 질문, 검토 상태 네 가지가 새 문서에 있었으며 등급별 분기는 명령에 남아 있었습니다. 줄 단위 출현 횟수로 비교하면 같은 근거를 한 곳으로 모은 줄이 “부족”으로 나오는데 의도한 병합인지 사람이 다시 대조해야 했습니다. 근거 문서의 일부 절은 문장 중간부터 옮겨져 있어 원래 명령의 앞뒤 문맥 없이는 읽기 어렵습니다. 문서를 옮기거나 합칠 때 이런 보존을 자동으로 확인하는 검사는 아직 없습니다.
8. 규칙을 적은 것과 검사가 도는 것은 어디서 갈릴까요?
절차를 줄이면서 일부 규칙은 문서 문장에서 실행 검사로 옮겼습니다. 옮긴 뒤에도 같은 질문이 남습니다. 그 검사는 어떤 입력을 보고, 실제로 어디서 호출되고, 계속 실행되는가입니다.
같은 문체 검사라도 입력마다 다릅니다
커밋 메시지와 PR 문체를 검사하는 스크립트는 입력에 따라 다르게 동작합니다. 이번 조사에서 합성 입력으로 실행한 결과, 정상 PR 본문은 exit 0, 경어체가 아닌 본문은 exit 1 이었습니다. 같은 추적 태그가 커밋 메시지에 있으면 경고만 내고 exit 0 이지만 PR 제목에 있으면 exit 1 입니다. 코드 펜스 안의 출력 인용은 검사에서 제외됩니다. 모두 입력별로 의도한 차이입니다.
커밋 메시지가 검사를 통과했다고 PR 본문까지 검사된 것은 아닙니다. PR 제목 검사와 커밋 메시지 hook 도 각각 다른 변경에서 따로 추가했습니다. 그리고 이 문체 검사는 지금 CI 에서 호출되지 않습니다. 제출 명령 안에서 에이전트가 실행할 때만 동작합니다.
CI 에 연결해도 검사하지 않는 부분이 남습니다
렌더러 self-test 는 CI 에 연결되지 않은 스크립트를 찾아 lint job 에 넣는 작업에서 CI 에 들어갔습니다. 그 뒤로는 변경마다 실행됩니다. 하지만 5장에서 본 것처럼 실제 검토 이력을 다시 돌리는 항목은 CI 에서 항상 건너뜁니다. 연결된 검사가 무엇을 건너뛰는지도 결과의 일부로 봐야 합니다. 검사 스크립트 자체가 지워지거나 호출이 빠졌을 때 이를 알려 줄 장치도 아직 없습니다.
표시된 숫자가 복구로 이어졌습니다
완료된 작업 행을 별도 파일로 옮기는 절차를 만든 직후, 그 다음 작업의 완료를 반영하는 커밋이 완료 파일을 덮어썼습니다. 67,553B, 59줄이던 파일이 875B, 2줄로 줄었고 방금 끝낸 작업의 행 하나만 남았습니다. 복구한 뒤에는 그 행이 더해져 68,427B, 60줄이 됐습니다.
완료 확인 단계에서 7장의 재개 판정 함수는 0, 즉 “완료”를 출력했습니다. 방금 끝낸 작업의 PR 링크가 남아 있었으므로 함수의 조건은 모두 맞았습니다. 같은 출력에서 작업 목록의 완료 행과 진행 중인 행을 세어 보여 주는 요약 도구가 “완료 표기 행 1”을 표시했습니다. 그 출력 직후 에이전트가 main 의 내용에 새 행을 붙여 복구했고, 덮어쓴 사실은 저도 알고 있었습니다.
이 사건에서는 숫자를 보여 주는 기능이 문제를 드러냈고 검사 옵션은 그때 실행하지 않았습니다. 그 요약 도구의 검사 옵션은 사건 뒤에 앞선 조사에서 따로 실행해 봤는데 읽을 수 있는 빈 완료 파일과 중복 행이 있는 파일을 모두 정상(exit 0)으로 처리했습니다. 파일이 없을 때만 exit 3 이었습니다. 이 결과는 앞선 조사에서 넘겨받은 것이고 이번에 같은 실험을 다시 돌리지는 않았습니다. 덮어쓴 파일도 행 하나가 남은 읽을 수 있는 파일이었으므로, 검사 옵션을 실행했더라도 오류로 보고하지는 않았을 것으로 봅니다. 표시된 숫자를 누군가 보지 않았다면 복구로 이어지지 않았을 수 있습니다.
정리하면 규칙이 실제로 지켜지는지는 네 단계로 나눠 확인해야 했습니다. 문서에 적은 규칙, 그 규칙을 실행하는 검사, 그 검사를 실제로 부르는 호출 지점, 그리고 그 호출이 CI 처럼 매번 실행되는 곳에 연결됐는지입니다. 한 단계가 갖춰졌다고 다음 단계가 따라오지는 않았습니다.
지금 실행 경로에는 별도 검토가 없습니다
여기까지의 검토 규칙은 Claude Code 명령으로 작업할 때의 절차입니다. 이후 검토 모델로 쓰던 도구를 작업 에이전트로도 쓰게 되면서 그 에이전트가 읽는 작업 지침(skill)을 같은 저장소에 따로 만들었습니다. 이 skill 들은 명령 문서의 별도 모델 검토 단계를 적용하지 않습니다. 검토 상태에는 별도 검토를 호출하지 않았다고 적고 지적 0건이나 외부 검토 통과를 주장하지 않도록 했습니다. 지금 이 프로젝트에서 별도 모델 검토가 몇 번 돌았는지는 앞의 규칙만으로는 알 수 없고 어느 실행 경로로 작업했는지를 같이 봐야 합니다.
9. 더 좋은 방법은 없었을까요?
여기부터는 작업을 마친 뒤 덧붙이는 사후 분석입니다. 규칙을 만들 때는 다른 조직이 같은 문제를 어떻게 다뤘는지 찾아보지 않았습니다. 아래 사례는 모두 여러 사람이 함께 쓰는 리뷰 도구이고 규모와 측정 방식이 이 프로젝트와 다르므로 그쪽 숫자를 이 글의 숫자와 비교하지 않았습니다. 이 방식들을 하네스에 적용했다면 실제로 더 나았을지도 확인하지 않았습니다.
지적의 쓸모를 개발자의 행동으로 셌다면
Google 은 코드 리뷰에 정적 분석 결과를 띄우면서 개발자가 지적을 보고 아무 조치도 하지 않은 경우를 “effective false positive”로 셉니다(Lessons from Building Static Analysis Tools at Google). 분석이 틀렸더라도 개발자가 코드를 고쳤다면 여기에 들지 않고, 실제 결함이어도 개발자가 이해하지 못해 그냥 넘겼다면 여기에 듭니다. 그래서 이 비율은 이 글의 기각보다 넓습니다. 코드 리뷰에 띄우는 검사는 이 비율이 10% 미만이어야 합니다. 이와 별도로 개발자가 “Not useful” 을 누른 비율이 10% 를 넘으면 그 분석기를 끄고, 작성자가 고친 뒤에 다시 켭니다. 이 기준은 정적 분석 도구에 쓰는 것입니다.
이 하네스는 지적이 쓸모 있었는지를 따로 세지 않았습니다. 4장의 442건과 307건을 다시 계산할 수 없었던 것도 지적과 처리 결과를 같은 식별자로 잇지 않았기 때문입니다. 지적마다 반영, 이월, 기각을 같은 식별자로 남겼다면 어떤 종류의 지적이 계속 기각되거나 이월되는지 셀 수 있었을 것이고, 그 숫자를 보고 프롬프트나 severity 기본값을 바꿀 수 있었을 것입니다.
지금 고치지 않을 지적을 검토하는 쪽에서 표시했다면
Google 의 코드 리뷰 지침은 리뷰어에게 코멘트의 심각도를 표시하라고 권합니다(How to write code review comments). Nit 는 사소하지만 하는 편이 좋은 것, Optional 은 좋은 생각이지만 필수는 아닌 것, FYI 는 이번 변경에서 하길 기대하지 않는 것입니다. FYI 는 이 글의 이월과 가깝습니다. 다만 이 하네스에서는 지적을 받은 쪽이 나중에 처리를 정하고, 이 지침에서는 리뷰어가 코멘트를 남길 때 표시합니다.
Uber 의 AI 리뷰 도구 uReview 는 코멘트를 보여 주기 전에 거릅니다(uReview: Scalable, Trustworthy GenAI for Code Review at Uber). 코멘트마다 확신도를 따로 매겨 낮은 것을 버리고, 비슷한 코멘트는 합치고, 범주를 붙여 개발자 평가가 계속 낮았던 범주를 보여 주지 않습니다. 가독성 nit, 사소한 로그 수정, 영향이 작은 성능 개선, 스타일이 그런 범주였습니다.
P2 의 이월 기본값도 낮은 등급의 지적을 지금 할 일에서 빼는 장치입니다. 그러나 어떤 범주의 지적이 쓸모없었는지 따로 남기지 않아서 범주를 정해 거를 근거도 없었습니다.
재검토를 같은 지적이 다시 나오는지로 확인했다면
uReview 는 코멘트가 해결됐는지를 최종 커밋에 리뷰를 다섯 번 다시 돌려 가립니다. 다시 돌린 결과 가운데 의미가 비슷한 코멘트가 하나도 없으면 해결된 것으로 봅니다. 제 하네스의 두 번째 검토는 반영한 수정 목록을 주고 각 수정이 지적을 해소했는지 모델에게 직접 묻습니다. 제가 보기에 같은 지적이 다시 나오는지 보는 방식은 검토 결과가 실행마다 달라지는 것을 전제로 합니다. 그래서 한 번의 실행에서 지적이 사라진 것을 해소로 읽지 않습니다.
다만 이 방식은 변경마다 검토를 여러 번 돌려야 합니다. 하네스를 줄인 이유가 작은 작업에도 붙는 검토 비용이었으므로 그대로 가져오기는 어렵습니다. 제 생각으로는 되돌리기 어려운 L 등급 변경에서만 반영한 항목에 같은 지적이 다시 나오는지 따로 확인하는 정도가 맞았을 것입니다.
검토 깊이를 변경에 맞춰 바꿨다면
Anthropic 의 Claude Code 코드 리뷰는 크거나 복잡한 변경에 에이전트를 더 붙여 깊게 읽고 사소한 변경은 가볍게 봅니다(Bringing Code Review to Claude Code). 찾은 버그를 검증해 오탐을 거른 뒤 심각도 순으로 정렬하고, PR 승인은 사람이 합니다. 변경에 따라 검토량을 바꾸는 점은 6장의 등급과 같은 생각입니다. 차이는 판정 시점입니다. 이 하네스는 diff 크기를 모르는 계획 단계에서 영향 범위로 등급을 정하고, 그 도구는 실제 변경을 보고 검토 깊이를 정합니다.
그 도구는 찾은 지적을 사용자에게 보여 주기 전에 한 번 더 검증합니다. 이 프로젝트에서는 지적이 사실인지를 지적을 받은 쪽이 확인하고 기각할 때만 근거를 다시 봅니다. 검증을 검토 단계 안에 두면 반영과 이월을 정하기 전에 사실이 아닌 지적 일부를 먼저 거를 수 있습니다. 그 검증이 이 프로젝트의 지적에서 얼마나 걸러 냈을지는 알 수 없습니다.
Google 의 정적 분석과 uReview 는 지적이 어떻게 처리됐는지를 기록하고 그 기록으로 거르는 기준을 고쳤습니다. 이 하네스는 지적별 처리 결과를 남기지 않은 채 규칙부터 정했습니다.
10. 처음 질문으로 돌아가면
AI 검토를 더하는 대신, 어떤 지적을 지금 처리하고 언제 멈출지 어떻게 정할까?
이번 작업에서 정한 기준을 다른 프로젝트에도 옮길 수 있는 형태로 정리하면 다음과 같습니다.
| 질문 | 정한 기준 | 남은 조건 |
|---|---|---|
| 결과를 받았는가 | 정상 결과, 지적 0건, 실행 실패, 건너뜀을 다른 상태로 기록 | 렌더러는 배열 안 항목을 검사하지 않고, null 항목은 실패 코드 대신 exit 1 |
| 이 지적을 지금 고칠까 | 사실 여부, 이번 변경이 만든 문제인지, 미루면 무엇이 깨지는지를 따로 묻고 반영, 이월, 기각 중 선택 | 이월의 해소율과 이월로 놓친 결함은 측정하지 않음 |
| 무엇을 다시 검토할까 | 전체 재탐색 대신 반영한 수정 목록을 기준으로 해소 여부와 회귀만 확인 | 환불 사례의 재검토는 diff 전체를 본 회차라, 좁힌 질문의 효과는 확인하지 못함 |
| 언제 멈출까 | P1 0건 대신 회차 상한과 예산으로 종료하고, 넘길 때는 이유와 확인을 남김 | 지금 규칙은 환불 사례의 두 결함을 재검토하지 않거나 이월함 |
| 작업마다 얼마나 쓸까 | 줄 수 대신 모듈, 공유 계약, 되돌림 범위로 등급을 나누고 S 에도 전제 확인은 유지 | 실제 검토를 받은 계획은 2개뿐이라 배분의 효과는 평가하지 못함 |
| 절차를 줄이면 무엇이 사라질까 | 보존한 기능과 사라진 기능을 나눠 적음 | lock 이 막던 동시 실행, 조회 실패와 부재의 구분 |
| 규칙이 실제로 도는가 | 문서, 검사, 호출 지점, CI 연결을 따로 확인 | 문체 검사는 CI 밖, 완료 파일 검사는 빈 파일과 중복을 통과시킴 |
절차를 줄인 결과로 지금 확인할 수 있는 것은 정책과 구현이 어떻게 바뀌었는지, 문서 파일이 몇 바이트 줄었는지, 등급 기준을 실제 diff 에 다시 적용해도 같은 등급이 나왔는지입니다. 이 자료로는 절차를 줄여서 품질이 좋아졌다거나 토큰과 시간을 얼마나 아꼈다고 말할 수 없습니다. 작은 작업에 절차가 과하다는 것은 제 경험에서 출발한 문제의식이고 그 경험을 수치로 바꾸는 측정은 아직 하지 않았습니다.
규칙의 근거로 쓴 숫자도 다시 봐야 했습니다. 서로 다른 표기를 각각 센 442건과 307건, 실패한 시도와 본문 언급이 섞인 20회, 열거와 맞지 않는 10건은 개별 사례가 보여 주는 것보다 큰 결론의 근거로 쓰였습니다. 검토 실행과 지적, 중복, 처리 결과를 같은 식별자로 잇는 기록을 남기지 않는 한, 이런 비율은 다시 계산할 수 없습니다.
아직 고치지 않은 문제도 있습니다. 렌더러가 배열 안 항목을 검증하고 예외를 실패 코드로 처리하도록 바꾸는 일, 규칙 근거 문서와 렌더러 주석에 남은 정정 전 수치를 고치는 일, 재개 판정 함수가 조회 실패를 부재와 구분하도록 하는 일이 남아 있습니다. 검사기가 CI 에 계속 연결돼 있는지 확인하는 장치, 문서를 옮기거나 합칠 때 내용 보존을 확인하는 검사, 빈 완료 파일과 중복 행을 실패로 처리하는 검사도 아직 없습니다.
참고
- 환불 실행 원장과 복구 엔진 PR
- Gateway 서명 내부 토큰 PR
- CQRS 로컬 캐시와 Redis fail-open PR
- 리뷰 루프 범위 증가 차단과 작업 등급 도입 PR
- 검토 JSON 렌더러와 실패 응답 판별 PR
- 부하 재측정과 하네스 개선이 함께 들어간 PR, PR 제목 추적 태그 검사가 들어간 PR, 커밋 메시지 문체 hook PR
- 명령 근거 서술의 별도 문서 분리 PR
- 등급 판정 기준의 사후 검증 PR
- 미배선 스크립트의 lint job 편입 PR
- 완료 항목 아카이브와 완료 행 이동 절차 PR, 완료 파일을 덮어쓰고 복구한 PR
- 계획과 구현 공통 검토 규칙의 단일 문서화 PR
- 이 하네스를 처음 만든 과정