대부분의 리뷰 체크리스트는 플라시보다

팀에 코드 리뷰 체크리스트가 있다면, 아묘도 열지 않는 위키 페이지에 묻혀 있을 가능성이 높다. 아마도 “check for off-by-one errors”나 “verify error handling” 같은 내용이 적혀 있을 것이다. 이것들은 사실이다. 하지만 행동을 바꾸기에는 너무 모호하다.

“check for bugs”를 확인하라고 하는 체크리스트는 체크리스트가 아니다. 버그가 존재한다는 사실을 상기시켜 주는 것일 뿐이다.

Fagan inspection의 체크리스트는 다른 목적을 가진다. 걱정해야 할 사항의 목록이 아니라, 리뷰 대상인 실제 코드베이스에서 관찰된 특정 결함 범주에 리뷰어의 주의를 집중시키는 구조화된 도구다. 데이터에서 구축되고, 아티팩트 유형에 맞게 조정되며, 의무적인 개별 준비 과정에서 사용된다. 올바르게 사용하면, 구조화된 인스펙션이 비공식 리뷰보다 3~4배 더 많은 결함을 발견하는 주요 이유 중 하나다.

체크리스트를 “구조화”하는 것은 무엇인가

여기서 “구조화”라는 단어가 중요하다. 구조화된 체크리스트는 더 긴 체크리스트가 아니다. 특정한 설계 속성을 가진 체크리스트다.

첫째, 이탈한 결함 데이터에서 파생된다. 항목들은 일반적인 모범 사례 문서가 아니라 실제로 프로덕션에 유입된 버그에서 나온다. 최근 세 번의 인시던트가 모두 비동기 정리 작업의 race conditions과 관련이 있다면, 해당 범주는 자체 체크리스트 항목을 갖게 된다. null dereferences가 2년간 문제가 되지 않았다면 해당 항목은 제거된다.

둘째, 리뷰 대상 아티팩트 유형으로 범위가 한정된다. Fagan inspection은 원래 요구사항 문서, 설계 문서, 소스 코드, 테스트 계획에 대해 서로 다른 체크리스트를 사용했다. 각 아티팩트에는 서로 다른 결함 범주가 있다. 설계 문서 체크리스트는 인터페이스 일관성과 결합도를 묻는다. 소스 코드 체크리스트는 경계 조건과 리소스 정리를 묻는다. 둘을 섞으면 둘 다 희석된다.

셋째, 회의 중이 아니라 개별 준비 과정에서 사용된다. 각 인스펙터는 그룹이 모이기 전에 체크리스트와 함께 혼자 자료를 읽는다. 체크리스트는 각 사람이 코드를 독립적으로 읽을 때 무엇을 보는지 형성한다. 공유 참조 문서가 아니다. 개인의 렌즈다.

넷째, 사용할 수 있을 만큼 짧다. 40개 항목의 체크리스트는 도구가 아니라 카탈로그다. Fagan은 아티팩트 유형당 대략 10~15개 항목을 권장했다. 이 제약이 우선순위 설정을 강제한다. 중요한 범주는 남기고 잡음은 버린다.

자체 데이터로 구축하는 방법

구조화된 체크리스트를 구축하는 가장 좋은 방법은 이미 무엇이 잘못되었는지 살펴 보는 것이다. 다음은 실용적인 프로세스다.

인시던트 추적기, 버그 데이터베이스 또는 사후 분석 노트에서 시작하라. 리뷰를 빠져나가 프로덕션이나 테스트에 도달한 최근 20개의 결함을 추출하라. 각각을 증상이 아닌 근본 원인으로 분류하라. “Page crashed”는 증상이다. “Missing null check after external API response”는 근본 원인이다.

근본 원인을 범주로 묶으라. 이탈한 결함의 80%가 3~5개 범주에 속할 것이다. 그것이 바로 체크리스트 항목들이다.

각 범주에 대해 모호한 상기시킴이 아닌 구체적인 트리거 질문을 작성하라. “Check for nulls”는 모호하다. “For every function that calls an external API, verify the response is validated before use”는 트리거 질문이다. 리뷰어에게 정확히 무엇을 찾고 어디서 찾아야 하는지 알려준다.

