Devin.KR

AI 개발 · 심화

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

팀에서 AI 를 쓰는 규칙 - 무엇을 맡기고 누가 책임지나

1인 바이브 코딩과 팀 개발의 차이, 변경의 책임자는 사람, 팀 AI 사용 규칙 문서(허용 범위·금지 범위·표시 방법), 비밀값·고객 데이터 반입 금지, 완성 코드는 변경 요청 목록을 규칙표로 검사해 허용·사람 검토·금지로 분류해 출력한다

개발자KR · 원고 갱신

이 장에서 배우는 것

혼자 만드는 프로그램에서는 아이디어를 코드로 옮기고 실행 결과를 확인하는 사람이 대체로 같다. 팀 저장소에서는 코드를 요청한 사람, 변경을 검토하는 사람, 운영 중인 서비스를 맡는 사람이 서로 다를 수 있다. 따라서 인공지능(AI)이 코드를 빨리 만들어 주는 것만으로는 작업이 끝나지 않는다. 어떤 정보를 도구에 넣어도 되는지, 어떤 변경을 맡길 수 있는지, 누가 결과를 설명하고 책임질지를 먼저 정해야 한다.

이 책에서는 작은 쇼핑몰 주문 서비스 저장소를 함께 고친다. 저장소에는 주문 처리 코드와 재고·결제 모의 객체가 있다. 모의 객체는 실제 외부 시스템에 접속하지 않고 정해진 응답을 돌려주는 대역이다. 이번에는 주문 기능을 구현하기 전에 팀의 AI 사용 규칙을 세우고, 변경 요청을 그 규칙으로 분류하는 Java 프로그램을 만든다.

  • 혼자 하는 바이브 코딩과 팀 저장소 작업의 차이를 설명한다.
  • AI가 만든 변경에도 사람 책임자가 필요한 이유를 구분한다.
  • 허용 범위, 사람 검토 범위, 금지 범위와 사용 표시 방법을 문서로 정한다.
  • 비밀값과 고객 데이터를 도구에 넣지 않는 경계를 세운다.
  • 여러 규칙이 겹쳐도 더 엄격한 결과를 선택하는 분류 프로그램을 실행한다.

문제 상황

팀원 다온이 주문 서비스의 결제 실패 안내 문구를 고치려 한다. 다온은 AI에게 저장소 일부를 보여 주고 변경안을 받는다. AI는 문구 수정과 함께 결제 모의 객체의 실패 응답도 성공 응답으로 바꾼다. 화면에서 오류가 사라졌으므로 다온은 작업이 끝났다고 생각한다. 그러나 이 변경은 안내 문구를 개선한 것이 아니라 실패 상황을 감춘 것이다.

다른 팀원 서우는 같은 날 재고 확인 코드를 정리한다. 오류 상황을 설명하려고 운영 로그를 붙여 넣으려다가 로그 안에 고객 연락처와 접근 토큰이 있다는 사실을 발견한다. 두 작업은 모두 주문 서비스를 고치려는 시도지만 판단해야 할 경계가 다르다. 결제 결과의 의미를 바꾸는 변경은 사람이 검토해야 하고, 비밀값이나 고객 데이터를 외부 도구에 전달하는 요청은 진행을 막아야 한다.

AI의 제안: 실패 안내를 없애기 위해 결제 모의 객체가 항상 성공을 반환하도록 바꾸면 된다. 실제 오류 로그까지 제공하면 상황에 맞춰 더 자세히 수정할 수 있다.

이 답에는 실행 가능한 제안처럼 보이는 문장이 들어 있다. 하지만 팀이 원하는 것은 결제 실패를 숨기는 것도, 실제 고객 정보를 도구에 전달하는 것도 아니다. 자연스러운 설명은 변경의 정당성을 보장하지 않는다. 팀은 제안을 읽기 전에 적용할 수 있는 공통 규칙을 갖고 있어야 한다.

여기서는 변경 요청 하나에 담당자와 특징 목록을 붙인다. 프로그램은 그 특징을 규칙표와 대조해 허용, 사람 검토, 금지 중 하나를 출력한다. 이 결과는 작업을 시작할 때 사용할 분류다. 코드를 검사해 안전성을 증명하거나 병합을 승인하는 기능은 아니다.

혼자 실행한 결과가 팀의 완료 조건은 아니다

바이브 코딩은 자연어로 의도를 전달하고 생성된 결과를 실행하면서 작업을 발전시키는 방식으로 이해할 수 있다. 혼자 작은 실험을 할 때는 이 흐름이 간단하다. 자신의 의도와 결과를 직접 비교하고, 마음에 들지 않으면 다시 요청하면 된다. 팀에서는 다른 사람이 그 의도를 알지 못한다는 문제가 추가된다.

