Devin.KR

AI 개발 · 심화

AI 코딩 도구로 팀 개발하기 - Java 실무 저장소편

장애 재현과 원인 찾기 - 로그·가설·최소 재현

로그에서 단서 고르기, AI 에게 줄 최소 재현 만들기, 가설을 하나씩 검증, 그럴듯하지만 틀린 원인 진단 피하기, 완성 코드는 주문 로그 문자열에서 실패 패턴을 찾아 최소 재현 입력을 만들고 고치기 전후 결과를 출력한다

개발자KR · 원고 갱신

이 장에서 배우는 것

주문이 거절되었다는 로그를 보고 바로 재고 검사 코드를 바꾸면, 증상과 원인을 혼동하기 쉽다. 결제 재시도 메시지 옆에서 오류가 발생했다고 해서 결제가 원인인 것도 아니다. 장애를 고치는 일은 눈에 띄는 문장을 설명하는 일이 아니라, 관찰한 실패를 같은 조건에서 다시 만들고 그 조건을 하나씩 줄이는 일이다.

앞 장에서 기존 동작을 특성 테스트로 붙잡았다면, 여기서는 잘못된 동작이 일어나는 입력을 붙잡는다. 작은 쇼핑몰 저장소에 AI가 만든 변경이 들어온 뒤, 남은 재고와 주문 수량이 같은 주문이 거절되었다. 팀은 주문 로그에서 단서를 고르고, 외부 연결 없이 실행되는 최소 재현을 만든 다음, 수정 전후를 같은 입력으로 비교한다.

  • 로그의 관찰 사실과 원인에 대한 추측을 구분한다.
  • 주문 식별자와 사건 종류로 관련 로그를 좁힌다.
  • AI에게 전달할 재현 입력과 기대 결과를 명시한다.
  • 가설 하나를 바꿀 때마다 같은 입력으로 결과를 확인한다.
  • 실패가 유지되는 작은 입력을 만들고 수정 전후를 비교한다.

문제 상황

팀이 관리하는 주문 서비스는 재고를 확인한 다음 결제 모의 객체를 호출한다. 결제가 승인되면 재고를 차감하고 주문을 확정한다. 최근 변경 이후 일부 주문이 재고 부족으로 거절되었다. 운영 담당자는 “분명 재고가 있었는데 주문할 수 없었다”는 제보와 함께 주문 로그 몇 줄을 전달했다.

같은 시간대에는 결제 재시도 로그도 있었다. AI에게 로그 전체를 주자 다음과 같은 답이 돌아왔다.

결제 재시도 과정에서 재고가 먼저 차감된 것으로 보인다. 재시도 전에 재고를 복구하면 해결될 가능성이 높다.

그럴듯한 설명이지만 아직 근거가 없다. 로그에 재시도가 있다는 사실과 재고가 먼저 차감되었다는 주장은 서로 다르다. 주문 식별자도 확인하지 않았고, 차감 전후의 재고도 비교하지 않았다. 이 설명을 그대로 구현하면 실제로 차감되지 않은 재고를 늘리는 새 오류가 생길 수 있다.

이번 예제의 업무 규칙은 명확하다. 주문 수량은 양수이고 재고는 음수가 아니다. 재고가 주문 수량 이상이면 주문을 받을 수 있다. 결제 모의 객체는 항상 승인한다. 따라서 재고가 3개이고 주문 수량도 3개라면 결과는 주문 확정과 남은 재고 0개다. 이 규칙은 AI의 진단과 별개로 팀이 확인한 요구다.

제보에 포함된 로그에는 서로 다른 주문이 섞여 있다. 주문 A는 결제 재시도 후 확정되었다. 주문 B는 재고 3개에 수량 4개를 요청했으므로 거절이 정상이다. 주문 C는 재고 3개에 수량 3개를 요청했는데 거절되었다. 주문 D는 결제 거절 기록이다. 먼저 이 차이를 분리해야 한다.

로그에서 사실과 추측을 나눈다

