Devin.KR

AI 개발 · 심화

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

처음 보는 저장소를 AI 와 함께 파악하기

진입점·도메인 객체·의존 방향 찾기, AI 요약을 코드로 사실 확인하기, 틀린 요약의 전형(없는 메서드, 반대 방향 의존), 완성 코드는 소스 문자열 묶음에서 클래스와 호출 관계를 뽑아 의존 표를 출력하고 AI 요약 문장과 대조한다

개발자KR · 원고 갱신

이 장에서 배우는 것

처음 보는 저장소에서 곧바로 코드를 고치면, 변경할 위치보다 먼저 잘못된 전제를 고르게 된다. 주문 처리의 시작점이 어디인지, 재고를 누가 차감하는지, 결제 실패를 누가 받는지부터 알아야 한다. AI는 이 탐색을 도울 수 있지만, 설명이 자연스럽다는 이유만으로 저장소의 사실이 되지는 않는다.

앞 장에서 작업의 책임을 사람에게 두었다면, 여기서는 그 책임을 코드 읽기로 구체화한다. 작은 쇼핑몰 주문 서비스를 대상으로 AI의 설명을 검증 가능한 문장으로 나누고, 소스에서 찾은 근거와 대조한다. 마지막에는 소스 문자열 묶음에서 클래스·메서드·호출 관계를 추출하는 프로그램을 만든다.

  • 외부 요청을 받는 진입점과 주문·재고·결제 객체의 역할을 구분한다.
  • 호출하는 쪽에서 호출받는 쪽으로 의존 방향을 읽는다.
  • AI 요약의 메서드 이름과 호출 관계를 소스에서 확인한다.
  • 간단한 분석기의 결과와 분석 범위를 함께 설명한다.

문제 상황

팀에 합류한 개발자가 주문 서비스 저장소를 처음 열었다. 파일은 많지 않지만 이름이 비슷하다. 주문을 나타내는 Order도 있고, 주문 처리를 맡는 OrderService도 있다. 재고와 결제는 각각 Inventory와 Payment가 맡는다. 개발자는 AI에게 저장소 구조를 설명해 달라고 요청했고, 다음 답을 받았다.

Entry.run이 주문 처리의 진입점이다. Inventory.reserve가 OrderService.place를 호출하여 주문을 진행한다. 결제 취소는 Payment.refund에서 처리한다. OrderService.place는 Payment.pay를 호출한다.

첫 문장과 마지막 문장은 맞다. 가운데 두 문장은 다르다. 재고 예약을 요청하는 쪽은 주문 서비스이며, 결제 모의 객체에는 refund라는 메서드가 없다. 이 설명을 그대로 믿으면 개발자는 없는 취소 경로를 찾거나, 재고 객체에 주문 진행 책임을 넣으려 할 수 있다.

이번 작업의 목표는 이 설명을 바탕으로 기능을 추가하는 것이 아니다. 실제 구조와 설명이 일치하는 부분을 가려 내는 것이다. 아직 확인하지 않은 실패 처리나 거래 취소 정책까지 추측하지 않는다. 이번 예제의 결제와 재고는 호출 흐름을 보여 주는 모의 객체이며, 실제 외부 시스템과 연결하지 않는다.

진입점에서 도메인 객체까지 따라간다

진입점(entry point)은 특정 실행 흐름이 시작되는 위치다. 프로그램 전체의 시작은 main일 수 있지만, 주문 처리 흐름의 시작은 요청을 받는 메서드일 수 있다. 저장소를 읽을 때는 “어떤 흐름의 진입점인가”를 함께 말해야 한다. 이름이 run이라는 이유만으로 주문의 시작점이라고 단정할 수는 없다.

이 예제에서는 Entry.run을 주문 처리의 시작으로 정해 두었다. Entry는 OrderService에 처리를 맡기고, OrderService는 재고 예약과 결제를 요청한다. Order는 주문 금액을 제공한다. 이름만 보고 역할을 정하지 않고, 메서드 본문에서 실제로 하는 일을 확인한다.