다온의 화면에서 오류가 사라졌다는 사실만으로는 서우가 변경을 받아들일 수 없다. 서우에게는 원래 요구가 무엇이었는지, 실패 응답을 바꾼 이유가 무엇인지, 누가 그 판단을 했는지가 필요하다. 팀 작업의 결과에는 코드뿐 아니라 설명할 수 있는 근거와 책임의 연결도 포함된다.

개인 실험과 팀 저장소에서 달라지는 판단 기준
항목개인 실험팀 저장소
의도작업한 사람이 기억한다다른 사람이 읽을 수 있게 남긴다
완료 판단원하는 실행 결과를 확인한다공유한 요구와 책임 범위도 확인한다
실패 영향실험을 다시 시작할 수 있다동료의 작업과 서비스에 영향을 준다
AI의 위치아이디어와 구현을 돕는다제안을 만들며 승인 권한은 갖지 않는다

변경의 책임자는 그 변경의 목적과 영향을 설명하는 사람이다. AI를 사용했다는 이유로 책임이 도구로 이동하지 않는다. 담당자는 자신이 이해하지 못한 코드를 그대로 제출하지 않아야 하며, 요청과 관계없는 변경이 섞이면 이유를 확인하거나 범위를 줄여야 한다. 검토자도 AI의 설명을 승인 근거로 대신 사용할 수 없다.

책임을 진다는 말은 담당자 혼자 모든 문제를 발견해야 한다는 뜻이 아니다. 결제 정책을 모르면 정책을 아는 사람에게 묻고, 데이터 취급 여부가 불분명하면 전달을 멈추는 것도 책임 있는 행동이다. 팀은 개인의 기억력에 기대는 대신 담당자, 검토가 필요한 경계, 작업 중단 조건을 문서에 적는다.

AI는 변경을 제안하고 사람 담당자는 범위와 근거를 설명하며 팀이 수용 여부를 판단한다

그림의 단계는 책임을 넘겨 없애는 절차가 아니다. AI가 제안한 뒤에도 담당자는 남아 있고, 팀이 변경을 받아들인 뒤에도 누가 어떤 근거로 수정했는지 확인할 수 있어야 한다.

팀 규칙은 허용 범위와 중단 조건을 함께 적는다

팀 규칙 문서의 목적은 사용할 수 있는 기능을 길게 나열하는 데 있지 않다. 작업자가 같은 요청을 보고 비슷한 판단을 하도록 만드는 데 있다. 저장소 루트에 AI 사용 규칙 문서를 두고 팀원이 작업 전에 읽을 수 있게 한다. 도구가 문서를 자동으로 읽는지는 별개의 문제다. 자동으로 읽는 기능이 없어도 사람에게 적용되는 규칙은 유효하다.

다음 표는 이 예제 팀이 합의한 문서의 핵심 내용이다. 허용은 AI의 도움을 받아 작업해도 된다는 뜻이다. 사람 검토는 담당자 외에 해당 영역의 판단을 할 사람이 범위와 조건을 확인해야 한다는 뜻이다. 금지는 현재 요청을 그대로 실행하거나 도구에 전달하면 안 된다는 뜻이다.

주문 서비스 저장소의 AI 사용 규칙 초안
구분대상팀의 행동남길 정보
허용문서, 합성 자료로 만든 테스트, 모의 객체 내부 정리담당자가 범위 안에서 사용한다목적, 도움받은 범위, 확인 결과
사람 검토주문·재고·결제 동작, 권한 판단, 운영 설정 변경관련 판단을 할 사람의 검토를 받는다영향 범위와 검토가 필요한 이유
금지비밀값 또는 고객 데이터를 도구에 전달하는 요청전달을 중단하고 요청을 다시 만든다값 자체를 제외한 중단 사유
표시AI의 제안이 반영된 변경변경 요청에 사용 사실을 적는다담당자와 채택·수정한 범위

작업 범위가 작다고 영향도 작은 것은 아니다. 결제 성공 여부를 뒤집는 수정은 한 줄이어도 사람 검토 대상이다. 반대로 문서가 길다는 이유만으로 위험한 변경이 되지는 않는다. 규칙은 줄 수보다 바뀌는 의미와 전달되는 정보를 기준으로 삼아야 한다.

사용 사실은 판단에 필요한 만큼 표시한다

