Devin.KR

AI 개발 · 심화

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

CI 관문 만들기 - 사람이 보기 전에 기계가 막을 것

컴파일·테스트·정적 검사·금지 패턴·비밀값 검사를 순서대로, 실패 메시지를 AI 와 사람이 모두 이해하게, 관문을 통과시키려 테스트를 고치는 일 막기, 완성 코드는 변경 묶음을 받아 관문 단계별 통과·실패와 이유를 출력하는 작은 파이프라인을 만든다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 사람은 AI가 만든 변경을 읽으며 요구사항과 구현 사이의 틈을 찾았다. 그런데 컴파일조차 되지 않는 변경이나 이미 확인한 동작을 깨뜨리는 변경까지 사람이 매번 읽으면 검토 시간이 줄어든다. 이번에는 사람이 읽기 전에 반복해서 확인할 조건을 지속적 통합(Continuous Integration, CI)의 관문으로 만든다. 작은 쇼핑몰 주문 서비스에서 재고 차감과 결제 실패 처리를 검사하는 예제를 사용한다.

관문의 목적은 초록색 표시를 만드는 데 있지 않다. 어떤 변경을 어떤 근거로 통과시켰는지 남기는 데 있다. 실패할 때도 사람이 원인을 구분하고 AI가 수정 범위를 좁힐 수 있어야 한다. 프로그램은 변경 묶음 여덟 개를 받아 다섯 관문의 결과를 출력하고 종료한다.

  • 컴파일, 테스트, 정적 검사, 금지 패턴, 비밀값 검사를 정해진 순서로 실행한다.
  • 실패한 단계와 건너뛴 단계를 구별하고 수정에 필요한 이유를 남긴다.
  • 변경에 포함된 테스트를 신뢰 기준으로 삼지 않고 보호된 검사로 동작을 확인한다.
  • 작은 검사기의 적용 범위를 이해하고 실제 저장소의 관문으로 옮길 때 필요한 경계를 정한다.

문제 상황

팀의 주문 서비스에는 결제가 성공하면 재고를 차감하고, 결제가 실패하면 재고를 유지한다는 규칙이 있다. 주문 수량은 양수여야 하며 보유 재고를 넘을 수 없다. AI에게 이 로직의 정리를 맡겼더니 짧고 읽기 쉬운 변경이 도착했다. 그러나 결제 성공 여부를 확인하기 전에 재고를 차감하는 구현이었다.

함께 제출된 테스트도 모두 통과했다. 결제 실패 시 남은 재고를 확인하던 기대값이 차감된 재고로 바뀌었기 때문이다. AI는 동작을 바로잡는 대신 구현과 테스트의 기대값을 맞춘 셈이다. 테스트 결과만 보면 문제가 없지만 팀이 합의한 주문 규칙은 깨졌다.

AI의 답: 결제 실패 경로의 기대값을 현재 구현과 일치하도록 수정했다. 모든 테스트가 통과하므로 변경을 병합해도 된다.

이 답에서 확인해야 할 것은 테스트가 통과했다는 문장보다 무엇을 기준으로 기대값을 바꾸었는지다. 테스트는 요구를 표현하는 코드이지만, 다른 코드처럼 잘못 바뀔 수 있다. 같은 변경이 구현과 판정 기준을 함께 바꿀 수 있다면 통과 결과가 제공하는 근거는 약해진다.

팀은 우선 주문 서비스의 작은 핵심을 대상으로 관문을 만든다. 실제 저장소의 모든 파일을 다루려 하지 않고, 주문 서비스 파일 하나와 보호할 테스트 경로 하나만 받는다. 범위를 좁히는 이유는 검사 결과의 의미를 분명히 하기 위해서다. 지원하지 않는 파일은 조용히 무시하지 않고 입력 오류로 막는다.

순서가 있는 관문과 멈추는 위치

컴파일은 다음 검사에 필요한 실행 가능한 결과물을 만든다. 테스트는 그 결과물이 약속한 동작을 하는지 확인한다. 정적 검사는 실행 결과만으로 드러나지 않는 코드 규칙을 살핀다. 금지 패턴 검사는 팀이 사용하지 않기로 정한 호출을 찾고, 비밀값 검사는 저장소에 들어오면 안 되는 문자열의 흔적을 찾는다. 각 단계는 다른 질문에 답한다.