같은 주문 흐름 안에서도 객체의 책임은 다르다
객체확인할 코드이 예제의 책임
Entryrun의 호출 대상주문 처리를 시작한다
OrderServiceplace의 본문재고·결제·주문 객체를 조합한다
Ordertotal의 반환값주문 금액을 제공한다
Inventory, Paymentreserve, pay의 본문외부 처리 자리를 대신하는 모의 객체다

도메인 객체(domain object)는 업무 개념을 코드로 표현한 객체다. 이 예제의 Order는 주문이라는 업무 개념을 표현하지만, OrderService는 여러 객체를 불러 작업 순서를 묶는다. 두 이름에 Order가 들어간다는 공통점보다 실제 책임의 차이가 더 중요하다. 다른 저장소에서는 책임 배치가 다를 수 있으므로 이 구성을 모든 프로젝트의 규칙으로 확대하지 않는다.

주문 흐름은 Entry에서 시작하고 OrderService가 재고와 결제 및 주문 객체를 호출한다

그림의 화살표는 호출하는 쪽에서 호출받는 쪽으로 향한다. Inventory가 OrderService의 필드로 들어 있다는 말과 Inventory가 OrderService를 호출한다는 말은 다르다. 필드 선언은 보유 관계를 보여 주고, 메서드 본문의 호출은 실행 중 사용하는 방향을 보여 준다. 이번 의존 표는 이 중 직접 호출 관계를 요약한다.

AI의 설명을 확인 가능한 주장으로 바꾼다

“주문 관련 기능을 담당한다”는 설명은 넓어서 맞고 틀림을 판단하기 어렵다. 반면 “OrderService.place가 Inventory.reserve를 직접 호출한다”는 문장은 호출 위치를 찾아 확인할 수 있다. AI에게도 클래스 이름만 나열하게 하기보다, 메서드 이름과 호출 대상을 함께 적게 요청하는 편이 낫다.

주문 처리의 시작 메서드, 업무 객체, 직접 호출 관계를 각각 적어라. 호출 방향은 호출자 → 호출 대상 순서로 적어라. 소스에서 확인하지 못한 역할은 추정이라고 표시하고, 존재하지 않는 메서드를 보충하지 마라.

이 요청도 정확성을 보장하지는 않는다. 사람이 확인할 단위를 작게 만들 뿐이다. 사람이 읽을 요약에는 업무 용어를 쓰되, 검증 목록에는 정확한 클래스명과 메서드명을 남긴다. 예를 들어 “재고를 확보한다”라는 설명 옆에 OrderService.place → Inventory.reserve라는 확인 대상을 붙인다.

AI 요약을 소스에서 확인할 대상으로 나눈다
요약의 주장확인 대상판단 기준
Entry.run이 진입점이다Entry의 run 선언존재 확인 후 호출 위치를 읽는다
재고가 주문 서비스를 호출한다Inventory.reserve의 본문해당 방향의 직접 호출이 있는가
결제 취소 메서드가 있다Payment의 refund 선언해당 클래스에 선언되어 있는가
주문 서비스가 결제한다OrderService.place의 본문Payment.pay 호출이 있는가

메서드가 있다는 사실만으로 그 역할까지 증명되지는 않는다. run 선언을 찾았다고 외부 요청이 반드시 그곳으로 들어온다는 뜻은 아니다. 실제 저장소에서는 실행 설정과 호출하는 코드도 확인해야 한다. 이번 프로그램은 Entry.run의 존재만 자동으로 확인하며, 진입점이라는 역할은 예제의 실행 흐름을 읽어 사람이 판단한다.

또한 직접 호출과 간접 호출을 구분해야 한다. Entry.run은 OrderService.place를 거쳐 Payment.pay에 도달하지만, Entry.run의 본문에는 Payment.pay 호출이 없다. 따라서 이번 표에서 Entry의 직접 의존 대상은 OrderService 하나다. 도달 가능한 모든 객체를 한 칸에 섞으면 변경의 영향을 읽기 어려워진다.