로그(log)는 실행 중 남긴 관찰 기록이다. 기록되지 않은 내부 동작까지 증명하지는 않는다. “재고 부족으로 거절했다”는 문장은 프로그램이 그 분기를 선택했다는 증거다. 실제 재고가 부족했다는 업무적 판단까지 보장하지 않는다. 잘못된 조건문도 자신이 선택한 분기의 이름을 정상적으로 출력할 수 있다.

이번 로그 형식은 시각, 주문 식별자, 사건, 검사 직전 재고, 주문 수량, 상태, 사유를 세로줄로 구분한다. 시각은 모두 고정 문자열이다. 재고 값의 의미도 ‘검사 직전’으로 고정한다. 차감 후 재고를 같은 이름으로 기록하면, 로그를 읽는 사람이 입력과 결과를 뒤섞을 수 있다.

로그에서 확인한 사실과 아직 확인하지 않은 주장
관찰말할 수 있는 사실추가 확인할 것
주문 A에 재시도 기록이 있다A에서 재시도 사건이 기록되었다C도 같은 경로를 지났는가
주문 B가 재고 부족으로 거절되었다수량 4가 재고 3보다 크다업무 규칙과 결과가 일치하는가
주문 C가 재고 부족으로 거절되었다기록된 재고와 수량은 모두 3이다같은 값으로 다시 거절되는가
주문 D가 결제로 거절되었다거절 사유가 재고 부족과 다르다이번 조사 범위에 포함되는가

단서를 고를 때는 사건과 상태와 사유를 함께 본다. 로그 문자열 전체에 REJECTED가 포함되었는지만 검사하면, 다른 필드의 내용이나 별도 사건까지 끌어들일 수 있다. 먼저 필드를 나누고, 최종 결과 사건인지 확인한 뒤, 재고 부족 거절만 후보로 모은다.

후보라는 말도 중요하다. 재고 부족 거절 두 건 중 하나는 정상 동작이다. 오류처럼 보이는 단어를 찾는 단계와 업무 규칙을 어긴 결과를 찾는 단계는 다르다. 완성 프로그램은 먼저 후보 두 건을 찾고, 수정 전 코드를 재실행하여 기대 결과와 다른 한 건을 분리한다.

로그 후보를 고른 뒤 업무 규칙과 재실행 결과를 비교해야 실제 실패를 구분할 수 있다

주문 식별자는 관련 기록을 연결하는 열쇠다. 다만 식별자가 같다는 이유만으로 모든 기록이 같은 시도의 기록이라고 단정해서는 안 된다. 실제 저장소에서는 재시도 번호나 요청 식별자가 필요할 수 있다. 이번 예제는 주문마다 재고 검사 입력 하나만 기록하며, 동시 실행과 재시도 상태는 재현 대상에서 제외한다.

이 제외는 원인을 미리 확정한다는 뜻이 아니다. 외부 조건을 제거해도 실패가 남는지 확인하려는 실험이다. 결제 모의 객체가 항상 승인하고 요청이 하나뿐인데도 같은 거절이 발생한다면, 결제 실패나 동시 실행은 그 작은 재현에 필요한 조건이 아니다.

최소 재현과 가설 검증을 연결한다

최소 재현(minimal reproduction)은 실패를 다시 일으키는 데 필요한 조건을 작게 남긴 실행 예다. 전체 주문 서비스, 데이터베이스, 서버를 모두 켜는 대신 재고와 수량 두 값만으로 같은 판단 오류를 보일 수 있다면, 조사 대상이 크게 줄어든다. 다만 작아진 예가 원래 장애와 같은 실패를 나타내는지는 계속 확인해야 한다.

재현 자료에는 입력만 있으면 부족하다. 실행 방법, 기대 결과, 실제 결과, 성공 여부를 판정하는 규칙이 함께 있어야 한다. “이 코드가 이상하다”는 요청보다 “재고 3, 수량 3에서 확정을 기대하지만 거절된다”는 요청이 검증 가능한 답을 받기 쉽다.

