
적대적 리뷰는 지적 사항을 늘리지 않는다. 7개의 버그를 심은 PR로 3가지 조건을 비교했다
요약
코드 리뷰 시 '적대적 프레이밍(Adversarial Framing)'을 적용했을 때의 버그 검출률을 실험했습니다. 7개의 의도적인 결함을 심은 PR을 대상으로 Claude Code의 Codex 플러그인을 활용해 일반 리뷰와 적대적 리뷰의 성능을 비교했습니다.
핵심 포인트
- 적대적 프레이밍은 리뷰어에게 결함이 있다고 가정하게 하여 더 깊은 분석을 유도함
- 테스트를 통과하지만 실무에서 문제가 되는 버그를 검출하는 것이 핵심
- Claude Code의 Codex 플러그인을 통해 별도 프롬프트 없이도 적대적 리뷰 가능
- 단순 지적 건수보다 실제 결함 검출률을 측정하는 것이 중요함
「리뷰를 다른 컨텍스트로 분리하여, 『당신은 이것을 작성하지 않은 회의적인 리뷰어다. 결함이 있다고 가정하고 찾아라』라고 적대적 프레이밍 (Adversarial Framing)을 부여하면, 자기자찬으로 끝나지 않는 지적 사항이 돌아온다」.
r/ClaudeAI 의 그러한 게시물이 upvote 860 개를 넘어 지지를 받고 있습니다 (2026년 8월 기준).
저도 비슷한 발상으로, 리뷰를 부탁할 때의 프롬프트 (Prompt)를 조금씩 강화해 왔습니다.
엄격하게 말하면 그만큼 발견될 것이라는 감각입니다.
다만, 이를 확인하려고 하면 「지적 사항이 몇 건 돌아왔는가」만으로는 부족합니다.
건수는 부풀릴 수 있기 때문입니다.
그래서, 미리 결함 7개를 심어둔 Pull Request (PR)를 준비하여, 검출률로 측정했습니다. 조건은 3가지입니다.
결론부터 말씀드리면, 가장 엄격하게 설정한 조건이 가장 많이 놓쳤습니다.
소재로는 TypeScript + Hono + node:sqlite를 사용한 태스크 관리 API를 사용했습니다.
검증용으로 배치해 둔 샌드박스 (Sandbox) 리포지토리입니다.
리뷰 대상은 태그 지정, 페이지네이션 (Pagination), 태스크 공유를 추가하는 기능 추가 PR입니다.
규모는 7 files changed, 421 insertions(+), 4 deletions(-)로, 리뷰가 표면적이 될 만큼 크지도 않고, 차이가 나지 않을 정도로 작지도 않은 수준을 목표로 했습니다.
이 diff (차이)에 실무에서 흔히 발생하는 결함을 7개 심었습니다.
| # | 결함 | 중요도 |
|---|---|---|
| 1 | 인가 체크 누락 (타인의 태스크를 ID 지정으로 읽을 수 있음) | critical |
| ... | high | |
| 4 | 에러 무시 (빈 catch 문으로 태그 취득을 뭉개버림) | medium |
| 5 | limit의 미클램핑 (NaN · 상한 없음) | medium |
| 6 | 경쟁 상태 (Race Condition) (공유 추가 시의 check-then-act) | medium |
| 7 | N+1 쿼리 (N+1 Query) | low |
심을 때 가장 신경 쓴 점은 테스트가 통과하도록 만드는 것이었습니다.
npm test는 12건 모두 green입니다.
CI가 빨간색(실패)이 되는 버그는 리뷰 이전에 해결해야 할 문제이며, 실제로 문제가 되는 것은 「테스트를 통과하여 남는 버그」이기에 그쪽에 맞췄습니다.
공정성을 위해 수행한 것은 다음 3가지입니다.
- 구현과 버그 삽입은 별도의 서브 에이전트 (Sub-agent)에게 위임하였고, 정답 리스트는 3가지 조건 모두의 리뷰가 끝날 때까지 저 자신도 열어보지 않았습니다.
- 3가지 조건 모두 입력은 동일한 diff뿐이며, 정답 리스트는 어떤 조건의 컨텍스트에도 넣지 않았습니다.
- 보고 기준 ("실질적인 문제만. 스타일이나 명명 규칙은 보고하지 않는다")과 출력 형식을 모든 조건에서 통일했습니다.
심어놓은 결함은 「리뷰에서 발견될 수 있는 형태」로 만든 것입니다.
현실의 버그 분포와는 다릅니다.
여기서 나오는 검출률은 조건 간의 비교에 사용하는 수치로 읽어주시기 바랍니다.
비교한 것은 다음 3가지입니다.
| 조건 | 실행하는 것 | 역할 |
|---|---|---|
| ① | Claude의 서브 에이전트 (Opus 5) | 일반적인 코드 리뷰 |
| ② | /codex:review | 순수한 별도 LLM 리뷰 |
| ③ | /codex:adversarial-review | 적대적 프레이밍 |
②와 ③은 Claude Code의 공식 Codex 플러그인에 포함된 명령어입니다.
직접 적대적 프롬프트를 작성하지 않아도, 순수한 상태와 적대적인 상태 모두 처음부터 준비되어 있습니다. 이것을 깨달은 것이 이번의 출발점이었습니다.
이번에는 대화 외부에서 실행하고 싶었기 때문에, 플러그인에 동봉된 스크립트를 직접 호출하고 있습니다.
# 순수한 리뷰
node "$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs" review \
--base main --scope branch --wait
...
--base main --scope branch로 「main과의 차이점을 리뷰한다」는 지정이 되며, --wait로 포그라운드 (Foreground) 실행이 됩니다.
평소 사용 시에는 /codex:review와 /codex:adversarial-review를 그대로 입력하면 동일하게 수행할 수 있습니다.
조건 ①은 ②, ③과 입도(Granularity)를 맞추기 위해 적대적인 문구를 하나도 넣지 않은 소박한 의뢰로 설정했습니다.
「이 PR을 리뷰해 주세요」에 보고 기준과 출력 형식을 지정 사항으로 추가했을 뿐입니다.
여기에 「엄격하게 봐줘」라고 써버리면, 비교하고자 하는 대상 자체가 섞이게 됩니다.
3가지 조건을 동일한 PR에 적용한 결과입니다.
| 조건 | 총 지적 사항 | 심은 버그 7건 검출 | 심지 않은 실제 지적 | 소요 시간 |
|---|---|---|---|---|
| ① 일반 리뷰 | 10건 | 7/7 | 3건 | 5분 37초 |
| ② 순수 Codex | 8건 | 6/7 | 2건 | 5분 13초 |
| ③ 적대적 Codex | 4건 | 3/7 | 1건 | 2분 31초 |
적대적 프레이밍 (Adversarial Framing)을 추가한 조건이 지적 수와 검출률 모두에서 최하위였습니다.
소요 시간도 절반 이하입니다.
다만, 심각도 (Severity)로 나누면 양상이 달라집니다.
| 심각도 | 심은 개수 | ① 일반 | ② 순수 | ③ 적대적 |
|---|---|---|---|---|
| critical | 1 | 1/1 | 1/1 | 1/1 |
| ... |
적대적 리뷰는 critical과 high를 놓치지 않았습니다. 놓친 것은 medium 이하뿐입니다. 찾지 못했다기보다는, 보고하는 기준을 높이고 있다는 동작으로 보입니다.
③가 놓친 4건은 묵인된 사항, limit의 미클램핑 (unclamped), N+1, 경합 상태 (Race Condition)였습니다.
이 중 limit 건은 독립된 지적 사항으로는 올라오지 않았으나, off-by-one 수정 제안 과정에서 "page와 limit을 양의 정수로 검증해 주세요"라고 언급되었습니다.
보이기는 했으나, 독립된 지적으로 제기할 정도는 아니라고 판단한 것으로 읽힙니다.
예상치 못했던 점은, 3가지 조건 모두 제가 심지 않은 진짜 버그를 찾아냈다는 것입니다.
task_tags와 task_shares의 외래 키 (Foreign Key)에 ON DELETE CASCADE가 없어, 태그를 붙인 태스크나 공유한 태스크를 삭제하면 외래 키 제약 조건으로 인해 500 에러가 발생하는 문제입니다.
기존의 삭제 API가 새로운 기능 추가로 인해 망가지는 리그레션 (Regression)에 해당합니다.
구현을 담당한 서브 에이전트가 자연스럽게 저지른 실수로, 정답 리스트에도 포함되어 있지 않았습니다.
②는 여기에 더해 "자기 자신에게 태스크 공유가 201로 통과됨"을, ①은 "Number(true) === 1이므로 {"user_id": true}가 ID 1로의 공유로서 성립함", "추가된 테스트의 어서션 (Assertion)이 느슨하여 off-by-one을 검출할 수 없음"을 꼽았습니다.
마지막 항목은 심어둔 결함 #3이 CI를 통과한 이유 그 자체입니다.
지적 내용뿐만 아니라, 그 이면의 접근 방식에서도 차이가 있었습니다.
3가지 조건 모두 diff를 읽는 것만으로 끝내지 않았습니다.
조건 ①은 다른 사용자의 토큰으로 API를 호출하여, 타인의 태스크가 200으로 반환되는 것까지 직접 시연해 보였습니다.
?limit=abc로 500이 되는 것, {"user_id": true}가 통과되는 것.
둘 다 실행해서 확인한 뒤에 보고하고 있습니다.
조건 ②가 실행한 것은 npm test와 npm run typecheck, 그리고 node --import tsx로 구축한 검증용 데이터베이스였습니다.
조건 ③은 node -e로 DatabaseSync를 기동하여 외래 키 제약 조건이 기본적으로 활성화되어 있는지 확인했습니다.
흥미로웠던 점은 ③의 중간 과정입니다.
첫 번째 패스에서는 approve (문제 없음)라고 판정했으나, 조사를 진행하면서 needs-attention으로 뒤집었습니다.
한 번 읽고 끝내는 것이 아니라, 자신의 초기 판단을 깨뜨리려 노력했습니다.
이만큼 손을 움직이고도 보고가 4건뿐이었던 이유는 프롬프트를 보면 알 수 있습니다.
적대적 리뷰의 프롬프트는 플러그인 안에 마크다운 (Markdown) 파일로 들어 있습니다.
<calibration_rules>
Prefer one strong finding over several weak ones.
Do not dilute serious issues with filler.
...
"약한 지적을 여러 개 늘어놓기보다, 강한 지적을 하나", "심각한 문제를 군더더기로 희석하지 마라".
보고 수를 압축하라는 지시가 명확하게 적혀 있습니다.
같은 파일에는 공격 대상의 우선순위도 지정되어 있습니다.
<attack_surface>
비용이 많이 들거나, 위험하거나, 탐지하기 어려운 유형의 실패에 우선순위를 두십시오:
- 인증 (auth), 권한 (permissions), 테넌트 격리 (tenant isolation), 그리고 신뢰 경계 (trust boundaries)
...
인증 (auth), 데이터 파괴, 경합 상태 (race conditions)와 같이 비용이 많이 들고 발견하기 어려운 실패에 리소스를 집중하는 설계입니다.
이번에 critical과 high가 만점을 받은 것은, 이러한 우선순위가 그대로 작용한 결과라고 생각합니다.
즉, 적대적 프레이밍 (adversarial framing)이 하고 있는 것은 탐색 범위를 넓히는 것이 아니라, 보고 기준을 높이는 것이었습니다.
엄격하게 하면 더 많이 나올 것이라는 저의 원래 이해는 정반대였던 셈입니다.
한 가지 설명되지 않는 점은 ③이 경합 상태 (race conditions)를 놓쳤다는 것입니다.
race conditions는 공격 표면 (attack surface)에 명시적으로 언급되어 있음에도 불구하고, 공유 추가의 check-then-act (존재를 확인한 후 삽입하기 전까지 다른 요청이 끼어들 수 있는 구조)는 보고되지 않았습니다.
3가지 조건 중에서 찾아낸 것은 ①뿐이었으며, 심지어 심각도 (severity)는 low 판정이었습니다.
공격 표면에 언급하는 것과 실제로 찾아내는 것은 별개라는 의미일지도 모릅니다.
숫자가 깔끔하게 나온 만큼, 조건을 적어둡니다.
①과 ②③은 모델이 다릅니다.
①은 Opus 5, ②③은 Codex CLI (gpt-5 계열)입니다.
①이 가장 많았다는 것을
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기