구체적인 예를 들어 보겠다. 팀이 Python 서비스를 제공하고 최근 20개의 프로덕션 이슈를 분석했다고 가정하자. 다음과 같은 분포를 발견했다:

  • 6개 이슈: 외부 호출 시 누락된 오류 처리
  • 5개 이슈: 잘못된 데이터베이스 트랜잭션 경계
  • 4개 이슈: 페이지네이션 로직의 off-by-one
  • 3개 이슈: 캐시된 데이터의 race conditions
  • 2개 이슈: 민감한 데이터 로깅

체크리스트는 각 범주당 하나씩 총 5개 항목을 가져야 한다. 각 항목은 팀의 특정 패턴에 연결된 트리거 질문이어야 한다.

#!/usr/bin/env python3
"""Build a structured review checklist from escaped defect data."""

from collections import Counter
from dataclasses import dataclass
from typing import List


@dataclass
class EscapedDefect:
    id: str
    root_cause: str
    trigger_question: str


def build_checklist(defects: List[EscapedDefect], max_items: int = 10) -> List[str]:
    """Build a Fagan-style checklist from escaped defect data.

    Groups by root cause, sorts by frequency, and returns trigger
    questions for the top categories.
    """
    counts = Counter(d.root_cause for d in defects)
    top_causes = counts.most_common(max_items)

    # Map root causes back to their most representative trigger question
    cause_to_question = {}
    for d in defects:
        if d.root_cause not in cause_to_question:
            cause_to_question[d.root_cause] = d.trigger_question

    checklist = []
    for cause, count in top_causes:
        question = cause_to_question[cause]
        checklist.append(f"[{count}x] {question}")

    return checklist


# Example: escaped defects from a Python web service
DEFECTS = [
    EscapedDefect("BUG-101", "missing-external-error-handling",
                  "For every external API call, is the response validated before use?"),
    EscapedDefect("BUG-102", "missing-external-error-handling",
                  "For every external API call, is the response validated before use?"),
    EscapedDefect("BUG-103", "missing-external-error-handling",
                  "For every external API call, is the response validated before use?"),
    EscapedDefect("BUG-104", "transaction-boundary-error",
                  "Does every database write have the correct transaction scope?"),
    EscapedDefect("BUG-105", "transaction-boundary-error",
                  "Does every database write have the correct transaction scope?"),
    EscapedDefect("BUG-106", "pagination-off-by-one",
                  "For every pagination query, are the limit and offset tested at boundaries?"),
    EscapedDefect("BUG-107", "cache-race-condition",
                  "For every cached value, is there a clear invalidation or TTL strategy?"),
    EscapedDefect("BUG-108", "sensitive-data-in-logs",
                  "Does any log statement include user data, tokens, or PII?"),
]

if __name__ == "__main__":
    checklist = build_checklist(DEFECTS, max_items=5)
    print("Structured Review Checklist")
    print("=" * 40)
    for item in checklist:
        print(f"  [ ] {item}")

이 스크립트를 자체 버그 데이터에 대해 실행하면 다른 사람의 것이 아니라 자체 실패 패턴에 연결된 체크리스트를 갖게 된다.

리뷰 세션은 실제로 어떤 모습인가

체크리스트를 손에 들고 리뷰 세션은 특정한 리듬을 갖는다.

개별 준비 기간 동안 각 인스펙터는 시간당 대략 100~150줄의 속도로 혼자 자료를 읽는다. 그들은 체크리스트를 렌즈로 사용하고 스크립트로 사용하지 않는다. 체크리스트를 순서대로 항목별로 진행하지 않는다. 코드를 자연스럽게 읽다가 체크리스트 범주와 일치하는 패턴을 만나면 멈춰서 신중히 조사한다.

체크리스트는 읽기를 대체하지 않는다. 뇌가 놓칠 가능성이 있는 것들을 위한 패턴 매처다.

인스펙션 미팅에서 리더는 코드를 소리 내어 다시 말한다. 인스펙터가 잠재적 결함을 발견하면 즉시 지적한다. 체크리스트 범주는 공통 어휘를 제공한다. “이상해 보인다”고 말하는 대신 인스펙터는 “트랜잭션 경계 오류처럼 보인다, 범주 2”라고 말할 수 있다. 중재자가 기록한다. 작성자는 듣는다. 아무도 수정안을 제안하지 않는다.