다음 주문 판단을 조사하라. 업무 규칙은 재고가 수량 이상이면 확정한다는 것이다. 입력은 재고 3, 수량 3이며 결제는 항상 승인한다. 기대 결과는 CONFIRMED와 남은 재고 0이지만, 현재 결과는 REJECTED와 남은 재고 3이다. 먼저 관찰 사실과 가설을 분리하고, 가설마다 반박할 수 있는 실행 조건을 제시하라. 확인되지 않은 재고 복구 코드는 추가하지 말라.

AI의 답은 결론이 아니라 실험 제안으로 받는다. “경계 조건이 원인이다”라는 말에도 실행 증거가 필요하다. 재고가 수량보다 큰 경우, 같은 경우, 작은 경우를 나누면 어떤 경계에서 동작이 달라지는지 확인할 수 있다.

한 번에 하나씩 확인하는 원인 가설
가설확인 방법이번 예제의 관찰판단 범위
결제 재시도가 필요하다항상 승인하는 모의 객체로 실행한다같은 거절이 남는다재현에 재시도가 필요하지 않다
동시 요청이 필요하다상태를 공유하지 않는 단일 호출로 실행한다같은 거절이 남는다재현에 동시 실행이 필요하지 않다
재고와 수량의 경계 판단이 다르다크다·같다·작다를 각각 실행한다같은 경우만 규칙과 다르다비교 연산을 직접 조사한다

실패를 재현한 뒤에는 입력을 줄인다. 예제는 재고와 수량을 함께 하나씩 줄이되, 줄인 입력에서도 수정 전 결과가 업무 규칙과 다른지 매번 확인한다. 3과 3은 2와 2가 되고, 다시 1과 1이 된다. 수량은 양수여야 하므로 여기서 멈춘다.

이 축소 방식은 모든 가능한 입력을 탐색하는 일반 알고리즘이 아니다. 두 값을 함께 줄이는 경로에서 실패를 유지하는 작은 예를 얻는다. 이번 실패는 두 값이 같은 양수인 조건으로 설명되므로 1과 1이 충분하다. 상품 종류나 할인 조건이 얽힌 장애라면 다른 축소 규칙이 필요하다.

입력을 줄일 때마다 같은 실패가 유지되는지 검사하고 유효한 가장 작은 양수 수량에서 멈춘다

수정 전 코드는 재고가 수량 이하이면 거절한다. 이 조건에는 두 값이 같은 경우도 포함된다. 업무 규칙에 맞게 거절 조건을 재고가 수량보다 작은 경우로 바꾼다. 수정이 작다고 검증을 생략하지 않는다. 원래 실패 입력과 축소 입력을 모두 다시 실행하고, 이미 맞았던 정상 확정과 정상 거절도 그대로인지 확인한다.

이때 실패 입력의 전후 출력은 달라야 한다. 같은 입력에 수정 전후가 언제나 같은 출력을 내야 한다고 요구하면 버그도 보존하게 된다. 대신 정상 입력은 같은 결과를 유지하는지, 실패 입력은 요구한 결과로 바뀌는지 구분한다. 같은 구현을 같은 입력으로 두 번 실행했을 때 결과가 같은지도 별도로 확인한다.

완성 코드

다음 프로그램은 파일을 읽거나 쓰지 않는다. 고정된 주문 로그 문자열에서 후보를 찾고, 업무 규칙과 다른 입력을 골라 축소한다. 결제 모의 객체는 상태를 갖지 않으며 항상 승인한다. 재고도 입력 값으로만 전달하므로 실행마다 같은 결과가 나온다.

로그 형식은 이 예제를 위한 고정 규약이다. 필드가 일곱 개가 아니면 즉시 거절하며, 빈 마지막 필드도 보존한다. 실제 서비스의 여러 로그 형식을 모두 받아들이는 수집기는 아니다. 입력 범위는 작은 재고 값이며, 축소 과정도 그 범위에서 실행한다.

Main.java

import java.util.ArrayList;
import java.util.List;

public class Main {
    enum Status {
        CONFIRMED, REJECTED
    }

    record Input(int stock, int quantity) {
        Input {
            if (stock < 0 || quantity <= 0) {
                throw new IllegalArgumentException("invalid input");
            }
        }
    }