작은 분석기가 확인하는 범위를 정한다

완성 프로그램은 자바 소스 문자열 다섯 개를 입력으로 받는다. 먼저 클래스와 공개 메서드 이름을 모은다. 그다음 필드의 변수 이름을 타입에 연결하고, 메서드 본문의 호출을 해석한다. service.place라는 표현을 만나면 service 필드의 타입이 OrderService인지 찾아 OrderService.place로 바꾼다.

이 작업은 두 번에 나누어 진행한다. 첫 번째 순회에서 전체 선언을 모아야, 두 번째 순회에서 아직 읽지 않은 파일의 메서드도 확인할 수 있다. 문자열 목록의 순서를 바꿨다는 이유로 결제 메서드를 못 찾는 일이 없어야 한다. 출력은 정렬된 자료구조를 사용하여 입력 순서에 영향을 받지 않게 한다.

선언을 먼저 수집한 뒤 호출을 해석해야 입력 파일 순서와 무관하게 요약을 대조할 수 있다

여기서 사용하는 정규 표현식(regular expression)은 자바 문법 전체를 분석하지 않는다. 입력은 문자열 하나당 클래스 하나이며, 필드는 private final 타입 변수 형태다. 공개 메서드는 인자 없이 선언하고 본문까지 한 줄에 쓴다. 호출은 필드이름.메서드이름() 형태만 사용한다. 주석·문자열 속 호출 모양, 상속, 인터페이스, 오버로딩, 지역 변수, 여러 줄 메서드는 분석 대상에 넣지 않는다.

이 제한은 사소한 단서가 아니다. 분석 대상이 제한을 벗어나면 빠뜨린 호출이 있어도 결과가 출력될 수 있다. 따라서 결과의 “불일치”는 주어진 입력과 지원 형식 안에서만 해석한다. 실제 저장소를 이 프로그램에 그대로 넣어 전체 구조를 확인했다고 말해서는 안 된다. 여기서는 도구의 출력에 근거 범위를 붙이는 습관을 익힌다.

AI 문장을 자동으로 자연어 해석하지도 않는다. 사람이 문장을 읽고, 메서드 존재 확인 또는 직접 호출 확인이라는 구조화된 항목으로 옮긴다. 문장을 잘못 옮길 가능성도 있으므로 문장과 확인 대상을 함께 출력한다. 확인 도구가 무엇을 검사했는지 보이지 않으면 새로운 오해가 생긴다.

완성 코드

다음 파일 하나로 실행한다. 소스 문자열은 분석할 주문 서비스의 입력이며, Main 자체의 소스와는 구분한다. 프로그램은 입력 소스를 컴파일하거나 주문을 실행하지 않고 구조를 추출한다. 파일·네트워크·시각·난수를 사용하지 않는다.