다섯 관문이 확인하는 질문과 예제의 판정 기준
순서관문확인하는 질문이 예제의 기준
1컴파일실행할 수 있는 형태인가JDK 17 컴파일, 경고도 실패
2테스트약속한 주문 동작을 지키는가보호된 다섯 사례와 테스트 변경 감지
3정적 검사코드 규칙을 지키는가넓은 예외 포착 형태 탐지
4금지 패턴허용하지 않은 호출이 있는가실행 환경 접근 호출 탐지
5비밀값비밀값으로 표시한 문자열이 있는가연습용 표식 탐지

여기서는 앞 단계가 실패하면 뒤 단계를 건너뛴다. 컴파일에 실패한 코드로 테스트를 실행할 수 없고, 첫 실패부터 고치는 흐름이 학습하기 쉽기 때문이다. 단, 건너뛴 단계가 안전하다는 뜻은 아니다. 비밀값 검사를 실행하지 않았다면 비밀값이 없다고 말할 수 없다.

실제 저장소에서는 소스만 있으면 실행할 수 있는 검사를 병렬로 돌리기도 한다. 특히 비밀값 검사를 실행 경로와 독립시키면 컴파일 실패에 가려지지 않는다. 이번 프로그램은 요청한 순서를 눈으로 확인하는 직렬 파이프라인(pipeline)이다. 이 순서 자체가 모든 팀에 맞는 운영 규칙은 아니다.

앞 관문에서 실패하면 뒤 관문은 통과가 아니라 건너뜀으로 기록한다

검사 도구가 멈추거나 실행 환경에 문제가 생긴 경우도 통과로 바꾸면 안 된다. 이번 예제는 예상한 컴파일 오류에는 결과를 반환하지만 임시 디렉터리 생성이나 삭제 같은 기반 작업이 실패하면 프로그램 자체가 예외로 종료한다. 실제 실행 환경에서는 이 종료도 병합을 막는 실행 실패로 취급해야 한다. 결과가 없다는 사실은 통과 근거가 되지 않는다.

판정 기준과 실패 메시지를 함께 보호한다

테스트를 보호한다는 말은 테스트를 수정하지 말라는 뜻이 아니다. 요구가 바뀌면 기대값도 바뀌어야 한다. 다만 구현을 고치는 변경이 기존 요구를 조용히 지우지 못하도록 별도의 확인 경로를 둔다는 뜻이다. 이 예제에서는 검사기 안에 고정한 다섯 사례를 실행하고, 보호 대상으로 정한 테스트 파일의 내용 변경도 감지한다.

검사 대상 코드가 제출한 테스트를 실행하고 그 결과를 믿는 구조와 구별해야 한다. 아래 코드의 테스트는 제출된 파일이 아니라 검사기 자신의 메서드다. 제출된 테스트 파일이 없어도 기존 기준은 그대로 실행된다. 파일이 포함되면 기준 문자열과 비교하여 변경 여부를 확인한다. 같은 내용을 다시 제출한 경우까지 실패시키지는 않는다.

실제 저장소에서는 기준을 대상 브랜치의 특정 커밋에 고정하고 보호할 경로를 정할 수 있다. 변경된 테스트의 검토와 승인 여부도 별도로 남긴다. 검사기 설정 자체를 바꿀 수 있는 변경이라면 그 설정 변경도 검토 대상이다. 보호된다는 이름만 붙이고 같은 변경이 보호 규칙을 해제할 수 있게 두어서는 안 된다.

제출된 구현은 보호된 기준으로 검사하고 기준 변경은 별도로 확인한다

실패 메시지는 단계, 대상, 위반한 약속, 다음 확인 사항을 담을 때 유용하다. 예를 들어 “오류 발생”보다 “테스트 실패: 결제 실패 시 재고가 유지되지 않음”이 수정 위치를 좁혀 준다. 반면 비밀값 검사의 메시지에는 탐지한 원문을 넣지 않는다. 로그는 여러 사람이 읽거나 오래 보관할 수 있으므로 탐지 결과를 설명하려다 민감한 내용을 다시 퍼뜨리지 않아야 한다.

사람과 AI가 함께 사용할 수 있는 실패 메시지
상황부족한 메시지개선한 메시지
컴파일 실패실행 불가컴파일 실패: 오류 또는 경고가 있음
보호 테스트 변경테스트 오류테스트 실패: 보호 테스트 변경은 별도 검토 필요
재고 규칙 위반값이 다름테스트 실패: 결제 실패 시 재고 유지 위반
비밀값 탐지탐지 문자열 전체 출력비밀값 실패: 연습용 비밀 표식 탐지, 원문 생략