체크리스트는 미팅 중에 참조되지 않는다. 그 시점까지 인스펙터들은 이미 내면화했다. 미팅은 각자가 독립적으로 발견한 것을 교차 검증하기 위한 것이다.

일반적인 체크리스트가 실패하는 이유

체크리스트를 시도하는 대부분의 팀은 잘못된 종류를 사용해서 포기한다.

블로그 게시물에서 복사한 일반적인 체크리스트에는 감정적인 무게가 없다. 리뷰어에게 자신의 코드베이스에서는 일어난 적도 없는 일을 찾으라고 요청한다. 리뷰어는 훑어보고 익숙한 것이 없으면 언제나 그래왔던 것처럼 diff를 읽으러 돌아간다.

이탈한 결함에서 구축된 구조화된 체크리스트는 다르다. 각 항목은 실제 시간을 소모한 실제 인시던트를 나타낸다. 리뷰어는 사후 분석을 봤기 때문에 이러한 버그를 알고 있다. 체크리스트는 리뷰 행동을 구체적이고 기억에 남는 실패와 연결한다.

또 다른 흔한 실패는 체크리스트를 준비 도구가 아닌 회의 도구로 다루는 것이다. 리뷰 미팅 중에 체크리스트를 꺼내면 그것은 그룹 훑어보기를 위한 스크립트가 된다. 모두가 같은 항목을 읽고 같은 코드를 보고 같은 뻔한 관찰에 수렴한다. 체크리스트의 힘은 개별 준비에 있다. 여섯 명이 동일한 범주를 독립적으로 적용하여 서로 다른 것을 발견하는 곳이다.

대부분의 팀이 무시하는 유지보수 부담

체크리스트는 기념비가 아니다. 코드보다 빨리 썩는 살아있는 문서다.

문제가 생길 때마다 체크리스트 항목을 추가하고 한 번도 제거하지 않으면 6개월 후에는 40개 항목이 된다. 그 시점에서 리뷰어들은 벽지처럼 대하기 시작한다.

Fagan은 몇 번의 인스펙션마다 체크리스트 자체를 검토할 것을 권장했다. 최근 5번의 리뷰에서 발견을 유발하지 않은 항목을 제거하라. 프로덕션으로 이탈한 새로운 결함 범주에 대해서만 항목을 추가하라. 총계를 15개 미만으로 유지하라. 짧게 유지할 수 없다면 우선순위를 정하지 않는 것이다.

다음은 과거 발견 데이터를 기반으로 체크리스트를 가지치기하는 경량 스크립트다:

#!/usr/bin/env python3
"""Prune a review checklist: remove items that no longer trigger findings."""

from dataclasses import dataclass
from typing import List, Dict


@dataclass
class ChecklistItem:
    category: str
    trigger_question: str
    findings_last_5_reviews: int


def prune_checklist(items: List[ChecklistItem], min_findings: int = 1) -> List[ChecklistItem]:
    """Remove checklist items that have not produced findings recently.

    A Fagan-style checklist should be short enough to be usable.
    Items that sit idle waste attention.
    """
    kept = [item for item in items if item.findings_last_5_reviews >= min_findings]
    removed = [item for item in items if item.findings_last_5_reviews < min_findings]

    print(f"Kept {len(kept)} items, removed {len(removed)} items")
    for item in removed:
        print(f"  REMOVED: [{item.category}] {item.trigger_question}")

    return kept


# Example: current checklist with finding counts from the last 5 reviews
CURRENT_CHECKLIST = [
    ChecklistItem("missing-external-error-handling",
                  "For every external API call, is the response validated before use?", 4),
    ChecklistItem("transaction-boundary-error",
                  "Does every database write have the correct transaction scope?", 3),
    ChecklistItem("pagination-off-by-one",
                  "For every pagination query, are the limit and offset tested at boundaries?", 0),
    ChecklistItem("cache-race-condition",
                  "For every cached value, is there a clear invalidation or TTL strategy?", 1),
    ChecklistItem("sensitive-data-in-logs",
                  "Does any log statement include user data, tokens, or PII?", 0),
]

