본문 바로가기

확장 가능한 게시글 설계

구현 선택 경계를 안전하게 만들고 사용자 입력부터 게시글 저장·내보내기까지 역할 기반 객체를 조립하며 객체 지향 설계를 종합합니다.

역할과 구현을 분리한 뒤에도 프로그램은 어느 순간 구체 구현을 선택해야 합니다.

핵심 서비스에서 분기를 제거했다고 선택 책임까지 사라지는 것은 아닙니다.

이 절에서는 선택 실패를 재현하고, 조립점·기본 구현·등록소를 이용해 변경 범위를 통제한 다음 게시글 전체 흐름을 완성합니다.


잘못된 옵션과 실행 실패

문자열 옵션을 구현으로 바꾸는 메서드가 찾지 못한 경우 null을 반환하면, 실패 원인은 선택 경계에 있지만 예외는 나중의 서비스 호출에서 발생합니다.

호출 스택이 길어질수록 어떤 입력이 문제였는지 찾기 어려워집니다.

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

알 수 없는 옵션 문자열을 검증할 첫 위치는 select입니다. 다른 경로에서 서비스 객체를 만드는 호출도 있을 수 있으므로, 필수 전략의 null 검사는 앞 절처럼 생성자에서 별도로 보장할 수 있습니다.

지원하지 않는 값이면 즉시 IllegalArgumentException으로 거부하거나, 아무 일도 하지 않는 기본 구현을 반환하거나, 사용 가능한 선택지를 다시 보여 줄 수 있습니다.

어떤 정책이 맞는지는 사용자 입력인지 내부 설정인지에 따라 달라지지만 null을 정상 전략처럼 흘려보내지 않는 원칙은 같습니다.


조립점과 안전한 기본 구현

콘솔 프로그램에서는 잘못된 선택 때문에 전체 기록을 잃는 것보다 안내 문자열을 반환한 뒤 다시 입력받는 정책이 적절할 수 있습니다.

UnsupportedExporter는 Exporter 계약을 지키면서 오류를 값으로 표현하는 기본 구현입니다.

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

switch는 남아 있지만 한 조립 함수에 모였습니다. 여기서 default는 "xml"처럼 지원하지 않는 비어 있지 않은 문자열을 처리합니다. select(null)은 이 switch에서 NullPointerException을 던지며 UnsupportedExporter를 만들지 않습니다. 호출자가 null도 입력할 수 있다면 그 조건을 별도로 검증해야 합니다.

새 MarkdownExporter를 도입하면 select와 새 구현은 바뀌지만 ExportService는 바뀌지 않습니다.

OCP 판단의 기준은 조건문이 세상에서 완전히 사라졌는지가 아니라 핵심 정책에서 구현 선택 변화가 격리됐는지입니다.

기본 구현을 쓸 때는 조용히 실패를 숨기지 않습니다.

UnsupportedExporter처럼 어떤 옵션이 잘못됐는지 반환하거나 기록해야 합니다.

정말 아무 동작도 필요 없는 SilentExporter라면 이름과 사용 위치로 의도를 드러냅니다.

같은 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가 예외를 던져 뒤 서비스 생성·내보내기를 막습니다.

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

결제 시스템의 확장 구조

게시글 내보내기만의 특별한 기법은 아닙니다.

결제 서비스가 현금·카드의 구체 클래스를 직접 분기하면 새 결제 수단마다 핵심 흐름을 수정합니다.

PaymentMethod.pay(amount) 역할을 만들고 CashPayment와 CardPayment가 구현하면, PaymentService는 양수 금액 검증과 승인 흐름만 소유합니다.

새 계좌이체 수단은 PaymentMethod를 구현해 조립점에 연결합니다.

금액의 공통 유효성은 서비스 한 곳에 유지하고, 수단별 수수료는 각 구현이 계산합니다.

이 구분이 무너지면 서비스가 모든 수단의 세부 규칙을 다시 분기하게 됩니다.


구현 선택 배열의 책임

구현이 많아지면 긴 switch 대신 Exporter[] 배열에 전략 객체를 모으고 각 객체의 option() 값을 차례로 비교할 수 있습니다.

배열은 ch3에서 배운 문법이므로 새로운 컨테이너 없이도 역할 기반 선택을 연습할 수 있습니다.

같은 이름이 두 번 들어가지 않는지 조립할 때 확인하고, 찾지 못한 이름은 즉시 거부합니다.

이 장에서는 상태가 없는 전략 인스턴스를 배열에 보관합니다.

가변 크기 등록소와 이름 기반 자료구조는 컬렉션을 배운 뒤에 확장합니다.

작은 프로그램에서는 여전히 switch가 더 단순할 수 있으며, 선택 방식이 ExportService의 실행 책임 안으로 새어 들어가지 않는지가 핵심입니다.


게시글 조립 과정

다음 프로그램은 Scanner로 사용자 입력을 읽고, Board가 게시글 불변식을 지키며, ExportService가 선택된 전략을 실행합니다.

예제를 반복 실행할 수 있도록 문자열 입력을 사용하지만 실제 콘솔에서는 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) {
            this.exporter = exporter;
        }

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

입력 해석은 main, 게시글 검증은 Board, 표현 변환은 Exporter, 실행 조정은 ExportService에 있습니다.

BoardExportCli의 순서를 직접 따라가면 option을 읽은 뒤 두 게시글의 수치를 해석하고 Board.add를 실행한 다음 select를 호출합니다. 따라서 지원하지 않는 옵션이라도 메모리의 게시글 추가는 먼저 끝났을 수 있습니다. 옵션 오류가 있으면 서비스 생성과 내보내기는 하지 않지만 앞선 입력 처리까지 되돌리는 코드는 아닙니다. 수치 입력이 잘못되면 반대로 parseInt에서 먼저 실패하여 옵션 선택까지 도달하지 않습니다.

클래스가 많다는 사실보다 각 변경 이유가 겹치지 않는지가 중요합니다.

입력 형식을 파일로 바꿔도 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 반환과 최종 출력의 주체가 다릅니다.

객체 지향 설계 판단 흐름

객체 지향 설계는 인터페이스를 많이 만드는 기술이 아니라 상태와 행동을 책임 단위로 배치하고, 변하는 곳이 안정된 곳을 흔들지 않게 만드는 과정입니다.

  1. 서로 함께 쓰이는 데이터와 행동을 객체 책임으로 묶습니다.
  2. 생성자에서 필수 상태와 협력자를 받아 유효한 객체만 만듭니다.
  3. 패키지와 접근 제어로 외부에 공개할 계약을 제한합니다.
  4. static을 선택할 때는 그 상태나 동작이 인스턴스별 책임인지 클래스 전체의 책임인지 먼저 판단합니다. 공통 정책과 무상태 유틸리티가 대표적인 사례이며, 앞서 배운 클래스 전체의 집계 상태처럼 공유가 의도된 경우도 있습니다.
  5. final은 재대입 금지와 불변 객체를 구분해 적용합니다.
  6. 상속은 진짜 타입 관계와 대체 가능성이 있을 때 공통 계층으로 사용합니다.
  7. 다형적 참조와 오버라이딩으로 구체 종류 분기를 공통 계약 호출로 바꿉니다.
  8. 독립적인 능력과 변경 전략은 작은 인터페이스로 분리합니다.
  9. 구현 선택은 조립점에 모으고 핵심 서비스는 역할에 의존시킵니다.
  10. 새 요구를 실제로 추가해 기존 코드의 수정 범위를 측정합니다.

좋은 설계는 모든 미래 요구를 예측하지 않습니다.

현재 중복과 결합을 관찰하고, 두 번째 구현이 등장했을 때 공통 역할을 확인하며, 세 번째 구현을 추가해 확장 경계를 검증합니다.

추상화가 오히려 흐름을 숨기면 단순한 구체 코드로 돌아갈 수 있어야 합니다.


다음 확장 주제

이제 한 프로세스 안의 객체 협력은 설계할 수 있습니다.

다음 단계에서는 예외를 계층화해 실패 계약을 만들고, 제네릭으로 타입 안전한 컬렉션과 역할을 표현하며, 파일·데이터베이스·네트워크 같은 외부 경계를 다룹니다.

단위 테스트를 배울 때도 구체 구현 대신 작은 역할을 주입하는 구조가 테스트 대역을 자연스럽게 만듭니다.

동시성이나 웹 애플리케이션으로 범위가 커져도 핵심 질문은 유지됩니다.

누가 상태를 소유하는가, 어떤 객체가 변경을 승인하는가, 실패는 어느 경계에서 변환되는가, 새 구현이 기존 정책을 수정하게 만드는가를 계속 묻습니다.


연습 문제

실습 시작 코드

앞의 select는 이름별 분기를 직접 작성했습니다. 다음 파일은 전략 자신에게 이름을 묻고 배열에서 찾는 방식으로 같은 선택 책임을 옮긴 시작 코드입니다. 먼저 Text 출력 한 줄을 확인한 뒤 이 파일을 연습 문제의 출발점으로 사용합니다. 배열과 그 원소는 null이 아니며 각 전략의 option()은 변하지 않는 이름을 돌려준다고 가정합니다.

src/ExporterRegistryPreparation.java
public final class ExporterRegistryPreparation {
    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));
    }

    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) {
            this.exporters = exporters.clone();
            for (int left = 0; left < exporters.length; left++) {
                for (int right = left + 1; right < exporters.length; right++) {
                    if (exporters[left].option().equals(exporters[right].option())) {
                        throw new IllegalArgumentException(
                                "duplicate=" + exporters[left].option());
                    }
                }
            }
        }
        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

ExporterRegistry의 전략 배열에 JsonExporter를 추가해 {"title":"가입 인사","viewCount":40}을 출력하세요.

ExportService는 수정하지 말고, 배열 조립 코드와 새 구현만 변경합니다.

같은 이름을 가진 전략이 두 개 들어오면 생성 시 거부해야 합니다. 시작 코드의 중복 검사와 검색 실패 정책은 유지하며 새 전략을 두 번 넣는 경우도 확인하세요. 해설의 Registry는 시작 코드의 ExporterRegistry와 같은 역할을 짧은 이름으로 표현한 것입니다. 제목은 고정 문자열로 확인하고, 특수 문자를 처리할 때는 앞 절의 JSON 이스케이프 규칙을 적용합니다.

해설 보기
src/JsonRegistryExercise.java
public final class JsonRegistryExercise {
    public static void main(String[] args) {
        Registry registry = new Registry(new Exporter[]{new JsonExporter()});

        ExportService service = new ExportService(registry.find("json"));
        System.out.println(service.export("가입 인사", 40));
    }

    private interface Exporter {
        String option();
        String format(String title, int 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 Registry {
        private final Exporter[] exporters;

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

        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}

추가된 변경은 JsonExporter와 전략 배열의 한 원소뿐입니다.

중복 검증과 알 수 없는 이름 검증은 Registry의 공통 정책이므로 새 형식에도 그대로 적용됩니다.

14~17장에서 컬렉션을 배우면 고정 배열을 가변 등록 구조로 바꿀 수 있습니다. 이름으로 전략을 찾는 Map은 16장의 맵에서 이어서 다룹니다.