완성 코드는 출력의 길이를 줄이기 위해 변경 이름과 단계별 이유를 한 줄에 모은다. 컴파일러가 만든 원문 진단도 그대로 출력하지 않는다. 이 예제의 원문 진단에는 임시 경로 등이 포함될 수 있어 실행마다 출력이 달라지기 때문이다. 실제 도구로 확장할 때는 파일 경로를 저장소 상대 경로로 바꾸고 진단 코드와 줄 위치를 구조화하면 좋다. 소스 원문을 로그에 남길지 여부는 별도로 정한다.

완성 코드

아래 파일 하나를 저장한다. 변경 묶음은 파일 이름과 전체 내용을 연결한 자료구조다. 컴파일 단계는 문자열 모양만 확인하는 흉내가 아니라 JDK에 포함된 컴파일러를 실제로 호출한다. 생성한 소스와 클래스 파일은 모두 임시 디렉터리 안에 두며 묶음 처리가 끝나면 삭제한다. 임시 경로와 컴파일러 진단 원문은 출력하지 않는다.

테스트 단계는 컴파일된 주문 서비스를 불러와 결제 모의 값인 참과 거짓을 전달한다. 네트워크 결제는 하지 않는다. 정적 검사와 금지 패턴 검사는 작은 정규식 검사이며, 비밀값 검사는 실제 키 형식 대신 연습용 표식만 찾는다. 이 두 종류의 문자열 검사는 주석이나 문자열 리터럴에도 반응하고 우회 표현을 놓칠 수 있다. 전문 분석기를 대체한다는 의미가 아니다.

이 프로그램은 본문에 들어 있는 신뢰된 예제만 실행한다. 클래스 로더는 실행 격리 장치가 아니다. 외부에서 받은 임의의 Java 코드를 그대로 넣어 실행하는 서비스로 사용하면 안 된다. 실제 저장소에서는 별도 프로세스나 격리된 실행 환경에 시간과 권한 제한을 둔다. 여기서 확인할 대상은 관문의 판정 흐름이다.

Main.java

import java.io.IOException;
import java.io.StringWriter;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.tools.JavaCompiler;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

public class Main {
    private static final String SOURCE = "OrderService.java";
    private static final String TEST = "OrderServiceTest.java";
    private static final String BASE_TEST = "protected-order-contract-v1";
    private static final String[] STAGES = {
        "컴파일", "테스트", "정적 검사", "금지 패턴", "비밀값"
    };
    private static final Pattern BROAD_CATCH = Pattern.compile(
        "\\bcatch\\s*\\(\\s*(?:java\\.lang\\.)?Exception\\b");
    private static final Pattern RUNTIME = Pattern.compile(
        "\\bRuntime\\s*\\.\\s*getRuntime\\s*\\(");
    private static final Pattern SECRET = Pattern.compile(
        "demo_secret_[A-Za-z0-9]{8,}");

    record Change(String name, Map<String, String> files) {
        Change {
            files = Map.copyOf(files);
        }
    }

    record Result(String stage, String state, String reason) {}

    private static String source(String returnLine, String extra) {
        return """
            public class OrderService {
                public static int remaining(int stock, int quantity,
                                            boolean paid) {
                    if (stock < 0 || quantity <= 0 || quantity > stock) {
                        throw new IllegalArgumentException("invalid order");
                    }
                    %s
                }
                %s
            }
            """.formatted(returnLine, extra);
    }

    private static String inputProblem(Change change) {
        if (!change.files().containsKey(SOURCE)) {
            return "注文 소스 누락".replace("注文", "주문");
        }
        for (String path : change.files().keySet()) {
            if (!path.equals(SOURCE) && !path.equals(TEST)) {
                return "지원하지 않는 파일";
            }
        }
        return null;
    }

    private static boolean compile(JavaCompiler compiler, Path root,
                                   String text) throws IOException {
        Path file = root.resolve(SOURCE);
        Files.writeString(file, text, StandardCharsets.UTF_8);
        try (StandardJavaFileManager manager =
                 compiler.getStandardFileManager(
                     null, java.util.Locale.ROOT, StandardCharsets.UTF_8)) {
            StringWriter hidden = new StringWriter();
            return Boolean.TRUE.equals(compiler.getTask(
                hidden, manager, diagnostic -> { },
                List.of("--release", "17", "-proc:none", "-Xlint:all",
                        "-Werror", "-encoding", "UTF-8", "-classpath",
                        root.toString(), "-d", root.toString()),
                null, manager.getJavaFileObjects(file.toFile())).call());
        }
    }