    record Result(Status status, int remaining) {
    }

    record Entry(String orderId, String event, Input input,
                 String status, String reason) {
    }

    static final class PaymentMock {
        boolean approve() {
            return true;
        }
    }

    private static final PaymentMock PAYMENT = new PaymentMock();
    private static int failures;

    // [1] 시각과 입력을 고정한 조사용 로그
    private static final String LOGS = String.join("\n",
        "2026-10-01T09:00:00|A|PAYMENT_RETRY|5|2|RETRY|TIMEOUT",
        "2026-10-01T09:00:01|A|RESULT|5|2|CONFIRMED|OK",
        "2026-10-01T09:00:02|B|RESULT|3|4|REJECTED|OUT_OF_STOCK",
        "2026-10-01T09:00:03|C|RESULT|3|3|REJECTED|OUT_OF_STOCK",
        "2026-10-01T09:00:04|D|RESULT|8|1|REJECTED|PAYMENT_DECLINED"
    );

    // [2] 문자열 포함 여부 대신 필드의 의미를 검사한다.
    static Entry parse(String line) {
        String[] fields = line.split("\\|", -1);
        if (fields.length != 7) {
            throw new IllegalArgumentException("expected 7 fields");
        }
        Input input = new Input(
            Integer.parseInt(fields[3]),
            Integer.parseInt(fields[4])
        );
        return new Entry(
            fields[1], fields[2], input, fields[5], fields[6]
        );
    }

    static List<Entry> findCandidates(String logs) {
        List<Entry> candidates = new ArrayList<>();
        for (String line : logs.lines().toList()) {
            Entry entry = parse(line);
            if (entry.event().equals("RESULT")
                    && entry.status().equals("REJECTED")
                    && entry.reason().equals("OUT_OF_STOCK")) {
                candidates.add(entry);
            }
        }
        return candidates;
    }

    // [3] 업무 규칙을 세 경우로 펼친 기대 결과
    static Result expected(Input input) {
        int stock = input.stock();
        int quantity = input.quantity();
        if (stock == quantity) {
            return new Result(Status.CONFIRMED, 0);
        }
        if (stock > quantity) {
            return new Result(Status.CONFIRMED, stock - quantity);
        }
        return new Result(Status.REJECTED, stock);
    }

    // [4] 수정 전 판단
    static Result before(Input input) {
        if (input.stock() <= input.quantity()) {
            return new Result(Status.REJECTED, input.stock());
        }
        if (!PAYMENT.approve()) {
            return new Result(Status.REJECTED, input.stock());
        }
        return new Result(
            Status.CONFIRMED, input.stock() - input.quantity()
        );
    }

    // [5] 수정 후 판단
    static Result after(Input input) {
        if (input.stock() < input.quantity()) {
            return new Result(Status.REJECTED, input.stock());
        }
        if (!PAYMENT.approve()) {
            return new Result(Status.REJECTED, input.stock());
        }
        return new Result(
            Status.CONFIRMED, input.stock() - input.quantity()
        );
    }

    // [6] 기대 결과와 다른 상태 또는 잔여 재고를 찾는다.
    static boolean reproduces(Input input) {
        return !before(input).equals(expected(input));
    }

    static Input minimize(Input original) {
        if (!reproduces(original)) {
            throw new IllegalArgumentException("failure required");
        }
        Input current = original;
        while (current.stock() > 0 && current.quantity() > 1) {
            Input smaller = new Input(
                current.stock() - 1, current.quantity() - 1
            );
            if (!reproduces(smaller)) {
                break;
            }
            current = smaller;
        }
        return current;
    }

    // [7] 결과와 검증 출력을 고정된 순서로 표시한다.
    static String describe(Result result) {
        return result.status() + ",remaining=" + result.remaining();
    }

