"승인(Approved)"은 리뷰의 증거가 아닙니다 — 실제 증거는 다음과 같습니다
요약
AI 생성 코드가 급증함에 따라 형식적인 코드 리뷰(Rubber-stamping) 문제가 심화되고 있습니다. LineageLens는 리뷰 시간, 리뷰 라인 수, 코멘트 수 등의 행동 신호를 분석하여 리뷰의 실제 깊이를 측정하는 솔루션을 제안합니다.
핵심 포인트
- AI 생성 코드는 문법적으로는 완벽해 보이지만 의도와 다를 수 있어 정밀한 리뷰가 필수적임
- 단순한 'Approved' 상태는 실제 코드 검토 여부를 보장하지 못하는 구조적 한계가 있음
- LineageLens는 리뷰 시간과 코멘트 수를 활용한 'depth_signal'로 리뷰 품질을 정량화함
- 라인당 소요 시간(time-per-line)은 리뷰의 진정성을 판단하는 가장 중요한 지표임
AI 코딩 도구를 사용하는 팀에서 끊임없이 발생하는 시나리오가 있습니다.
한 개발자가 스탠드업(standup) 미팅 전에 업무를 마무리하고 있습니다. GitHub에는 18시간 동안 열려 있는 PR(Pull Request)이 표시됩니다 — src/routes/auth.py에 340줄이 추가되었으며, 대부분 Copilot이 생성한 AI 생성 코드입니다. 개발자는 보조 모니터로 이를 열고, 약 4초 만에 끝까지 스크롤한 뒤 'Approve(승인)'를 클릭합니다. "괜찮아 보이네." PR이 머지(merge)됩니다. 상태: approved.
2주 후 누군가 묻습니다: 이 인증(auth) 리팩토링이 실제로 리뷰되었나요?
당신의 스택에 있는 모든 시스템에 저장된 답변은 '예'입니다.
진짜 답변은 '의미 있는 수준에서 이루어지지 않았다'는 것입니다.
"승인(approved)"과 "리뷰(reviewed)" 사이의 간극
이 문제는 행동의 문제가 아니라 구조적인 문제입니다. 대부분의 팀은 태만한 개발자를 보유하고 있는 것이 아닙니다. 너무 많은 PR, 언뜻 보기에 구문적으로 올바르게 보이는 너무 많은 AI 생성 디프(diffs), 그리고 30초짜리 형식적인 승인(rubber-stamp)과 20분간의 라인별 리뷰(line-by-line review)를 구분할 시스템이 없는 것이 문제입니다. 두 경우 모두 동일한 approved 상태를 생성합니다.
AI가 생성하지 않은 코드의 경우, 이것은 항상 문제였습니다. 하지만 AI가 생성한 코드의 경우, 이는 실질적으로 다른 문제입니다. 사람이 작성한 코드는 작성자의 맥락(context)을 내포하고 있습니다 — 주변 코드, 변수 이름, 구조를 통해 의도를 추론할 수 있는 경우가 많습니다. 반면 AI가 생성한 코드는 개발자가 완전히 예상하지 못한 동작을 수행하면서도 완전히 관용적인(idiomatic) 형태를 띨 수 있습니다. 코드는 프롬프트(prompt)의 하류 산출물(downstream artifact)입니다. 프롬프트의 맥락과 모델이 생성한 결과물에 대한 실제 리뷰 없이는, "approved"는 당신이 열어보지도 않은 상자에 붙은 라벨일 뿐입니다.
리뷰가 발생할 때 LineageLens가 포착하는 것
LineageLens의 백엔드에는 인간의 리뷰를 서명된 증명(signed attestation)으로 기록하는 POST /review/attest 엔드포인트가 있습니다. 페이로드(payload)에는 단순한 판결을 넘어선 네 가지 필드가 포함됩니다:
{
"scopeRef": "pr/12345",
"linesReviewed": 340,
...
linesReviewed, secondsOnDiff, 그리고 commentCount는 코드 리뷰 세션에서 발생하는 가공되지 않은 행동 신호 (behavioral signals)입니다. 그런 다음 엔드포인트는 이 값들로부터 depth_signal을 계산합니다. 이는 판결 (verdict)을 대체하는 것이 아니라, 그 위에 실행되는 추가적인 분류 계층 (classification layer)입니다.
depth_signal 공식
전체 공식은 lineagelens-backend/app/services/human_review_service.py에 문서화되어 있습니다. 이 공식은 의도적으로 투명하게 설계되었습니다. 만약 점수를 사용하여 머지 (merge)를 차단하려 한다면, 그 임계값 (thresholds)은 감사 (auditable) 가능해야 하기 때문입니다:
# 입력 신호 (Input signals)
time_per_line = seconds_on_diff / max(lines_reviewed, 1)
comment_count = 리뷰어가 남긴 인라인 (inline) 또는 PR 코멘트
...
시간 신호 (time signal)가 가장 높은 가중치 (40점)를 가집니다. 왜냐하면 라인당 소요 시간 (time-per-line)은 실제로 읽지 않고는 속이기 가장 어려운 신호이기 때문입니다. 340라인의 디프 (diff)에 30초를 소비한 개발자는 라인당 0.088초를 기록했습니다. 해당 디프에서 시간 점수를 최대치로 받으려면, 약 28분(라인당 약 5초)을 소비해야 합니다.
코멘트 신호 (comment signal, 30점)는 참여도 (engagement)를 포착합니다. 세 개 이상의 인라인 코멘트를 남긴 리뷰어는 디프 내용에 명확히 참여한 것입니다. 인증 (auth) 파일 내의 AI가 생성한 340라인에 코멘트가 하나도 없다는 것은 의미 있는 부재 (absence)입니다.
커버리지 신호 (coverage signal, 30점)는 단독으로는 정보 가치가 가장 낮습니다. linesReviewed는 자기 보고 (self-reported) 방식이기 때문입니다. 하지만 시간 신호와 결합하면 일관성 검사 (consistency check) 역할을 합니다. 만약 42초 동안 340라인을 리뷰했다고 주장한다면, 라인당 시간 (time_per_line)은 0.12초가 됩니다. 이는 time_score를 바닥으로 떨어뜨리며, 커버리지와 상관없이 달성 가능한 총점을 제한합니다.
러버 스탬프 (rubber-stamp) 오버라이드
공식에서 가장 중요한 규칙은 점수 산정 방식이 아니라 바로 이것입니다:
# 비현실적으로 빠른 경우 오버라이드: time_per_line < 1.0 s → 항상 "shallow"
# (예: 400라인에 3초와 같은 대규모 디프의 러버 스탬프 승인을 플래그 처리).
...
리뷰어가 라인당 1초 미만을 소비했다면 — 댓글 수와 관계없이, 리뷰한 라인 수와 관계없이, 판결(verdict)과 관계없이 — 결과는 원시 점수(raw score) 0점과 함께 shallow가 됩니다.
이는 이 글의 상단에서 언급한 시나리오를 포착합니다. 340라인에 4초 = 라인당 0.012초. 게이트(gate)는 shallow를 반환합니다. 서명된 증명(signed attestation)이 이를 기록합니다. adequate 이상의 깊이를 요구하도록 구성된 머지 게이트(merge gate)는 해당 PR을 거부합니다.
라인당 1.0초라는 임계값은 과학적 상수(scientific constant)가 아닌 판단의 영역입니다. 코드베이스를 잘 아는 진정으로 빠른 독자라면 라인당 2초를 기록할 수도 있습니다. 명백한 오류를 찾기 위해 훑어보는 리뷰어는 1.5초를 쓸 수도 있습니다. 이 오버라이드(override)는 무엇을 찾고 있는지 알고 있는 빠른 리뷰어를 처벌하기 위한 것이 아니라, 명백히 불가능한 리뷰 속도를 포착하도록 조정되었습니다.
증명 기록(attestation record)의 형태
compute_depth_signal()이 실행된 후, record_review()는 결과를 서명하고 두 개의 행(row)을 영구 저장합니다:
Attestation row:
subject_type: "review"
subject_id: "pr/12345"
...
서명된 성명(signed statement)은 서명을 무효화하지 않고서는 깊이 분류(depth classification)를 소급하여 변경할 수 없음을 의미합니다. 감사 추적(audit trail)은 단순히 누군가가 주장한 내용이 아니라, 리뷰 이벤트에 암호학적으로 결합된 행동 신호(behavioral signals)가 말해주는 것입니다.
이는 종료된(closed) PR과는 다른 종류의 증거입니다. 종료된 PR은 누군가가 승인(approve)을 클릭했다는 사실을 알려줍니다. 증명(attestation)은 그들이 얼마나 시간을 소비했는지, 그것이 어떤 깊이에 해당했는지, 그리고 분류가 shallow, adequate, 또는 deep이었는지를 알려줍니다.
CI 머지 게이트(CI merge gate)
게이트 엔드포인트는 POST /review/gate/{pr_ref}?min_depth=adequate에 위치합니다. 이는 PR이 머지될 준비가 된 시점에 CI(JWT가 아닌 X-API-Key 인증)에 의해 호출되도록 설계되었습니다:
GET /review/gate/pr/12345?min_depth=adequate
→ 200 OK
...
게이트(gate)는 depth_signal >= min_depth 이고 verdict == "approved" 일 때만 통과됩니다. min_depth에 사용할 수 있는 유효한 값은 세 가지입니다: shallow, adequate, deep. 깊이 순위는 서수(ordinal)로 지정됩니다: shallow=0, adequate=1, deep=2.
실질적인 효과: 높은 민감도를 요구하는 경로(인증(auth), 결제(payments), 보안(security))의 경우, min_depth=deep으로 설정하고 최소 70점을 요구합니다. 이는 대략 라인당 최소 5초, 3개 이상의 댓글, 그리고 50라인 이상의 검토를 의미합니다. 이것이 진정한 리뷰입니다. 표준 경로의 경우, min_depth=adequate로 설정하고 35점 이상을 요구하는 것은 3초 만에 이루어지는 승인을 차단하면서도, 빠르지만 몰입도 있게 참여하는 리뷰어는 통과시키는 합리적인 하한선(floor)이 됩니다.
ASCII 흐름도 (ASCII flow diagram)
개발자가 AI가 생성한 PR을 리뷰함
│
▼
...
이것이 해결하지 못하는 것
깊이 신호(depth signal)는 행동적 대리 지표(behavioral proxy)이지, 이해도 테스트가 아닙니다. 시스템을 속이는 법을 아는 개발자는 diff(차이점)를 30분 동안 띄워놓고 일반적인 댓글 세 개를 남김으로써, AI가 생성한 내용을 실제로 이해하지 못한 채 deep 분류를 달성할 수 있습니다. 이는 이 설계만의 고유한 결함은 아닙니다. 측정 가능한 모든 신호는 속임수의 대상이 될 수 있습니다. 이 시스템의 가치는 속일 수 없다는 점에 있는 것이 아니라, 가장 흔한 실패 모드(단순한 형식적 승인(rubber-stamping))를 잡아내는 최소한의 기준을 설정하고, 행동 신호에 대한 감사 가능한 기록(auditable record)을 생성한다는 점에 있습니다.
또한 이 공식은 일반적인 PR 리뷰가 아닌, AI가 생성한 코드 리뷰에 맞춰 조정되었습니다. time_score에 적용된 '라인당 최대 5초'는 AI가 생성한 코드가 작성자의 패턴을 알고 있는 사람이 작성한 코드보다 더 주의 깊은 읽기가 필요할 수 있다는 가정을 반영합니다. 밀집된 알고리즘 구현을 리뷰하는 팀은 기준이 더 높아야 한다고 합리적으로 주장할 수 있습니다. 3개라는 댓글 임계값은 340라인의 diff에 대해서는 너무 낮을 수 있습니다. 이것들은 논의할 가치가 있는 매개변수(parameters)이며, 실제로 논의되어야 합니다.
리스크 발견(Risk Discovery)과의 연결
depth_signal 시스템이 존재하는 이유는 reviewStatus만으로는 충분한 필터가 되지 않기 때문입니다. LineageLens에서의 전체 Risk Discovery 쿼리는 다음과 같습니다:
lineagelens report . --unreviewed --category auth
깊이 분류 (depth classification) 없이는, "unreviewed"는 리뷰 기록이 전혀 없는 코드만을 잡아낼 뿐입니다. 하지만 이 분류가 있다면 정의를 확장할 수 있습니다. 기록된 유일한 리뷰가 shallow인 코드는 운영 관점에서 리뷰된 코드보다는 unreviewed에 더 가깝습니다. 이를 통해 필터는 의미 있게 더 엄격해집니다.
더 자세한 내용은 lineage-website.vercel.app에서 확인하세요. Hashnode 포스트에서는 제가 이 공식을 결정하기 전에 고려했던 설계상의 트레이드오프 (tradeoffs)에 대해 더 심도 있게 다룹니다.
댓글을 위한 질문 하나: rubber-stamp override를 위한 하한선으로 라인당 1초가 적절할까요? auth-path 코드에 대해서는 어떤 임계값 (threshold)을 설정하시겠습니까? 그리고 이 방식을 더 신뢰할 수 있게 만들기 위해 제가 놓치고 있는 신호 (signals)는 무엇일까요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기