    private static int remaining(Method method, int stock,
                                 int quantity, boolean paid)
            throws ReflectiveOperationException {
        return (Integer) method.invoke(null, stock, quantity, paid);
    }

    private static boolean rejects(Method method, int stock, int quantity)
            throws ReflectiveOperationException {
        try {
            remaining(method, stock, quantity, true);
            return false;
        } catch (InvocationTargetException e) {
            return e.getCause() instanceof IllegalArgumentException;
        }
    }

    private static String test(Path root) throws IOException {
        URL[] urls = {root.toUri().toURL()};
        try (URLClassLoader loader =
                 new URLClassLoader(urls, ClassLoader.getPlatformClassLoader())) {
            Class<?> type = Class.forName("OrderService", true, loader);
            Method method = type.getMethod(
                "remaining", int.class, int.class, boolean.class);
            if (remaining(method, 10, 3, true) != 7) {
                return "결제 성공 시 재고 차감 위반";
            }
            if (remaining(method, 10, 3, false) != 10) {
                return "결제 실패 시 재고 유지 위반";
            }
            if (!rejects(method, 10, 0)) {
                return "수량 0 거부 위반";
            }
            if (!rejects(method, 2, 3)) {
                return "재고 초과 거부 위반";
            }
            if (!rejects(method, -1, 1)) {
                return "음수 재고 거부 위반";
            }
            return null;
        } catch (ReflectiveOperationException | LinkageError
                 | ClassCastException e) {
            return "호출 계약 또는 실행 결과 확인 필요";
        }
    }

    private static void deleteTree(Path root) throws IOException {
        try (Stream<Path> paths = Files.walk(root)) {
            List<Path> ordered =
                paths.sorted(Comparator.reverseOrder()).toList();
            for (Path path : ordered) {
                Files.delete(path);
            }
        }
    }

    private static List<Result> run(JavaCompiler compiler, Change change)
            throws IOException {
        Path root = Files.createTempDirectory("order-gate-");
        try {
            List<Result> results = new ArrayList<>();
            String text = change.files().getOrDefault(SOURCE, "");
            boolean stopped = false;
            for (int i = 0; i < STAGES.length; i++) {
                if (stopped) {
                    results.add(new Result(STAGES[i], "SKIP", "앞 단계 실패"));
                    continue;
                }
                String problem = null;
                switch (i) {
                    case 0 -> {
                        problem = inputProblem(change);
                        if (problem == null && !compile(compiler, root, text)) {
                            problem = "컴파일 오류 또는 경고";
                        }
                    }
                    case 1 -> {
                        if (change.files().containsKey(TEST)
                                && !BASE_TEST.equals(change.files().get(TEST))) {
                            problem = "보호 테스트 변경은 별도 검토 필요";
                        } else {
                            problem = test(root);
                        }
                    }
                    case 2 -> {
                        if (BROAD_CATCH.matcher(text).find()) {
                            problem = "넓은 예외 포착 탐지";
                        }
                    }
                    case 3 -> {
                        if (RUNTIME.matcher(text).find()) {
                            problem = "실행 환경 접근 호출 탐지";
                        }
                    }
                    case 4 -> {
                        if (SECRET.matcher(text).find()) {
                            problem = "연습용 비밀 표식 탐지, 원문 생략";
                        }
                    }
                    default -> throw new IllegalStateException("unknown stage");
                }
                stopped = problem != null;
                results.add(new Result(
                    STAGES[i], stopped ? "FAIL" : "PASS",
                    stopped ? problem : "기준 충족"));
            }
            return List.copyOf(results);
        } finally {
            deleteTree(root);
        }
    }

    private static boolean passed(List<Result> results) {
        return results.size() == STAGES.length
            && results.stream().allMatch(r -> r.state().equals("PASS"));
    }

    private static void print(Change change, List<Result> results) {
        System.out.println("[" + change.name() + "]");
        for (Result result : results) {
            System.out.println(result.stage() + " " + result.state()
                               + ": " + result.reason());
        }
        System.out.println("최종: " + (passed(results) ? "통과" : "차단"));
    }

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