    static void show(String name, Input input) {
        System.out.println(name + " stock=" + input.stock()
            + ",quantity=" + input.quantity());
        System.out.println("  expected=" + describe(expected(input)));
        System.out.println("  before=" + describe(before(input)));
        System.out.println("  after=" + describe(after(input)));
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            failures++;
        }
    }

    public static void main(String[] args) {
        List<Entry> candidates = findCandidates(LOGS);
        List<Entry> mismatches = new ArrayList<>();
        for (Entry entry : candidates) {
            if (reproduces(entry.input())) {
                mismatches.add(entry);
            }
        }

        System.out.println("candidates=" + candidates.size());
        System.out.println("mismatches=" + mismatches.size());
        check(candidates.size() == 2, "candidate filter");
        check(mismatches.size() == 1, "one mismatch");
        if (mismatches.size() != 1) {
            throw new IllegalStateException("expected one mismatch");
        }

        Entry failure = mismatches.get(0);
        Input original = failure.input();
        Input minimal = minimize(original);
        System.out.println("order=" + failure.orderId());
        show("original", original);
        show("minimal", minimal);

        check(reproduces(minimal), "failure survives reduction");
        check(minimal.equals(new Input(1, 1)), "minimal input");
        check(after(original).equals(expected(original)),
            "original fixed");
        check(after(minimal).equals(expected(minimal)),
            "minimal fixed");

        Input enough = new Input(3, 2);
        Input shortage = new Input(3, 4);
        show("enough", enough);
        show("shortage", shortage);
        check(before(enough).equals(after(enough))
                && after(enough).equals(expected(enough)),
            "enough unchanged");
        check(before(shortage).equals(after(shortage))
                && after(shortage).equals(expected(shortage)),
            "shortage unchanged");
        check(before(minimal).equals(before(minimal))
                && after(minimal).equals(after(minimal)),
            "repeatable");

        System.out.println("checks failed=" + failures);
        if (failures != 0) {
            throw new IllegalStateException("check failed");
        }
    }
}

줄별 해설

[1] 조사 자료를 고정한다. 로그의 순서와 시각을 상수로 둔다. 현재 시각이나 실제 결제 응답을 사용하지 않으므로 실행할 때마다 출력이 달라지지 않는다. 주문 A의 재시도는 눈에 띄지만, C의 실패를 설명하는 필수 조건은 아니다. 관련 없어 보이는 기록도 일부 남겨 필터가 조사 범위를 제대로 좁히는지 확인한다.

[2] 로그를 구조로 바꾼다. split의 첫 인자는 정규 표현식이므로 세로줄을 문자 자체로 해석하도록 이스케이프한다. 두 번째 인자 -1은 끝의 빈 필드를 버리지 않게 한다. 형식이 잘못된 줄을 조용히 정상으로 처리하지 않는다. 이 프로그램에서는 잘못된 조사 자료를 빨리 드러내도록 예외를 발생시킨다.

Entry에는 이번 판단에 필요한 필드만 남긴다. 시각은 원본 문자열에 있지만 재현 입력에는 들어가지 않는다. 시간에 따라 재고가 달라지는 기능을 재현하는 것이 아니기 때문이다. Input은 음수 재고와 0 이하 수량을 생성 단계에서 거절한다. 따라서 축소 과정이 업무상 존재하지 않는 주문을 만들어 실패라고 주장하지 않게 된다.

[3] 기대 결과의 근거를 따로 둔다. expected는 같은 경우, 큰 경우, 작은 경우를 나누어 업무 규칙을 표현한다. 특히 같은 경우를 별도 분기로 적어 남은 재고가 0임을 드러낸다. 이 메서드가 독립된 진실을 자동으로 제공하는 것은 아니다. 팀이 규칙을 잘못 이해하면 기대 결과도 틀린다. 코드 작성 전에 확인한 업무 규칙이 판단의 근거다.

[4]와 [5]는 같은 입력을 받는다. 수정 전후의 차이는 재고 거절 조건에 있는 등호 하나다. 두 메서드 모두 결제 승인을 확인하고, 확정이면 수량만큼 재고를 줄인다. 입력 객체는 불변이고 PaymentMock은 상태가 없으므로 앞선 실행이 다음 실행의 재고를 바꾸지 않는다. 실제 서비스 객체가 재고를 보관한다면 비교할 때마다 동일한 초기 상태를 새로 준비해야 한다.