코드 변경 제안인 풀 리퀘스트(PR)에는 AI 사용 사실을 표시한다. 대화 내용을 전부 붙이는 방식은 필요한 근거를 찾기 어렵게 만든다. 목적, 도움받은 부분, 사람이 확인한 부분, 담당자를 짧게 남기는 편이 낫다. 도구가 제안했지만 채택하지 않은 내용도 판단에 영향을 주었다면 이유를 적는다.

목적: 결제 실패 안내 문구를 읽기 쉽게 바꾼다.
AI 사용: 문구 후보와 모의 객체 수정안을 받았다.
사람 판단: 문구만 채택했다. 실패 응답을 성공으로 바꾸는 제안은 요구와 달라 제외했다.
확인: 성공·실패 상황에서 안내 문구가 각각 유지되는지 확인했다.
담당자: 다온.

이 표시는 코드의 품질 인증서가 아니다. 다음 사람이 어떤 부분을 주의해서 읽을지 알려 주는 작업 정보다. AI를 사용하지 않은 변경인 것처럼 숨기는 것도, 사용했다는 표시만 남기고 확인을 생략하는 것도 팀 규칙의 목적과 맞지 않는다.

비밀값과 고객 데이터는 요청을 만들 때부터 제외한다

비밀값에는 접근 토큰, 비밀번호, 비공개 키 등이 포함된다. 고객 데이터에는 이름, 연락처, 배송지, 개인을 알아볼 수 있는 주문 내역 등이 포함될 수 있다. 이 예제 팀은 이러한 정보를 AI 입력에 반입하지 않는 규칙을 사용한다. 로그, 화면 캡처, 설정 파일, 질문에 첨부한 예외 메시지도 입력에 해당한다.

실제 값을 조금 가리기만 하면 충분하다고 가정하지 않는다. 고객 이름을 지워도 연락처와 주문 번호가 함께 남을 수 있다. 상황을 설명할 때는 필요한 필드와 상태만 골라 처음부터 합성 자료를 만든다. 결제 실패를 설명하려면 실제 토큰 대신 가상의 결제 식별자와 실패 상태만으로도 충분한지 먼저 판단한다.

뒤의 프로그램은 실제 값을 읽거나 가리는 도구가 아니다. 요청 작성자가 비밀값 전달 여부와 고객 데이터 전달 여부를 특징으로 표시했다고 가정한다. 숨겨진 비밀값을 자동 탐지하지 못하므로 사람이 잘못 표시하면 분류도 잘못될 수 있다. 금지 판정 뒤에는 원문을 그대로 다시 제출하지 않고, 민감한 정보를 제거해 새 요청으로 구성해야 한다.

규칙이 겹치면 더 엄격한 결과를 선택한다

“테스트 수정”이라는 이름만 보고 허용하면 문제가 생긴다. 테스트 작성을 위해 고객 주문 내역을 전달하려는 요청은 테스트라는 허용 조건과 고객 데이터라는 금지 조건을 동시에 만족한다. 먼저 발견한 허용 규칙에서 검사를 끝내면 금지 조건을 놓친다.

완성 프로그램은 모든 규칙을 검사한다. 금지의 우선순위를 가장 높게, 사람 검토를 그다음으로, 허용을 가장 낮게 둔다. 여러 규칙에 해당하면 가장 높은 우선순위의 판정을 선택한다. 해당 규칙이 하나도 없으면 사람 검토로 보낸다. 아직 정의하지 않은 작업을 자동으로 허용하지 않기 위해서다.

모든 규칙을 검사해 금지, 사람 검토, 허용 순으로 선택하고 일치 규칙이 없으면 사람 검토로 보낸다

담당자가 없는 요청도 사람 검토 대상이다. 문서 수정이라는 허용 조건이 있어도 담당자 부재가 더 높은 판정을 만든다. 다만 담당자 이름이 적혔다는 사실만으로 그 사람이 변경을 이해했다고 증명되지는 않는다. 프로그램은 규칙 적용을 돕고, 사람은 분류에 쓰인 정보와 실제 작업이 맞는지 확인한다.

구현에서는 요청을 문자열 검색으로 분류하지 않는다. 예를 들어 설명에 “결제”가 들어갔는지 검사하면 결제 용어 설명 문서까지 동작 변경으로 오해할 수 있다. 대신 요청 작성자가 선택한 특징을 열거형으로 표현한다. 설명은 사람이 읽고, 규칙은 명시한 특징을 검사하도록 역할을 나눈다.

완성 코드

다음 코드를 Main.java로 저장한다. JDK 17 표준 라이브러리만 사용하며 파일이나 네트워크에 접근하지 않는다. 예제에는 실제 비밀값이나 고객 데이터가 없고, 전달하려는 요청이라는 특징만 있다. 분류 결과를 출력한 뒤 작은 자체 검사를 실행하고 바로 종료한다.