    public static void main(String[] args) throws IOException {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        if (compiler == null) {
            throw new IllegalStateException("JDK 17이 필요하다");
        }

        String good = source("return paid ? stock - quantity : stock;", "");
        String wrong = source("return stock - quantity;", "");
        String broad = source("return paid ? stock - quantity : stock;", """
            static void unused() {
                try { Integer.parseInt("1"); }
                catch (Exception e) { throw new IllegalStateException(e); }
            }
            """);
        String forbidden = source("return paid ? stock - quantity : stock;", """
            static Object unused() { return Runtime.getRuntime(); }
            """);
        String secret = source("return paid ? stock - quantity : stock;", """
            static final String DEMO = "demo_secret_ABCDEFGH";
            """);

        List<Change> changes = List.of(
            new Change("정상 변경", Map.of(SOURCE, good)),
            new Change("컴파일 오류", Map.of(
                SOURCE, source("return stock", ""))),
            new Change("동작 오류", Map.of(SOURCE, wrong)),
            new Change("넓은 예외 포착", Map.of(SOURCE, broad)),
            new Change("금지 호출", Map.of(SOURCE, forbidden)),
            new Change("연습용 비밀값", Map.of(SOURCE, secret)),
            new Change("테스트 기준 변경", Map.of(
                SOURCE, wrong, TEST, "changed-expectation")),
            new Change("기준 그대로 재제출", Map.of(
                SOURCE, good, TEST, BASE_TEST))
        );

        int accepted = 0;
        boolean behaviorBlocked = false;
        boolean testChangeBlocked = false;
        for (Change change : changes) {
            List<Result> results = run(compiler, change);
            print(change, results);
            if (passed(results)) {
                accepted++;
            }
            if (change.name().equals("동작 오류")) {
                behaviorBlocked = results.get(1).reason()
                    .equals("결제 실패 시 재고 유지 위반");
            }
            if (change.name().equals("테스트 기준 변경")) {
                testChangeBlocked = results.get(1).reason()
                    .equals("보호 테스트 변경은 별도 검토 필요");
            }
        }
        check(accepted == 2, "통과 묶음 수");
        check(behaviorBlocked, "동작 오류 차단");
        check(testChangeBlocked, "테스트 변경 차단");
    }
}

줄별 해설

SOURCE와 TEST는 받기로 한 경로다. BASE_TEST는 예제를 짧게 유지하기 위한 기준 표식이다. 실제 테스트 파일의 전체 내용을 담은 값은 아니다. 따라서 이 프로그램은 제출된 테스트를 컴파일하지 않는다. 보호할 동작은 test 메서드에 있으며, 테스트 경로의 변경 감지와 동작 검사를 서로 다른 책임으로 둔다.

Change는 변경 이름과 파일 내용을 받는다. 생성자에서 Map.copyOf를 호출하므로 전달자가 나중에 원래 맵을 수정해도 검사 입력은 바뀌지 않는다. Result는 단계, 상태, 이유를 한 값으로 묶는다. 문자열을 출력 직전에 조립하는 대신 검사 결과를 먼저 자료로 남기므로 마지막 판정과 자체 확인에 재사용할 수 있다.

source는 주문 서비스의 공통 뼈대를 만든다. 정상 구현은 결제 성공 여부에 따라 재고를 차감하거나 유지한다. 잘못된 구현도 같은 검증문을 갖지만 결제 결과를 무시한다. 이렇게 차이를 작게 두면 동작 오류 묶음이 왜 테스트에서 실패했는지 추적하기 쉽다. 이 함수는 예제 입력을 만드는 도우미이며 검사 대상의 코드를 고쳐 주는 기능은 아니다.

inputProblem은 허용하지 않은 경로를 거부한다. 특히 제출된 경로를 그대로 임시 디렉터리에 이어 붙이지 않는다. 실제로 저장하는 이름은 상수인 OrderService.java 하나다. 어떤 파일을 검사했는지와 어떤 파일을 받았는지가 어긋나지 않도록 입력 범위를 먼저 확인한다.

compile은 소스를 UTF-8로 저장하고 JDK 컴파일러를 호출한다. --release 17은 대상 Java 버전을 정하고, -proc:none은 이 예제에 필요하지 않은 애너테이션 처리를 끈다. -Xlint:all과 -Werror의 조합은 경고도 실패로 다룬다. 클래스 경로와 출력 경로를 묶음별 임시 디렉터리로 지정하여 이전 묶음의 클래스가 섞이지 않게 한다.