[6]은 실패의 정의를 유지한다. reproduces는 예외가 발생했는지나 REJECTED라는 단어가 나왔는지만 보지 않는다. 상태와 남은 재고가 모두 담긴 Result를 기대 결과와 비교한다. 레코드가 제공하는 equals는 두 구성 요소를 비교하므로, 확정 상태만 맞고 재고가 틀리는 경우도 차이로 잡힌다.

minimize는 먼저 원래 입력이 실패를 재현하는지 확인한다. 이후 값을 줄일 때도 reproduces를 다시 호출한다. 입력이 작아졌다는 이유만으로 채택하지 않고 같은 실패가 남아 있을 때만 교체한다. 재고가 0이 되거나 수량이 1이 되면 반복을 끝낸다. 이번 자료에서는 1과 1에서 멈추며, 이 결과는 아래 검사에서도 확인한다.

[7]은 관찰과 판정을 함께 보여 준다. show는 기대 결과와 전후 결과를 나란히 출력한다. check는 조사 도구가 예상대로 후보를 찾았는지, 축소가 실패를 유지했는지, 수정이 정상 동작을 보존했는지 검사한다. 하나라도 실패하면 마지막에 예외가 발생하므로 PASS와 FAIL을 읽는 사람뿐 아니라 실행 종료 상태를 보는 사람도 실패를 알 수 있다.

원래 실패와 축소 실패에서 수정 전 결과가 잘못된 것은 의도한 관찰이다. 따라서 그 자체를 FAIL로 출력하지 않는다. “기존 오류를 재현했는가”라는 검사는 참이어야 한다. 재현 검사와 수정 후 동작 검사를 구분해야 출력된 PASS가 무엇을 보장하는지 이해할 수 있다.

실행 결과

Main.java를 빈 작업 디렉터리에 저장한 뒤 다음 명령을 실행한다. out은 컴파일러가 만드는 클래스 출력 디렉터리다. 프로그램 자체는 파일을 만들지 않는다. JDK 17의 표준 라이브러리만 사용하며 경고를 유발하는 비검사 형변환이나 원시 타입은 없다.

javac -d out *.java
java -cp out Main

예상 출력은 다음과 같다. 결제 재시도와 결제 거절은 후보에서 빠지고, 재고 부족 거절 두 건 중 한 건만 업무 규칙과 다르다.

candidates=2
mismatches=1
PASS candidate filter
PASS one mismatch
order=C
original stock=3,quantity=3
  expected=CONFIRMED,remaining=0
  before=REJECTED,remaining=3
  after=CONFIRMED,remaining=0
minimal stock=1,quantity=1
  expected=CONFIRMED,remaining=0
  before=REJECTED,remaining=1
  after=CONFIRMED,remaining=0
PASS failure survives reduction
PASS minimal input
PASS original fixed
PASS minimal fixed
enough stock=3,quantity=2
  expected=CONFIRMED,remaining=1
  before=CONFIRMED,remaining=1
  after=CONFIRMED,remaining=1
shortage stock=3,quantity=4
  expected=REJECTED,remaining=3
  before=REJECTED,remaining=3
  after=REJECTED,remaining=3
PASS enough unchanged
PASS shortage unchanged
PASS repeatable
checks failed=0

original과 minimal에서는 같은 입력을 수정 전후에 전달했지만 결과가 바뀐다. 이것이 의도한 수정이다. enough와 shortage에서는 같은 입력에 같은 상태와 재고가 출력되어 기존의 올바른 동작이 유지된다. repeatable은 같은 구현을 반복 호출한 결과가 같은지 확인한다. 다만 이 작은 검사가 실제 서비스 전체의 결정성을 보장하지는 않는다.

이 출력은 제시한 코드에서 계산되는 예상 결과다. 독자는 자신의 환경에서 명령을 실행하여 동일한 출력을 확인해야 한다. 후보 수가 다르거나 FAIL이 나오면 수정으로 넘어가기 전에 로그 내용과 실행한 소스가 같은지부터 확인한다.

