Devin.KR

소프트웨어 공학 · 심화

코드 리뷰 실무 - 버그를 잡고 팀을 키우는 읽기

자동 리뷰 도구와 사람의 역할 - 종합 실습

정적 검사·AI 리뷰 의견을 1차 거름망으로, 사람은 요구·설계·위험 판단, 오탐 다루기, 종합 실습으로 반납 기능 PR 하나를 처음부터 끝까지 리뷰한다. 완성 코드는 자동 검사 결과와 사람 체크리스트를 합쳐 최종 리뷰 보고서를 출력한다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 리뷰를 받는 쪽이 변경 목적과 확인 방법을 정리했다. 이제 그 자료를 받은 리뷰어의 자리로 돌아온다. 마지막 실습은 도서관 대출 서비스의 반납 기능 변경 요청(PR, Pull Request) 하나를 읽고, 자동 검사 의견을 분류하고, 수정 결과를 확인한 뒤 최종 리뷰 보고서를 남기는 일이다.

도구가 의견을 많이 내놓았다고 검토가 끝난 것은 아니다. 의견이 실제 코드에 맞는지, 요구에 어긋나는 동작이 있는지, 어떤 증거로 승인을 결정할지 사람이 연결해야 한다. 이 장에서는 그 연결 과정을 실행 가능한 작은 프로그램으로 만든다.

  • 정적 검사와 인공지능(AI) 리뷰가 제공하는 정보를 1차 거름망으로 사용한다.
  • 자동 의견을 수정 완료, 중복, 오탐으로 나누고 판단 근거를 남긴다.
  • 반납 기능의 요구, 설계, 위험을 사람이 확인할 항목으로 정리한다.
  • 수정 전후의 정상 동작과 결함 재현 사례를 같은 입력으로 실행한다.
  • 자동 검사 결과와 사람 체크리스트를 합쳐 최종 리뷰 보고서를 출력한다.

문제 상황

팀에 다음 변경 요청이 올라왔다. 대출 기록에 반납일을 저장하고, 화면에 표시할 반납 결과를 돌려주는 기능이다. 작성자는 정상 반납 한 건이 성공하는 것을 확인했다. 자동 검사에서는 “반납일을 기록하기 전에 이미 반납했는지 확인하라”라는 의견이 나왔고, AI 리뷰에서는 같은 지적과 함께 “대출 기록이 null이면 예외가 발생한다”라는 의견이 나왔다.

리뷰어는 세 의견을 곧바로 세 결함으로 세지 않는다. 같은 문제를 두 도구가 반복했을 수 있다. null을 허용하지 않는 메서드라면 예외가 발생한다는 사실만으로 결함이라 할 수도 없다. 반면 자동 의견에 없더라도 반납일을 다시 기록하여 최초 기록을 잃는 동작은 요구와 대조해야 한다.

실습에서 사용할 요구는 다음과 같이 합의되어 있다. 외부 요청을 읽고 대출 기록을 찾는 작업은 호출자가 끝냈다고 가정한다. 이 장의 검토 범위는 전달받은 대출 기록을 반납 상태로 바꾸는 메서드다.

  • 대출 중인 기록을 반납하면 전달받은 날짜를 반납일로 저장한다.
  • 이미 반납한 기록을 다시 반납하면 최초 반납일을 유지하고 “이미 반납됨”을 반환한다.
  • 대출 식별자는 반납 전후에 유지한다.
  • 대출 기록과 처리 날짜는 필수 입력이다. null이면 호출 계약 위반으로 즉시 예외를 발생시킨다.
  • 메서드는 전달받은 기록을 직접 바꾸지 않고 결과 기록을 반환한다. 저장 작업은 호출자가 맡는다.

리뷰 의견: 이미 반납한 기록에 다른 날짜를 전달하면 최초 반납일이 바뀐다. 재요청에서는 최초 반납일을 유지한다는 요구에 따라, 날짜를 기록하기 전에 반납 여부를 확인해야 한다. 다음 날 같은 기록을 다시 반납하는 사례로 수정 결과를 확인하자.

이 의견에는 관찰한 동작, 적용할 요구, 확인할 입력이 들어 있다. “검사기가 경고했다”는 사실은 검토를 시작할 단서다. 수정 이유를 설명하는 근거는 요구와 실행 결과다. 도구 이름으로 의견의 무게를 정하면 오탐을 받아들이거나 실제 문제를 놓치기 쉽다.

자동 의견을 증거와 연결하기