Main.java

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {
    private static final Pattern CLASS =
            Pattern.compile("(?m)^class (\\w+) \\{");
    private static final Pattern FIELD =
            Pattern.compile("\\s*private final (\\w+) (\\w+) = .*;");
    private static final Pattern METHOD =
            Pattern.compile("\\s*public \\w+ (\\w+)\\(\\) \\{.*}");
    private static final Pattern CALL =
            Pattern.compile("\\b(\\w+)\\.(\\w+)\\s*\\(");

    record Analysis(Map<String, Set<String>> methods,
                    Set<String> calls) {
    }

    record Claim(String sentence, String from, String to) {
    }

    public static void main(String[] args) {
        List<String> sources = sources();
        Analysis analysis = analyze(sources);

        System.out.println("클래스 | 메서드 | 직접 의존");
        for (String owner : analysis.methods().keySet()) {
            Set<String> dependencies = new TreeSet<>();
            for (String edge : analysis.calls()) {
                if (edge.startsWith(owner + ".")) {
                    String target = edge.substring(edge.indexOf(" -> ") + 4);
                    dependencies.add(target.substring(0, target.indexOf('.')));
                }
            }
            System.out.println(owner + " | "
                    + String.join(",", analysis.methods().get(owner)) + " | "
                    + (dependencies.isEmpty()
                    ? "-" : String.join(",", dependencies)));
        }

        System.out.println("호출 근거");
        for (String edge : analysis.calls()) {
            System.out.println(edge);
        }

        System.out.println("AI 요약 대조");
        List<Claim> claims = List.of(
                new Claim("Entry.run이 주문 진입점이다",
                        "Entry.run", ""),
                new Claim("재고가 주문 서비스를 호출한다",
                        "Inventory.reserve", "OrderService.place"),
                new Claim("결제 취소 메서드가 있다",
                        "Payment.refund", ""),
                new Claim("주문 서비스가 결제를 호출한다",
                        "OrderService.place", "Payment.pay")
        );
        for (Claim claim : claims) {
            boolean matched = claim.to().isEmpty()
                    ? hasMethod(analysis, claim.from())
                    : hasCall(analysis, claim.from(), claim.to());
            String subject = claim.to().isEmpty()
                    ? claim.from() + " 존재"
                    : claim.from() + " -> " + claim.to();
            System.out.println((matched ? "일치" : "불일치")
                    + " | " + claim.sentence() + " | " + subject);
        }

        System.out.println("검증");
        check(hasMethod(analysis, "Entry.run"), "진입 메서드 존재");
        check(!hasMethod(analysis, "Payment.refund"), "없는 메서드 거부");
        check(hasCall(analysis, "OrderService.place", "Inventory.reserve"),
                "정방향 호출");
        check(!hasCall(analysis, "Inventory.reserve", "OrderService.place"),
                "역방향 호출 거부");
        check(!hasCall(analysis, "Entry.run", "Payment.pay"),
                "간접 호출을 직접 호출에서 제외");

        List<String> reversed = new ArrayList<>(sources);
        java.util.Collections.reverse(reversed);
        check(analysis.equals(analyze(reversed)), "입력 순서와 무관");

        System.out.println("오류 수정 비교: 이전 / 이후");
        compare("클래스 범위",
                looseMethod(sources, "place"),
                hasMethod(analysis, "Payment.place"));
        compare("호출 방향",
                reversedCall(analysis, "Inventory.reserve",
                        "OrderService.place"),
                hasCall(analysis, "Inventory.reserve",
                        "OrderService.place"));
        compare("진입점 이름",
                guessedEntry("Payment.run"),
                hasMethod(analysis, "Payment.run"));
    }

    private static Analysis analyze(List<String> sources) {
        Map<String, Set<String>> methods = new TreeMap<>();
        for (String source : sources) {
            String owner = ownerOf(source);
            if (methods.containsKey(owner)) {
                throw new IllegalArgumentException("중복 클래스: " + owner);
            }
            Set<String> names = new TreeSet<>();
            for (String line : source.lines().toList()) {
                Matcher method = METHOD.matcher(line);
                if (method.matches()) {
                    names.add(method.group(1));
                }
            }
            methods.put(owner, names);
        }

        Set<String> calls = new TreeSet<>();
        for (String source : sources) {
            String owner = ownerOf(source);
            Map<String, String> fields = new TreeMap<>();
            List<String> lines = source.lines().toList();
            for (String line : lines) {
                Matcher field = FIELD.matcher(line);
                if (field.matches()) {
                    fields.put(field.group(2), field.group(1));
                }
            }
            for (String line : lines) {
                Matcher method = METHOD.matcher(line);
                if (!method.matches()) {
                    continue;
                }
                String from = owner + "." + method.group(1);
                Matcher call = CALL.matcher(line);
                while (call.find()) {
                    String receiver = call.group(1);
                    String targetOwner = fields.get(receiver);
                    if (targetOwner == null) {
                        throw new IllegalArgumentException(
                                "지원하지 않는 수신자: " + receiver);
                    }
                    String to = targetOwner + "." + call.group(2);
                    if (!hasMethod(new Analysis(methods, calls), to)) {
                        throw new IllegalArgumentException(
                                "호출 대상 선언 없음: " + to);
                    }
                    calls.add(from + " -> " + to);
                }
            }
        }
        return new Analysis(methods, calls);
    }

    private static String ownerOf(String source) {
        Matcher matcher = CLASS.matcher(source);
        if (!matcher.find()) {
            throw new IllegalArgumentException("클래스 선언 없음");
        }
        return matcher.group(1);
    }

    private static boolean hasMethod(Analysis analysis, String qualified) {
        int dot = qualified.indexOf('.');
        if (dot < 1 || dot == qualified.length() - 1) {
            return false;
        }
        String owner = qualified.substring(0, dot);
        String name = qualified.substring(dot + 1);
        return analysis.methods().getOrDefault(owner, Set.of()).contains(name);
    }

    private static boolean hasCall(
            Analysis analysis, String from, String to) {
        return analysis.calls().contains(from + " -> " + to);
    }

    private static boolean looseMethod(List<String> sources, String name) {
        return sources.stream().anyMatch(source -> source.contains(name + "("));
    }

    private static boolean reversedCall(
            Analysis analysis, String from, String to) {
        return analysis.calls().contains(to + " -> " + from);
    }

    private static boolean guessedEntry(String qualified) {
        return qualified.endsWith(".run");
    }

    private static void compare(String name, boolean before, boolean after) {
        System.out.println(name + " | " + before + " / " + after
                + " | 같은 결과=" + (before == after));
        check(before && !after, name + " 오판 제거");
    }

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

    private static List<String> sources() {
        return List.of(
                """
                class Entry {
                    private final OrderService service = new OrderService();
                    public void run() { service.place(); }
                }
                """,
                """
                class OrderService {
                    private final Inventory inventory = new Inventory();
                    private final Payment payment = new Payment();
                    private final Order order = new Order();
                    public int place() { inventory.reserve(); payment.pay(); return order.total(); }
                }
                """,
                """
                class Order {
                    public int total() { return 1200; }
                }
                """,
                """
                class Inventory {
                    public void reserve() { }
                }
                """,
                """
                class Payment {
                    public void pay() { }
                }
                """
        );
    }
}