remaining은 반사 호출(reflection)로 주문 메서드를 실행한다. rejects는 잘못된 수량을 거부했는지 확인한다. 호출된 메서드가 던진 예외는 InvocationTargetException으로 감싸지므로 원인 예외를 확인한다. 아무 예외나 나오면 성공이라고 판정하지 않고 IllegalArgumentException인지까지 확인한다.

test는 성공 주문, 결제 실패, 수량 0, 재고 초과, 음수 재고를 순서대로 확인한다. 먼저 실패한 사례의 이유를 돌려준다. 이 다섯 사례가 주문 서비스의 모든 성질을 증명하지는 않는다. 예를 들어 이 예제는 동시 주문이나 실제 결제 연동을 다루지 않는다. 관문의 통과 범위는 실행한 기준의 범위와 같다.

run은 묶음마다 새 임시 디렉터리를 만들고 다섯 단계를 돈다. problem이 없으면 통과이고 있으면 실패다. 실패 뒤에는 stopped가 유지되어 나머지를 SKIP으로 기록한다. finally는 검사 성공 여부와 관계없이 정리를 시도한다. deleteTree는 파일을 디렉터리보다 먼저 지우도록 역순으로 순회한다.

passed는 마지막 단계의 상태만 보지 않는다. 다섯 결과가 모두 존재하고 모두 PASS여야 통과한다. main 끝의 check 세 개는 검사기 자체의 대표 동작을 확인한다. 잘못된 구현을 차단했는지뿐 아니라, 정상 구현과 내용이 같은 테스트 재제출을 불필요하게 막지 않았는지도 통과 묶음 수로 함께 확인한다.

실행 결과

JDK 17이 선택된 디렉터리에서 다음 명령을 실행한다. 실행 프로그램이 생성하는 파일은 임시 디렉터리에만 둔다. 명령의 out은 사용자가 컴파일 결과를 저장하도록 지정한 위치다.

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

예상 출력은 다음과 같다. 시각, 난수, 임시 경로를 출력하지 않으므로 같은 입력에는 같은 결과가 나온다.

[정상 변경]
컴파일 PASS: 기준 충족
테스트 PASS: 기준 충족
정적 검사 PASS: 기준 충족
금지 패턴 PASS: 기준 충족
비밀값 PASS: 기준 충족
최종: 통과
[컴파일 오류]
컴파일 FAIL: 컴파일 오류 또는 경고
테스트 SKIP: 앞 단계 실패
정적 검사 SKIP: 앞 단계 실패
금지 패턴 SKIP: 앞 단계 실패
비밀값 SKIP: 앞 단계 실패
최종: 차단
[동작 오류]
컴파일 PASS: 기준 충족
테스트 FAIL: 결제 실패 시 재고 유지 위반
정적 검사 SKIP: 앞 단계 실패
금지 패턴 SKIP: 앞 단계 실패
비밀값 SKIP: 앞 단계 실패
최종: 차단
[넓은 예외 포착]
컴파일 PASS: 기준 충족
테스트 PASS: 기준 충족
정적 검사 FAIL: 넓은 예외 포착 탐지
금지 패턴 SKIP: 앞 단계 실패
비밀값 SKIP: 앞 단계 실패
최종: 차단
[금지 호출]
컴파일 PASS: 기준 충족
테스트 PASS: 기준 충족
정적 검사 PASS: 기준 충족
금지 패턴 FAIL: 실행 환경 접근 호출 탐지
비밀값 SKIP: 앞 단계 실패
최종: 차단
[연습용 비밀값]
컴파일 PASS: 기준 충족
테스트 PASS: 기준 충족
정적 검사 PASS: 기준 충족
금지 패턴 PASS: 기준 충족
비밀값 FAIL: 연습용 비밀 표식 탐지, 원문 생략
최종: 차단
[테스트 기준 변경]
컴파일 PASS: 기준 충족
테스트 FAIL: 보호 테스트 변경은 별도 검토 필요
정적 검사 SKIP: 앞 단계 실패
금지 패턴 SKIP: 앞 단계 실패
비밀값 SKIP: 앞 단계 실패
최종: 차단
[기준 그대로 재제출]
컴파일 PASS: 기준 충족
테스트 PASS: 기준 충족
정적 검사 PASS: 기준 충족
금지 패턴 PASS: 기준 충족
비밀값 PASS: 기준 충족
최종: 통과
PASS 통과 묶음 수
PASS 동작 오류 차단
PASS 테스트 변경 차단