실무에서 자주 틀리는 것

문자열 검색만으로 실패를 확정한다

다음 잘못된 선택기는 REJECTED라는 문자가 어디에 있든 후보로 넣는다. 결제 거절까지 재고 문제로 모이므로 후보의 의미가 흐려진다. 고친 선택기는 파싱한 필드 세 개를 함께 검사한다. 아래 클래스는 Main.java와 같은 디렉터리에 두고 컴파일할 수 있으며, main은 잘못된 선택과 고친 선택을 실제로 비교한다.

class CandidateSelectionExample {
    static boolean wrong(String line) {
        return line.contains("REJECTED");
    }

    static boolean corrected(String line) {
        Main.Entry entry = Main.parse(line);
        return entry.event().equals("RESULT")
            && entry.status().equals("REJECTED")
            && entry.reason().equals("OUT_OF_STOCK");
    }

    public static void main(String[] args) {
        String line =
            "2026-10-01T09:00:04|D|RESULT|8|1|REJECTED|PAYMENT_DECLINED";
        System.out.println(wrong(line));
        System.out.println(corrected(line));
    }
}

두 출력은 차례로 true와 false다. 고친 선택기도 장애 원인을 판정하지는 않는다. 조사할 후보를 더 정확하게 고를 뿐이다. 후보를 얻은 다음 업무 규칙과 재실행 결과를 비교하는 단계가 여전히 필요하다.

비교 조건에서 같은 경우를 빠뜨린다

재고가 부족하면 거절한다는 설명을 코드로 옮기면서 ‘같아도 부족하다’는 조건을 넣기 쉽다. 아래 wrong은 완성 코드의 잘못된 재고 거절 조건이며, corrected는 고친 조건이다. 같은 입력 1과 1을 실행하면 거절 여부가 달라진다.

class BoundaryExample {
    static boolean wrong(int stock, int quantity) {
        return stock <= quantity;
    }

    static boolean corrected(int stock, int quantity) {
        return stock < quantity;
    }

    public static void main(String[] args) {
        System.out.println(wrong(1, 1));
        System.out.println(corrected(1, 1));
    }
}

두 출력은 true와 false다. 비교 연산자를 바꾸는 근거는 로그에 등호가 보였기 때문이 아니다. 재고가 수량 이상이면 확정한다는 업무 규칙과, 같은 경우에만 나타나는 재현 결과가 함께 근거가 된다. 다른 도메인에서 예약 재고를 남겨야 한다면 규칙부터 달라질 수 있다.

작은 입력이면 재현도 유지된다고 믿는다

재고와 수량을 무조건 1로 바꾸면 원래 조건을 바꿀 수 있다. 재고가 0이고 수량이 1인 정상 거절 입력도 1과 1로 바꾸면 이번 경계 오류가 나타난다. 이것은 원래 사례를 줄인 것이 아니라 다른 실패를 만든 것이다. 고친 코드는 먼저 원래 실패를 확인하는 minimize를 사용한다.

class ReductionExample {
    static Main.Input wrong(Main.Input input) {
        return new Main.Input(1, 1);
    }

    static Main.Input corrected(Main.Input input) {
        return Main.minimize(input);
    }