줄별 해설

CLASS부터 CALL까지의 상수는 입력에서 찾을 모양을 정의한다. CLASS의 줄 시작 조건은 클래스 선언을 찾고, FIELD는 필드의 타입과 변수 이름을 각각 묶는다. METHOD는 공개 메서드 이름을 묶으며, CALL은 점 앞의 수신자와 점 뒤의 메서드 이름을 묶는다. 이 패턴들이 지원하는 형식은 앞서 정한 작은 입력 문법과 같다.

Analysis는 클래스별 메서드 집합과 직접 호출 집합을 담는다. Claim은 AI 문장, 확인할 메서드, 호출 대상을 묶는다. to가 빈 문자열이면 메서드 존재를 검사하고, 값이 있으면 from에서 to로 향하는 직접 호출을 검사한다. 문자열 하나에 자연어와 분석 규칙을 뒤섞지 않기 위한 구분이다.

main의 첫 반복문은 클래스별 의존 표를 만든다. 호출 근거가 현재 클래스 이름과 점으로 시작하면, 화살표 오른쪽에서 대상 클래스 이름을 꺼낸다. 메서드 호출은 여러 개여도 같은 대상 클래스는 집합에 한 번만 남는다. 호출 근거 자체는 다음 반복문에서 따로 출력하므로 요약표에서 생략된 메서드 정보도 확인할 수 있다.

AI 요약 대조에서는 네 문장을 차례로 검사한다. 첫 문장의 출력이 “일치”여도 확인 대상에는 Entry.run 존재라고 적힌다. 자동 검사가 진입점 역할까지 증명한 것처럼 읽지 않게 범위를 드러낸 것이다. 나머지 문장도 각각 선언 존재 또는 직접 호출이라는 기준에 따라 판정한다.