Main.java

import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;

public class Main {
    enum Decision {
        ALLOW("허용", 1),
        REVIEW("사람 검토", 2),
        FORBID("금지", 3);

        final String label;
        final int priority;

        Decision(String label, int priority) {
            this.label = label;
            this.priority = priority;
        }
    }

    enum Signal {
        DOCUMENT, TEST, MOCK_INTERNAL,
        BUSINESS_CHANGE, AUTH_CHANGE, PRODUCTION_CHANGE,
        SECRET_INPUT, CUSTOMER_INPUT
    }

    record Request(
            String id, String description,
            String owner, Set<Signal> signals) {
        Request {
            signals = Set.copyOf(signals);
        }

        boolean has(Signal signal) {
            return signals.contains(signal);
        }
    }

    record Rule(
            Decision decision, String reason,
            Predicate<Request> matches) {
    }

    record Result(Decision decision, String reason) {
    }

    static List<Rule> rules() {
        return List.of(
            new Rule(Decision.FORBID, "비밀값 전달",
                r -> r.has(Signal.SECRET_INPUT)),
            new Rule(Decision.FORBID, "고객 데이터 전달",
                r -> r.has(Signal.CUSTOMER_INPUT)),
            new Rule(Decision.REVIEW, "담당자 미지정",
                r -> r.owner().isBlank()),
            new Rule(Decision.REVIEW, "서비스 동작 변경",
                r -> r.has(Signal.BUSINESS_CHANGE)),
            new Rule(Decision.REVIEW, "권한 판단 변경",
                r -> r.has(Signal.AUTH_CHANGE)),
            new Rule(Decision.REVIEW, "운영 설정 변경",
                r -> r.has(Signal.PRODUCTION_CHANGE)),
            new Rule(Decision.ALLOW, "문서 작업",
                r -> r.has(Signal.DOCUMENT)),
            new Rule(Decision.ALLOW, "테스트 작업",
                r -> r.has(Signal.TEST)),
            new Rule(Decision.ALLOW, "모의 객체 내부 정리",
                r -> r.has(Signal.MOCK_INTERNAL))
        );
    }

    static Result classify(Request request, List<Rule> rules) {
        Result best = null;
        for (Rule rule : rules) {
            if (!rule.matches().test(request)) {
                continue;
            }
            if (best == null
                    || rule.decision().priority > best.decision().priority) {
                best = new Result(rule.decision(), rule.reason());
            }
        }
        return best == null
            ? new Result(Decision.REVIEW, "미분류 요청")
            : best;
    }

    static Request request(
            String id, String description, String owner,
            Signal... signals) {
        EnumSet<Signal> selected = EnumSet.noneOf(Signal.class);
        Collections.addAll(selected, signals);
        return new Request(id, description, owner, selected);
    }

    static int failures;

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

    public static void main(String[] args) {
        List<Rule> policy = rules();
        List<Request> requests = List.of(
            request("R1", "주문 용어 설명", "다온",
                Signal.DOCUMENT),
            request("R2", "재고 모의 객체 내부 정리", "서우",
                Signal.MOCK_INTERNAL),
            request("R3", "결제 실패를 성공으로 변경", "다온",
                Signal.MOCK_INTERNAL, Signal.BUSINESS_CHANGE),
            request("R4", "접근 토큰을 첨부한 오류 질문", "서우",
                Signal.TEST, Signal.SECRET_INPUT),
            request("R5", "고객 주문 내역으로 테스트 작성", "다온",
                Signal.TEST, Signal.CUSTOMER_INPUT),
            request("R6", "담당자 없는 문서 수정", "",
                Signal.DOCUMENT),
            request("R7", "새로운 작업 유형", "서우"),
            request("R8", "주문 조회 권한 변경", "다온",
                Signal.AUTH_CHANGE)
        );

        for (Request item : requests) {
            Result result = classify(item, policy);
            String owner = item.owner().isBlank() ? "미지정" : item.owner();
            System.out.println(item.id() + " | " + result.decision().label
                + " | " + result.reason() + " | 담당자: " + owner);
        }

        List<Decision> expected = List.of(
            Decision.ALLOW, Decision.ALLOW, Decision.REVIEW,
            Decision.FORBID, Decision.FORBID, Decision.REVIEW,
            Decision.REVIEW, Decision.REVIEW
        );
        List<Decision> actual = requests.stream()
            .map(r -> classify(r, policy).decision())
            .toList();
        check(actual.equals(expected), "예제 요청 전체 판정");

        Request mixed = request("T1", "허용과 금지 중첩", "다온",
            Signal.DOCUMENT, Signal.TEST, Signal.SECRET_INPUT);
        check(classify(mixed, policy).decision() == Decision.FORBID,
            "허용보다 금지 우선");

        List<Rule> reversed = new ArrayList<>(policy);
        Collections.reverse(reversed);
        List<Decision> reversedDecisions = requests.stream()
            .map(r -> classify(r, reversed).decision())
            .toList();
        check(actual.equals(reversedDecisions), "규칙 역순에도 같은 판정");

        EnumSet<Signal> original = EnumSet.of(Signal.TEST);
        Request copied = new Request("T2", "특징 복사 확인", "서우", original);
        original.add(Signal.SECRET_INPUT);
        check(!copied.has(Signal.SECRET_INPUT), "요청 특징 방어적 복사");

        Request unknown = request("T3", "미분류", "서우");
        check(classify(unknown, policy).decision() == Decision.REVIEW,
            "미분류는 사람 검토");

        Request production = request("T4", "운영 설정 수정", "다온",
            Signal.DOCUMENT, Signal.PRODUCTION_CHANGE);
        check(classify(production, policy).decision() == Decision.REVIEW,
            "문서 표시보다 운영 설정 검토 우선");

        if (failures > 0) {
            throw new IllegalStateException("검사 실패: " + failures);
        }
    }
}