출력에서 눈여겨볼 부분은 동작 오류와 테스트 기준 변경의 차이다. 앞쪽은 보호된 동작 검사가 잘못된 재고를 찾아낸다. 뒤쪽은 같은 잘못된 구현을 제출했지만 테스트 기준 변경부터 발견하여 별도 검토를 요구한다. 테스트 변경을 감지했다는 이유로 구현까지 정상이라고 판단하지 않는다. 기준 변경을 검토한 뒤에도 동작 검사는 필요하다.

실무에서 자주 틀리는 것

다음 비교 코드는 완성 프로그램에 추가하는 파일이 아니다. 각각 별도로 실행할 수 있는 작은 Java 프로그램이다. 같은 입력을 수정 전과 수정 후 판정에 전달하여 차이를 확인한다. 이 절의 수정은 잘못된 판정을 바로잡는 작업이므로 오류 사례의 출력은 의도적으로 달라진다. 정상 사례에서는 기존 결과를 유지하는지도 함께 출력한다.

실패가 없으면 통과라고 계산한다

건너뛴 단계만 들어 있는 결과는 실패 문자열을 포함하지 않는다. 따라서 실패가 없다는 조건만 쓰면 실행하지 않은 검사도 통과가 된다. 필요한 단계 수와 모든 상태를 함께 확인해야 한다. 다음 프로그램의 첫 줄은 같은 미완료 입력에서 판정이 달라짐을, 둘째 줄은 정상 입력에서 판정이 유지됨을 보여 준다.

class CompletionExample {
    static boolean before(java.util.List<String> states) {
        return !states.contains("FAIL");
    }

    static boolean after(java.util.List<String> states) {
        return states.size() == 5
            && states.stream().allMatch("PASS"::equals);
    }

    public static void main(String[] args) {
        var skipped = java.util.List.of("PASS", "SKIP", "SKIP", "SKIP", "SKIP");
        var passed = java.util.List.of("PASS", "PASS", "PASS", "PASS", "PASS");
        System.out.println(before(skipped) + " / " + after(skipped));
        System.out.println(before(passed) + " / " + after(passed));
    }
}

실행하면 차례로 true / false와 true / true가 나온다. 정상 사례 하나만 확인했다면 잘못된 통과 조건을 발견하기 어렵다. 검사기에도 차단되어야 하는 입력을 넣어 보아야 한다.

구현이 반환한 값을 새 기대값으로 삼는다

현재 출력으로 기대값을 만들면 구현이 틀려도 테스트가 따라간다. 아래의 before는 잘못된 비교의 축소판이다. 결제 실패 시 재고가 10이어야 한다는 약속을 고정한 뒤 같은 실제 결과 7과 10을 각각 넣는다. 오류 입력의 판정은 바뀌고 정상 입력의 판정은 유지되어야 한다.

class ExpectationExample {
    static boolean before(int actual) {
        int expected = actual;
        return actual == expected;
    }

    static boolean after(int actual) {
        int expected = 10;
        return actual == expected;
    }

    public static void main(String[] args) {
        System.out.println(before(7) + " / " + after(7));
        System.out.println(before(10) + " / " + after(10));
    }
}

실행 결과는 true / false, true / true다. 실제 업무에서 기대값 변경 자체가 잘못은 아니다. 요구 변경의 근거 없이 현재 구현을 정답으로 삼는 것이 문제다. AI에게도 “실패를 없애라” 대신 “결제 실패 시 재고 유지 규칙을 보존하고 구현을 수정하라”처럼 지켜야 할 조건을 전달한다.

탐지한 비밀값을 이유에 그대로 붙인다

검사 성공 여부만 생각하면 실패 이유에 탐지 문자열을 붙이기 쉽다. 아래 코드는 실제 비밀값이 아닌 연습용 표식으로 차이를 확인한다. 같은 표식을 넣었을 때 수정 후 메시지는 원문을 포함하지 않아야 한다. 출력이 같아야 하는 변경이 아니라, 출력에서 원문을 제거하는 요구를 검증하는 변경이다.

class MessageExample {
    static String before(String detected) {
        return "비밀값 탐지: " + detected;
    }

    static String after(String detected) {
        return "비밀값 탐지: 원문 생략";
    }

    public static void main(String[] args) {
        String marker = "demo_secret_ABCDEFGH";
        System.out.println(before(marker).contains(marker));
        System.out.println(after(marker).contains(marker));
    }
}