analyze의 첫 번째 순회는 모든 클래스의 공개 메서드를 수집한다. TreeMap과 TreeSet을 사용하므로 클래스·메서드·호출의 출력 순서가 일정하다. 중복 클래스는 조용히 덮어쓰지 않고 예외로 알린다. 이 예제는 메서드 인자가 없으므로 이름만 저장하지만, 실제 자바 분석에서는 매개변수 타입을 포함한 식별이 필요하다.

두 번째 순회의 fields에는 변수 이름이 키, 타입 이름이 값으로 들어간다. inventory라는 키를 조회하면 Inventory가 나온다. 메서드가 있는 줄에서 inventory.reserve()를 찾으면 현재 메서드를 호출자로, Inventory.reserve를 호출 대상으로 기록한다. 대상 선언까지 확인하므로 입력에서 payment.refund()를 호출하도록 바꾸면 요약 대조 전에 예외가 발생한다.

hasMethod는 점을 기준으로 클래스명과 메서드명을 나누어 해당 클래스의 집합을 조회한다. hasCall은 방향을 포함한 호출 문자열을 조회한다. 이 작은 프로그램에서는 식별자에 점이나 화살표가 들어가지 않으므로 문자열 표현으로 충분하다. 더 넓은 문법을 다룬다면 호출자와 대상을 별도 자료형으로 저장하는 편이 관리하기 쉽다.

check는 조건이 참이면 PASS를 출력하고, 거짓이면 FAIL을 출력한 뒤 실행을 중단한다. 소스 순서를 뒤집은 검사에서는 두 Analysis의 내용이 같은지 확인한다. 마지막 세 비교는 일부러 잘못 만든 판정과 올바른 판정을 같은 질문에 적용한다. 업무 코드의 변경은 없으며, 검증 도구의 오판이 제거되는지를 확인한다.

실행 결과

Main.java가 있는 디렉터리에서 다음 두 명령을 실행한다. JDK 17 표준 라이브러리만 필요하며, 컴파일 경고까지 확인하려면 첫 명령처럼 옵션을 붙인다. out은 컴파일러가 만드는 클래스 출력 디렉터리이며, 프로그램 자체는 파일을 쓰지 않는다.

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

예상 출력은 다음과 같다. 호출과 클래스는 정렬되지만, AI 문장은 입력한 순서대로 출력된다.

클래스 | 메서드 | 직접 의존
Entry | run | OrderService
Inventory | reserve | -
Order | total | -
OrderService | place | Inventory,Order,Payment
Payment | pay | -
호출 근거
Entry.run -> OrderService.place
OrderService.place -> Inventory.reserve
OrderService.place -> Order.total
OrderService.place -> Payment.pay
AI 요약 대조
일치 | Entry.run이 주문 진입점이다 | Entry.run 존재
불일치 | 재고가 주문 서비스를 호출한다 | Inventory.reserve -> OrderService.place
불일치 | 결제 취소 메서드가 있다 | Payment.refund 존재
일치 | 주문 서비스가 결제를 호출한다 | OrderService.place -> Payment.pay
검증
PASS | 진입 메서드 존재
PASS | 없는 메서드 거부
PASS | 정방향 호출
PASS | 역방향 호출 거부
PASS | 간접 호출을 직접 호출에서 제외
PASS | 입력 순서와 무관
오류 수정 비교: 이전 / 이후
클래스 범위 | true / false | 같은 결과=false
PASS | 클래스 범위 오판 제거
호출 방향 | true / false | 같은 결과=false
PASS | 호출 방향 오판 제거
진입점 이름 | true / false | 같은 결과=false
PASS | 진입점 이름 오판 제거