줄별 해설

import 구문은 컬렉션과 조건 함수를 가져온다. Predicate는 요청 하나를 받아 참이나 거짓을 반환하는 조건의 자료형이다. 별도 라이브러리 없이 각 규칙의 검사 조건을 값처럼 보관할 수 있다.

Decision 선언은 화면에 표시할 이름과 우선순위를 함께 둔다. 열거형의 선언 순서를 우선순위로 사용하지 않고 숫자를 따로 적었다. 나중에 선언 위치를 옮겨도 규칙의 의미가 바뀌지 않게 하기 위해서다. 이 숫자는 품질 점수가 아니라 프로그램 안의 비교 기준이다.

Signal 선언은 작업의 특징을 표현한다. TEST와 SECRET_INPUT은 동시에 존재할 수 있다. 분류할 때 요청 하나를 한 종류로만 고르게 만들면 이런 중첩을 표현하기 어렵다. 결제 모의 객체를 수정하더라도 응답의 의미를 바꾸면 BUSINESS_CHANGE를 함께 표시해야 한다.

Request 선언은 식별자, 설명, 담당자, 특징 집합을 묶는다. 레코드는 이러한 값을 묶는 간결한 자료형이다. 생성자 안의 Set.copyOf는 전달받은 집합의 수정 불가능한 복사본을 만든다. 원래 집합을 나중에 수정해도 이미 만든 요청의 특징은 바뀌지 않는다. 이 예제는 문자열과 집합에 null을 전달하지 않는 입력을 사용한다.

Rule과 Result 선언은 규칙과 판정 결과를 구분한다. 규칙에는 조건 함수가 있지만 결과에는 선택한 판정과 이유만 있다. 설명 문자열은 그대로 출력하므로 실제 비밀값을 사유에 넣지 않아야 한다. “비밀값 전달”이라는 범주만으로도 중단 이유를 설명할 수 있다.

rules 메서드가 실행 가능한 규칙표다. 담당자 부재, 서비스 동작, 권한 판단, 운영 설정은 사람 검토로 보낸다. 문서·테스트·모의 객체 내부 정리는 허용 조건이지만 더 높은 조건을 덮어쓰지 못한다. 팀 문서가 바뀌면 이 메서드와 관련 검사도 함께 바꾸어야 한다.

classify 메서드는 best를 비운 상태에서 모든 규칙을 순회한다. 조건이 거짓이면 다음 규칙으로 넘어가고, 참이면 현재 결과보다 우선순위가 높은지 비교한다. 첫 일치에서 반환하지 않는 부분이 중요하다. 끝까지 일치가 없을 때만 미분류라는 사유로 사람 검토를 반환한다.

우선순위가 같은 규칙이 여러 개면 먼저 만난 규칙의 사유를 남긴다. 따라서 규칙을 재배치해도 판정은 같지만 대표 사유는 달라질 수 있다. 예를 들어 비밀값 전달과 고객 데이터 전달이 동시에 표시되면 현재 순서에서는 비밀값 전달이 출력된다. 모든 사유를 수집하는 기능은 이번 프로그램의 범위에 넣지 않았다.