정적 검사(static analysis)는 코드를 실행하지 않고 정해진 규칙에 맞는 패턴을 찾는다. 사용하지 않는 값처럼 비교적 분명한 항목도 있고, 특정 호출 주변을 사람이 살펴보라는 넓은 규칙도 있다. 규칙이 무엇을 확인했는지 알아야 결과의 의미를 해석할 수 있다. 이 실습의 검사기는 날짜 기록 호출이 보이면 재반납 처리를 확인하라는 의견을 낸다. 분기 구조 전체를 이해하는 검사기는 아니다.

AI 리뷰는 코드와 설명을 바탕으로 확인할 후보를 제안할 수 있다. 다만 프로젝트 계약을 잘못 추정하거나 이미 있는 검사를 놓칠 수 있다. 여기서는 네트워크와 API 키를 사용하지 않는다. 미리 받은 것으로 가정한 AI 의견 두 개를 프로그램에 고정 데이터로 넣는다. 실행 중에 AI를 호출하거나 새로운 의견을 생성하는 프로그램은 아니다.

자동 의견은 발견 경로와 판단 결과를 나누어 보관한다. 발견 경로는 정적 검사인지 AI 리뷰인지 나타낸다. 판단 결과는 사람이 증거를 검토한 뒤 기록한다. 같은 결함을 여러 경로에서 찾더라도 수정 건수는 한 건으로 관리할 수 있다. 원래 의견을 삭제하지 않으면 무엇을 확인했는지도 남는다.

이번 변경에서 자동 의견을 판정한 근거
의견발견 경로판단근거
S1: 재반납 처리 확인정적 검사수정 완료다음 날 재요청해도 최초 반납일을 유지하도록 수정하고 검증한다.
A1: 최초 반납일 덮어쓰기AI 리뷰중복S1과 같은 분기 누락을 가리킨다. S1의 수정과 검증을 연결한다.
A2: null 입력 예외AI 리뷰오탐필수 입력 계약에 따라 null을 거부하며 두 인자를 명시적으로 검사한다.

오탐(false positive)은 이 변경에서 결함으로 판정할 수 없는 의견이다. 귀찮아서 무시한다는 뜻이 아니다. A2는 예외가 발생한다는 관찰 자체는 맞지만, 그 동작이 요구 위반이라는 해석이 틀렸다. 계약과 코드 위치를 근거로 남겨야 다음 리뷰어도 판단을 이어갈 수 있다.

규칙을 넓게 적용한 결과와 오탐도 구분해야 한다. S1은 재반납 분기가 있는지 확인하라는 요청이므로 수정 후에도 같은 패턴을 찾을 수 있다. 경고 문구가 사라졌는지만으로 수정 여부를 판단하면 이 규칙에서는 계속 같은 일을 반복하게 된다. 필요한 동작을 실행해 확인하고 그 결과로 의견을 닫는다.

자동 의견은 사람의 증거 검토를 거쳐 수정 완료, 중복, 오탐으로 나뉜다

사람은 요구·설계·위험을 판단한다

자동 검사 결과가 비어 있어도 사람의 검토 항목은 남는다. 도구가 현재 코드의 문법과 패턴을 읽는 동안, 사람은 이 변경이 업무에서 무엇을 보존해야 하는지 묻는다. 반납일은 화면에 잠깐 표시할 값인지, 이후 처리의 기준이 되는 최초 기록인지에 따라 재요청 처리의 의미가 달라진다. 이 실습에서는 최초 기록을 보존한다는 요구가 명확하므로 리뷰어가 이를 확인 항목으로 만든다.

설계 판단은 결과를 누가 저장하는지까지 포함한다. 이 메서드는 변경된 기록을 반환하고 호출자가 저장한다. 그러므로 검토 범위 안에서는 원본이 유지되는지와 식별자가 보존되는지를 확인한다. 반환값을 무시한 채 저장하지 않는 호출부까지 안전하다고 결론 내릴 수는 없다. 보고서에 검토 범위를 적는 이유가 여기에 있다.

위험 판단은 확인하지 않은 상황도 드러내는 일이다. 이 프로그램은 한 번의 호출에서 재반납 여부를 판단한다. 여러 요청이 같은 미반납 기록을 각각 읽은 뒤 저장하는 상황까지 검증하지는 않는다. 실제 저장 계층이 포함된 변경이라면 그 계층에서 최초 기록을 지킬 수 있는지 별도 증거가 필요하다. 작은 실행 예제가 통과했다고 서비스 전체의 보장을 확대하지 않는다.

체크리스트(checklist)의 항목은 “설계 확인”처럼 넓게 쓰기보다 무엇을 보면 완료인지 드러나게 쓴다. 아래 항목의 완료 여부는 코드에 적은 설명만으로 결정하지 않는다. 정상 반납, 다음 날 재반납, 필수 입력 거부, 원본 보존을 실행한 결과와 연결한다.

사람이 완료를 판단할 확인 항목
관점확인 질문완료 증거
요구정상 반납과 재반납이 합의한 결과를 내는가정상 결과 비교와 최초 반납일 보존 검사
설계원본과 식별자, 필수 입력 계약을 지키는가원본 보존·식별자 유지·null 거부 검사
위험검토 결과의 적용 범위가 드러나는가단일 호출 범위와 저장 계층 제외를 보고서에 명시

최종 승인은 자동 의견의 개수로 결정하지 않는다. 실습 프로그램은 미처리 의견이 없고, 사람 항목이 모두 완료이며, 실행 검사가 모두 통과할 때 “범위 내 승인”을 출력한다. 이는 보고서 조립 규칙이다. 사람이 근거를 읽고 판정값을 넣는 작업까지 자동으로 수행했다는 뜻은 아니다.

반납 PR을 끝까지 읽는 순서

먼저 변경 요청의 목적과 위의 요구를 읽는다. 다음으로 변경 전 메서드가 어떤 입력을 받고 무엇을 반환하는지 확인한다. 기존 메서드는 필수 입력을 검사한 뒤 반납일을 곧바로 기록한다. 정상 반납에서는 맞지만 이미 반납한 기록에도 같은 경로를 적용한다. 자동 의견 S1과 A1은 이 지점으로 모인다.

수정 후 메서드는 필수 입력 검사를 유지하고, 날짜를 기록하기 전에 이미 반납했는지 확인한다. 이때 기존 기록을 그대로 담은 결과를 반환한다. 기존에 성공하던 정상 입력은 전후 결과 전체를 비교한다. 결함을 고친 입력은 출력이 달라져야 하므로, 전후 동등성을 요구하는 대신 합의한 새 결과가 나오는지 확인한다.

아래 코드는 변경 전후를 함께 보관한다. BeforeReturnService와 AfterReturnService가 각각 리뷰 대상의 전후 코드다. 두 구현을 하나의 실행에 넣어 입력과 실행 조건이 다른 탓에 비교가 흔들리지 않게 했다. 날짜도 현재 시각을 읽지 않고 고정값을 전달한다.

정적 검사에는 변경 전후 메서드의 본문을 문자열로 전달한다. 이 문자열은 전체 소스 파일을 대신하는 검사 입력이며 컴파일 대상은 아니다. 실제 동작 검증은 두 서비스 구현을 호출하여 수행한다. 자동 의견의 판정과 사람 항목은 리뷰어가 증거를 확인한 뒤 기록한 데이터로 표현한다.

정상 입력은 전후 결과를 비교하고 결함 입력은 수정 후 요구 충족을 확인한 뒤 보고서로 모은다

완성 코드

다음 내용을 Main.java로 저장한다. JDK 17 표준 라이브러리만 사용하며 파일을 쓰거나 외부 서비스에 접속하지 않는다. 검사 입력 문자열의 줄바꿈은 읽기 편하게 배치한 것이고, 실행 결과는 고정된 입력과 목록 순서로 결정된다.

Main.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class Main {
    record Loan(String id, LocalDate returnedOn) {
        Loan withReturnedOn(LocalDate date) {
            return new Loan(id, date);
        }
    }

    record ReturnResult(Loan loan, String message) {}

    static final class BeforeReturnService {
        ReturnResult finish(Loan loan, LocalDate today) {
            Objects.requireNonNull(loan, "loan");
            Objects.requireNonNull(today, "today");
            return new ReturnResult(
                    loan.withReturnedOn(today), "반납 완료");
        }
    }

    static final class AfterReturnService {
        ReturnResult finish(Loan loan, LocalDate today) {
            Objects.requireNonNull(loan, "loan");
            Objects.requireNonNull(today, "today");
            if (loan.returnedOn() != null) {
                return new ReturnResult(loan, "이미 반납됨");
            }
            return new ReturnResult(
                    loan.withReturnedOn(today), "반납 완료");
        }
    }

    enum Decision {
        OPEN("미처리"),
        FIXED("수정 완료"),
        DUPLICATE("중복"),
        FALSE_POSITIVE("오탐");

        final String label;

        Decision(String label) {
            this.label = label;
        }
    }

    record Finding(String id, String origin, String message) {}

    record Assessment(
            Finding finding, Decision decision, String evidence) {}

    record HumanCheck(String name, boolean done, String evidence) {}

    static final String BEFORE_BODY = """
            Objects.requireNonNull(loan, "loan");
            Objects.requireNonNull(today, "today");
            return new ReturnResult(
                    loan.withReturnedOn(today), "반납 완료");
            """;

    static final String AFTER_BODY = """
            Objects.requireNonNull(loan, "loan");
            Objects.requireNonNull(today, "today");
            if (loan.returnedOn() != null) {
                return new ReturnResult(loan, "이미 반납됨");
            }
            return new ReturnResult(
                    loan.withReturnedOn(today), "반납 완료");
            """;

    static List<Finding> scan(String methodBody) {
        List<Finding> findings = new ArrayList<>();
        if (methodBody.contains(".withReturnedOn(today)")) {
            findings.add(new Finding(
                    "S1", "정적 검사", "반납일 기록 전 재반납 처리 확인"));
        }
        return List.copyOf(findings);
    }

    static List<Finding> suppliedAiFindings() {
        return List.of(
                new Finding("A1", "AI 리뷰", "최초 반납일 덮어쓰기"),
                new Finding("A2", "AI 리뷰", "null 입력 예외"));
    }

    static Assessment assess(Finding finding) {
        return switch (finding.id()) {
            case "S1" -> new Assessment(
                    finding, Decision.FIXED,
                    "재반납 분기 추가, 최초 반납일 보존 PASS");
            case "A1" -> new Assessment(
                    finding, Decision.DUPLICATE,
                    "S1과 같은 원인, 같은 수정으로 확인");
            case "A2" -> new Assessment(
                    finding, Decision.FALSE_POSITIVE,
                    "필수 입력 계약과 명시적 null 검사 확인");
            default -> new Assessment(
                    finding, Decision.OPEN, "사람의 추가 검토 필요");
        };
    }

    static int passed;
    static int failed;

    static boolean check(boolean condition, String name) {
        if (condition) {
            passed++;
            System.out.println("PASS " + name);
        } else {
            failed++;
            System.out.println("FAIL " + name);
        }
        return condition;
    }

    static boolean rejectsNull(Runnable action) {
        try {
            action.run();
            return false;
        } catch (NullPointerException expected) {
            return true;
        }
    }

    static void printReport(
            List<Assessment> assessments,
            List<HumanCheck> humanChecks) {
        System.out.println("=== 최종 리뷰 보고서 ===");
        System.out.println("대상: 반납 기능 PR");
        System.out.println(
                "범위: 단일 호출의 상태 변환, 저장 계층 제외");

        for (Assessment assessment : assessments) {
            Finding finding = assessment.finding();
            System.out.println(
                    finding.id() + " [" + finding.origin() + "] "
                    + finding.message() + " - "
                    + assessment.decision().label);
            System.out.println("  근거: " + assessment.evidence());
        }

        for (HumanCheck item : humanChecks) {
            System.out.println(
                    "사람 [" + item.name() + "] "
                    + (item.done() ? "완료" : "미완료")
                    + " - " + item.evidence());
        }

        long open = assessments.stream()
                .filter(a -> a.decision() == Decision.OPEN)
                .count();
        long completed = humanChecks.stream()
                .filter(HumanCheck::done)
                .count();
        boolean approved = open == 0
                && completed == humanChecks.size()
                && failed == 0;

        System.out.println("자동 의견: " + assessments.size()
                + "건, 미처리: " + open + "건");
        System.out.println("사람 확인: " + completed
                + "/" + humanChecks.size());
        System.out.println("실행 검사: PASS " + passed
                + ", FAIL " + failed);
        System.out.println(
                "판정: " + (approved ? "범위 내 승인" : "보완 필요"));
    }

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2030, 4, 10);
        LocalDate nextDay = day.plusDays(1);
        Loan active = new Loan("L-1", null);
        BeforeReturnService before = new BeforeReturnService();
        AfterReturnService after = new AfterReturnService();

        ReturnResult oldNormal = before.finish(active, day);
        ReturnResult newNormal = after.finish(active, day);
        boolean normalSame = check(
                oldNormal.equals(newNormal), "정상 반납 전후 결과 동일");
        boolean normalExpected = check(
                newNormal.loan().returnedOn().equals(day)
                && newNormal.message().equals("반납 완료"),
                "정상 반납 요구 충족");
        boolean originalKept = check(
                active.returnedOn() == null, "원본 기록 유지");
        boolean idKept = check(
                newNormal.loan().id().equals(active.id()), "대출 식별자 유지");

        Loan returned = newNormal.loan();
        ReturnResult oldRetry = before.finish(returned, nextDay);
        ReturnResult newRetry = after.finish(returned, nextDay);
        boolean oldBug = check(
                oldRetry.loan().returnedOn().equals(nextDay),
                "변경 전 덮어쓰기 재현");
        boolean retryFixed = check(
                newRetry.loan().equals(returned)
                && newRetry.message().equals("이미 반납됨"),
                "변경 후 최초 반납일 보존");
        boolean nullLoanRejected = check(
                rejectsNull(() -> after.finish(null, day)),
                "null 대출 거부");
        boolean nullDayRejected = check(
                rejectsNull(() -> after.finish(active, null)),
                "null 날짜 거부");

        List<Finding> initial = new ArrayList<>(scan(BEFORE_BODY));
        initial.addAll(suppliedAiFindings());
        List<Assessment> assessments = initial.stream()
                .map(Main::assess)
                .toList();

        boolean warningRemains = check(
                scan(AFTER_BODY).size() == 1,
                "수정 후 넓은 규칙 의견 유지");
        boolean duplicateLinked = check(
                assessments.stream().anyMatch(a ->
                        a.finding().id().equals("A1")
                        && a.decision() == Decision.DUPLICATE),
                "중복 의견 연결");

        boolean scopeReviewed = true;
        List<HumanCheck> humanChecks = List.of(
                new HumanCheck(
                        "요구",
                        normalSame && normalExpected && oldBug && retryFixed,
                        "정상 반납과 다음 날 재반납 실행"),
                new HumanCheck(
                        "설계",
                        originalKept && idKept
                                && nullLoanRejected && nullDayRejected,
                        "원본 보존, 식별자와 필수 입력 계약 확인"),
                new HumanCheck(
                        "위험",
                        scopeReviewed && warningRemains && duplicateLinked,
                        "단일 호출 범위, 저장 계층은 별도 검토"));

        printReport(assessments, humanChecks);
    }
}