마지막 비교에서 전후 출력이 다른 것은 의도한 결과다. 존재하지 않는 관계를 참으로 판정하던 오류를 고쳤기 때문이다. 동작을 유지하는 정리 작업과 달리 오류 수정에서는 모든 출력이 같아야 한다고 요구하면 안 된다. 같은 입력에 대해 무엇이 달라져야 하는지를 먼저 정하고, 여기서는 잘못된 참 판정이 거짓으로 바뀌는지 실행 중 확인한다.

표에 Inventory와 Payment의 직접 의존이 없다고 나오는 것은 외부 시스템과 무관하다는 일반적인 결론이 아니다. 제공한 모의 객체의 메서드 본문이 비어 있기 때문이다. 분석 결과를 읽을 때는 실제 운영 객체와 예제 입력의 차이를 유지해야 한다.

실무에서 자주 틀리는 것

메서드 이름만 찾고 소유 클래스를 생략한다

잘못된 코드는 전체 소스 어디엔가 place(가 있으면 참을 반환한다. Payment.place의 존재를 묻는데 OrderService.place의 선언이나 호출이 근거로 사용된다. 호출 문자열과 선언도 구분하지 못한다. 다음 메서드는 완성 코드의 오류 비교에서 실제로 호출된다.

private static boolean looseMethod(List<String> sources, String name) {
    return sources.stream().anyMatch(source -> source.contains(name + "("));
}

고친 코드는 클래스 범위를 먼저 정하고 그 클래스의 메서드 집합을 확인한다. 완성 코드의 비교는 같은 Payment.place 질문에 대해 이전 true, 이후 false를 출력한다. 존재 여부를 확인하는 질문에 다른 클래스의 선언이 섞이지 않는다.

private static boolean hasMethod(Analysis analysis, String qualified) {
    int dot = qualified.indexOf('.');
    if (dot < 1 || dot == qualified.length() - 1) {
        return false;
    }
    String owner = qualified.substring(0, dot);
    String name = qualified.substring(dot + 1);
    return analysis.methods().getOrDefault(owner, Set.of()).contains(name);
}

관계는 찾았지만 화살표를 뒤집는다

AI 요약에서 두 클래스가 함께 등장하면 연결 자체만 기억하기 쉽다. 그러나 “재고가 주문을 진행한다”와 “주문 서비스가 재고를 예약한다”는 서로 다른 구조다. 다음 잘못된 코드는 인자로 받은 호출자와 대상을 반대로 조회한다.

private static boolean reversedCall(
        Analysis analysis, String from, String to) {
    return analysis.calls().contains(to + " -> " + from);
}

고친 코드는 호출자에서 대상으로 향하는 순서를 그대로 보존한다. Inventory.reserve → OrderService.place라는 같은 입력에서 판정은 true에서 false로 바뀐다. 반대 방향을 거부하는 검사와 실제 정방향을 받아들이는 검사를 함께 두어야 방향을 읽는 기준이 분명해진다.

private static boolean hasCall(
        Analysis analysis, String from, String to) {
    return analysis.calls().contains(from + " -> " + to);
}

익숙한 이름으로 진입점을 만들어 낸다

다음 잘못된 코드는 run으로 끝나면 해당 메서드가 있다고 취급한다. 실제로 없는 Payment.run도 통과한다. 이름에 관한 관습은 탐색 후보를 고르는 데 쓸 수 있지만, 선언 존재를 대신하지 못한다.

private static boolean guessedEntry(String qualified) {
    return qualified.endsWith(".run");
}

존재 확인에는 앞서 고친 hasMethod를 사용한다. 완성 코드에서 그 차이를 실행으로 확인하는 부분은 다음과 같다. 두 판정에 같은 Payment.run 질문을 전달한다.

compare("진입점 이름",
        guessedEntry("Payment.run"),
        hasMethod(analysis, "Payment.run"));

수정 후 존재가 확인되더라도 진입점의 역할은 추가로 읽어야 한다. 실행 설정에서 선택되는지, 외부 요청이 들어오는지, 다른 메서드가 먼저 준비 작업을 하는지를 살핀다. 이번 예제에서는 Entry.run의 본문과 예제에 정한 흐름을 근거로 판단한다. 결제 모의 객체에 run을 새로 추가하더라도 그것만으로 주문의 시작점이 되지는 않는다.

한눈에 보기

저장소 요약은 주장마다 근거와 범위를 붙여 읽는다
확인할 것찾을 근거피할 오해
진입점메서드 선언, 호출 위치, 실행 설정이름만으로 시작점을 단정한다
도메인 객체보유 값과 메서드 본문클래스 이름을 책임의 증거로 삼는다
직접 의존 방향호출자 본문과 수신자 타입관계를 뒤집거나 간접 호출을 섞는다
AI 요약정확한 클래스·메서드·호출 관계설명의 자연스러움을 정확성으로 여긴다
분석 결과의 범위입력 형식과 지원하는 문법제한된 추출기를 전체 자바 분석기로 여긴다

이번 작업의 산출물은 새 기능이 아니라 근거가 붙은 저장소 지도다. 팀원이 이 지도를 읽고 주문 처리의 시작과 직접 호출 대상을 찾을 수 있어야 한다. 다음 장에서는 이렇게 확인한 책임 경계를 이용해 변경 작업을 하나의 검토 가능한 크기로 나눈다.

연습 문제

  1. “Entry.run이 Payment.pay를 직접 호출한다”라는 주장을 claims에 추가하라. 판정을 예상하고 실행 결과와 비교하라. 직접 호출은 아니지만 결제에 도달한다고 설명할 수 있는 근거도 적어라.
  2. OrderService의 입력 소스에서 payment.pay()를 payment.refund()로 바꾸라. 실행이 어느 단계에서 멈추는지 확인하라. AI의 refund 존재 주장만 추가한 경우와 차이를 설명하라.
  3. Inventory 입력 소스의 reserve 선언 앞에 public int available() { return 3; }을 한 줄로 추가하라. 클래스 표와 호출 근거 중 무엇이 바뀌는지 예상한 뒤 실행하라.
  4. place의 본문을 여러 줄로 나누라. 출력이 줄어드는 이유를 설명하고, 이 결과만으로 “주문 서비스의 의존이 없어졌다”고 말할 수 있는지 판단하라.

정답과 해설

  1. from을 Entry.run, to를 Payment.pay로 지정하면 불일치다. 직접 호출 집합에 해당 항목이 없기 때문이다. Entry.run → OrderService.place와 OrderService.place → Payment.pay라는 두 근거를 이어 간접 도달을 설명할 수 있다. 현재 hasCall은 이 두 단계를 합쳐 판단하지 않는다.
  2. 호출 추출 과정에서 대상 선언을 확인하다가 “호출 대상 선언 없음: Payment.refund” 예외가 발생한다. analyze가 끝나지 않아 클래스 표도 출력되지 않는다. 반면 AI 주장에만 refund가 있으면 입력 소스는 유효한 예제 형태를 유지하므로 분석을 마친 뒤 해당 주장에 불일치를 출력한다. 입력 코드의 문제와 설명의 문제는 발생 위치가 다르다.
  3. Inventory의 메서드 칸이 available,reserve로 바뀐다. 메서드 이름을 정렬하므로 추가한 위치와 무관하게 available이 먼저 나온다. 새 메서드에는 호출이 없고 기존 코드도 available을 부르지 않으므로 직접 의존 표와 호출 근거는 그대로다. 선언의 증가와 호출 관계의 증가는 별개다.
  4. METHOD 패턴은 선언과 본문이 한 줄에 있는 형식만 인식한다. 본문을 여러 줄로 나누면 place 선언과 내부 호출을 놓치며, Entry.run에서 OrderService.place를 확인할 때 대상 선언 없음 예외가 날 수 있다. 이는 업무 구조가 바뀐 증거가 아니라 입력이 분석 범위를 벗어난 결과다. 지원 형식을 지키거나, 더 넓은 문법을 해석하는 분석 수단을 사용한 뒤 다시 판단해야 한다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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