    public static void main(String[] args) {
        Main.Input input = new Main.Input(0, 1);
        System.out.println(Main.reproduces(input));
        System.out.println(Main.reproduces(wrong(input)));
        try {
            corrected(input);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

출력은 false, true, failure required 순서다. 원래 입력에서 실패가 없는데 축소 결과에 실패가 생겼으므로 잘못된 축소다. 원래 실패와 같은 성질이 남아 있는지 검사하는 조건이 있어야 최소 재현이라는 이름을 붙일 수 있다.

세 예제의 클래스는 public이 아니므로 별도 파일로 두어도 된다. 기본 실행 명령의 Main 출력에는 영향을 주지 않는다. 각 예제를 확인하려면 컴파일 후 java -cp out CandidateSelectionExample처럼 해당 클래스 이름으로 실행한다.

한눈에 보기

로그 조사에서 수정 확인까지 남겨야 할 근거
단계남기는 것다음 단계의 조건
단서 선택주문 식별자, 사건, 상태, 사유조사 후보의 의미가 명확하다
실패 판정업무 규칙과 실제 결과의 차이정상 거절과 잘못된 거절을 구분했다
재현고정 입력과 실행 방법외부 연결 없이 같은 차이가 나온다
축소실패가 유지되는 작은 입력입력 유효성과 실패 조건을 지켰다
수정 확인같은 입력의 전후 상태와 재고실패는 고치고 정상 동작은 유지했다

이번 재현은 재고 비교 분기의 오류를 보여 준다. 실제 장애 전체를 설명하려면 저장소의 같은 분기에 이 입력이 도달하는지도 확인해야 한다. 로그가 다른 버전에서 나온 것인지, 기록된 재고가 실제 검사 값인지 확인하지 않았다면 조사 범위를 과장하지 않는다. AI의 설명도 그 근거가 닿는 범위까지만 채택한다.

원인을 찾았다는 사실과 변경을 받아들여도 된다는 판단도 구분한다. 이 장에서는 재현과 원인 검증에 집중했다. 다음 장에서는 생성된 변경을 대상으로 입력, 권한, 비밀값, 의존성과 관련된 보안 문제를 살펴본다.

연습 문제

  1. LOGS에 주문 E의 재고 부족 거절을 추가하라. 재고는 2, 수량도 2로 한다. 후보 수와 불일치 수가 각각 얼마가 되는지 예상하고 실행하라. main이 하나의 불일치만 허용한다는 조건도 함께 검토하라.
  2. 재고 0, 수량 1을 수정 전후에 실행하라. 기대 상태와 남은 재고를 적고, 이 입력을 최소 재현이라고 부를 수 있는지 설명하라.
  3. 수정 후 상태는 CONFIRMED이지만 재고를 차감하지 않는 실수를 넣었다고 가정하라. 기존 검사 중 무엇이 이를 발견하는지 설명하고, 상태만 비교하면 놓치는 이유를 적어라.
  4. AI가 “재고가 부족했던 것이 원인”이라고 답했다. 주문 C의 로그와 실행 결과를 근거로, 확인된 사실 두 가지와 아직 확인할 사항 한 가지를 구분하여 답하라.

정답과 해설

  1. 후보는 3건이고 불일치는 2건이다. B는 여전히 정상 거절이며 C와 E가 업무 규칙과 다르다. 기존 프로그램은 one mismatch 검사에서 FAIL을 출력하고 이어서 예외로 종료한다. 여러 실패를 조사하려면 하나를 임의로 고르기보다 각 불일치에 대해 축소와 수정 후 검사를 반복하도록 바꾼다. 후보 수 검사도 새 자료에 맞게 수정한다.
  2. 기대 결과는 REJECTED와 남은 재고 0이다. 수정 전후 모두 이 결과를 낸다. 업무 규칙과 실제 결과의 차이가 없으므로 이번 장애의 재현 입력이 아니다. minimize에 전달하면 failure required 예외가 발생한다. 값이 작다는 사실만으로 최소 재현이 되지는 않는다.
  3. original fixed와 minimal fixed가 차이를 발견한다. enough unchanged도 수정 후 재고가 기대값과 달라진 것을 발견한다. Result.equals는 상태와 remaining을 함께 비교한다. 상태만 검사하면 주문이 확정되었다는 점만 확인하므로 재고 차감 누락을 놓친다.
  4. 확인된 사실은 C의 로그에 검사 직전 재고와 수량이 모두 3으로 기록되었다는 점, 그리고 같은 입력으로 수정 전 판단을 실행하면 거절되고 경계 조건을 고치면 확정된다는 점이다. 추가로 실제 서비스에서도 로그에 기록한 값이 해당 재고 검사에 그대로 전달되었는지 확인해야 한다. 로그의 OUT_OF_STOCK라는 사유만으로 실제 부족을 확정해서는 안 된다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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