request 보조 메서드는 가변 인자로 받은 특징을 EnumSet에 담는다. 특징이 없는 요청도 만들 수 있어 미분류 처리를 확인하기 쉽다. main의 requests는 팀에 들어온 변경 요청 목록이다. 설명에 등장하는 토큰과 고객 내역은 값이 아니라 작업 설명이며 실제 자료는 포함하지 않는다.

출력 반복문은 각 요청의 판정과 대표 사유, 담당자를 표시한다. 이후 expected와 actual을 비교해 목록 전체의 판정을 검사한다. 규칙 역순 검사는 같은 요청 목록에 규칙 순서만 바꿔 적용한 뒤 같은 판정 목록이 나오는지 확인한다. 비교 대상에 사유 문자열은 포함하지 않는다.

check 함수는 검사마다 PASS 또는 FAIL을 출력한다. 실패 개수도 누적하므로 앞에서 실패해도 뒤의 검사를 계속 볼 수 있다. 마지막에 실패가 하나라도 있으면 예외를 던진다. 정상 실행에서는 예외 없이 종료한다. 시각이나 난수를 쓰지 않고 집합의 순회 순서도 출력하지 않아 실행 결과가 일정하다.

실행 결과

Main.java가 있는 디렉터리에서 다음 명령을 차례로 실행한다. 컴파일 명령은 성공하면 별도 메시지를 출력하지 않는다.

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

예상 출력은 다음과 같다.

R1 | 허용 | 문서 작업 | 담당자: 다온
R2 | 허용 | 모의 객체 내부 정리 | 담당자: 서우
R3 | 사람 검토 | 서비스 동작 변경 | 담당자: 다온
R4 | 금지 | 비밀값 전달 | 담당자: 서우
R5 | 금지 | 고객 데이터 전달 | 담당자: 다온
R6 | 사람 검토 | 담당자 미지정 | 담당자: 미지정
R7 | 사람 검토 | 미분류 요청 | 담당자: 서우
R8 | 사람 검토 | 권한 판단 변경 | 담당자: 다온
PASS 예제 요청 전체 판정
PASS 허용보다 금지 우선
PASS 규칙 역순에도 같은 판정
PASS 요청 특징 방어적 복사
PASS 미분류는 사람 검토
PASS 문서 표시보다 운영 설정 검토 우선

R3는 모의 객체 내부 정리라는 허용 특징을 가지고도 사람 검토가 된다. 결제 실패를 성공으로 바꾸는 행위가 서비스 동작 변경으로 표시되었기 때문이다. R4와 R5는 테스트 작업이지만 전달하려는 정보가 금지 조건에 해당한다. R6는 문서 작업이어도 담당자가 없으므로 사람 검토로 간다.

PASS는 적어 둔 입력과 기대 판정이 일치했다는 뜻이다. 팀 규칙이 충분한지, 요청 작성자가 특징을 올바르게 골랐는지까지 증명하지 않는다. 이 실행 환경에서 프로그램을 직접 실행하면 위 결과를 대조할 수 있다. 특히 규칙의 순서를 바꾸는 경우에는 “판정이 같아야 한다”는 요구와 “사유까지 같아야 한다”는 요구를 구분해서 확인해야 한다.

실무에서 자주 틀리는 것

첫 허용 조건에서 검사를 끝낸다

다음 코드는 완성 프로그램의 Main 클래스 안에 넣을 수 있는 실행 가능한 메서드다. 잘못된 메서드는 테스트라는 이유로 즉시 허용한다. 같은 요청에 비밀값 전달 특징이 있어도 뒤의 검사를 실행하지 않는다.

static Decision wrongTestGate(Request request) {
    if (request.has(Signal.TEST)) {
        return Decision.ALLOW;
    }
    return classify(request, rules()).decision();
}

static Decision fixedTestGate(Request request) {
    return classify(request, rules()).decision();
}

수정 전후를 확인할 때는 정상 입력의 결과 유지와 문제 입력의 결과 교정을 함께 본다. 다음 메서드도 Main 클래스 안에 넣고 main에서 호출할 수 있다. 합성 자료로 만든 테스트 요청은 두 구현 모두 허용한다. 비밀값 전달 요청에서는 잘못된 결과가 허용이고 수정한 결과가 금지임을 각각 확인한다.

static void compareTestGates() {
    Request safe = request("C1", "합성 자료 테스트", "다온", Signal.TEST);
    Request blocked = request("C2", "비밀값 전달 요청", "다온",
        Signal.TEST, Signal.SECRET_INPUT);

    check(wrongTestGate(safe) == fixedTestGate(safe),
        "수정 전후 정상 입력 판정 유지");
    check(wrongTestGate(blocked) == Decision.ALLOW,
        "수정 전 문제 입력의 오류 재현");
    check(fixedTestGate(blocked) == Decision.FORBID,
        "수정 후 문제 입력 차단");
}