줄별 해설

Loan 선언의 returnedOn은 아직 반납하지 않았을 때만 null이다. 필수 인자인 loan 자체가 null인 상황과 구분해야 한다. withReturnedOn은 새 Loan을 반환한다. 원본을 바꾸지 않으므로 호출 전후의 값을 나란히 비교할 수 있다. ReturnResult는 결과 기록과 화면에 전달할 메시지를 함께 묶는다.

BeforeReturnService의 finish는 두 필수 인자를 확인한 다음 날짜를 기록한다. 누락된 것은 입력 유효성 검사가 아니라 이미 반납한 상태를 처리하는 분기다. AfterReturnService는 같은 필수 입력 검사 아래에 그 분기를 추가했다. 이미 반납했다면 기존 Loan을 결과에 넣으므로 최초 반납일과 식별자를 함께 유지한다.

Decision에는 미처리 상태도 있다. 모든 의견을 무조건 완료로 바꾸는 구조라면 새 종류의 의견이 들어와도 보고서가 승인될 수 있다. assess의 default 분기는 알 수 없는 의견을 미처리로 남긴다. 이번 assess는 이 실습에서 사람이 내린 판정의 기록이다. 의견 번호만 보고 실제 결함을 추론하는 범용 판정기로 사용해서는 안 된다.

scan의 contains 호출은 날짜 기록 호출의 존재만 확인한다. 분기가 앞에 있는지, 다른 경로에서 기록하는지 이해하지 못한다. 따라서 수정 후에도 S1을 반환한다. warningRemains 검사는 이 한계를 의도대로 재현하는지 확인한다. 실제 코드와 검사 입력 문자열을 함께 유지해야 하는 부담도 있다. 이 예제에서는 두 본문을 나란히 제시하지만, 실제 도구를 연결할 때는 검토 중인 소스 자체를 검사 입력으로 사용해야 한다.

suppliedAiFindings는 고정된 두 의견을 제공한다. 외부 모델 호출이 없으므로 같은 실행에서 의견의 문장이나 순서가 바뀌지 않는다. initial은 정적 검사 의견 뒤에 AI 의견을 붙인다. 이후 목록의 순서를 바꾸지 않아 보고서도 S1, A1, A2 순으로 출력된다.

