코드 리뷰 187건을 분류했더니, 버그 지적은 5건 중 1건이었다
코드 리뷰 187건을 분류했더니, 버그 지적은 5건 중 1건이었다
코드 리뷰는 정말 버그를 잡는 공정인가, 아니면 우리가 버그 검출이라고 착각해 온 다른 무언가인가.
도입
2026년 8월 26일 밤, 일본 개발자 커뮤니티 Qiita에 「베테랑 엔지니어의 PR 리뷰 187건을 분류해 봤더니, 버그는 5건에 1건밖에 지적되지 않았다」는 제목의 글이 올라왔다. 좋아요 98개, 스톡 73개. 일본 커뮤니티에서 이 정도 반응은 “다들 짚이는 데가 있었다”는 뜻이다.
화제성보다 흥미로운 것은 이 글이 만들어진 방식이다. 저자 ktdatascience는 자기 리뷰가 왜 얄팍한지 알 수 없었다고 적는다. 같은 PR을 보는데 자신은 “LGTM”으로 끝나고, 옆자리 동료는 정확한 지적을 열 건 낸다. 무엇을 보면 그렇게 되느냐고 물어도 “신경 쓰이는 데를 볼 뿐”이라는 답만 돌아온다. 그래서 세어 보기로 한다. GitHub REST API로 그 동료의 인라인 리뷰 코멘트를 리포지토리 단위로 전부 긁어왔다. 리포 3개, PR 44개에서 212건. 스레드 안의 답글 21건과 같은 리뷰가 중복 제출되어 본문이 완전히 일치한 4건을 빼면 187건. 이 187건을 전부 읽고 “무엇에 관해 말하고 있는가”라는 축으로 분류했다.
결과는 저자의 예상과 어긋났다. 최대 세력은 버그 지적이 아니었다. 지적한 시점에 아무것도 망가져 있지 않은 영역이 전체의 3분의 1을 차지했고, 저자 자신이 한 건도 손대지 못하고 있던 곳이 정확히 거기였다. “솔직히 꽤 풀이 죽었다”고 그는 적는다.
먼저 이 데이터의 성격을 못 박아 두자. 기사 말미가 스스로 밝히듯 “건수와 분류는 필자가 수작업으로 분류한 사적 데이터셋”이다. 리뷰어 한 명, 리포 세 개, 분류자 한 명. 통계적 표본이 아니고, 다른 조직에 그대로 옮겨 붙일 수 있는 상수도 아니다. 그럼에도 읽을 가치가 있는 이유는, 이 데이터가 우리가 좀처럼 관측하지 않는 것 — 코드 리뷰가 실제로 무엇을 생산하고 있는가 — 을 숫자의 형태로 만들어 놓았기 때문이다. 리뷰의 효과에 관한 조직의 믿음은 대부분 측정된 적이 없다.
187건의 지도: 리뷰가 실제로 말하고 있던 것
저자는 187건을 세 축으로 나눴다. 형(무엇을 지적했는가: 조건 오류, 데드코드, 비대칭 등), 동작(찾아내기 위해 무엇을 열었는가: 기존의 다른 파일, 호출처, 공식 문서 등), 그리고 영역(애초에 무엇에 주의를 향하고 있는가). 기사가 다루는 것은 세 번째다. 리뷰에서 손이 멎는 원인은 대개 “찾는 법을 모른다”가 아니라 “거기에 주의가 가 있지 않다”이기 때문이라는 판단이다. 이 판단 자체가 이미 하나의 주장인데, 뒤에 나오는 숫자가 그것을 뒷받침한다.
187건은 11개 대영역(그 아래 37개 중영역)으로 갈렸다. 건수 순으로 옮기면 이렇다.
| 순위 | 영역 | 이 영역이 던지는 질문 | 건수 |
|---|---|---|---|
| 1 | 옆 코드와 맞춰져 있는가 | 같은 일을 하는 자리와 어긋나 있지 않은가 | 31 |
| 2 | 계약과 의도가 보존되는가 | 반년 뒤의 사람이 같은 판단에 도달할 수 있는가 | 30 |
| 3 | 값 자체가 옳은가 | 이 숫자, 이 판정은 맞는가 | 25 |
| 4 | 실패를 알아챌 수 있는가 | 망가졌을 때 사람이 알 수 있는가 | 17 |
| 5 | 쓰는 사람 눈에는 어떤가 | 운용 담당자와 사용자 손에서 무슨 일이 일어나는가 | 16 |
| 6 | 외부와의 경계 | 내가 쓰지 않은 것이 정말 그렇게 도는가 | 15 |
| 7 | 데이터가 깨지지 않는가 | 떨어졌을 때, 동시에 들어왔을 때 무엇이 남는가 | 13 |
| 7 | 설계의 전망 | 다음에 만지는 사람이 읽을 수 있는가 | 13 |
| 9 | 속도와 비용 | 몇 초, 얼마가 드는가 | 12 |
| 10 | 변경은 어디까지 미치는가 | 작성자가 보고 있는 화면 밖에도 닿는가 | 11 |
| 11 | 검증할 수 있는가 | 효과가 있었는지 나중에 확인할 수 있는가 | 4 |
상위 둘이 61건, 전체의 33%다. 그리고 통상적으로 “버그”라 부를 만한 것 — 3위의 값 정확성 25건과 7위의 데이터 무결성 13건 — 은 합쳐 38건, 20%다. 5건 중 1건. 이것이 제목이 된 숫자다.
여기서 한 가지를 분명히 해야 한다. 이 20%는 “리뷰가 존재하는 버그의 20%를 잡았다”는 뜻이 아니다. 분모는 버그가 아니라 리뷰 코멘트다. 리뷰를 빠져나간 버그가 몇 건이었는지는 이 데이터셋에 아예 들어 있지 않고, 들어올 방법도 없다. 이 데이터가 말할 수 있는 것은 단 하나, 한 명의 뛰어난 리뷰어가 자기 리뷰 시간의 대부분을 버그가 아닌 것에 쓰고 있었다는 사실이다. 그것만으로도 충분히 무겁다. 리뷰를 버그 게이트라고 부를 때 우리는 그 게이트가 실제로 무엇을 통과시키고 무엇을 붙잡는지 본 적이 없기 때문이다.
레이어를 나누면 그림이 또 바뀐다. 같은 사람의 리뷰인데도 대상이 백엔드냐, 모바일이냐, 웹 프런트냐에 따라 두꺼운 영역이 확연히 달랐다. 백엔드에서는 “값의 정확성”이 20%로 돌출한다(금액, 시각, DB 제약). 모바일은 “데이터가 깨지지 않는가”, “변경의 파급”, “외부 SDK”로 쏠린다. 웹 프런트는 “일관성”과 “의도의 보존” 둘만으로 절반을 차지하고, “외부와의 경계”와 “검증할 수 있는가”는 0건이다. 즉 11개 영역을 매번 전부 훑을 필요는 없고, 차분이 속한 레이어로 좁힐 수 있다.
분류가 끝난 뒤 저자는 같은 PR에 자신이 달았던 리뷰를 같은 11영역에 배분해 봤다. 3위(값)와 5위(쓰는 사람)에는 들어 있었지만 1위, 2위, 10위에는 한 건도 없었다. 보고 있던 영역이 셋뿐이었다는 것이다. 실력 차의 정체가 “센스”가 아니라 주의를 향하는 자리의 개수로 환원된 순간이다. 그리고 이 환원이야말로 이 글이 커뮤니티에서 퍼진 이유다. 센스는 배울 수 없지만 체크리스트는 배울 수 있다.
diff는 지역적이고, 버그는 전역적이다
왜 버그 지적이 5건 중 1건에 그치는가. 기사의 두 번째 축이 답의 절반을 준다. “찾아내기 위해 무엇을 열었는가”로 세어 보면, 187건 중 129건(69%)이 차분 바깥을 본 결과였다. 차분만 위에서 아래로 읽어서 낼 수 있는 것은 58건(31%)뿐이다. 1위 영역은 더 극단적이어서, 31건 중 27건(87%)이 “리포 안의 다른 자리를 열었다”는 동작에서 나왔다.
이 숫자는 코드 리뷰라는 공정의 구조적 한계를 정확히 가리킨다. 우리가 리뷰라고 부르는 것의 표현 형식은 diff다. diff는 변경된 줄을 보여주도록 최적화된 표현이고, 변경되지 않은 줄과의 상호작용은 원리적으로 보여주지 않는다. 그런데 버그의 상당수는 변경된 줄 자체가 아니라 변경된 줄과 변경되지 않은 줄 사이의 관계에서 태어난다. diff는 지역적이고 버그는 전역적이다. 리뷰어가 게으른 게 아니라, 도구가 보여주는 화면이 애초에 버그가 사는 곳을 잘라내 놓았다.
기사가 든 사례들이 이 진단과 정확히 겹친다. 합계와 명세의 로직이 어긋난 건이 있었다. 합계 쪽은 Math.abs(quantity)에 이상치 폴백 0으로 집계하는데, 각 행의 표시는 Math.abs 없이 폴백 1이었다. 통상 데이터에서는 값이 맞아떨어지지만 반품 등으로 수량이 음수가 되면 각 행을 더해도 합계가 안 맞는다. 이 지적은 diff 안에서는 불가능하다. “합계”와 “명세”는 반드시 짝이니 둘 다 연다, 라는 동작이 선행해야 한다.
더 서늘한 것은 리팩터링 사례다. 공통 처리를 다른 모듈로 떼어내면서 원래 클래스에 위임용 별칭 5개를 남겼는데, 이동한 쪽이 내부에서 모듈 글로벌을 직접 참조하고 있어 그 별칭은 프로덕션 코드에서 참조 0이었다. 결과적으로 테스트에서 @patch를 걸어도 실물이 호출되고, SQL 상수를 바꿔 써도 실행되는 SQL은 변하지 않는 상태가 된다. 저자의 표현대로 “동작 불변 리팩터링인데 테스트의 이음매만 조용히 가짜가 되어 있었다”. 이건 diff 안에서는 완벽하게 통과하는 변경이다.
그렇다면 버그 검출이라는 일은 무엇이 대신해야 하는가. 후보는 다섯 개쯤 된다. 각각이 리뷰보다 잘하는 것과 못하는 것이 갈린다.
테스트는 회귀에 강하고 상시 실행되지만, 구현과 같은 전제 위에서 쓰인다. 기사가 든 단가와 합계의 취하기 잘못 — 목록의 금액 표시가 price * quantity에서 price 단독으로 바뀌어 수량 곱셈이 사라진 건 — 은 실기에서 같은 거래가 목록 550엔, 상세 230엔으로 어긋나는 데까지 확인한 뒤의 지적이었다. 저자는 이런 유형에 대해 “테스트는 통과한다. 테스트도 같은 착각으로 쓰이기 때문”이라고 못 박는다. 테스트는 내가 상상할 수 있는 실패만 검출한다.
타입 시스템은 상시 검사라는 점에서 리뷰보다 강하지만, 선언된 만큼만 검사한다. 기사의 TanStack Query 사례가 그렇다. 쿼리의 meta에 독자 키를 넘기면서 Register 인터페이스에 queryMeta를 선언하지 않아 meta의 타입이 Record<string, unknown>인 채였고, 그래서 어떤 키 이름이든 통과하며 오타를 컴파일이 잡지 못한다. 여기서 지적의 서술 방식이 중요하다. “타입이 느슨하다”가 아니라 “오타가 통과한다”라고 말하면 고쳐야 할 이유가 분명해진다. 사람이 as string 캐스트로 타입 검사를 스스로 꺼 놓는 경우도 마찬가지로, 그것을 발견하는 것은 결국 리뷰다.
정적 분석과 린터는 사실 이 데이터에서 가장 큰 자동화 여지를 드러낸다. 저자가 우선 습관화하겠다고 고른 중영역 여섯 개 가운데 이중 정의(9건), 데드코드(8건), 변경의 파급 범위(7건)는 상당 부분 grep 수준의 기계 작업이다. 리뷰어가 하고 있던 일의 일부는 도구가 이미 할 수 있는데 아무도 켜 두지 않아 사람이 손으로 대신하고 있던 것이다. 반대로 “합계와 명세는 같은 규칙을 써야 한다”는 것은 도메인 지식이고, 린터는 그런 짝을 알지 못한다.
계약과 불변식은 리뷰보다 근본적으로 세다. 리뷰는 1회성이고 스키마 제약은 상시이기 때문이다. 기사의 7위 사례가 정확히 그 부재를 말한다. GROUP BY user_id 후 MIN(company_id)를 취하는 구현에 대해, DB 제약이 1사용자 대 1회사를 강제하지 않으므로(한쪽은 회사 단위 유니크, 다른 쪽은 시설 단위 유니크) 복수 소속 행이 만들어지는 순간 화면에서는 한쪽만 보이게 된다는 지적이다. 앱이 암묵적으로 전제하고 있는 카디널리티를 스키마 정의까지 열어서 확인한 것이며, 올바른 해결은 리뷰를 더 열심히 하는 것이 아니라 제약을 거는 것이다.
카나리아 배포와 관측성은 나머지를 받는다. 그런데 여기에 순환이 있다. 4위 영역 “실패를 알아챌 수 있는가”의 17건은 기능으로서는 전부 정상 동작하고, 저자의 말대로 기능 테스트에서는 1건도 떨어지지 않는다. HTTP 클라이언트 aspida가 throwHttpErrors: false(기본값)로 초기화되어 있어 await한 요청이 400이나 500을 받아도 예외를 던지지 않고, 그 결과 try/catch로 감쌌는데 catch 절에 들어가지 않아 API가 거부해도 “변경했습니다” 성공 배너가 뜬다. 설정 파일 한 줄이 “try/catch가 있으니 안전하다”는 직감을 뒤집는다. 관측성은 배포 후에 작동하지만, 관측 코드가 옳게 심겼는지는 코드에서만 보인다. 그래서 이 영역은 끝까지 리뷰에 남는다.
기사에서 가장 정교한 지적도 이 계열이다. 유효기간 판정에 타임존 없는 expires_at(안에는 UTC라고 생각하고 넣은 값)과 타임존이 있는 NOW()를 비교하는 SQL이 있었다. 타입이 다르니 PostgreSQL은 한쪽을 변환해 비교하는데, 이때 타임존 없는 값은 세션의 TimeZone 설정에 따른 로컬 시각으로 해석된다. 설정이 Asia/Tokyo라면 UTC로 넣은 값이 JST로 읽혀 실제보다 9시간 빠르게 “기한 만료”로 판정된다. 금전이나 포인트의 유효기간이라면 아직 살아 있어야 할 것이 사라진다. 지적은 “지금은 정상 동작한다”는 사실을 먼저 인정하고, 왜 지금은 도는지까지 쓴다. DB 컨테이너의 TimeZone이 미지정이라 우연히 UTC이기 때문이다. 즉 DB 기동 옵션 하나가 바뀌면 깨진다.
-- 양변 모두 타임존 없는 UTC가 되므로 세션 설정에 좌우되지 않는다
SELECT * FROM items WHERE expires_at < NOW() AT TIME ZONE 'utc';
이 지적을 테스트가 낼 수 있는가. 테스트 환경의 DB도 같은 이유로 UTC이므로 낼 수 없다. 타입 검사기가 낼 수 있는가. SQL 문자열 안이므로 낼 수 없다. 린터는 패턴을 등록해 두면 낼 수 있지만, 그 패턴을 등록하려면 누군가 먼저 이 사고를 이해해야 한다. 남는 것은 사람이다.
리뷰의 목표를 다시 쓰면 무엇이 달라지는가
여기까지의 재해석을 실무 언어로 옮기면 이렇게 된다. 코드 리뷰가 생산하는 가치는 버그 검출 하나가 아니라 여러 개이며, 버그 검출은 그중 지분이 5분의 1인 항목이다. 나머지는 설계 정합성(옆 코드와의 일관성), 의도와 근거의 보존, 명명과 가독성, 컨벤션 정렬, 그리고 코드 소유권의 분산이다. 1위와 2위 영역이 공유하는 성질을 저자는 정확히 짚는다. 지적한 시점에 아무것도 망가져 있지 않다. 망가지는 것은 선택지를 하나 더 추가할 때, 공통 스타일 값을 바꿀 때, 한쪽만 고칠 때다. 요컨대 미래의 개수(改修)다. 이 리뷰어의 작업은 PR을 통과시키기 위한 검사가 아니라 코드베이스의 열화 속도를 늦추는 작업으로 설계돼 있었다.
그렇다면 리뷰를 “버그를 잡는 공정”으로 광고할 때 어떤 왜곡이 생기는가. 세 가지가 관측 가능하다. 첫째, 게이트 신뢰의 과대평가다. 리뷰를 통과했으니 버그가 없다는 추론은 이 데이터 어디에도 근거가 없는데, 조직은 리뷰 승인을 안전 신호로 읽는다. 둘째, 투자 압력의 이동이다. 리뷰가 버그를 잡아 준다고 믿으면 테스트, 스키마 제약, 관측성에 들어갈 예산이 “리뷰를 더 꼼꼼히”라는 무형의 요구로 대체된다. 앞 절에서 봤듯 그중 상당수는 사람보다 기계가 더 싸게, 더 상시로 할 수 있는 일이다. 셋째가 가장 해롭다. 사고가 났을 때 원인이 “리뷰어가 놓쳤다”는 개인 귀책으로 수렴한다. diff라는 표현 형식이 구조적으로 감추는 것을 사람의 주의력 부족으로 기록하는 순간, 그 조직은 같은 사고를 반복할 수밖에 없다.
대안적인 광고 문구는 이렇게 쓸 수 있다. 리뷰의 목표는 이 변경이 6개월 뒤의 개입 비용을 얼마나 올리는지를 낮추는 것이다. 이렇게 쓰면 1위, 2위 영역에 시간을 쓰는 것이 직무 태만이 아니라 직무 그 자체가 된다.
AI 리뷰어가 이 그림을 바꾸는가. 조건을 달아야 한다. 기계에 유리한 쪽부터 보면, 187건의 69%가 “차분 바깥을 봤다”는 작업량 문제였다는 사실은 자동화 여지가 크다는 신호다. 파일을 한 장 더 여는 일, 정의된 상수를 grep하는 일, 짝이 되는 파일(목록과 상세, 합계와 명세, iOS와 Android)을 나란히 놓는 일에는 전문 지식이 거의 필요 없다고 저자 스스로 적고 있다. 지치지 않는 기계가 인간 리뷰어보다 유리한 지점이다.
기계에 불리한 쪽도 셋이다. 하나, 도구에 실제로 주어지는 입력이 여전히 diff뿐이라면 기계는 같은 한계를 더 빠르게 반복할 뿐이다. 리포지토리 전체를 입력으로 받는 구성과 diff만 받는 구성은 이름이 같아도 다른 물건이다. 둘, 1위와 2위 영역은 “이 리포의 관례가 무엇인가”를 알아야 성립하는데, 관례는 대개 문서화되어 있지 않고 코드에 흩어져 있다. 검색으로 회수할 수는 있지만 여러 선례 중 무엇이 정본인지는 판단이며, 판단이 틀리면 기계는 잘못된 관례를 강제하는 쪽으로 작동한다. 셋, 지적의 비용 구조가 다르다. 사람 리뷰어는 지적 수를 스스로 억제하지만 기계는 억제할 이유가 없어, 신호 대 잡음이 나빠지면 팀은 리뷰 코멘트 전체를 무시하게 된다. AI 리뷰어가 이 그림을 실제로 바꾸는 조건은 셋이 겹칠 때다. 입력이 diff가 아니라 리포일 것, 출력이 우선순위화되어 있을 것, 그리고 어느 영역을 봤고 어느 영역을 보지 않았는지 스스로 보고할 것. 마지막 조건이 특히 중요하다. 사람 리뷰어의 사각지대가 셋뿐이었다는 것을 저자는 세어 보고서야 알았다.
당장 실행 가능한 처방도 이 글 안에 있다. 저자는 비용 대비 효과가 높은 중영역 여섯 개를 골랐다. 근거의 보존(수치 리터럴에 “이 근거는 어디에 적혀 있는가”를 묻는다) 20건, 짝의 비대칭(짝이 되는 이름을 찾아 나란히 놓는다) 10건, 이중 정의(새로 정의한 상수 값을 grep한다) 9건, 데드코드(참조를 grep으로 센다) 8건, silent failure(HTTP 클라이언트 설정을 열고 catch의 도달 조건을 전부 말한다) 8건, 변경의 파급 범위(변경한 부품의 호출처를 grep으로 센다) 7건. 합계 62건, 전체의 33%다. 그리고 1위인 근거의 보존은 20건 중 14건이 차분을 읽는 것만으로 낼 수 있었다. 파일을 한 장도 열지 않고 시작할 수 있는 것이 건수가 가장 많다는 뜻이다.
결론
리드에 던진 질문으로 돌아가자. 코드 리뷰는 버그를 잡는 공정인가. 적어도 이 데이터셋에서는 아니다. 뛰어난 리뷰어의 시간 80%는 버그가 아닌 것에 쓰이고 있었고, 그 80%가 사라진다면 남는 것은 “지금 당장은 아무 문제 없는” 코드베이스와 6개월 뒤의 개수 비용이다. 그렇다고 리뷰가 쓸모없다는 결론은 나오지 않는다. 틀린 것은 리뷰가 아니라 리뷰에 붙여 놓은 라벨이다.
라벨을 바꾸면 따라오는 조정이 있다. 버그 검출의 상당 부분은 테스트, 타입, 린터, 스키마 제약, 관측성으로 옮겨야 한다. 그것들은 상시 실행되고 사람의 주의력에 의존하지 않는다. 리뷰에 남겨야 할 것은 기계가 표현할 수 없는 것들 — 팀의 관례, 판단의 근거, 짝의 대칭성, 그리고 이 코드를 아는 사람을 한 명 더 만드는 일이다. 이 배분을 명시하지 않은 채 “리뷰를 더 꼼꼼히”라고 말하는 것은 아무 지시도 하지 않은 것과 같다.
마지막은 이 글이 스스로 제안하는 실험이다. 저자는 반나절이면 된다고 적는다. 팀의 지난 분기 리뷰 코멘트를 API로 전부 긁어 같은 축으로 세어 보라. 11개 영역 중 몇 개가 채워져 있는가. 그리고 채워지지 않은 영역은, 정말 다른 공정이 덮고 있어서 비어 있는가, 아니면 그냥 아무도 보고 있지 않아서 비어 있는가. 두 경우는 숫자 위에서 똑같이 0으로 보이지만, 하나는 설계이고 다른 하나는 사고다.
출처: