본문 바로가기

옵션에 맞는 구현 고르기와 객체지향 설계 정리

옵션 문자열로 구현 객체를 고르는 코드를 한곳에 모으고 지원하지 않는 옵션을 처리하는 방법을 비교한 뒤, 게시글 내보내기 프로그램을 완성하며 객체지향 설계를 정리합니다.

앞 글에서 ExportService가 Exporter 역할에만 의존하도록 바꾸었습니다. 이제 서비스는 넘겨받은 전략 객체에 format을 맡길 뿐이므로, 새 형식이 생겨도 서비스 코드는 고치지 않아도 됩니다. 그런데 실제 프로그램에서는 사용자가 csv 같은 옵션을 입력하고, 누군가는 그 문자열을 보고 new CsvExporter()를 실행해야 합니다. 서비스에서 분기를 없앴다고 해서 어떤 구현을 쓸지 고르는 일까지 사라지지는 않습니다.

이 글에서는 그 선택 코드를 어디에 둘지, 그리고 사용자가 지원하지 않는 옵션을 입력하면 어떻게 처리할지를 다룹니다. 그다음 입력부터 출력까지 이어지는 게시글 내보내기 프로그램을 완성하고, 5장부터 배운 객체지향 설계의 판단들을 이 프로그램 위에서 정리합니다.

선택 메서드가 null을 돌려주면 생기는 일

옵션 문자열을 구현 객체로 바꾸는 메서드를 처음 만들면, 아는 이름에는 해당 객체를 돌려주고 모르는 이름에는 null을 돌려주기 쉽습니다. '찾지 못했다'를 나타내는 가장 손쉬운 값처럼 보이기 때문입니다. 다음 프로그램은 지원하지 않는 옵션 "xml"로 이런 메서드를 호출하고, 받은 결과를 그대로 서비스에 넘깁니다.

lab/NullExporterSelection.java
public final class NullExporterSelection {
    public static void main(String[] args) {
        Exporter exporter = select("xml");
        ExportService service = new ExportService(exporter);
        service.export("가입 인사", 40);
    }

    private static Exporter select(String option) {
        if (option.equals("text")) return new TextExporter();
        if (option.equals("csv")) return new CsvExporter();
        return null;
    }

    private interface Exporter {
        String format(String title, int viewCount);
    }

    private static final class TextExporter implements Exporter {
        public String format(String title, int viewCount) {
            return title + "=" + viewCount;
        }
    }

    private static final class CsvExporter implements Exporter {
        public String format(String title, int viewCount) {
            return title + "," + viewCount;
        }
    }

    private static final class ExportService {
        private final Exporter exporter;

        ExportService(Exporter exporter) {
            this.exporter = exporter;
        }

        void export(String title, int viewCount) {
            System.out.println(exporter.format(title, viewCount));
        }
    }
}
실행 결과
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "NullExporterSelection$Exporter.format(String, int)" because "this.exporter" is null
	at NullExporterSelection$ExportService.export(NullExporterSelection.java:38)
	at NullExporterSelection.main(NullExporterSelection.java:5)

이렇게 예외와 함께 출력되는 내용을 스택 트레이스(stack trace)라고 합니다. 첫 줄의 메시지는 this.exporter가 null이어서 format을 호출할 수 없었다는 뜻이고, 그 아래 at으로 시작하는 줄들은 예외가 발생한 순간의 호출 스택을 맨 위 프레임부터 보여 줍니다. NullExporterSelection$ExportService는 NullExporterSelection 안에 선언한 ExportService 클래스를 가리킵니다. 즉 예외는 38번 줄의 exporter.format(...) 호출에서 났고, 그 export를 부른 곳은 main의 5번 줄입니다. 스택 트레이스를 읽는 방법은 return과 호출 스택에서, 이런 NullPointerException 메시지는 null과 NullPointerException에서 다뤘습니다.

여기서 눈여겨볼 점은 진짜 원인인 select가 출력 어디에도 보이지 않는다는 것입니다. select는 null을 돌려주고 이미 정상적으로 끝났기 때문에, 예외가 난 순간의 호출 스택에는 그 null을 받아 쓰려던 메서드들만 남아 있습니다. 이 예제는 짧아서 원인을 금방 찾을 수 있습니다. 하지만 서비스를 만드는 코드와 export를 호출하는 코드가 멀리 떨어져 있다면, 이 null이 어디서 왔는지 거꾸로 추적해야 합니다.

지원하지 않는 옵션이라는 사실을 가장 먼저 아는 곳은 select입니다. 그러니 그 자리에서 처리하는 것이 자연스럽고, 방법은 크게 세 가지입니다. IllegalArgumentException을 던져 바로 멈추거나, 옵션이 잘못됐다는 사실을 알려 주는 기본 구현을 돌려주거나, 대화형 콘솔이라면 사용할 수 있는 옵션을 보여 주고 다시 입력받을 수 있습니다. 어느 방법이 맞는지는 옵션이 어디서 오느냐에 따라 다릅니다. 사람이 입력한 값이라면 다시 기회를 주는 편이 친절하고, 설정 파일에 적힌 값이라면 프로그램이 시작할 때 바로 멈추는 편이 문제를 빨리 드러냅니다.

어느 쪽을 고르든 null을 정상적인 전략인 것처럼 흘려보내지 않는다는 원칙은 같습니다. 그리고 select만 고친다고 끝나지도 않습니다. 다른 코드가 ExportService를 직접 만들면서 null을 넘길 수도 있기 때문입니다. 앞 글처럼 생성자에서도 null을 거부해 두면, 어떤 경로로 들어오든 서비스를 만드는 순간 실패하므로 오류가 원인 가까이에서 드러납니다.

잘못된 옵션을 알려 주는 기본 구현

콘솔 프로그램에서 옵션을 한 글자 잘못 입력했다고 프로그램이 통째로 멈추면, 그때까지 입력한 내용도 함께 잃게 됩니다. 이런 경우에는 예외를 던지는 대신 무엇이 잘못됐는지 알려 주는 문자열을 결과로 돌려주는 정책이 나을 수 있습니다. 다음 프로그램의 UnsupportedExporter가 그런 일을 하는 기본 구현입니다. 여기서 기본 구현은 인터페이스의 default 메서드가 아니라, 맞는 구현을 찾지 못했을 때 대신 돌려주는 구현 객체를 뜻합니다.

src/SafeExporterSelection.java
public final class SafeExporterSelection {
    public static void main(String[] args) {
        ExportService known = new ExportService(select("csv"));
        ExportService unknown = new ExportService(select("xml"));

        System.out.println(known.export("가입 인사", 40));
        System.out.println(unknown.export("가입 인사", 40));
    }

    private static Exporter select(String option) {
        return switch (option) {
            case "text" -> new TextExporter();
            case "csv" -> new CsvExporter();
            default -> new UnsupportedExporter(option);
        };
    }

    private interface Exporter {
        String format(String title, int viewCount);
    }

    private static final class TextExporter implements Exporter {
        public String format(String title, int viewCount) {
            return title + "=" + viewCount;
        }
    }

    private static final class CsvExporter implements Exporter {
        public String format(String title, int viewCount) {
            return title + "," + viewCount;
        }
    }

    private static final class UnsupportedExporter implements Exporter {
        private final String option;

        UnsupportedExporter(String option) {
            this.option = option;
        }

        public String format(String title, int viewCount) {
            return "unsupported=" + option;
        }
    }

    private static final class ExportService {
        private final Exporter exporter;

        ExportService(Exporter exporter) {
            if (exporter == null) throw new IllegalArgumentException("exporter");
            this.exporter = exporter;
        }

        String export(String title, int viewCount) {
            return exporter.format(title, viewCount);
        }
    }
}
가입 인사,40
unsupported=xml

첫 줄은 select("csv")가 돌려준 CsvExporter가 제목과 조회 수를 쉼표로 이은 결과입니다. 두 번째 줄에서는 "xml"이 어느 case에도 맞지 않아 switch의 default 가지가 UnsupportedExporter("xml")을 만들었습니다. 이 객체의 format은 제목과 조회 수를 쓰지 않고 unsupported=xml만 돌려줍니다. UnsupportedExporter도 Exporter의 format을 구현했으므로 ExportService는 다른 전략과 똑같이 호출할 뿐이고, 서비스 안에 '옵션이 잘못됐는가'를 따지는 분기는 생기지 않습니다.

switch 자체는 여전히 남아 있습니다. 달라진 점은 그것이 프로그램 곳곳에 흩어지지 않고 select 하나에 모였다는 것입니다. 구현 객체를 만들고 서로 연결하는 곳을 조립점이라고 부르는데, 이 장의 첫 글에서 main이 알림 구현을 골라 서비스에 넘기던 곳이 바로 조립점이었습니다. 이 프로그램에서는 select와 그것을 호출하는 main이 조립점입니다. select처럼 어떤 구현을 만들지 골라서 돌려주는 메서드는 흔히 팩터리(factory)라고도 부릅니다.

새 형식을 추가하는 상황을 떠올리면 이 구조의 이점이 분명해집니다. MarkdownExporter를 추가하려면 새 클래스를 만들고 select에 case를 한 줄 넣어야 하지만, ExportService는 한 글자도 바뀌지 않습니다. 개방-폐쇄 원칙이 요구하는 것은 프로그램에서 조건문을 모두 없애는 것이 아닙니다. 구현을 고르는 방식이 바뀌어도 핵심 코드인 서비스는 고치지 않아도 되게 하는 것입니다.

select가 어디까지 처리하는지도 정확히 알아 두어야 합니다. default가 받는 것은 "xml"처럼 null이 아니면서 지원하지 않는 문자열입니다. select(null)을 호출하면 문자열 switch가 null을 비교하지 못하고 그 자리에서 NullPointerException을 던지므로, UnsupportedExporter는 만들어지지 않습니다. 호출하는 쪽이 null을 넘길 수 있다면 그 경우는 따로 검사해야 합니다.

기본 구현을 쓸 때는 실패를 조용히 감추지 않도록 주의합니다. UnsupportedExporter가 결과에 옵션 이름을 담는 것도 화면이나 로그에서 무엇이 잘못됐는지 알 수 있게 하려는 것입니다. 다만 이 안내 문구는 정상 결과와 같은 자리로 나갑니다. 결과를 파일에 저장하는 프로그램이었다면 unsupported=xml이 내보내기 파일에 그대로 기록되므로, 이 방식은 결과를 사람이 바로 읽는 대화형 프로그램에 더 잘 맞습니다.

정말 아무 일도 하지 않는 구현이 필요하다면 SilentExporter처럼 이름에 그 의도를 드러내고, 그 구현을 쓰는 곳도 분명히 합니다. 이렇게 null 대신 '아무것도 하지 않는' 객체를 두는 방법을 널 객체(Null Object) 패턴이라고 합니다.

더 깊이: null 옵션까지 기본 구현으로 보낼까

Java 21부터는 switch에 case null을 쓸 수 있습니다. select의 default 줄을 case null, default -> new UnsupportedExporter(option);으로 바꾸면 select(null)도 예외 없이 UnsupportedExporter를 돌려주고, 내보내기 결과는 unsupported=null이 됩니다. 하지만 null을 잘못된 옵션과 똑같이 다루는 것이 옳은지는 따로 따져 봐야 합니다.

Scanner의 nextLine()은 빈 문자열을 돌려줄 수는 있어도 null을 돌려주지는 않습니다. 읽을 줄이 더 없으면 null 대신 NoSuchElementException을 던집니다. 그러니 이 프로그램에서 null 옵션이 생긴다면 사용자의 입력 실수가 아니라 코드의 버그입니다. 버그를 기본 구현으로 덮어 두면 원인을 찾기 어려우므로, 이럴 때는 select 첫머리에서 Objects.requireNonNull(option)을 호출해 바로 거부하는 편이 낫습니다. java.util.Objects의 이 메서드는 인수가 null이면 그 자리에서 NullPointerException을 던집니다. switch에서 null을 다루는 규칙은 패턴 매칭을 다루는 글에서 자세히 살펴봅니다.

결제 수단에도 같은 구조 적용하기

옵션에 따라 구현을 고르고 서비스는 역할만 아는 구조는 내보내기에만 쓰는 기법이 아닙니다. 결제 서비스가 '현금이면 이렇게, 카드면 저렇게' 하는 분기를 직접 가지고 있다면, 계좌이체 같은 수단이 하나 늘 때마다 승인 흐름이 담긴 핵심 코드를 고쳐야 합니다. 결제 수단을 PaymentMethod라는 역할로 만들고 현금과 카드가 각각 구현하게 하면 다음과 같습니다.

src/PaymentRoles.java
public final class PaymentRoles {
    public static void main(String[] args) {
        PaymentService cash = new PaymentService(new CashPayment());
        PaymentService card = new PaymentService(new CardPayment());

        System.out.println(cash.pay(10000));
        System.out.println(card.pay(10000));
        try {
            card.pay(0);
        } catch (IllegalArgumentException error) {
            System.out.println("거부: " + error.getMessage());
        }
    }

    private interface PaymentMethod {
        String pay(int amount);
    }

    private static final class CashPayment implements PaymentMethod {
        @Override
        public String pay(int amount) {
            return "현금 " + amount + "원, 수수료 0원";
        }
    }

    private static final class CardPayment implements PaymentMethod {
        @Override
        public String pay(int amount) {
            int fee = amount * 3 / 100;
            return "카드 " + amount + "원, 수수료 " + fee + "원";
        }
    }

    private static final class PaymentService {
        private final PaymentMethod method;

        PaymentService(PaymentMethod method) {
            if (method == null) throw new IllegalArgumentException("method");
            this.method = method;
        }

        String pay(int amount) {
            if (amount <= 0) throw new IllegalArgumentException("amount=" + amount);
            return "승인: " + method.pay(amount);
        }
    }
}
승인: 현금 10000원, 수수료 0원
승인: 카드 10000원, 수수료 300원
거부: amount=0

PaymentService의 pay가 하는 일은 두 가지뿐입니다. 금액이 양수인지 검사하고, 결제 수단이 돌려준 결과 앞에 승인:을 붙입니다. 두 가지 모두 어떤 수단으로 결제하든 똑같이 적용되는 규칙입니다. 반면 수수료는 수단마다 다르므로 각 구현이 정합니다. 카드는 amount * 3 / 100으로 금액의 3%인 300원을 계산하고, 현금은 수수료 0원을 붙입니다.

금액을 int로 다루고 정수 나눗셈으로 1원 미만을 버린 것은 예제를 단순하게 하려는 선택입니다. 실제 결제 코드라면 금액의 범위에 맞는 타입과 반올림 규칙부터 정해야 합니다.

세 번째 줄은 금액 0이 서비스에서 거부된 결과입니다. 이 검사는 method.pay(amount)를 부르기 전에 있으므로 CardPayment의 수수료 계산은 아예 실행되지 않습니다. 덕분에 각 구현은 금액이 언제나 양수라고 믿고 자기 계산에만 집중할 수 있습니다.

정리하면, 모든 수단에 공통인 규칙은 서비스 한 곳에 두고 수단마다 다른 규칙은 각 구현에 둡니다. 이 구분이 무너져 서비스가 카드 수수료까지 직접 계산하기 시작하면, 서비스는 다시 지금 결제 수단이 무엇인지 물어야 하고 앞에서 없앤 분기가 되살아납니다. 계좌이체를 추가하는 일은 연습 문제 3에서 직접 해 봅니다.

구현이 많아지면 배열에 모아 이름으로 찾기

형식이 두세 개일 때는 select의 switch로 충분합니다. 형식이 더 늘어나면 각 전략에게 자기 옵션 이름을 돌려주는 option() 메서드를 주고, 전략 객체들을 Exporter[] 배열에 모아 두었다가 이름이 맞는 것을 찾는 방법도 있습니다. 이렇게 구현을 미리 등록해 두고 이름으로 찾아 주는 객체를 등록소(registry)라고 합니다.

src/ExporterRegistrySelection.java
public final class ExporterRegistrySelection {
    public static void main(String[] args) {
        ExporterRegistry registry = new ExporterRegistry(
                new Exporter[]{new TextExporter(), new CsvExporter()});
        ExportService service = new ExportService(registry.find("text"));
        System.out.println(service.export("가입 인사", 40));

        try {
            registry.find("xml");
        } catch (IllegalArgumentException error) {
            System.out.println("선택 실패: " + error.getMessage());
        }
    }

    private interface Exporter {
        String option();
        String format(String title, int viewCount);
    }

    private static final class TextExporter implements Exporter {
        @Override public String option() { return "text"; }
        @Override public String format(String title, int viewCount) {
            return title + "=" + viewCount;
        }
    }

    private static final class CsvExporter implements Exporter {
        @Override public String option() { return "csv"; }
        @Override public String format(String title, int viewCount) {
            return title + "," + viewCount;
        }
    }

    private static final class ExporterRegistry {
        private final Exporter[] exporters;

        ExporterRegistry(Exporter[] exporters) {
            Exporter[] copy = exporters.clone();
            for (int left = 0; left < copy.length; left++) {
                for (int right = left + 1; right < copy.length; right++) {
                    if (copy[left].option().equals(copy[right].option())) {
                        throw new IllegalArgumentException("duplicate=" + copy[left].option());
                    }
                }
            }
            this.exporters = copy;
        }

        Exporter find(String option) {
            for (Exporter exporter : exporters) {
                if (exporter.option().equals(option)) return exporter;
            }
            throw new IllegalArgumentException("unknown=" + option);
        }
    }

    private static final class ExportService {
        private final Exporter exporter;
        ExportService(Exporter exporter) { this.exporter = exporter; }
        String export(String title, int viewCount) {
            return exporter.format(title, viewCount);
        }
    }
}
가입 인사=40
선택 실패: unknown=xml

ExporterRegistry의 생성자는 먼저 받은 배열을 clone()으로 복사합니다. 배열의 clone()은 같은 원소를 담은 새 배열을 만들므로, 등록소를 만든 뒤에 호출한 쪽이 원래 배열의 칸을 바꿔도 등록소는 영향을 받지 않습니다. 이어서 두 겹의 반복문으로 모든 전략 쌍의 이름을 비교하고, 같은 이름이 있으면 duplicate= 뒤에 그 이름을 붙여 예외를 던집니다. 검사를 통과한 사본만 필드에 저장하므로, 일단 만들어진 등록소에는 이름이 겹치는 전략이 없습니다.

find("text")는 배열을 앞에서부터 훑다가 option()이 "text"인 TextExporter를 찾아 돌려주므로 첫 줄 가입 인사=40이 나옵니다. find("xml")은 끝까지 훑어도 맞는 전략이 없어 unknown=xml 예외를 던지고, main의 catch가 이를 받아 두 번째 줄을 출력합니다. find도 찾지 못했을 때 null을 돌려주지 않고 그 자리에서 실패하므로, 앞에서 본 null 문제가 생기지 않습니다. find(null)을 호출해도 "text".equals(null)이 false이므로, 문자열 switch와 달리 NullPointerException 대신 unknown=null로 실패합니다.

이 방식에서는 어떤 이름이 어떤 구현인지에 대한 정보가 각 구현 안으로 옮겨 가고, 이름으로 찾는 방법과 실패했을 때의 규칙은 등록소 한 곳에만 남습니다. 새 형식을 추가할 때는 구현 클래스를 만들고 배열에 원소 하나를 넣으면 되며, 중복 검사와 찾지 못한 이름을 거부하는 규칙은 새 형식에도 그대로 적용됩니다. 배열을 훑으며 각 전략의 option()을 모으면 '사용할 수 있는 옵션: text, csv' 같은 안내 문구도 만들 수 있습니다.

이 코드는 배열과 그 원소가 null이 아니고, 각 전략의 option()이 언제 불러도 같은 이름을 돌려준다고 가정합니다. 또 find는 찾을 때마다 같은 전략 객체를 돌려줍니다. 이 장의 전략들은 필드가 없어 상태가 없으므로 한 객체를 여러 번 써도 문제가 없습니다. 만약 전략이 지금까지 변환한 건수 같은 상태를 가진다면, 한 객체를 여러 곳에서 함께 쓸 때 그 상태가 섞이므로 필요할 때마다 새 객체를 만드는 방법을 생각해야 합니다.

배열은 3장에서 배운 문법만으로 쓸 수 있다는 장점이 있지만, 크기가 고정되어 있고 이름을 찾을 때마다 처음부터 훑어야 합니다. 이름을 키로 바로 찾는 구조는 16장의 Map에서 다룹니다. 그리고 작은 프로그램이라면 여전히 switch가 더 읽기 쉬울 수 있습니다. 어느 방식을 고르든 중요한 것은 구현을 고르는 코드가 ExportService 안으로 들어가지 않는 것입니다.

더 깊이: 조립 코드를 프레임워크나 JDK에 맡기기

이 글에서는 main과 select가 직접 new로 구현을 만들어 연결했습니다. 이렇게 객체 조립을 프로그램 시작 지점 가까운 조립점 한곳에 모은 것을 컴포지션 루트(composition root)라고 부릅니다. 프로그램이 커지면 조립 코드도 함께 길어지는데, 스프링(Spring) 같은 프레임워크에서는 의존성 주입(dependency injection, DI) 컨테이너가 이 일을 대신 맡습니다. 어떤 구현을 어떤 역할에 연결할지 설정이나 애노테이션으로 알려 주면, 컨테이너가 객체를 만들어 생성자에 넣어 줍니다. 서비스가 생성자로 역할을 받는 지금의 구조가 그대로 쓰이므로, 이 장에서 연습한 설계는 프레임워크를 쓸 때도 이어집니다. 순수 Java로 조립하던 객체를 스프링 컨테이너로 옮기는 과정은 Spring 컨테이너 전환에서 볼 수 있습니다.

JDK에도 비슷한 도구가 있습니다. java.util.ServiceLoader는 어떤 인터페이스의 구현 목록을 클래스 경로의 설정 파일이나 모듈 선언에서 찾아 줍니다. 등록소의 배열을 코드에 직접 적는 대신 이 목록을 쓰면, 새 형식을 담은 JAR 파일을 추가하는 것만으로 구현을 늘릴 수 있습니다. 사용법은 모듈과 ServiceLoader를 다루는 글에서 살펴봅니다.

입력부터 출력까지 게시글 내보내기 완성하기

다음 프로그램은 지금까지의 조각을 하나로 잇습니다. 입력의 첫 줄을 내보내기 옵션으로 읽고, 이어지는 두 줄씩을 게시글의 제목과 조회 수로 읽어 Board에 추가합니다. 그런 다음 옵션에 맞는 전략으로 게시글 목록을 문자열로 바꿔 출력합니다. 몇 번을 실행해도 같은 결과가 나오도록 Scanner에 고정 문자열을 넣었으며, 키보드로 입력하려면 new Scanner(System.in)으로 바꾸면 됩니다.

src/BoardExportCli.java
import java.util.Scanner;

public final class BoardExportCli {
    public static void main(String[] args) {
        Scanner scanner = new Scanner("csv\n가입 인사\n40\n로그인 구현\n50\n");
        String option = scanner.nextLine();
        Board board = new Board();
        board.add(scanner.nextLine(), Integer.parseInt(scanner.nextLine()));
        board.add(scanner.nextLine(), Integer.parseInt(scanner.nextLine()));

        Exporter exporter = select(option);
        ExportService service = new ExportService(exporter);
        System.out.println(service.export(board.snapshot()));
    }

    private static Exporter select(String option) {
        return switch (option) {
            case "text" -> new TextExporter();
            case "csv" -> new CsvExporter();
            default -> throw new IllegalArgumentException("unknown=" + option);
        };
    }

    private record Post(String title, int viewCount) { }

    private static final class Board {
        private final Post[] entries = new Post[10];
        private int size;

        void add(String title, int viewCount) {
            if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
            if (size == entries.length) throw new IllegalStateException("full");
            entries[size++] = new Post(title, viewCount);
        }

        Post[] snapshot() {
            Post[] copy = new Post[size];
            for (int index = 0; index < size; index++) copy[index] = entries[index];
            return copy;
        }
    }

    private interface Exporter {
        String format(Post[] entries);
    }

    private static final class TextExporter implements Exporter {
        public String format(Post[] entries) {
            String result = "";
            for (int index = 0; index < entries.length; index++) {
                if (index > 0) result += " | ";
                result += entries[index].title() + "=" + entries[index].viewCount();
            }
            return result;
        }
    }

    private static final class CsvExporter implements Exporter {
        public String format(Post[] entries) {
            String result = "title,viewCount";
            for (Post entry : entries) {
                result += "\n" + entry.title() + "," + entry.viewCount();
            }
            return result;
        }
    }

    private static final class ExportService {
        private final Exporter exporter;

        ExportService(Exporter exporter) {
            if (exporter == null) throw new IllegalArgumentException("exporter");
            this.exporter = exporter;
        }

        String export(Post[] entries) {
            return exporter.format(entries);
        }
    }
}
title,viewCount
가입 인사,40
로그인 구현,50

실행 순서대로 따라가 보겠습니다. main은 첫 줄 csv를 옵션으로 읽고, 제목 가입 인사와 조회 수 40, 제목 로그인 구현과 조회 수 50을 읽어 board.add를 두 번 호출합니다. Board는 제목이 비어 있지 않은지, 조회 수가 양수인지, 배열에 자리가 남았는지를 검사한 뒤 게시글을 저장합니다. 이어서 select("csv")가 돌려준 CsvExporter로 ExportService를 만들고, board.snapshot()이 채워진 두 칸만 담은 새 배열을 만들어 서비스에 넘깁니다. CsvExporter는 머리글 title,viewCount 뒤에 게시글마다 줄바꿈과 제목,조회 수를 이어 붙인 문자열을 돌려주고, main이 이 문자열을 출력해 세 줄이 나옵니다.

각 객체가 맡은 일을 보면 main은 입력을 읽어 해석하고 객체를 만들어 연결하며, Board는 게시글을 검사하고 보관합니다. Exporter 구현은 게시글 배열을 문자열로 바꾸기만 하고, ExportService는 선택된 전략에 그 일을 맡긴 뒤 결과를 돌려줍니다. 화면에 출력하는 것이 전략이 아니라 main이라는 점도 눈여겨보세요. 전략은 문자열을 돌려줄 뿐이므로, 같은 결과를 파일에 쓰거나 네트워크로 보내게 되어도 전략은 고칠 필요가 없습니다.

전략이 받는 배열이 Board의 보관 배열이 아니라 사본이라는 점도 중요합니다. 사본의 칸을 바꿔도 게시판은 그대로입니다. 사본의 칸들이 같은 Post 객체를 가리키기는 하지만 Post는 값을 바꿀 수 없는 레코드이므로, 전략이 게시판의 내용을 바꿀 방법은 없습니다.

클래스가 많아 보이지만 중요한 것은 클래스의 수가 아니라 각 클래스가 바뀌는 이유가 서로 겹치지 않는다는 점입니다. 입력을 파일에서 읽도록 바꿔도 Exporter는 영향을 받지 않고, Markdown 형식을 추가해도 Board의 검사 규칙은 그대로입니다. 아래 단계 그림에서 값이 어느 객체를 거쳐 최종 출력이 되는지 확인해 보세요.

고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다

main이 옵션과 두 값을 읽어 Board.add를 먼저 수행한 후 select·snapshot·format·콘솔 출력으로 이어집니다. 옵션 오류 전에도 저장이 끝났을 수 있는 순서와 책임 전환이 핵심입니다.

고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다

BoardExportCli의 main, Board, ExportService와 CsvExporter가 실제로 읽고 보관하고 전달하고 반환하는 값을 대응한다.

입력·검증·변환·출력을 서로 다른 객체가 맡습니다

BoardExportCli 원본의 고정 문자열 입력은 csv, 가입 인사·40, 로그인 구현·50입니다. 외부 콘솔 입력이나 파일 저장은 실행하지 않습니다.

입력·검증·변환·출력을 서로 다른 객체가 맡습니다고정Scanner입력해석,Board검증저장,main의csv선택과snapshot전달,서비스위임,전략문자열반환,main출력의값흐름이다. 반환과출력의소유자를구별한다. 메모리Board가두항목을보관한뒤전략을선택한다.
main: 입력 해석고정 Scanner 입력csv / 두 제목 / 40, 50
연결 관계main: 입력 해석 · 고정 Scanner 입력 · csv / 두 제목 / 40, 50 → Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2
Board: 검증·저장두 번 add 성공용량 10 / size 2
연결 관계Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2 → main: 선택·전달 · select("csv") · snapshot 배열 길이 2
main: 선택·전달select("csv")snapshot 배열 길이 2
연결 관계main: 선택·전달 · select("csv") · snapshot 배열 길이 2 → ExportService · format(entries) 위임
ExportServiceformat(entries) 위임
연결 관계ExportService · format(entries) 위임 → CsvExporter · 문자열을 반환 · header + 두 레코드
CsvExporter문자열을 반환header + 두 레코드
연결 관계CsvExporter · 문자열을 반환 · header + 두 레코드 → main: 콘솔 출력 · title,viewCount · 가입 인사,40 · 로그인 구현,50
main: 콘솔 출력title,viewCount가입 인사,40로그인 구현,50

실선: 값 전달과 다음 실행 · format은 반환, main이 출력원본은 고정 문자열 입력 · 파일 저장·롤백 없음

입력·검증·변환·출력을 서로 다른 객체가 맡습니다고정Scanner입력해석,Board검증저장,main의csv선택과snapshot전달,서비스위임,전략문자열반환,main출력의값흐름이다. 반환과출력의소유자를구별한다. 메모리Board가두항목을보관한뒤전략을선택한다.
main: 입력 해석고정 Scanner 입력csv / 두 제목 / 40, 50
연결 관계main: 입력 해석 · 고정 Scanner 입력 · csv / 두 제목 / 40, 50 → Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2
Board: 검증·저장두 번 add 성공용량 10 / size 2
연결 관계Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2 → main: 선택·전달 · select("csv") · snapshot 배열 길이 2
main: 선택·전달select("csv")snapshot 배열 길이 2
연결 관계main: 선택·전달 · select("csv") · snapshot 배열 길이 2 → ExportService · format(entries) 위임
ExportServiceformat(entries) 위임
연결 관계ExportService · format(entries) 위임 → CsvExporter · 문자열을 반환 · header + 두 레코드
CsvExporter문자열을 반환header + 두 레코드
연결 관계CsvExporter · 문자열을 반환 · header + 두 레코드 → main: 콘솔 출력 · title,viewCount · 가입 인사,40 · 로그인 구현,50
main: 콘솔 출력title,viewCount가입 인사,40로그인 구현,50

실선: 값 전달과 다음 실행반환과 출력의 소유자는 다름

입력 해석부터 반환 문자열 출력까지
책임 소유자실제 동작이 실행에서 남거나 전달되는 값
mainScanner와 parseInt로 두 항목을 읽고 select("csv")로 전략을 조립두 번의 board.add 뒤 CsvExporter를 서비스에 주입
Board제목·조회 수·용량을 검증해 보관하고 snapshot()을 반환보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2
ExportServiceexporter.format(entries)로 전달하고 결과를 반환구체 형식 분기 없이 Post[2]를 선택된 전략에 전달
CsvExporter헤더와 각 게시글의 제목·조회 수를 문자열로 반환
title,viewCount
가입 인사,40
로그인 구현,50
main
실제 동작: Scanner와 parseInt로 두 항목을 읽고 select("csv")로 전략을 조립
이 실행에서 남거나 전달되는 값: 두 번의 board.add 뒤 CsvExporter를 서비스에 주입
Board
실제 동작: 제목·조회 수·용량을 검증해 보관하고 snapshot()을 반환
이 실행에서 남거나 전달되는 값: 보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2
ExportService
실제 동작: exporter.format(entries)로 전달하고 결과를 반환
이 실행에서 남거나 전달되는 값: 구체 형식 분기 없이 Post[2]를 선택된 전략에 전달
CsvExporter
실제 동작: 헤더와 각 게시글의 제목·조회 수를 문자열로 반환
이 실행에서 남거나 전달되는 값:
title,viewCount
가입 인사,40
로그인 구현,50

최종 문자열을 콘솔에 출력하는 곳은 main입니다. 전략은 문자열을 반환할 뿐 저장이나 출력을 하지 않습니다.

snapshot()은 배열 슬롯만 복사하며 같은 Post 원소 참조를 담습니다. 표의 용량·참조 관계는 원문 코드에 근거한 상태 모델이고 메모리 주소를 측정한 결과가 아닙니다.

이 예의 조건

  • 고정 Scanner 문자열이며 메모리 저장만 있고 파일 저장·롤백은 없습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2고정 Scanner 입력 해석, Board 검증·저장, main의 csv 선택과 snapshot 전달, 서비스 위임, 전략 문자열 반환, main 출력의 값 흐름이다. 반환과 출력의 소유자를 구별한다. 메모리 Board가 두 항목을 보관한 뒤 전략을 선택한다.
6두 제목
740, 50
10용량 10
11size 2
원 도표의 설명과 주석
구분내용
설명고정 Scanner 입력 해석, Board 검증·저장, main의 csv 선택과 snapshot 전달, 서비스 위임, 전략 문자열 반환, main 출력의 값 흐름이다. 반환과 출력의 소유자를 구별한다. 메모리 Board가 두 항목을 보관한 뒤 전략을 선택한다.
전체 읽기 정보고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다 고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다 BoardExportCli의 main, Board, ExportService와 CsvExporter가 실제로 읽고 보관하고 전달하고 반환하는 값을 대응시킨다. 입력·검증·변환·출력을 서로 다른 객체가 맡습니다 BoardExportCli 원본의 고정 문자열 입력은 csv , 가입 인사 · 40 , 로그인 구현 · 50 입니다. 외부 콘솔 입력이나 파일 저장은 실행하지 않습니다. 입력 해석부터 반환 문자열 출력까지 책임 소유자 실제 동작 이 실행에서 남거나 전달되는 값 main Scanner 와 parseInt 로 두 항목을 읽고 select("csv") 로 전략을 조립 두 번의 board.add 뒤 CsvExporter 를 서비스에 주입 Board 제목·조회 수·용량을 검증해 보관하고 snapshot() 을 반환 보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2 ExportService exporter.format(entries) 로 전달하고 결과를 반환 구체 형식 분기 없이 Post[2] 를 선택된 전략에 전달 CsvExporter 헤더와 각 게시글의 제목·조회 수를 문자열로 반환 title,viewCount 가입 인사,40 로그인 구현,50 main 실제 동작: Scanner 와 parseInt 로 두 항목을 읽고 select("csv") 로 전략을 조립 이 실행에서 남거나 전달되는 값: 두 번의 board.add 뒤 CsvExporter 를 서비스에 주입 Board 실제 동작: 제목·조회 수·용량을 검증해 보관하고 snapshot() 을 반환 이 실행에서 남거나 전달되는 값: 보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2 ExportService 실제 동작: exporter.format(entries) 로 전달하고 결과를 반환 이 실행에서 남거나 전달되는 값: 구체 형식 분기 없이 Post[2] 를 선택된 전략에 전달 CsvExporter 실제 동작: 헤더와 각 게시글의 제목·조회 수를 문자열로 반환 이 실행에서 남거나 전달되는 값: title,viewCount 가입 인사,40 로그인 구현,50 최종 문자열을 콘솔에 출력하는 곳은 main 입니다. 전략은 문자열을 반환할 뿐 저장이나 출력을 하지 않습니다. snapshot() 은 배열 슬롯만 복사하며 같은 Post 원소 참조를 담습니다. 표의 용량·참조 관계는 원문 코드에 근거한 상태 모델이고 메모리 주소를 측정한 결과가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력과 보관

main이 csv, 두 제목 40/50을 읽고 Board.add를 호출합니다.

  • Board: 용량 10, size 2
  • 검증: 두 add 성공
  • 입력 해석과 게시글 보관의 책임이 분리됩니다.
선택·위임

main이 csv를 선택하고 길이 2 snapshot을 전달합니다.

  • ExportService: format 위임
  • CsvExporter: 문자열 반환
  • 전략은 보관 중인 Board를 직접 콘솔 출력하지 않습니다.
호출자 출력

main이 반환된 header와 두 레코드를 출력합니다.

  • header: title,viewCount
  • records: 가입 인사,40 / 로그인 구현,50
  • format 반환과 최종 출력의 주체가 다릅니다.

고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다

main이 옵션과 두 값을 읽어 Board.add를 먼저 수행한 후 select·snapshot·format·콘솔 출력으로 이어집니다. 옵션 오류 전에도 저장이 끝났을 수 있는 순서와 책임 전환이 핵심입니다.

고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다

BoardExportCli의 main, Board, ExportService와 CsvExporter가 실제로 읽고 보관하고 전달하고 반환하는 값을 대응한다.

입력·검증·변환·출력을 서로 다른 객체가 맡습니다

BoardExportCli 원본의 고정 문자열 입력은 csv, 가입 인사·40, 로그인 구현·50입니다. 외부 콘솔 입력이나 파일 저장은 실행하지 않습니다.

입력·검증·변환·출력을 서로 다른 객체가 맡습니다고정Scanner입력해석,Board검증저장,main의csv선택과snapshot전달,서비스위임,전략문자열반환,main출력의값흐름이다. 반환과출력의소유자를구별한다. 메모리Board가두항목을보관한뒤전략을선택한다.
main: 입력 해석고정 Scanner 입력csv / 두 제목 / 40, 50
연결 관계main: 입력 해석 · 고정 Scanner 입력 · csv / 두 제목 / 40, 50 → Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2
Board: 검증·저장두 번 add 성공용량 10 / size 2
연결 관계Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2 → main: 선택·전달 · select("csv") · snapshot 배열 길이 2
main: 선택·전달select("csv")snapshot 배열 길이 2
연결 관계main: 선택·전달 · select("csv") · snapshot 배열 길이 2 → ExportService · format(entries) 위임
ExportServiceformat(entries) 위임
연결 관계ExportService · format(entries) 위임 → CsvExporter · 문자열을 반환 · header + 두 레코드
CsvExporter문자열을 반환header + 두 레코드
연결 관계CsvExporter · 문자열을 반환 · header + 두 레코드 → main: 콘솔 출력 · title,viewCount · 가입 인사,40 · 로그인 구현,50
main: 콘솔 출력title,viewCount가입 인사,40로그인 구현,50

실선: 값 전달과 다음 실행 · format은 반환, main이 출력원본은 고정 문자열 입력 · 파일 저장·롤백 없음

입력·검증·변환·출력을 서로 다른 객체가 맡습니다고정Scanner입력해석,Board검증저장,main의csv선택과snapshot전달,서비스위임,전략문자열반환,main출력의값흐름이다. 반환과출력의소유자를구별한다. 메모리Board가두항목을보관한뒤전략을선택한다.
main: 입력 해석고정 Scanner 입력csv / 두 제목 / 40, 50
연결 관계main: 입력 해석 · 고정 Scanner 입력 · csv / 두 제목 / 40, 50 → Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2
Board: 검증·저장두 번 add 성공용량 10 / size 2
연결 관계Board: 검증·저장 · 두 번 add 성공 · 용량 10 / size 2 → main: 선택·전달 · select("csv") · snapshot 배열 길이 2
main: 선택·전달select("csv")snapshot 배열 길이 2
연결 관계main: 선택·전달 · select("csv") · snapshot 배열 길이 2 → ExportService · format(entries) 위임
ExportServiceformat(entries) 위임
연결 관계ExportService · format(entries) 위임 → CsvExporter · 문자열을 반환 · header + 두 레코드
CsvExporter문자열을 반환header + 두 레코드
연결 관계CsvExporter · 문자열을 반환 · header + 두 레코드 → main: 콘솔 출력 · title,viewCount · 가입 인사,40 · 로그인 구현,50
main: 콘솔 출력title,viewCount가입 인사,40로그인 구현,50

실선: 값 전달과 다음 실행반환과 출력의 소유자는 다름

입력 해석부터 반환 문자열 출력까지
책임 소유자실제 동작이 실행에서 남거나 전달되는 값
mainScanner와 parseInt로 두 항목을 읽고 select("csv")로 전략을 조립두 번의 board.add 뒤 CsvExporter를 서비스에 주입
Board제목·조회 수·용량을 검증해 보관하고 snapshot()을 반환보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2
ExportServiceexporter.format(entries)로 전달하고 결과를 반환구체 형식 분기 없이 Post[2]를 선택된 전략에 전달
CsvExporter헤더와 각 게시글의 제목·조회 수를 문자열로 반환
title,viewCount
가입 인사,40
로그인 구현,50
main
실제 동작: Scanner와 parseInt로 두 항목을 읽고 select("csv")로 전략을 조립
이 실행에서 남거나 전달되는 값: 두 번의 board.add 뒤 CsvExporter를 서비스에 주입
Board
실제 동작: 제목·조회 수·용량을 검증해 보관하고 snapshot()을 반환
이 실행에서 남거나 전달되는 값: 보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2
ExportService
실제 동작: exporter.format(entries)로 전달하고 결과를 반환
이 실행에서 남거나 전달되는 값: 구체 형식 분기 없이 Post[2]를 선택된 전략에 전달
CsvExporter
실제 동작: 헤더와 각 게시글의 제목·조회 수를 문자열로 반환
이 실행에서 남거나 전달되는 값:
title,viewCount
가입 인사,40
로그인 구현,50

최종 문자열을 콘솔에 출력하는 곳은 main입니다. 전략은 문자열을 반환할 뿐 저장이나 출력을 하지 않습니다.

snapshot()은 배열 슬롯만 복사하며 같은 Post 원소 참조를 담습니다. 표의 용량·참조 관계는 원문 코드에 근거한 상태 모델이고 메모리 주소를 측정한 결과가 아닙니다.

이 예의 조건

  • 고정 Scanner 문자열이며 메모리 저장만 있고 파일 저장·롤백은 없습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2고정 Scanner 입력 해석, Board 검증·저장, main의 csv 선택과 snapshot 전달, 서비스 위임, 전략 문자열 반환, main 출력의 값 흐름이다. 반환과 출력의 소유자를 구별한다. 메모리 Board가 두 항목을 보관한 뒤 전략을 선택한다.
6두 제목
740, 50
10용량 10
11size 2
원 도표의 설명과 주석
구분내용
설명고정 Scanner 입력 해석, Board 검증·저장, main의 csv 선택과 snapshot 전달, 서비스 위임, 전략 문자열 반환, main 출력의 값 흐름이다. 반환과 출력의 소유자를 구별한다. 메모리 Board가 두 항목을 보관한 뒤 전략을 선택한다.
전체 읽기 정보고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다 고정 입력 두 건은 책임 경계를 거쳐 CSV 문자열이 된다 BoardExportCli의 main, Board, ExportService와 CsvExporter가 실제로 읽고 보관하고 전달하고 반환하는 값을 대응시킨다. 입력·검증·변환·출력을 서로 다른 객체가 맡습니다 BoardExportCli 원본의 고정 문자열 입력은 csv , 가입 인사 · 40 , 로그인 구현 · 50 입니다. 외부 콘솔 입력이나 파일 저장은 실행하지 않습니다. 입력 해석부터 반환 문자열 출력까지 책임 소유자 실제 동작 이 실행에서 남거나 전달되는 값 main Scanner 와 parseInt 로 두 항목을 읽고 select("csv") 로 전략을 조립 두 번의 board.add 뒤 CsvExporter 를 서비스에 주입 Board 제목·조회 수·용량을 검증해 보관하고 snapshot() 을 반환 보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2 ExportService exporter.format(entries) 로 전달하고 결과를 반환 구체 형식 분기 없이 Post[2] 를 선택된 전략에 전달 CsvExporter 헤더와 각 게시글의 제목·조회 수를 문자열로 반환 title,viewCount 가입 인사,40 로그인 구현,50 main 실제 동작: Scanner 와 parseInt 로 두 항목을 읽고 select("csv") 로 전략을 조립 이 실행에서 남거나 전달되는 값: 두 번의 board.add 뒤 CsvExporter 를 서비스에 주입 Board 실제 동작: 제목·조회 수·용량을 검증해 보관하고 snapshot() 을 반환 이 실행에서 남거나 전달되는 값: 보관 배열 용량 10 · size = 2 · 새 사본 배열 길이 2 ExportService 실제 동작: exporter.format(entries) 로 전달하고 결과를 반환 이 실행에서 남거나 전달되는 값: 구체 형식 분기 없이 Post[2] 를 선택된 전략에 전달 CsvExporter 실제 동작: 헤더와 각 게시글의 제목·조회 수를 문자열로 반환 이 실행에서 남거나 전달되는 값: title,viewCount 가입 인사,40 로그인 구현,50 최종 문자열을 콘솔에 출력하는 곳은 main 입니다. 전략은 문자열을 반환할 뿐 저장이나 출력을 하지 않습니다. snapshot() 은 배열 슬롯만 복사하며 같은 Post 원소 참조를 담습니다. 표의 용량·참조 관계는 원문 코드에 근거한 상태 모델이고 메모리 주소를 측정한 결과가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력과 보관

main이 csv, 두 제목 40/50을 읽고 Board.add를 호출합니다.

  • Board: 용량 10, size 2
  • 검증: 두 add 성공
  • 입력 해석과 게시글 보관의 책임이 분리됩니다.
선택·위임

main이 csv를 선택하고 길이 2 snapshot을 전달합니다.

  • ExportService: format 위임
  • CsvExporter: 문자열 반환
  • 전략은 보관 중인 Board를 직접 콘솔 출력하지 않습니다.
호출자 출력

main이 반환된 header와 두 레코드를 출력합니다.

  • header: title,viewCount
  • records: 가입 인사,40 / 로그인 구현,50
  • format 반환과 최종 출력의 주체가 다릅니다.

옵션이 틀리면 어디까지 실행될까

이번에는 입력의 첫 줄을 csv에서 xml로 바꿔 실행해 봅니다. 5번 줄의 문자열만 바뀌므로 나머지 줄 번호는 위 코드와 같습니다.

첫 줄을 xml로 바꾼 실행 결과
Exception in thread "main" java.lang.IllegalArgumentException: unknown=xml
	at BoardExportCli.select(BoardExportCli.java:20)
	at BoardExportCli.main(BoardExportCli.java:11)

예외는 select의 default 가지가 있는 20번 줄에서 났고, select를 부른 곳은 main의 11번 줄입니다. 앞의 null 예제와 달리 원인이 된 메서드가 스택 트레이스 맨 위에 그대로 보입니다. 그런데 11번 줄에 오기 전에 7번부터 9번 줄까지, 즉 Board를 만들고 게시글 두 개를 추가하는 일은 이미 끝났습니다. 예외는 그 뒤의 서비스 생성과 내보내기를 막을 뿐, 앞에서 추가한 게시글을 되돌리지는 않습니다.

지금은 예외로 프로그램 전체가 끝나므로 메모리에 추가된 게시글도 함께 사라져 문제가 드러나지 않습니다. 하지만 Board가 게시글을 파일에 저장하는 구조였다면, 옵션 오류가 났을 때 게시글은 이미 파일에 기록된 뒤입니다. 반대로 두 번째 조회 수가 forty처럼 숫자가 아니면, 첫 게시글이 추가된 다음 Integer.parseInt가 NumberFormatException을 던져 select까지 가지도 못합니다. 이처럼 무엇을 먼저 검사하느냐에 따라 사용자가 보는 오류와 이미 끝난 작업이 달라지므로, 검사 순서도 설계의 일부입니다. 옵션을 먼저 확인하도록 순서를 바꾸는 일은 연습 문제 2에서 해 봅니다.

지원하지 않는 옵션 xml 하나를 지금까지 본 세 프로그램이 각각 어떻게 처리했는지 나란히 놓아 보겠습니다. null을 돌려준 프로그램은 서비스를 호출할 때가 되어서야 실패했습니다. 기본 구현을 돌려준 프로그램은 실패하는 대신 안내 문자열을 출력했고, BoardExportCli는 옵션을 고르는 순간 예외로 멈췄습니다.

같은 xml 옵션도 실패를 처리하는 경계가 다르다

xml 선택에서 null 반환 뒤 서비스 NPE, 안내 전략 출력, 선택 시 예외로 서비스 미생성이 서로 갈립니다. 세 독립 프로그램의 실패 시점과 실행 허가를 대응 단계로 볼 수 있습니다.

같은 xml 옵션도 실패를 처리하는 경계가 다르다

NullExporterSelection은 null을 전달해 나중에 실패하고 SafeExporterSelection은 안내 값을 반환하며 BoardExportCli의 선택 함수는 알 수 없는 옵션을 즉시 거부한다.

같은 xml 옵션도 실패를 처리하는 경계가 다릅니다

지원하지 않는 xml을 어디에서 처리하는지 비교합니다. 첫 두 행은 원본 main, 마지막 행은 BoardExportCli.select에 같은 옵션을 넣은 진단입니다.

같은 xml 옵션도 실패를 처리하는 경계가 다릅니다 지원하지않는비null문자열xml의세독립경로를비교한다. null반환은서비스호출NPE,안내전략은unsupported문자열반환출력,선택시예외는이후서비스생성과내보내기를막는다. 입력파싱과앞선메모리저장을되돌린다는뜻은아니다. null 전달 xml 입력 select → null 관찰 결과 서비스 호출: NPE 안내 전략 xml 입력 UnsupportedExporter 관찰 결과 출력: unsupported=xml 선택 시 거부 xml 입력 select에서 예외 관찰 결과 서비스 생성 없음 세 프로그램의 독립 경로 비교 · null 입력은 대상 아님
같은 xml 옵션도 실패를 처리하는 경계가 다릅니다 지원하지않는비null문자열xml의세독립경로를비교한다. null반환은서비스호출NPE,안내전략은unsupported문자열반환출력,선택시예외는이후서비스생성과내보내기를막는다. 입력파싱과앞선메모리저장을되돌린다는뜻은아니다. null 전달 xml 입력 select → null 관찰 결과 서비스 호출: NPE 안내 전략 xml 입력 UnsupportedExporter 관찰 결과 출력: unsupported=xml 선택 시 거부 xml 입력 select에서 예외 관찰 결과 서비스 생성 없음 세 프로그램의 독립 경로 비교 null 입력은 이 비교의 대상 아님
null 전달, 안내 전략, 선택 경계의 예외
프로그램·정책선택 결과서비스 호출·관찰 결과
실패 예제 · NullExporterSelectionselect("xml") → null서비스 호출까지 진행한 뒤 exporter.format에서 NullPointerException
안내 값을 반환 · SafeExporterSelectionUnsupportedExporter("xml")정상 호출 뒤 unsupported=xml을 출력
선택 시 거부 · BoardExportCliIllegalArgumentExceptionunknown=xml로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음
실패 예제 · NullExporterSelection
선택 결과: select("xml") → null
서비스 호출·관찰 결과: 서비스 호출까지 진행한 뒤 exporter.format에서 NullPointerException
안내 값을 반환 · SafeExporterSelection
선택 결과: UnsupportedExporter("xml")
서비스 호출·관찰 결과: 정상 호출 뒤 unsupported=xml을 출력
선택 시 거부 · BoardExportCli
선택 결과: IllegalArgumentException
서비스 호출·관찰 결과: unknown=xml로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음

BoardExportCli 원본은 문자열 입력의 csv를 선택합니다. xml 행은 원본 정상 실행의 출력이 아닙니다.

이 비교는 알 수 없는 비-null 문자열 옵션에 한정합니다. 기본 구현이 있다고 해서 null 입력, 입력 재시도, 파일 복구까지 구현된 것은 아닙니다.

이 예의 조건

  • 지원하지 않는 비-null xml의 세 독립 경로입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2지원하지 않는 비-null 문자열 xml의 세 독립 경로를 비교한다. null 반환은 서비스 호출에서 NPE를 일으키고, 안내 전략은 unsupported 문자열을 반환해 출력하며, 선택 시 예외는 이후 서비스 생성과 내보내기를 막는다. 입력 파싱과 앞선 메모리 저장을 되돌린다는 뜻은 아니다.
원 도표의 설명과 주석
구분내용
설명지원하지 않는 비-null 문자열 xml의 세 독립 경로를 비교한다. null 반환은 서비스 호출에서 NPE를 일으키고, 안내 전략은 unsupported 문자열을 반환해 출력하며, 선택 시 예외는 이후 서비스 생성과 내보내기를 막는다. 입력 파싱과 앞선 메모리 저장을 되돌린다는 뜻은 아니다.
전체 읽기 정보같은 xml 옵션도 실패를 처리하는 경계가 다르다 같은 xml 옵션도 실패를 처리하는 경계가 다르다 NullExporterSelection은 null을 전달해 나중에 실패하고 SafeExporterSelection은 안내 값을 반환하며 BoardExportCli의 선택 함수는 알 수 없는 옵션을 즉시 거부한다. 같은 xml 옵션도 실패를 처리하는 경계가 다릅니다 지원하지 않는 xml 을 어디에서 처리하는지 비교합니다. 첫 두 행은 원본 main , 마지막 행은 BoardExportCli.select 에 같은 옵션을 넣은 진단입니다. null 전달, 안내 전략, 선택 경계의 예외 프로그램·정책 선택 결과 서비스 호출·관찰 결과 실패 예제 · NullExporterSelection select("xml") → null 서비스 호출까지 진행한 뒤 exporter.format 에서 NullPointerException 안내 값을 반환 · SafeExporterSelection UnsupportedExporter("xml") 정상 호출 뒤 unsupported=xml 을 출력 선택 시 거부 · BoardExportCli IllegalArgumentException unknown=xml 로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음 실패 예제 · NullExporterSelection 선택 결과: select("xml") → null 서비스 호출·관찰 결과: 서비스 호출까지 진행한 뒤 exporter.format 에서 NullPointerException 안내 값을 반환 · SafeExporterSelection 선택 결과: UnsupportedExporter("xml") 서비스 호출·관찰 결과: 정상 호출 뒤 unsupported=xml 을 출력 선택 시 거부 · BoardExportCli 선택 결과: IllegalArgumentException 서비스 호출·관찰 결과: unknown=xml 로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음 BoardExportCli 원본은 문자열 입력의 csv 를 선택합니다. xml 행은 원본 정상 실행의 출력이 아닙니다. 이 비교는 알 수 없는 비-null 문자열 옵션에 한정합니다. 기본 구현이 있다고 해서 null 입력, 입력 재시도, 파일 복구까지 구현된 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
null 선택

select가 null을 서비스에 전달합니다.

  • 선택 결과: null
  • 실패를 선택 경계에서 잡지 않으면 뒤 호출에서 나타납니다.
안내 전략

UnsupportedExporter가 안내 문자열을 반환합니다.

  • 반환: unsupported=xml
  • 관측: 출력
  • 호출 가능한 전략 값으로 거절 내용을 전달합니다.
선택 시 예외

select가 예외를 던져 뒤 서비스 생성·내보내기를 막습니다.

  • 서비스: 생성 없음
  • 선행 메모리 저장: 되돌림 없음
  • 거절 경계가 바뀌어도 앞 부수 효과 롤백은 자동이 아닙니다.

같은 xml 옵션도 실패를 처리하는 경계가 다르다

xml 선택에서 null 반환 뒤 서비스 NPE, 안내 전략 출력, 선택 시 예외로 서비스 미생성이 서로 갈립니다. 세 독립 프로그램의 실패 시점과 실행 허가를 대응 단계로 볼 수 있습니다.

같은 xml 옵션도 실패를 처리하는 경계가 다르다

NullExporterSelection은 null을 전달해 나중에 실패하고 SafeExporterSelection은 안내 값을 반환하며 BoardExportCli의 선택 함수는 알 수 없는 옵션을 즉시 거부한다.

같은 xml 옵션도 실패를 처리하는 경계가 다릅니다

지원하지 않는 xml을 어디에서 처리하는지 비교합니다. 첫 두 행은 원본 main, 마지막 행은 BoardExportCli.select에 같은 옵션을 넣은 진단입니다.

같은 xml 옵션도 실패를 처리하는 경계가 다릅니다 지원하지않는비null문자열xml의세독립경로를비교한다. null반환은서비스호출NPE,안내전략은unsupported문자열반환출력,선택시예외는이후서비스생성과내보내기를막는다. 입력파싱과앞선메모리저장을되돌린다는뜻은아니다. null 전달 xml 입력 select → null 관찰 결과 서비스 호출: NPE 안내 전략 xml 입력 UnsupportedExporter 관찰 결과 출력: unsupported=xml 선택 시 거부 xml 입력 select에서 예외 관찰 결과 서비스 생성 없음 세 프로그램의 독립 경로 비교 · null 입력은 대상 아님
같은 xml 옵션도 실패를 처리하는 경계가 다릅니다 지원하지않는비null문자열xml의세독립경로를비교한다. null반환은서비스호출NPE,안내전략은unsupported문자열반환출력,선택시예외는이후서비스생성과내보내기를막는다. 입력파싱과앞선메모리저장을되돌린다는뜻은아니다. null 전달 xml 입력 select → null 관찰 결과 서비스 호출: NPE 안내 전략 xml 입력 UnsupportedExporter 관찰 결과 출력: unsupported=xml 선택 시 거부 xml 입력 select에서 예외 관찰 결과 서비스 생성 없음 세 프로그램의 독립 경로 비교 null 입력은 이 비교의 대상 아님
null 전달, 안내 전략, 선택 경계의 예외
프로그램·정책선택 결과서비스 호출·관찰 결과
실패 예제 · NullExporterSelectionselect("xml") → null서비스 호출까지 진행한 뒤 exporter.format에서 NullPointerException
안내 값을 반환 · SafeExporterSelectionUnsupportedExporter("xml")정상 호출 뒤 unsupported=xml을 출력
선택 시 거부 · BoardExportCliIllegalArgumentExceptionunknown=xml로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음
실패 예제 · NullExporterSelection
선택 결과: select("xml") → null
서비스 호출·관찰 결과: 서비스 호출까지 진행한 뒤 exporter.format에서 NullPointerException
안내 값을 반환 · SafeExporterSelection
선택 결과: UnsupportedExporter("xml")
서비스 호출·관찰 결과: 정상 호출 뒤 unsupported=xml을 출력
선택 시 거부 · BoardExportCli
선택 결과: IllegalArgumentException
서비스 호출·관찰 결과: unknown=xml로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음

BoardExportCli 원본은 문자열 입력의 csv를 선택합니다. xml 행은 원본 정상 실행의 출력이 아닙니다.

이 비교는 알 수 없는 비-null 문자열 옵션에 한정합니다. 기본 구현이 있다고 해서 null 입력, 입력 재시도, 파일 복구까지 구현된 것은 아닙니다.

이 예의 조건

  • 지원하지 않는 비-null xml의 세 독립 경로입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2지원하지 않는 비-null 문자열 xml의 세 독립 경로를 비교한다. null 반환은 서비스 호출에서 NPE를 일으키고, 안내 전략은 unsupported 문자열을 반환해 출력하며, 선택 시 예외는 이후 서비스 생성과 내보내기를 막는다. 입력 파싱과 앞선 메모리 저장을 되돌린다는 뜻은 아니다.
원 도표의 설명과 주석
구분내용
설명지원하지 않는 비-null 문자열 xml의 세 독립 경로를 비교한다. null 반환은 서비스 호출에서 NPE를 일으키고, 안내 전략은 unsupported 문자열을 반환해 출력하며, 선택 시 예외는 이후 서비스 생성과 내보내기를 막는다. 입력 파싱과 앞선 메모리 저장을 되돌린다는 뜻은 아니다.
전체 읽기 정보같은 xml 옵션도 실패를 처리하는 경계가 다르다 같은 xml 옵션도 실패를 처리하는 경계가 다르다 NullExporterSelection은 null을 전달해 나중에 실패하고 SafeExporterSelection은 안내 값을 반환하며 BoardExportCli의 선택 함수는 알 수 없는 옵션을 즉시 거부한다. 같은 xml 옵션도 실패를 처리하는 경계가 다릅니다 지원하지 않는 xml 을 어디에서 처리하는지 비교합니다. 첫 두 행은 원본 main , 마지막 행은 BoardExportCli.select 에 같은 옵션을 넣은 진단입니다. null 전달, 안내 전략, 선택 경계의 예외 프로그램·정책 선택 결과 서비스 호출·관찰 결과 실패 예제 · NullExporterSelection select("xml") → null 서비스 호출까지 진행한 뒤 exporter.format 에서 NullPointerException 안내 값을 반환 · SafeExporterSelection UnsupportedExporter("xml") 정상 호출 뒤 unsupported=xml 을 출력 선택 시 거부 · BoardExportCli IllegalArgumentException unknown=xml 로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음 실패 예제 · NullExporterSelection 선택 결과: select("xml") → null 서비스 호출·관찰 결과: 서비스 호출까지 진행한 뒤 exporter.format 에서 NullPointerException 안내 값을 반환 · SafeExporterSelection 선택 결과: UnsupportedExporter("xml") 서비스 호출·관찰 결과: 정상 호출 뒤 unsupported=xml 을 출력 선택 시 거부 · BoardExportCli 선택 결과: IllegalArgumentException 서비스 호출·관찰 결과: unknown=xml 로 실패합니다. 이후의 서비스 생성·내보내기는 실행되지 않음 BoardExportCli 원본은 문자열 입력의 csv 를 선택합니다. xml 행은 원본 정상 실행의 출력이 아닙니다. 이 비교는 알 수 없는 비-null 문자열 옵션에 한정합니다. 기본 구현이 있다고 해서 null 입력, 입력 재시도, 파일 복구까지 구현된 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
null 선택

select가 null을 서비스에 전달합니다.

  • 선택 결과: null
  • 실패를 선택 경계에서 잡지 않으면 뒤 호출에서 나타납니다.
안내 전략

UnsupportedExporter가 안내 문자열을 반환합니다.

  • 반환: unsupported=xml
  • 관측: 출력
  • 호출 가능한 전략 값으로 거절 내용을 전달합니다.
선택 시 예외

select가 예외를 던져 뒤 서비스 생성·내보내기를 막습니다.

  • 서비스: 생성 없음
  • 선행 메모리 저장: 되돌림 없음
  • 거절 경계가 바뀌어도 앞 부수 효과 롤백은 자동이 아닙니다.

이 프로그램으로 돌아보는 객체지향 설계

객체지향 설계는 인터페이스를 많이 만드는 기술이 아닙니다. 상태와 행동을 알맞은 객체에 나누어 맡기고, 자주 바뀌는 부분이 안정된 부분을 흔들지 않게 배치하는 일입니다. BoardExportCli는 작은 프로그램이지만 5장부터 8장까지 배운 설계 판단이 거의 모두 들어 있습니다. 아래 표는 그동안 배운 내용을 질문으로 바꾸고, 이 프로그램이 각 질문에 어떻게 답하는지 정리한 것입니다.

설계 질문BoardExportCli에서배운 곳
함께 쓰이는 데이터와 행동을 한 객체에 모았는가Board가 게시글 배열과 개수, 추가 규칙 검사를 함께 가집니다5장
만들어진 객체가 처음부터 쓸 수 있는 상태인가ExportService는 생성자에서 null을 거부하므로 전략 없이 만들어질 수 없습니다5장, 8장
바깥에는 필요한 것만 공개하는가보관 배열과 개수는 private 필드로 두고, main은 add와 사본을 돌려주는 snapshot()만 씁니다5장
static이 맞는 자리인가select는 객체 상태 없이 옵션만 보고 구현을 고르므로 static입니다6장
final이 무엇을 막는지 아는가Board의 entries는 final이라 다른 배열로 바꿀 수 없지만, 배열의 칸은 add가 계속 채웁니다6장
상속은 진짜 타입 관계일 때만 쓰는가형식들 사이에 공유할 상태가 없으므로 상속 대신 인터페이스를 구현합니다7장
종류별 분기를 다형적 호출로 바꾸었는가ExportService는 형식을 묻지 않고 exporter.format만 호출합니다7장, 8장
바뀌는 부분을 작은 역할로 떼어 냈는가Exporter에는 format 메서드 하나만 있습니다7장, 8장
구현 선택을 한곳에 모았는가구체 전략을 new로 만드는 곳은 select뿐입니다8장
새 요구를 실제로 넣어 수정 범위를 확인했는가JSON이나 Markdown 형식을 추가해도 ExportService는 바뀌지 않습니다8장

표의 셋째 줄에는 덧붙일 점이 있습니다. 이 글의 예제는 한 파일에 담으려고 Board 같은 클래스를 BoardExportCli 안의 중첩 클래스로 선언했습니다. 그런데 중첩 클래스의 private 멤버는 그것을 감싸는 최상위 클래스 안이라면 어디서든 접근할 수 있으므로, main이 board.entries를 직접 건드려도 컴파일이 됩니다. Board를 별도 파일의 최상위 클래스로 옮겨야 컴파일러가 이런 접근을 실제로 막아 주며, 중첩 클래스의 규칙은 11장에서 자세히 다룹니다.

좋은 설계는 앞으로 생길 요구를 모두 예측하지 않습니다. 구현이 하나뿐일 때는 구체 클래스를 바로 써도 충분하고, 두 번째 구현이 생겨 같은 자리에 분기가 늘기 시작할 때 공통 역할이 드러납니다. 세 번째 구현을 실제로 추가해 보면 그 역할이 제대로 나뉘었는지 확인할 수 있습니다. 반대로 추상화가 흐름을 감추어 코드를 읽기만 어렵게 만든다면, 단순한 구체 코드로 되돌아가는 것도 올바른 판단입니다.

프로그램이 커지고 동시성이나 웹으로 범위가 넓어져도 설계할 때 묻는 질문은 크게 달라지지 않습니다. 앞으로 새 주제를 배울 때도 다음 네 가지를 계속 물어 보세요.

  • 이 상태는 누가 가지고 있는가?
  • 값을 바꿔도 되는지는 누가 판단하는가?
  • 실패는 어디서 알아차리고, 누구에게 어떻게 알리는가?
  • 새 구현을 넣을 때 기존 규칙을 고쳐야 하는가?

테스트를 작성할 때도 이 구조가 도움이 됩니다. 서비스가 생성자로 역할을 받으므로, 테스트에서는 진짜 구현 대신 미리 정한 값을 돌려주는 가짜 구현을 넣어 서비스만 따로 확인할 수 있습니다. 이런 가짜 구현을 테스트 대역(test double)이라고 부릅니다.

정리

  • 서비스에서 분기를 없애도 구현을 고르는 일은 남습니다. 그 일은 select나 main 같은 조립점 한곳에 모으고, 서비스는 역할에만 의존하게 합니다.
  • 고르는 메서드가 null을 돌려주면 오류가 원인과 먼 곳에서 드러납니다. 지원하지 않는 옵션은 고르는 자리에서 예외로 거부하거나, 무엇이 잘못됐는지 알려 주는 기본 구현으로 처리합니다.
  • case null이 없는 switch에 null이 들어오면 default로 가지 않고 NullPointerException이 납니다.
  • 모든 구현에 공통인 규칙은 서비스에 두고, 구현마다 다른 규칙은 각 구현에 둡니다.
  • 구현이 많아지면 전략이 자기 이름을 말하게 하고 등록소에서 찾을 수 있습니다. 중복된 이름과 찾지 못한 이름은 등록소 한 곳에서 거부합니다.
  • 예외는 뒤의 작업을 막지만 앞에서 끝난 작업을 되돌리지는 않습니다. 되돌리기 어려운 작업은 검사를 모두 마친 뒤에 합니다.

연습 문제

문제 1: 등록소에 JSON 형식 추가하기

앞의 ExporterRegistrySelection에 JsonExporter를 추가해 {"title":"가입 인사","viewCount":40}을 출력하세요. ExportService와 ExporterRegistry는 고치지 말고, 새 구현 클래스를 추가한 뒤 main의 조립 코드만 바꿉니다. 같은 이름의 전략을 두 번 넣으면 등록소를 만들 때 거부되는지도 확인하세요. 제목은 특수 문자가 없는 고정 문자열이라고 가정하며, 따옴표 같은 문자까지 처리하려면 앞 글의 JSON 이스케이프 규칙을 적용해야 합니다.

해설 보기
src/JsonRegistryExercise.java
public final class JsonRegistryExercise {
    public static void main(String[] args) {
        ExporterRegistry registry = new ExporterRegistry(new Exporter[]{
                new TextExporter(), new CsvExporter(), new JsonExporter()});
        ExportService service = new ExportService(registry.find("json"));
        System.out.println(service.export("가입 인사", 40));

        try {
            new ExporterRegistry(new Exporter[]{
                    new TextExporter(), new JsonExporter(), new JsonExporter()});
        } catch (IllegalArgumentException error) {
            System.out.println("등록 실패: " + error.getMessage());
        }
    }

    private interface Exporter {
        String option();
        String format(String title, int viewCount);
    }

    private static final class TextExporter implements Exporter {
        @Override public String option() { return "text"; }
        @Override public String format(String title, int viewCount) {
            return title + "=" + viewCount;
        }
    }

    private static final class CsvExporter implements Exporter {
        @Override public String option() { return "csv"; }
        @Override public String format(String title, int viewCount) {
            return title + "," + viewCount;
        }
    }

    private static final class JsonExporter implements Exporter {
        @Override public String option() { return "json"; }
        @Override public String format(String title, int viewCount) {
            return "{\"title\":\"" + title + "\",\"viewCount\":" + viewCount + "}";
        }
    }

    private static final class ExporterRegistry {
        private final Exporter[] exporters;

        ExporterRegistry(Exporter[] exporters) {
            Exporter[] copy = exporters.clone();
            for (int left = 0; left < copy.length; left++) {
                for (int right = left + 1; right < copy.length; right++) {
                    if (copy[left].option().equals(copy[right].option())) {
                        throw new IllegalArgumentException("duplicate=" + copy[left].option());
                    }
                }
            }
            this.exporters = copy;
        }

        Exporter find(String option) {
            for (Exporter exporter : exporters) {
                if (exporter.option().equals(option)) return exporter;
            }
            throw new IllegalArgumentException("unknown=" + option);
        }
    }

    private static final class ExportService {
        private final Exporter exporter;
        ExportService(Exporter exporter) { this.exporter = exporter; }
        String export(String title, int viewCount) {
            return exporter.format(title, viewCount);
        }
    }
}
{"title":"가입 인사","viewCount":40}
등록 실패: duplicate=json

JsonExporter는 option()으로 "json"을 돌려주고, format에서 제목과 조회 수를 JSON 모양의 문자열로 만듭니다. 자바 문자열 안에 큰따옴표를 넣으려면 \"로 써야 하므로 코드에는 역슬래시가 많지만, 출력에는 큰따옴표만 남습니다. 등록소에 넘기는 배열에 new JsonExporter() 하나를 더 넣었을 뿐인데 find("json")이 이 전략을 찾았고, ExportService와 ExporterRegistry는 한 줄도 바뀌지 않았습니다.

두 번째 줄은 중복 검사가 동작한 결과입니다. 배열이 [Text, Json, Json]이면 생성자는 0번과 1번, 0번과 2번, 1번과 2번 순서로 이름을 비교하고, 마지막 쌍에서 둘 다 json이므로 duplicate=json 예외를 던집니다. 중복 검사와 찾지 못한 이름을 거부하는 규칙은 등록소의 공통 규칙이므로 새 형식에도 저절로 적용됩니다.

문제 2: 옵션을 먼저 확인하도록 순서 바꾸기

BoardExportCli는 게시글을 모두 추가한 뒤에야 옵션을 확인합니다. 입력의 첫 줄을 xml로 바꾸고, 게시글을 하나도 추가하기 전에 옵션 오류가 나도록 main을 고치세요. 고친 뒤에는 원래 순서와 비교해 어떤 문장들이 실행되지 않게 되는지 설명하세요.

해설 보기

select 호출을 option을 읽은 바로 다음 줄로 옮깁니다. 나머지 클래스는 그대로 둡니다.

src/BoardExportCli.java의 main
public static void main(String[] args) {
    Scanner scanner = new Scanner("xml\n가입 인사\n40\n로그인 구현\n50\n");
    String option = scanner.nextLine();
    Exporter exporter = select(option);      // 옵션부터 확인한다

    Board board = new Board();
    board.add(scanner.nextLine(), Integer.parseInt(scanner.nextLine()));
    board.add(scanner.nextLine(), Integer.parseInt(scanner.nextLine()));

    ExportService service = new ExportService(exporter);
    System.out.println(service.export(board.snapshot()));
}
실행 결과
Exception in thread "main" java.lang.IllegalArgumentException: unknown=xml
	at BoardExportCli.select(BoardExportCli.java:21)
	at BoardExportCli.main(BoardExportCli.java:7)

줄 번호는 원래 파일의 main을 위 코드로 바꿨을 때 기준입니다. 이제 7번 줄의 select 호출에서 예외가 나므로 Board를 만드는 일도, 숫자를 해석하는 parseInt도, 두 번의 add도 실행되지 않습니다. 원래 순서에서는 이 문장들이 모두 끝난 뒤인 11번 줄의 select 호출에서 예외가 났습니다. 옵션은 입력의 첫 줄이라 게시글보다 먼저 읽을 수 있고 전략 객체를 미리 만들어도 아무 부작용이 없으므로, 이렇게 앞당겨도 손해가 없습니다.

일반적으로는 실패할 수 있는 검사를 먼저 마치고, 저장이나 전송처럼 되돌리기 어려운 작업은 마지막에 하는 순서가 안전합니다. 다만 이렇게 고쳐도 두 번째 게시글의 조회 수만 잘못된 경우에는 첫 게시글이 추가된 뒤에 실패합니다. 그것까지 막으려면 모든 입력을 먼저 읽고 검사한 다음 게시글을 한꺼번에 추가해야 합니다.

문제 3: 결제 규칙을 어디에 둘까

PaymentRoles에 두 가지 요구가 생겼습니다. 하나는 계좌이체 결제를 추가하는 것인데, 수수료는 금액과 상관없이 500원입니다. 다른 하나는 어떤 결제 수단이든 한 번에 100만 원까지만 결제할 수 있게 하는 것입니다. 두 규칙을 각각 어느 클래스에 넣을지 정하고, 이유를 설명한 뒤 코드로 확인하세요.

해설 보기

계좌이체 수수료는 계좌이체에만 해당하므로 새 구현 TransferPayment에 두고, 한도는 모든 수단에 공통이므로 PaymentService.pay에 둡니다. PaymentRoles에 다음 클래스를 추가하고 PaymentService의 pay 메서드를 고칩니다.

src/PaymentRoles.java에 추가하고 고칠 부분
private static final class TransferPayment implements PaymentMethod {
    @Override
    public String pay(int amount) {
        return "계좌이체 " + amount + "원, 수수료 500원";
    }
}

// PaymentService의 pay
String pay(int amount) {
    if (amount <= 0) throw new IllegalArgumentException("amount=" + amount);
    if (amount > 1_000_000) throw new IllegalArgumentException("limit=" + amount);
    return "승인: " + method.pay(amount);
}

main에서 card.pay(10000)을 출력하는 줄 다음에 아래 코드를 넣고 실행합니다. 금액 200만 원은 한도를 넘으므로 catch에서 거부 메시지가 출력되어야 합니다.

PaymentService transfer = new PaymentService(new TransferPayment());
System.out.println(transfer.pay(10000));
try {
    transfer.pay(2_000_000);
} catch (IllegalArgumentException error) {
    System.out.println("거부: " + error.getMessage());
}
승인: 현금 10000원, 수수료 0원
승인: 카드 10000원, 수수료 300원
승인: 계좌이체 10000원, 수수료 500원
거부: limit=2000000
거부: amount=0

세 번째 줄은 새 구현이 자기 수수료를 계산한 결과이고, 네 번째 줄은 200만 원이 서비스의 한도 검사에 걸린 결과입니다. 한도 검사는 서비스에 한 번만 적었지만 현금과 카드, 계좌이체에 모두 적용됩니다. 한도를 각 구현에 넣었다면 같은 검사를 세 클래스에 복사해야 하고, 네 번째 수단을 추가할 때 빠뜨리기 쉽습니다.

반대로 계좌이체 수수료를 서비스에 넣으면 서비스가 '지금 수단이 계좌이체인가'를 instanceof로 물어야 하므로, 앞에서 없앤 분기가 되살아납니다. 이번에 PaymentService가 바뀐 것은 결제 수단이 늘어서가 아니라 모든 수단에 적용되는 공통 규칙이 새로 생겼기 때문입니다. 수단을 추가하는 일 자체는 새 클래스와 조립 코드만으로 끝났습니다.

다음 글

다음 장에서는 모든 클래스가 물려받는 Object부터 살펴봅니다. 9장의 첫 글에서는 Object 타입 변수에 어떤 객체든 담을 수 있지만 호출할 수 있는 메서드는 줄어든다는 점과, toString과 equals가 어떻게 동작하는지를 다룹니다.

이 장에서 만든 구조는 뒤에서도 계속 쓰입니다. 실패를 알리는 방법은 12장의 예외에서 더 자세히 다룹니다. 고정 배열로 만든 게시판은 14장부터 17장까지 컬렉션을 배우며 크기가 늘어나는 구조로 바뀝니다.