check는 조건을 출력하고 통계를 누적하며 같은 조건을 반환한다. 반환한 값을 사람 확인 항목에 다시 사용하여 실행 결과와 완료 여부를 연결한다. rejectsNull은 기대한 NullPointerException만 성공으로 본다. 다른 예외까지 잡아 성공으로 처리하면 예상과 다른 실패를 숨길 수 있다.

main의 normalSame은 같은 active와 day를 두 구현에 전달해 결과 전체가 같은지 확인한다. 다만 전후가 똑같이 잘못될 수도 있으므로 normalExpected가 합의한 날짜와 메시지까지 확인한다. oldBug는 기존 결함이 재현되었다는 검사다. 결함 있는 동작이 올바르다는 뜻이 아니라, 수정이 필요한 사례를 실제로 잡았다는 뜻이다.

newRetry에는 oldRetry와 같은 returned, nextDay를 전달한다. 여기서는 결과가 같아야 한다고 검사하지 않는다. 이전 결과는 날짜를 덮어쓰고 수정 결과는 최초 날짜를 유지해야 하기 때문이다. “기존과 같음”과 “요구를 만족함”은 서로 다른 판단이며 입력의 목적에 따라 선택해야 한다.

scopeReviewed는 사람이 검토 범위 문구를 확인했다는 고정 기록이다. 프로그램이 저장 계층의 안전성을 증명한 값이 아니다. printReport는 이 기록과 실행 검사를 모아 출력한다. failed가 남아 있으면 다른 항목을 모두 완료로 기록해도 보완 필요가 된다. 보고서 문자열을 출력하는 일과 승인 근거를 만드는 일을 구분한 구성이다.

실행 결과

Main.java가 있는 디렉터리에서 다음 명령을 실행한다. 첫 번째 명령은 모든 경고를 켜고 경고가 있으면 컴파일을 실패시킨다. 이 코드에서는 경고가 발생하지 않으며 컴파일 성공 시 별도 출력이 없다. 일반적인 실행 명령인 javac -d out *.java로도 컴파일할 수 있다.

javac -Xlint:all -Werror -d out *.java
java -cp out Main

예상 출력은 다음과 같다. 실행 환경의 날짜나 임시 경로처럼 달라지는 값을 출력하지 않는다.

PASS 정상 반납 전후 결과 동일
PASS 정상 반납 요구 충족
PASS 원본 기록 유지
PASS 대출 식별자 유지
PASS 변경 전 덮어쓰기 재현
PASS 변경 후 최초 반납일 보존
PASS null 대출 거부
PASS null 날짜 거부
PASS 수정 후 넓은 규칙 의견 유지
PASS 중복 의견 연결
=== 최종 리뷰 보고서 ===
대상: 반납 기능 PR
범위: 단일 호출의 상태 변환, 저장 계층 제외
S1 [정적 검사] 반납일 기록 전 재반납 처리 확인 - 수정 완료
  근거: 재반납 분기 추가, 최초 반납일 보존 PASS
A1 [AI 리뷰] 최초 반납일 덮어쓰기 - 중복
  근거: S1과 같은 원인, 같은 수정으로 확인
A2 [AI 리뷰] null 입력 예외 - 오탐
  근거: 필수 입력 계약과 명시적 null 검사 확인
사람 [요구] 완료 - 정상 반납과 다음 날 재반납 실행
사람 [설계] 완료 - 원본 보존, 식별자와 필수 입력 계약 확인
사람 [위험] 완료 - 단일 호출 범위, 저장 계층은 별도 검토
자동 의견: 3건, 미처리: 0건
사람 확인: 3/3
실행 검사: PASS 10, FAIL 0
판정: 범위 내 승인

수정 후에도 넓은 규칙 의견이 유지된다는 PASS가 눈에 띈다. 이 결과는 검사기를 속였다는 의미가 아니다. 규칙이 제공하는 정보의 범위가 날짜 기록 호출의 존재까지임을 확인한 것이다. 보고서는 그 의견을 숨기는 대신 실제 분기와 재현 검사의 근거를 함께 제시한다.

보고서의 자동 의견 세 건은 결함 세 건을 뜻하지 않는다. 실제로 고친 원인은 재반납 분기 누락 한 건이다. 중복 의견은 그 수정에 연결했고, null 의견은 계약을 근거로 오탐 판정했다. 이렇게 구분해야 도구별 의견 수가 팀의 결함 수나 작업량으로 잘못 해석되지 않는다.

실무에서 자주 틀리는 것

경고가 남았다는 이유만으로 수정을 되돌린다