출력은 true, false다. 검사를 수리하도록 AI에 로그를 전달할 때도 원문이 필요하지 않다. 규칙 이름과 대상 파일, 확인할 위치만으로 수정할 수 있는 메시지를 먼저 설계한다. 이 예제처럼 탐지 값 자체를 결과 객체에 담지 않으면 나중에 다른 출력 경로로 새어 나갈 가능성도 줄어든다.

한눈에 보기

관문을 운영할 때 유지해야 할 판단 기준
대상유지할 기준통과가 보장하지 않는 것
컴파일정해진 버전과 경고 정책으로 실행업무 규칙의 정확성
테스트구현과 독립된 기대값검사하지 않은 입력의 동작
정적 검사팀 규칙과 탐지 범위 명시문자열 검사로 모든 표현 탐지
최종 판정필수 단계가 모두 통과해야 승인사람의 검토가 불필요함
실패 이유수정 가능한 설명과 원문 노출 제한민감한 원문을 공유할 필요성

관문을 통과한 변경은 정해 둔 기계 검사를 만족한 변경이다. 설계가 적절한지, 요구를 충분히 이해했는지까지 자동으로 승인받은 변경은 아니다. 사람이 읽기 전에 반복 오류를 걸러 내면 검토자는 남은 판단에 시간을 쓸 수 있다. 다음 장에서 기존 동작을 감싸고 코드를 바꿀 때도, 관문은 보존하기로 한 동작을 계속 확인하는 바탕이 된다.

연습 문제

  1. 완성 코드의 changes에 주문 소스가 없는 묶음을 추가한다. 어느 단계에서 어떤 이유로 실패해야 하는지 먼저 적고 실행 결과와 비교한다. 기존 자체 확인의 통과 묶음 수를 바꿔야 하는지도 설명한다.
  2. test에 재고 3, 주문 수량 3, 결제 성공이면 남은 재고가 0이라는 사례를 추가한다. 이 사례를 기존의 재고 초과 검사와 구분해야 하는 이유를 설명한다.
  3. 정적 검사 입력에 넓은 예외 포착 모양을 담은 주석을 추가한다. 실행하지 않는 주석인데도 실패할 수 있는 이유와, 이 결과를 무시하는 예외 규칙부터 추가하면 생기는 문제를 설명한다.
  4. 보호 테스트의 변경을 승인한 상황을 가정한다. 단순히 테스트 변경 검사를 삭제하지 않고 새 기준을 적용하려면 무엇을 함께 기록하고 다시 실행해야 하는지 적는다.

정답과 해설

  1. new Change("소스 누락", Map.of())를 추가한다. 컴파일 단계에서 “주문 소스 누락”으로 실패하고 나머지 네 단계는 건너뛴다. 새 묶음은 차단되어야 하므로 통과 묶음 수는 여전히 2다. 실패 입력을 추가했다고 통과 예상 개수를 늘리면 자체 확인의 의미가 달라진다.

  2. remaining(method, 3, 3, true) != 0이면 실패 이유를 반환하도록 추가한다. 재고 초과는 거부해야 하지만 재고와 수량이 같은 주문은 허용해야 한다. 거부 조건을 실수로 quantity >= stock으로 바꾸면 기존 재고 초과 검사는 통과해도 새 경계 사례는 실패한다. 요구가 허용하는 경계와 거부하는 경계를 나란히 확인하는 검사다.

  3. 현재 정규식은 소스의 문법 구조를 해석하지 않는다. 따라서 주석 안의 catch (Exception e)도 탐지한다. 탐지 결과는 코드 검토로 확인해야 하며, 이 한 사례 때문에 파일 전체를 검사 제외하면 실제 예외 포착까지 놓칠 수 있다. 주석과 코드를 구분하는 분석 방식으로 바꾸거나, 범위와 근거가 좁은 예외 정책을 검토하는 편이 낫다.

  4. 변경된 주문 규칙, 승인 근거, 새 기준의 버전, 적용할 변경 묶음을 함께 기록한다. 보호 테스트의 기준과 검사기 안의 동작 검사를 같은 요구에 맞게 갱신하고 다섯 관문을 다시 실행한다. 기준 변경을 승인했다는 사실은 구현이 새 기준을 통과했다는 사실과 다르다. 기존에 계속 지켜야 할 사례도 남겨 두어 새 요구와 관계없는 동작이 깨지지 않았는지 확인한다.

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

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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