if __name__ == "__main__":
    pruned = prune_checklist(CURRENT_CHECKLIST, min_findings=1)
    print("\nActive checklist:")
    for item in pruned:
        print(f"  [ ] [{item.findings_last_5_reviews}x] {item.trigger_question}")

이 검토를 분기별로 계획하라. 낡은 체크리스트는 도구를 무시하도록 리뷰어를 훈련시키기 때문에 없는 것보다 나쁘다.

트레이드오프: 데이터 대 속도

결함 데이터에서 구조화된 체크리스트를 구축하려면 시간이 걸린다. 정확한 인시던트 분류가 필요하다. 목록을 짧게 유지할 규율이 필요하다. 최신 상태를 유지할 프로세스가 필요하다.

일반적인 체크리스트는 작성하는 데 5분이 걸리고 무의미해지는 데 5주가 걸린다.

구조화된 버전은 생성은 느리지만 사용은 빠르다. 15개의 대상이 명확한 트리거 질문을 가진 리뷰어는 40개의 일반적인 상기시킴을 가진 리뷰어보다 코드를 더 효율적으로 스캔한다. 구체성이 시간을 절약한다.

실제 비용은 조직적이다. 근본 원인을 추출할 만큼 충분히 상세한 이탈 결함 기록이 필요하다. 많은 팀은 이것이 없다. 그들의 버그 추적기에는 “user report: page broken” 같은 제목만 있고 후속 분석은 없다. 근본 원인 데이터 없이는 증거에 기반한 체크리스트를 구축할 수 없다. 남의 것을 복사할 수밖에 없다.

자주 묻는 질문

What is a structured checklist-based review?

각 인스펙터가 실제 이탈 결함 데이터로 구축된 체크리스트를 그룹 인스펙션 미팅 전 의무적인 개별 준비 기간 동안 사용하는 리뷰 프로세스다. 체크리스트에는 일반적인 상기시킴이 아닌 대상이 명확한 트리거 질문이 포함된다.

How is this different from a normal code review checklist?

일반적인 체크리스트는 종종 일반적이고 템플릿에서 복사되어 리뷰 중에 무심코 참조된다. 구조화된 체크리스트는 팀의 특정 결함 이력에서 파생되고 아티팩트 유형으로 한정되며 정해진 읽기 속도로 집중적인 개별 준비 중에 사용된다.

How many items should a structured checklist have?

아티팩트 유형당 10~15개 항목. 그 이상이면 리뷰어는 훑어보기 시작한다. 5개 미만이면 범주를 놓치고 있을 가능성이 있다.

How often should the checklist be updated?

3~5회의 인스펙션 후 또는 분기별로. 발견을 생성하지 않은 항목을 제거하라. 새로 이탈한 결함 범주에 대해서만 항목을 추가하라.

Can this work without a full Fagan inspection process?

예. 체크리스트 자체가 Fagan 방식의 가장 이식 가능한 부분이다. 개별 준비를 의무화하고 리뷰어에게 자체 데이터로 구축된 구조화된 체크리스트를 제공한 다음 리더와 함께 time-boxed 미팅을 진행하라. 완전한 6단계 의식 없이도 비공식 리뷰보다 더 많은 결함을 발견할 수 있다.

한 모듈과 한 분기부터 시작하라

전체 리뷰 프로세스를 뜯어고칠 필요는 없다. 지난 6개월간 프로덕션 결함이 있었던 한 모듈을 선택하라. 근본 원인을 수집하라. 5개 항목의 체크리스트를 구축하라. 그룹 토론 전에 두 명의 리뷰어가 코드와 체크리스트에 30분을 할애하도록 요구하라.

그들이 찾은 것을 기록하라. 동일한 코드에 대해 일반적인 풀 리퀘스트 리뷰가 포착한 것과 비교하라. 이 단일 비교가 구조화된 체크리스트가 팀에 대해 노력할 가치가 있는지 알려줄 것이다.

데이터가 긍정적이라면 또 다른 모듈으로 확장하라. 데이터가 부정적이라면 비공식 리뷰가 이미 충분히 좋거나 결함 데이터가 유용한 체크리스트를 구축하기에 충분히 상세하지 않은 것이다. 어느 쪽이든 남의 의견이 아닌 실제 측정값을 갖게 된다.