다음은 틀린 판단 방식이다. 넓은 규칙의 결과가 비어 있어야 승인하도록 정하면, 분기를 올바르게 추가해도 보완 필요가 된다. 반대로 규칙이 놓친 요구 위반은 통과할 수 있다. 아래 두 클래스는 각각 독립적으로 컴파일할 수 있는 비교 예다.

final class WrongWarningGate {
    static boolean approve(int warningCount) {
        return warningCount == 0;
    }
}

고친 코드는 남은 미처리 의견과 요구 확인, 실행 실패를 판단 입력으로 받는다. 이 값들을 누가 어떤 근거로 만들었는지는 보고서에 남겨야 한다. 단순히 인자 이름을 바꾸는 것으로 근거가 생기지는 않는다.

final class BetterWarningGate {
    static boolean approve(
            int unresolvedCount, boolean requirementsChecked,
            int failedChecks) {
        return unresolvedCount == 0
                && requirementsChecked
                && failedChecks == 0;
    }
}

같은 변경의 자동 의견이 한 건 남아도 모두 처리했고 요구와 실행 검사가 통과했다면, 앞 코드는 거절하고 뒤 코드는 승인할 수 있다. 이것은 동작 보존을 위한 수정이 아니라 승인 기준을 바로잡는 변경이다. 완성 코드의 printReport가 이 기준을 실행으로 확인한다.

전후가 같기만 하면 맞는 코드라고 판단한다

다음 메서드는 전후 결과만 비교한다. 두 구현이 모두 반납일을 잘못 계산해도 true가 된다. 정상 입력에서 기존 동작을 유지하는지 확인하는 용도로는 쓸 수 있지만, 요구 충족의 증거로는 부족하다.

final class WrongComparison {
    static boolean verify(String before, String after) {
        return java.util.Objects.equals(before, after);
    }
}

고친 메서드는 기대 결과도 함께 확인한다. 완성 코드에서는 문자열 하나 대신 결과 기록 전체를 비교하고 날짜와 메시지를 따로 검사했다. 결함 재현 입력에는 전후 결과가 달라지는 것이 필요한 경우도 있으므로 그때는 수정 후 결과를 기대값과 비교한다.

final class BetterComparison {
    static boolean verify(
            String before, String after, String expected) {
        return java.util.Objects.equals(before, after)
                && java.util.Objects.equals(after, expected);
    }
}

두 메서드에 before와 after를 모두 “반납 완료”로 전달하고 expected도 같은 값으로 전달하면 결과는 모두 true다. expected만 “이미 반납됨”으로 바꾸면 앞 코드는 true, 뒤 코드는 false다. 완성 코드의 정상 결과 비교와 재반납 요구 검사가 이 두 판단을 실제 입력으로 나누어 수행한다.

오탐으로 닫으며 근거를 지운다

다음 코드는 의견을 오탐으로 판정하면서 이유를 비운다. 나중에 계약이 바뀌거나 다른 리뷰어가 같은 의견을 받으면 다시 처음부터 조사해야 한다. 코드가 짧아졌지만 검토 기록의 쓸모도 줄어든다.

final class WrongDismissal {
    record Entry(String id, String decision, String evidence) {}

    static Entry dismiss() {
        return new Entry("A2", "오탐", "");
    }
}

고친 코드는 판정이 의존한 계약과 확인 위치를 남긴다. null을 허용하는 계약으로 바뀐다면 이 근거도 다시 검토해야 한다. 도구 전체를 끄거나 같은 종류의 의견을 앞으로 모두 무시할 이유는 되지 않는다.

final class BetterDismissal {
    record Entry(String id, String decision, String evidence) {}

    static Entry dismiss() {
        return new Entry(
                "A2", "오탐",
                "필수 입력 계약이며 finish 시작에서 두 인자를 검사함");
    }
}

이 수정은 서비스의 반납 결과를 바꾸지 않는다. 완성 코드에서 정상 반납 전후 결과가 동일하다는 검사는 그대로 통과하고, 보고서의 A2 근거만 검토 가능한 기록이 된다. 동작을 바꾸는 수정과 검토 기록을 보강하는 수정을 구분해서 읽어야 한다.

한눈에 보기