기존 main의 마지막 검사 뒤에서 compareTestGates를 호출하면 위 이름으로 PASS가 세 줄 추가된다. 정상 입력은 같은 판정을 내지만 오류를 고치는 입력은 판정이 달라야 한다. 모든 입력의 결과를 같게 만드는 것은 이 수정의 목적이 아니다. 이 추가 실험은 완성 코드의 기본 출력에는 포함하지 않았다.

허용 판정을 병합 승인으로 사용한다

분류 프로그램은 문서 수정 요청을 허용할 수 있다. 그렇다고 실제 변경에 주문 금액 계산 수정이 섞이지 않았다는 뜻은 아니다. 다음의 잘못된 이름은 분류 결과에 병합 권한이 있는 것처럼 보이게 한다. 고친 이름은 AI 도움을 사용해 작업할 수 있는지로 의미를 좁힌다.

static boolean wrongCanMerge(Result result) {
    return result.decision() == Decision.ALLOW;
}

static boolean canUseAiWithinScope(Result result) {
    return result.decision() == Decision.ALLOW;
}

두 메서드는 같은 입력에 같은 boolean을 반환한다. 달라진 것은 값의 의미를 사용하는 경계다. 이름만 바꿔 놓고 호출한 쪽에서 계속 병합 승인으로 사용하면 문제가 해결되지 않는다. 허용 판정은 작업 범위를 정하는 데 사용하고, 변경 수용 판단은 사람이 별도로 해야 한다.

담당자 칸을 AI라는 글자로 채운다

담당자 칸은 도구 이름이 아니라 변경을 설명할 사람을 적는 곳이다. 아래의 잘못된 코드는 담당자가 없을 때 AI라는 문자열을 넣는다. 그러면 빈 문자열을 검사하는 규칙을 통과한다. 고친 코드는 미지정 상태를 유지해 사람 검토로 보낸다.

static Request wrongOwnerFallback() {
    return request("O1", "주문 설명 수정", "AI", Signal.DOCUMENT);
}

static Request fixedOwnerFallback() {
    return request("O1", "주문 설명 수정", "", Signal.DOCUMENT);
}

이 문제를 모든 도구 이름의 금지 목록으로 해결하려고 하면 목록 관리가 끝없이 늘어난다. 실제 저장소에서는 팀이 정한 담당자 식별 방식으로 사람을 연결해야 한다. 이 예제는 사람 이름의 진위를 검증하지 않으며, 빈 담당자를 놓치지 않는 수준만 구현했다.

검증 출력을 위해 민감한 원문을 다시 출력한다

금지 요청을 막았더라도 원문을 로그에 남기면 정보가 다른 경로로 퍼질 수 있다. 다음의 잘못된 메서드는 전달받은 원문을 그대로 출력한다. 고친 메서드는 이 예제처럼 팀이 부여한 식별자와 분류 사유만 출력한다. 비교 실험에도 실제 비밀값을 넣지 않는다.

static void wrongPrintReason(String rawInput) {
    System.out.println(rawInput);
}

static void printReason(Request request, Result result) {
    System.out.println(request.id() + " | " + result.reason());
}

식별자 자체에 고객 연락처를 넣는다면 이 수정만으로 충분하지 않다. R1 같은 작업용 식별자를 사용해야 한다. 이 예제의 사유는 규칙에 고정된 문자열이므로 원문의 일부를 포함하지 않는다. 실제 내용을 확인해야 할 상황은 팀이 정한 접근 범위 안에서 따로 처리한다.

한눈에 보기

팀의 판단과 프로그램의 역할을 연결한 요약
판단 지점팀 규칙프로그램의 처리사람에게 남는 일
변경 책임사람 담당자를 둔다빈 담당자를 사람 검토로 보낸다목적과 영향을 설명한다
허용 범위문서·테스트·모의 객체 내부 정리더 높은 조건이 없으면 허용한다실제 변경이 범위 안인지 확인한다
민감한 입력비밀값·고객 데이터 전달 금지해당 특징이 있으면 금지한다입력에서 실제로 제외한다
규칙 중첩더 엄격한 판정을 선택한다모든 규칙의 우선순위를 비교한다특징 누락을 확인한다
정의하지 않은 요청판단 없이 허용하지 않는다사람 검토로 보낸다범위를 확인하고 규칙을 보완한다
사용 표시목적·범위·확인·담당자를 남긴다이번 코드에서는 자동화하지 않는다변경 요청에 직접 기록한다