자동 도구와 사람이 최종 보고서에 기여하는 정보
대상하는 일남길 증거주의점
정적 검사정해진 패턴에서 확인 후보를 찾는다.규칙과 검사 대상경고 유무를 요구 충족과 같게 보지 않는다.
AI 리뷰검토할 가능성을 제안한다.원래 의견과 대상 코드추정한 계약과 실제 계약을 대조한다.
사람의 판정수정·중복·오탐을 구분한다.요구와 수정, 연결한 검사도구 이름을 판정 근거로 삼지 않는다.
실행 검사선택한 입력의 결과를 확인한다.고정 입력과 PASS/FAIL전후 동등성과 요구 충족을 구분한다.
최종 보고서의견 처리와 사람 확인을 모은다.미처리 수와 범위, 최종 판정확인하지 않은 계층까지 승인을 넓히지 않는다.

책에서 다룬 읽기의 목적은 수정할 줄을 많이 찾는 데 있지 않다. 변경이 지켜야 할 것을 확인하고 그 판단을 팀이 이어받을 수 있게 남기는 데 있다. 이번 실습의 결과물도 수정된 메서드만이 아니다. 무엇을 고쳤고, 어떤 의견은 왜 받아들이지 않았으며, 어디까지 확인했는지가 함께 남은 보고서다.

연습 문제

  1. AI 의견 목록에 식별자 A3, 내용 “반환한 대출 기록의 저장 여부 확인”을 추가하라. 기존 판정 코드는 바꾸지 않고 실행한다. 자동 의견 수, 미처리 수, 최종 판정을 예상하고 그 이유를 설명하라.
  2. AfterReturnService의 재반납 분기를 제거하고 실행하라. FAIL이 되는 검사와 사람 확인 항목을 찾으라. 수정 전후 정상 반납 결과가 같다는 검사만으로 이 변경을 승인할 수 없는 이유를 설명하라.
  3. AI 의견 목록에서 A2를 제거하라. 서비스 구현과 사람 확인 항목은 유지한다. 실행 검사 통계와 자동 의견 수, 최종 판정이 어떻게 달라지는지 예상하라.
  4. 요구가 “재반납에서도 가장 최근 처리일을 남긴다”로 바뀌었다고 가정하라. 코드를 먼저 바꾸지 말고 S1과 A1의 판정 근거, 재반납 검사, 보고서 문구 중 다시 검토할 부분을 적으라. 기존 수정을 그대로 유지하면 안 되는 이유를 설명하라.

정답과 해설

  1. 자동 의견은 네 건, 미처리는 한 건, 판정은 보완 필요가 된다. assess에 A3 분기가 없으므로 default에서 OPEN을 반환한다. 기존 실행 검사는 모두 통과하고 사람 확인도 기존 값이라면 완료로 출력된다. 그럼에도 미처리가 남아 승인이 막힌다. 저장 여부는 현재 검토 범위 밖이므로 호출부를 검토할지, 후속 확인 대상으로 연결할지 사람이 결정하고 근거를 남겨야 한다.

  2. “변경 후 최초 반납일 보존”이 FAIL이 된다. 정상 반납은 전과 같고, 기존 구현의 덮어쓰기도 그대로 재현된다. 따라서 PASS는 아홉, FAIL은 하나다. 요구 항목은 retryFixed가 false이므로 미완료이고 최종 판정도 보완 필요가 된다. 정상 입력의 동등성은 재반납 요구를 대신 확인하지 못한다. S1의 고정 판정 문구가 여전히 수정 완료라고 출력되어도 실행 실패가 승인을 막는다는 점도 확인할 수 있다. 실제 보고서를 제출할 때는 그 판정 기록 역시 수정해야 한다.

  3. 자동 의견은 두 건, 미처리는 여전히 없는 상태다. 실행 검사는 AI 의견 개수나 A2의 존재를 검사하지 않으므로 PASS 열 건, FAIL 없음이 유지된다. A1의 중복 연결도 남아 있어 사람 확인은 모두 완료이고 범위 내 승인이다. 도구가 null 의견을 주지 않아도 필수 입력 계약 검사는 사람이 정한 확인 항목으로 계속 실행된다.

  4. 최초 반납일 보존이 더 이상 요구가 아니므로 S1과 A1을 기존 이유로 수정 완료 또는 중복 처리할 수 없다. 새로운 요구에서 기록할 날짜의 의미를 다시 확인한 뒤 판정해야 한다. 재반납 검사는 최근 처리일이 nextDay인지 확인하도록 바뀌어야 하며 반환 메시지도 합의해야 한다. “최초 반납일 보존 PASS”라는 보고서 근거도 교체 대상이다. 과거의 수정 근거는 과거의 요구에 의존하므로 요구 변경 뒤에는 같은 결론을 그대로 사용할 수 없다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.