팀 규칙은 AI 사용을 선언하는 문서에 그치지 않아야 한다. 작업을 시작할 때 적용하고, 애매한 요청을 만나면 그 경계를 확인하는 기준으로 사용한다. 다음에는 이 경계를 유지하면서 처음 보는 주문 서비스 저장소의 구조와 변경 지점을 파악한다.

연습 문제

  1. 합성 자료만 사용하는 테스트 요청에 담당자가 없다. TEST 특징은 있고 비밀값·고객 데이터 특징은 없다. 판정과 사유를 예상하고, 완성 코드의 request와 classify를 이용해 check를 추가하라.
  2. R5에서 CUSTOMER_INPUT 특징을 실수로 빼면 어떤 판정이 나오는지 설명하라. 이 결과가 프로그램의 어떤 한계를 드러내는지 적고, 규칙 문서에 추가할 확인 문장을 작성하라.
  3. 비밀값 전달과 고객 데이터 전달이 동시에 표시된 요청을 만들라. 현재 규칙 순서와 역순에서 판정과 대표 사유를 예상하라. 규칙 역순 검사가 사유까지 비교하지 않는 이유를 설명하라.
  4. 외부 의존성을 추가하는 요청을 사람 검토로 분류하도록 특징과 규칙을 추가하라. DOCUMENT 특징이 함께 있어도 사람 검토가 되는지 확인하라. 라이브러리를 실제로 추가하거나 네트워크에 접속하지 않는다.

정답과 해설

첫 번째 문제. 판정은 사람 검토이고 사유는 담당자 미지정이다. 테스트 작업의 허용보다 담당자 부재의 우선순위가 높다. 다음 메서드를 Main 클래스 안에 추가하고 main에서 호출하면 해당 조건을 검사할 수 있다.

static void checkMissingOwner() {
    Request item = request("E1", "합성 자료 테스트", "", Signal.TEST);
    Result result = classify(item, rules());
    check(result.decision() == Decision.REVIEW
            && result.reason().equals("담당자 미지정"),
        "담당자 없는 테스트는 사람 검토");
}

두 번째 문제. 담당자가 있고 TEST만 남으므로 허용으로 분류된다. 프로그램은 설명 문자열에서 고객 데이터를 찾아내지 않으며 실제 첨부 자료도 읽지 않는다. 입력 특징의 누락을 스스로 알아낼 수 없다는 한계다. 문서에는 “요청자는 설명뿐 아니라 첨부 파일과 로그에도 비밀값·고객 데이터가 없는지 확인한 뒤 특징을 표시한다”라는 문장을 추가할 수 있다. 이 문장을 추가해도 자동 탐지 기능이 생기는 것은 아니다.

세 번째 문제. 두 순서 모두 금지다. 현재 순서에서는 비밀값 전달이 먼저이므로 그 사유가 남고, 역순에서는 고객 데이터 전달이 먼저이므로 그 사유가 남는다. 같은 우선순위에서는 먼저 선택한 결과를 유지하기 때문이다. 역순 검사는 판정 우선순위가 배열 순서에 의존하지 않는지를 확인한다. 대표 사유의 순서 독립성은 현재 구현의 요구가 아니다.

네 번째 문제. Signal에 DEPENDENCY_CHANGE를 추가하고 rules의 List.of 안에 다음과 같은 Rule을 추가하면 된다. 이 요청은 외부 의존성 변경이라는 정책상 특징만 나타내므로 예제의 실행 환경은 그대로 유지된다. 아래 메서드는 Signal을 확장한 Main 클래스 안에 넣을 수 있다.

static Rule dependencyRule() {
    return new Rule(Decision.REVIEW, "외부 의존성 변경",
        r -> r.has(Signal.DEPENDENCY_CHANGE));
}

static void checkDependencyRule() {
    List<Rule> extended = new ArrayList<>(rules());
    extended.add(dependencyRule());
    Request item = request("E4", "의존성 추가 안내", "다온",
        Signal.DOCUMENT, Signal.DEPENDENCY_CHANGE);
    check(classify(item, extended).decision() == Decision.REVIEW,
        "문서와 의존성 변경이 겹치면 사람 검토");
}

사람 검토 규칙을 목록 맨 뒤에 추가해도 문서 작업의 허용보다 우선한다. 이것이 모든 규칙을 검사하도록 만든 이유다. 규칙표는 반복되는 분류를 도와주지만, 변경을 맡길 범위와 그 결과를 받아들일 책임은 팀의 사람에게 남는다.

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

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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