본문 바로가기

전략 패턴과 개방-폐쇄 원칙

내보내기 형식을 Exporter 인터페이스 하나로 받는 서비스를 만들어 전략 패턴의 구조를 익히고, 새 형식을 추가할 때 기존 서비스 코드를 고치지 않는다는 개방-폐쇄 원칙을 직접 확인합니다.

앞 글에서 형식마다 필드를 두고 if 문으로 고르는 서비스는 형식이 늘 때마다 같은 클래스를 다시 고쳐야 했고, 두 형식이 동시에 선택되는 버그까지 만들었습니다. 해결 방향도 보았습니다. 어떤 형식이든 담을 수 있는 역할 타입의 필드를 하나만 두는 것입니다. 이 글에서는 그 역할을 Exporter 인터페이스로 만들고, 서비스가 생성자로 Exporter 하나를 받게 바꿉니다.

이렇게 바꿔 끼울 수 있는 처리 방식을 객체로 만들어 넘겨주는 설계를 전략 패턴(strategy pattern)이라고 부릅니다. 그리고 새 형식을 추가할 때 기존 서비스를 고치지 않아도 되는 상태를 개방-폐쇄 원칙(open-closed principle)이 지켜졌다고 말합니다. 둘 다 이름은 거창하지만 하는 일은 앞의 두 글에서 이미 본 것입니다. 이 글은 그 구조를 완성하고, 새 형식을 실제로 추가하면서 고치는 코드와 그대로 두는 코드가 어떻게 나뉘는지 확인합니다.

형식을 Exporter 하나로 받는 서비스

Exporter는 게시글의 제목과 조회 수를 받아 문자열 표현 하나를 돌려주는 역할입니다. TextExporter와 CsvExporter는 같은 입력을 서로 다른 형식의 문자열로 바꾸는 구현입니다. ExportService는 시작 줄과 끝 줄을 출력하는 공통 흐름을 맡고, 형식마다 달라지는 변환만 Exporter에게 맡깁니다.

src/RoleBasedExporter.java
public final class RoleBasedExporter {
    public static void main(String[] args) {
        ExportService textService = new ExportService(new TextExporter());
        ExportService csvService = new ExportService(new CsvExporter());

        textService.export("가입 인사", 40);
        csvService.export("로그인 구현", 50);
    }

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

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

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

    private static final class ExportService {
        private final Exporter exporter;

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

        void export(String title, int viewCount) {
            System.out.println("export:start");
            System.out.println(exporter.format(title, viewCount));
            System.out.println("export:end");
        }
    }
}
export:start
가입 인사=40
export:end
export:start
로그인 구현,50
export:end

main은 Text 형식을 넣은 서비스와 CSV 형식을 넣은 서비스를 하나씩 만들고 차례로 내보냅니다. 첫 세 줄은 textService의 출력입니다. 서비스가 export:start를 출력하고, TextExporter.format이 돌려준 가입 인사=40을 서비스가 출력한 뒤, 마지막으로 export:end를 출력합니다. 뒤의 세 줄은 csvService가 같은 순서로 실행한 결과이고, 가운데 줄만 CsvExporter가 만든 로그인 구현,50입니다.

앞 글의 서비스와 비교해 보면 달라진 점이 분명합니다. 서비스의 필드는 TextExporter도 CsvExporter도 아닌 Exporter 하나뿐이어서, 두 형식이 동시에 선택된 상태는 아예 만들 수 없습니다. 형식을 고르는 if 문도 서비스 안에서 사라졌습니다. 어떤 형식을 쓸지는 main이 생성자에 넣어 주는 객체로 정해지고, 서비스는 그 객체가 어떤 클래스인지 모른 채 format을 호출합니다.

출력 방식도 하나 바뀌었습니다. 앞 글의 내보내기 클래스들은 export 안에서 직접 System.out.println을 호출했지만, 이번 format은 문자열을 돌려주기만 하고 출력은 서비스가 합니다. 그래서 각 구현은 '어떤 문자열을 만드는가'만 정하고, 언제 무엇을 화면에 쓸지는 서비스가 한곳에서 정합니다. 아래 그림은 두 호출 각각에서 서비스와 전략이 어떤 순서로 일하는지 보여 줍니다.

형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다

Text 서비스 호출 뒤 CSV 서비스가 시작 출력→전략 문자열 반환→서비스 결과/끝 출력을 합니다. 서로 다른 서비스 객체의 실제 순서와 반환/출력 책임을 구분할 수 있습니다.

형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다

RoleBasedExporter의 두 정상 호출은 같은 서비스 흐름을 실행하지만 서로 다른 입력과 전략을 사용한다.

전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다

RoleBasedExporter의 Text·CSV 서비스는 서로 다른 객체와 입력을 사용합니다. 각 호출은 시작 출력 → 변환 결과 출력 → 종료 출력 순서이며, main은 Text 다음에 CSV 서비스를 호출합니다.

전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다 Text와CSV는독립서비스호출이다. 서비스가시작을출력한뒤전략format이문자열을반환하면서비스가결과와종료를출력한다. format예외시결과와종료에이르지못한다. 두호출의입력이서로다르다. Text 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 TextExporter.format 가입 인사=40 서비스: 결과 출력 가입 인사=40 서비스: 종료 출력 export:end CSV 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 CsvExporter.format 로그인 구현,50 서비스: 결과 출력 로그인 구현,50 서비스: 종료 출력 export:end main은 Text 호출 다음 CSV 호출 · 각각 다른 서비스 객체
전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다 Text와CSV는독립서비스호출이다. 서비스가시작을출력한뒤전략format이문자열을반환하면서비스가결과와종료를출력한다. format예외시결과와종료에이르지못한다. 두호출의입력이서로다르다. Text 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 TextExporter.format 가입 인사=40 서비스: 결과 출력 가입 인사=40 서비스: 종료 출력 export:end CSV 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 CsvExporter.format 로그인 구현,50 서비스: 결과 출력 로그인 구현,50 서비스: 종료 출력 export:end format 예외 시 결과·종료 출력 없음
같은 ExportService.export가 실행하는 세 단계
서비스의 실행 단계Text 전략 · 가입 인사, 40CSV 전략 · 로그인 구현, 50
시작 · 서비스가 출력export:startexport:start
변환 · 전략이 반환, 서비스가 출력TextExporter.format

가입 인사=40

CsvExporter.format

로그인 구현,50

정상 반환 뒤 · 서비스가 출력export:endexport:end
시작 · 서비스가 출력
Text 전략 · 가입 인사, 40: export:start
CSV 전략 · 로그인 구현, 50: export:start
변환 · 전략이 반환, 서비스가 출력
Text 전략 · 가입 인사, 40: TextExporter.format

가입 인사=40

CSV 전략 · 로그인 구현, 50: CsvExporter.format

로그인 구현,50

정상 반환 뒤 · 서비스가 출력
Text 전략 · 가입 인사, 40: export:end
CSV 전략 · 로그인 구현, 50: export:end

서비스는 final Exporter exporter 하나에 의존합니다. main이 각각 new TextExporter()와 new CsvExporter()를 생성자에 전달합니다.

시작·종료는 로그 출력이며 파일 저장이 아닙니다. format에서 예외가 나면 마지막 출력은 실행되지 않습니다. 두 구현의 문자열은 표시한 예제 입력에 대한 결과입니다.

이 예의 조건

  • Text와 CSV는 다른 입력의 독립 서비스 호출이며 main은 Text 뒤 CSV입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2Text와 CSV는 독립 서비스 호출이다. 서비스가 시작을 출력한 뒤 전략 format이 문자열을 반환하면 서비스가 결과와 종료를 출력한다. format 예외 시 결과와 종료에 이르지 못한다. 두 호출의 입력이 서로 다르다.
원 도표의 설명과 주석
구분내용
설명Text와 CSV는 독립 서비스 호출이다. 서비스가 시작을 출력한 뒤 전략 format이 문자열을 반환하면 서비스가 결과와 종료를 출력한다. format 예외 시 결과와 종료에 이르지 못한다. 두 호출의 입력이 서로 다르다.
전체 읽기 정보형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다 형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다 RoleBasedExporter의 두 정상 호출은 같은 서비스 흐름을 실행하지만 서로 다른 입력과 전략을 사용한다. 전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다 RoleBasedExporter 의 Text·CSV 서비스는 서로 다른 객체와 입력을 사용합니다. 각 호출은 시작 출력 → 변환 결과 출력 → 종료 출력 순서이며, main 은 Text 다음에 CSV 서비스를 호출합니다. 같은 ExportService.export가 실행하는 세 단계 서비스의 실행 단계 Text 전략 · 가입 인사, 40 CSV 전략 · 로그인 구현, 50 시작 · 서비스가 출력 export:start export:start 변환 · 전략이 반환, 서비스가 출력 TextExporter.format 가입 인사=40 CsvExporter.format 로그인 구현,50 정상 반환 뒤 · 서비스가 출력 export:end export:end 시작 · 서비스가 출력 Text 전략 · 가입 인사, 40: export:start CSV 전략 · 로그인 구현, 50: export:start 변환 · 전략이 반환, 서비스가 출력 Text 전략 · 가입 인사, 40: TextExporter.format 가입 인사=40 CSV 전략 · 로그인 구현, 50: CsvExporter.format 로그인 구현,50 정상 반환 뒤 · 서비스가 출력 Text 전략 · 가입 인사, 40: export:end CSV 전략 · 로그인 구현, 50: export:end 서비스는 final Exporter exporter 하나에 의존합니다. main 이 각각 new TextExporter() 와 new CsvExporter() 를 생성자에 전달합니다. 시작·종료는 로그 출력이며 파일 저장이 아닙니다. format 에서 예외가 나면 마지막 출력은 실행되지 않습니다. 두 구현의 문자열은 표시한 예제 입력에 대한 결과입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
서비스 시작

각 서비스가 export:start를 출력합니다.

  • Text 입력: 가입 인사 40
  • CSV 입력: 로그인 구현 50
  • 공통 실행 순서를 서비스가 소유합니다.
전략 반환

format이 각 문자열을 반환합니다.

  • Text: 가입 인사=40
  • CSV: 로그인 구현,50
  • 전략 반환 자체가 서비스의 출력과 다릅니다.
서비스 후속

정상 format 뒤 결과와 export:end를 출력합니다.

  • 출력 소유: 서비스
  • format 예외: 결과·종료 미도달
  • 마무리 출력은 전략의 정상 반환을 전제합니다.

형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다

Text 서비스 호출 뒤 CSV 서비스가 시작 출력→전략 문자열 반환→서비스 결과/끝 출력을 합니다. 서로 다른 서비스 객체의 실제 순서와 반환/출력 책임을 구분할 수 있습니다.

형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다

RoleBasedExporter의 두 정상 호출은 같은 서비스 흐름을 실행하지만 서로 다른 입력과 전략을 사용한다.

전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다

RoleBasedExporter의 Text·CSV 서비스는 서로 다른 객체와 입력을 사용합니다. 각 호출은 시작 출력 → 변환 결과 출력 → 종료 출력 순서이며, main은 Text 다음에 CSV 서비스를 호출합니다.

전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다 Text와CSV는독립서비스호출이다. 서비스가시작을출력한뒤전략format이문자열을반환하면서비스가결과와종료를출력한다. format예외시결과와종료에이르지못한다. 두호출의입력이서로다르다. Text 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 TextExporter.format 가입 인사=40 서비스: 결과 출력 가입 인사=40 서비스: 종료 출력 export:end CSV 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 CsvExporter.format 로그인 구현,50 서비스: 결과 출력 로그인 구현,50 서비스: 종료 출력 export:end main은 Text 호출 다음 CSV 호출 · 각각 다른 서비스 객체
전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다 Text와CSV는독립서비스호출이다. 서비스가시작을출력한뒤전략format이문자열을반환하면서비스가결과와종료를출력한다. format예외시결과와종료에이르지못한다. 두호출의입력이서로다르다. Text 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 TextExporter.format 가입 인사=40 서비스: 결과 출력 가입 인사=40 서비스: 종료 출력 export:end CSV 서비스 서비스: 시작 출력 export:start 전략: 문자열 반환 CsvExporter.format 로그인 구현,50 서비스: 결과 출력 로그인 구현,50 서비스: 종료 출력 export:end format 예외 시 결과·종료 출력 없음
같은 ExportService.export가 실행하는 세 단계
서비스의 실행 단계Text 전략 · 가입 인사, 40CSV 전략 · 로그인 구현, 50
시작 · 서비스가 출력export:startexport:start
변환 · 전략이 반환, 서비스가 출력TextExporter.format

가입 인사=40

CsvExporter.format

로그인 구현,50

정상 반환 뒤 · 서비스가 출력export:endexport:end
시작 · 서비스가 출력
Text 전략 · 가입 인사, 40: export:start
CSV 전략 · 로그인 구현, 50: export:start
변환 · 전략이 반환, 서비스가 출력
Text 전략 · 가입 인사, 40: TextExporter.format

가입 인사=40

CSV 전략 · 로그인 구현, 50: CsvExporter.format

로그인 구현,50

정상 반환 뒤 · 서비스가 출력
Text 전략 · 가입 인사, 40: export:end
CSV 전략 · 로그인 구현, 50: export:end

서비스는 final Exporter exporter 하나에 의존합니다. main이 각각 new TextExporter()와 new CsvExporter()를 생성자에 전달합니다.

시작·종료는 로그 출력이며 파일 저장이 아닙니다. format에서 예외가 나면 마지막 출력은 실행되지 않습니다. 두 구현의 문자열은 표시한 예제 입력에 대한 결과입니다.

이 예의 조건

  • Text와 CSV는 다른 입력의 독립 서비스 호출이며 main은 Text 뒤 CSV입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2Text와 CSV는 독립 서비스 호출이다. 서비스가 시작을 출력한 뒤 전략 format이 문자열을 반환하면 서비스가 결과와 종료를 출력한다. format 예외 시 결과와 종료에 이르지 못한다. 두 호출의 입력이 서로 다르다.
원 도표의 설명과 주석
구분내용
설명Text와 CSV는 독립 서비스 호출이다. 서비스가 시작을 출력한 뒤 전략 format이 문자열을 반환하면 서비스가 결과와 종료를 출력한다. format 예외 시 결과와 종료에 이르지 못한다. 두 호출의 입력이 서로 다르다.
전체 읽기 정보형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다 형식만 전략에 맡기고 시작·종료 출력은 서비스가 담당한다 RoleBasedExporter의 두 정상 호출은 같은 서비스 흐름을 실행하지만 서로 다른 입력과 전략을 사용한다. 전략은 문자열을 반환하고 서비스는 실행 순서를 맡습니다 RoleBasedExporter 의 Text·CSV 서비스는 서로 다른 객체와 입력을 사용합니다. 각 호출은 시작 출력 → 변환 결과 출력 → 종료 출력 순서이며, main 은 Text 다음에 CSV 서비스를 호출합니다. 같은 ExportService.export가 실행하는 세 단계 서비스의 실행 단계 Text 전략 · 가입 인사, 40 CSV 전략 · 로그인 구현, 50 시작 · 서비스가 출력 export:start export:start 변환 · 전략이 반환, 서비스가 출력 TextExporter.format 가입 인사=40 CsvExporter.format 로그인 구현,50 정상 반환 뒤 · 서비스가 출력 export:end export:end 시작 · 서비스가 출력 Text 전략 · 가입 인사, 40: export:start CSV 전략 · 로그인 구현, 50: export:start 변환 · 전략이 반환, 서비스가 출력 Text 전략 · 가입 인사, 40: TextExporter.format 가입 인사=40 CSV 전략 · 로그인 구현, 50: CsvExporter.format 로그인 구현,50 정상 반환 뒤 · 서비스가 출력 Text 전략 · 가입 인사, 40: export:end CSV 전략 · 로그인 구현, 50: export:end 서비스는 final Exporter exporter 하나에 의존합니다. main 이 각각 new TextExporter() 와 new CsvExporter() 를 생성자에 전달합니다. 시작·종료는 로그 출력이며 파일 저장이 아닙니다. format 에서 예외가 나면 마지막 출력은 실행되지 않습니다. 두 구현의 문자열은 표시한 예제 입력에 대한 결과입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
서비스 시작

각 서비스가 export:start를 출력합니다.

  • Text 입력: 가입 인사 40
  • CSV 입력: 로그인 구현 50
  • 공통 실행 순서를 서비스가 소유합니다.
전략 반환

format이 각 문자열을 반환합니다.

  • Text: 가입 인사=40
  • CSV: 로그인 구현,50
  • 전략 반환 자체가 서비스의 출력과 다릅니다.
서비스 후속

정상 format 뒤 결과와 export:end를 출력합니다.

  • 출력 소유: 서비스
  • format 예외: 결과·종료 미도달
  • 마무리 출력은 전략의 정상 반환을 전제합니다.

그림에서 보듯 이 순서에는 조건이 하나 있습니다. format이 정상적으로 문자열을 돌려줘야 그 문자열과 export:end가 출력됩니다. format이 예외를 던지면 가운데 println은 인자를 계산하다가 멈추고, export:end까지 가지 못한 채 메서드를 빠져나갑니다. 그러면 출력에는 export:start만 남습니다. 그러니 로그에 시작 줄이 있다는 것만으로 내보내기가 성공했다고 판단해서는 안 됩니다.

더 깊이: 람다로 전략 넘기기

Exporter처럼 추상 메서드가 하나뿐인 인터페이스는 클래스를 따로 만들지 않고 람다식으로도 구현할 수 있습니다. 예를 들어 new ExportService((title, viewCount) -> title + ":" + viewCount)라고 쓰면 그 자리에서 제목:조회수 형식의 전략이 만들어지고, 내보내면 가운데 줄에 가입 인사:40이 출력됩니다. JDK의 Comparator가 대표적인 예로, 정렬 알고리즘은 그대로 두고 비교 방법만 전략으로 넘깁니다. 람다와 함수형 인터페이스는 30장에서 자세히 다루고, 이 장에서는 전략의 구조가 잘 보이도록 이름 있는 클래스를 씁니다.

필수 전략이 빠진 서비스 막기

인터페이스를 도입했다고 객체가 저절로 완성되지는 않습니다. 다음 서비스는 Exporter 필드를 갖고 있지만 그 필드를 채우는 생성자도 setter도 없습니다. 이 상태로 export를 호출하면 어떻게 될까요?

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

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

    private static final class ExportService {
        private Exporter exporter;

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

값을 넣지 않은 참조형 필드는 기본값 null을 가지므로, exporter.format(...)은 null에 대고 메서드를 호출하는 셈이 되어 NullPointerException이 납니다. JDK 14에 도입되어 JDK 15부터 기본으로 켜진 기능 덕분에 이 예외 메시지에는 무엇이 null이었는지까지 나오므로, 메시지 끝의 "this.exporter" is null을 보면 원인이 필드라는 것을 바로 알 수 있습니다. 하지만 이 예외는 서비스를 만든 첫 줄이 아니라, 나중에 기능을 실제로 쓰는 둘째 줄에서 터집니다. 서비스를 만드는 코드와 쓰는 코드가 멀리 떨어진 프로그램이라면, 어디서 전략을 빠뜨렸는지 찾기 어려워집니다.

문제는 Exporter 인터페이스가 아니라, 전략 없이도 ExportService를 만들 수 있게 허용한 생성 방식입니다. 그래서 앞의 RoleBasedExporter는 세 가지를 함께 합니다. 꼭 필요한 협력 객체를 생성자로 받고, null인지 검사하고, final 필드에 저장합니다. final은 한 번 대입한 필드를 다른 값으로 바꾸지 못하게 할 뿐 null 대입까지 막아 주지는 않으므로, null 검사는 따로 해야 합니다.

검사가 있으면 실패하는 시점이 앞당겨집니다. new ExportService(null)이라고 쓰면 생성자에서 바로 IllegalArgumentException: exporter가 나고, 스택 트레이스도 서비스를 만든 줄을 가리킵니다. 검사를 지우면 생성은 아무 일 없이 끝나고, 나중에 export가 export:start까지 출력한 뒤에야 NullPointerException이 납니다. 잘못된 값을 받아들이는 순간 거부하면 원인과 증상이 같은 자리에 있게 됩니다. 실무에서는 같은 검사를 Objects.requireNonNull(exporter, "exporter") 한 줄로 쓰기도 하는데, 이 경우에는 IllegalArgumentException 대신 메시지가 exporter인 NullPointerException이 납니다.

이 글의 뒤쪽 예제 몇 개는 코드를 짧게 하려고 이 검사를 생략했습니다. 그 예제들은 호출하는 쪽이 언제나 실제 전략 객체를 넘긴다고 가정하고 읽으면 됩니다. 실행 중에 전략을 바꿔야 해서 setter를 둔다면, 처음부터 기본 전략을 넣어 두거나 setter에서도 null을 거부해서 전략이 빈 상태가 생기지 않게 해야 합니다.

전략 패턴의 구조

지금까지 만든 구조에 이름을 붙여 보겠습니다. 전략 패턴에는 세 가지 등장인물이 있습니다.

  • 전략 인터페이스: 바꿔 끼울 처리의 공통 모양입니다. 여기서는 Exporter와 그 메서드 format입니다.
  • 구체 전략: 그 인터페이스를 구현한 클래스입니다. TextExporter와 CsvExporter가 각각 하나의 전략입니다.
  • 문맥(context): 전략을 받아서 사용하는 쪽입니다. ExportService가 문맥이며, 공통 흐름을 실행하다가 달라지는 부분에서 전략에게 일을 넘깁니다.

문맥은 구체 전략의 클래스 이름을 모릅니다. 실행 중에 생성자로 받은 객체에게 format을 요청할 뿐이고, 그 호출이 TextExporter의 메서드로 갈지 CsvExporter의 메서드로 갈지는 오버라이딩된 메서드를 실제 객체의 타입으로 고르는 자바의 규칙이 정합니다. 7장에서 배운 다형성이 이 패턴을 떠받치는 셈입니다.

전략을 생성자로 받으면 그 서비스 객체는 사는 동안 하나의 형식만 씁니다. 형식을 바꾸고 싶으면 다른 전략을 넣은 서비스를 새로 만들면 되므로, 위 예제처럼 textService와 csvService를 따로 두는 것이 자연스럽습니다. 문서 편집기의 내보내기 메뉴처럼 실행 중에 같은 객체의 전략을 바꿔야 한다면 setExporter 같은 메서드를 둘 수도 있습니다. 다만 그러면 앞 절의 null 문제, 그리고 여러 스레드가 같은 서비스를 쓰는 도중에 전략이 바뀌는 문제를 따로 다뤄야 합니다. 실제로 필요하지 않은데 바꿀 수 있게 열어 두면, 객체가 가질 수 있는 상태만 늘어나고 따져야 할 경우도 많아집니다.

역할의 메서드는 클라이언트가 필요한 것에서 정한다

두 클래스에 우연히 같은 모양의 메서드가 있다고 해서 바로 인터페이스로 묶지는 않습니다. 역할로 묶으려면 클라이언트가 두 구현을 같은 의미로 바꿔 쓸 수 있어야 하고, 입력과 결과와 실패 방식도 일관되어야 합니다. Exporter.format의 의미는 '제목과 조회 수를 받아 하나의 문자열 표현을 만든다'입니다. 그런데 어떤 구현은 문자열을 만들면서 파일을 지우거나 네트워크로 데이터를 보낸다면, 메서드 모양이 같아도 다른 구현과 같은 역할이라고 보기 어렵습니다. 첫 글에서 본 것처럼 모양뿐 아니라 의미까지 같아야 바꿔 쓸 수 있습니다.

이름을 지을 때도 같은 기준을 씁니다. 인터페이스 이름은 구현 기술보다 클라이언트가 기대하는 역할을 드러내야 합니다. TextLike나 CsvCapable처럼 특정 구현을 떠올리게 하는 이름보다 Exporter가 '내보내는 일을 하는 객체'라는 의도를 더 잘 보여 줍니다. 메서드 이름도 run이나 process처럼 무엇이든 될 수 있는 이름보다, 무엇을 돌려주는지 읽히는 format이 낫습니다.

새 형식을 추가하면서 확인하는 개방-폐쇄 원칙

개방-폐쇄 원칙은 '소프트웨어 요소는 확장에는 열려 있고 수정에는 닫혀 있어야 한다'는 말로 알려져 있습니다. 한 번 작성한 코드를 절대 고치지 말라는 뜻은 아닙니다. 새 기능을 추가하는 일에는 열려 있되, 그 때문에 이미 검증한 핵심 코드를 매번 다시 고칠 필요는 없게 하라는 뜻입니다. 말로만 보면 막연하니 RoleBasedExporter에 JSON 형식을 실제로 추가해 보겠습니다. 먼저 Exporter를 구현하는 새 클래스를 다른 구현 옆에 추가합니다.

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

문자열 안의 \"는 큰따옴표 문자 자체를 뜻하는 이스케이프입니다. 이어서 main의 끝에 JSON 형식을 쓰는 서비스를 만드는 두 줄을 추가합니다.

ExportService jsonService = new ExportService(new JsonExporter());
jsonService.export("polymorphism", 60);

다시 실행하면 기존 여섯 줄 뒤에 다음 세 줄이 더 출력됩니다. 가운데 줄만 JSON 형식이고, 앞뒤의 export:start와 export:end는 앞의 두 서비스가 출력한 것과 같은 모양입니다. 시작과 끝을 찍는 일은 ExportService가, 가운데 한 줄을 만드는 일은 넘겨받은 전략이 맡기 때문입니다.

export:start
{"title":"polymorphism","viewCount":60}
export:end

이번 변경에서 무엇을 고쳤고 무엇을 그대로 두었는지 정리해 보면 다음과 같습니다.

코드변경이유
JsonExporter새로 추가새 형식의 변환 규칙
main두 줄 추가어떤 전략을 쓸지 고르는 조립 코드
Exporter그대로형식이 늘어도 역할의 모양은 같음
ExportService그대로형식을 모른 채 format만 호출함
TextExporter, CsvExporter그대로다른 형식과 상관없음

ExportService를 한 글자도 고치지 않고 새 형식을 지원하게 되었습니다. 이것이 '수정에는 닫혀 있다'의 실제 의미입니다. 반면 '확장에 열려 있다'는 Exporter를 구현한 클래스를 얼마든지 더 만들어 넣을 수 있다는 뜻입니다. 앞 글의 설계였다면 JSON을 추가하는 순간 서비스에 필드와 분기를 넣어야 했을 것입니다.

그렇다고 모든 코드가 그대로인 것은 아닙니다. main은 new JsonExporter()라는 구체 클래스 이름을 알아야 하므로 바뀌었습니다. 이것은 원칙이 깨진 것이 아니라 일부러 그렇게 나눈 결과입니다. 바뀌는 이유가 서로 다른 코드를 서로 다른 곳에 두었기 때문에, 형식을 추가할 때 고칠 곳이 새 구현과 조립 코드로 좁혀진 것입니다. 개방-폐쇄 원칙은 모든 코드를 모든 변경에서 지켜 주는 것이 아니라, 특정 종류의 변경, 여기서는 '형식 추가'로부터 핵심 서비스를 지켜 줍니다.

그 범위를 벗어나는 변경도 있습니다. JSON 출력에서 필드 순서를 바꾸거나 따옴표를 다루는 방식을 바꾸려면 JsonExporter를 고쳐야 합니다. 또 모든 형식에 작성 날짜를 넣어야 해서 format의 매개변수가 바뀐다면, Exporter와 모든 구현과 ExportService를 함께 고쳐야 합니다. 그래서 역할을 정할 때는 앞으로 어떤 변경이 자주 일어날지 생각해야 합니다. 형식은 자주 늘지만 입력 항목은 거의 바뀌지 않는다는 판단이 있었기에 format(String, int)라는 역할이 효과를 냅니다.

더 깊이: 개방-폐쇄 원칙이라는 이름의 출처

이 원칙은 버트런드 마이어(Bertrand Meyer)가 1988년 책 『Object-Oriented Software Construction』에서 처음 제시한 것으로 알려져 있습니다. 마이어가 생각한 방법은 상속이었습니다. 기존 클래스는 그대로 두고, 기능을 덧붙인 하위 클래스를 만들어 확장하는 것입니다. 1990년대에 들어서는 추상화된 인터페이스에 의존하고 구현을 갈아 끼우는 방식으로 다시 정의되어 널리 퍼졌는데, 로버트 C. 마틴(Robert C. Martin)이 1996년에 쓴 글이 대표적입니다. 이 장에서 쓰는 뜻은 뒤의 것입니다. 마틴은 이 원칙을 나중에 SOLID라고 불리게 된 다섯 가지 설계 원칙의 하나로 소개했고, 첫 글에서 본 리스코프 치환 원칙도 그 다섯 가지에 들어 있습니다.

제목에 따옴표나 쉼표가 들어 있다면

앞에서 만든 JsonExporter는 문자열을 그대로 이어 붙이므로, 제목이 polymorphism처럼 단순할 때만 올바른 JSON을 만듭니다. 제목에 큰따옴표가 들어 있으면 JSON 문자열이 중간에 끝나 버려 올바른 JSON이 아니게 됩니다. CSV도 마찬가지로 제목에 쉼표가 있으면 제목 하나가 두 칸으로 나뉩니다. 이 장의 게시판은 제목이 비었는지만 검사할 뿐 따옴표나 쉼표는 막지 않으므로, 각 형식의 구현이 자기 형식의 규칙대로 직접 처리해야 합니다.

JSON은 문자열 안의 큰따옴표와 역슬래시, 줄바꿈 같은 제어 문자를 이스케이프해야 하고, CSV는 필드 전체를 큰따옴표로 감싸고 안쪽의 큰따옴표를 두 번 써야 합니다. 다음 프로그램은 제목 가입 "인사", 다음에 두 규칙을 적용해 봅니다. 전략 구조와는 따로, 문자 처리만 보여 주는 작은 예제입니다.

src/EscapedTitleFormat.java
public final class EscapedTitleFormat {
    public static void main(String[] args) {
        String title = "가입 \"인사\", 다음";
        System.out.println("{\"title\":" + jsonString(title) + ",\"viewCount\":40}");
        System.out.println(csvField(title) + ",40");
    }

    private static String jsonString(String value) {
        StringBuilder result = new StringBuilder("\"");
        for (int index = 0; index < value.length(); index++) {
            char character = value.charAt(index);
            if (character == '"') result.append("\\\"");
            else if (character == '\\') result.append("\\\\");
            else if (character < 0x20) {
                result.append(String.format("\\u%04x", (int) character));
            } else result.append(character);
        }
        return result.append('"').toString();
    }

    private static String csvField(String value) {
        return "\"" + value.replace("\"", "\"\"") + "\"";
    }
}
{"title":"가입 \"인사\", 다음","viewCount":40}
"가입 ""인사"", 다음",40

jsonString은 문자를 하나씩 보면서 큰따옴표는 \"로, 역슬래시는 \\로 바꾸고, 코드 값이 0x20보다 작은 제어 문자는 \u000a 같은 유니코드 이스케이프로 바꿉니다. 그래서 첫 줄에서는 제목 안의 큰따옴표 앞에 역슬래시가 붙었습니다. 쉼표는 JSON 문자열 안에서 특별한 문자가 아니므로 그대로 남습니다. csvField는 제목 안의 큰따옴표를 두 개로 늘린 뒤 전체를 큰따옴표로 감싸므로, 둘째 줄의 쉼표는 필드를 나누지 않고 제목의 일부로 읽힙니다.

같은 문자라도 두 형식이 다루는 방법은 다릅니다. 제목에 줄바꿈이 있으면 JSON 쪽은 \u000a로 바뀌어 한 줄에 출력되지만, CSV 쪽은 큰따옴표로 감싼 필드 안에 실제 줄바꿈이 그대로 남아 두 줄로 출력됩니다. 형식마다 규칙이 다르다는 사실이야말로 이 처리를 서비스가 아니라 각 전략 안에 두어야 하는 이유입니다. 다만 이 예제는 제목 문자열 하나를 처리하는 데 필요한 만큼만 구현한 것이고, 여러 레코드를 파일로 쓰는 일까지 다루지는 않습니다. 실무에서는 검증된 JSON·CSV 라이브러리를 쓰는 편이 안전하며, 정확한 규칙은 JSON 문자열 규칙(RFC 8259)과 CSV 필드·레코드 규칙(RFC 4180)에서 확인할 수 있습니다.

게시판 전체를 내보내는 전략

지금까지는 게시글 하나를 내보냈습니다. 이번에는 게시판에 저장된 글 전체를 한 번에 내보내도록 바꿔 보겠습니다. Board는 게시글을 검사해 배열에 보관하고, Exporter의 format은 게시글 배열 전체를 받아 문자열 하나로 만듭니다. Text 형식은 글 사이에 공백으로 감싼 |를 넣어 한 줄로 만들고, CSV 형식은 첫 줄에 열 이름을 쓴 머리글을 두고 글마다 한 줄씩 씁니다.

src/BoardStrategy.java
public final class BoardStrategy {
    public static void main(String[] args) {
        Board board = new Board();
        board.add("가입 인사", 40);
        board.add("로그인 구현", 50);

        ExportService text = new ExportService(new TextExporter());
        ExportService csv = new ExportService(new CsvExporter());
        System.out.println(text.export(board.entries()));
        System.out.println(csv.export(board.entries()));
    }

    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[] entries() {
            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 {
        @Override
        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 {
        @Override
        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);
        }
    }
}
가입 인사=40 | 로그인 구현=50
title,viewCount
가입 인사,40
로그인 구현,50

첫 줄은 TextExporter가 만든 문자열입니다. 두 번째 게시글부터 앞에 구분자를 붙이므로 구분자가 글 사이에만 들어갑니다. 나머지 세 줄은 CsvExporter가 돌려준 문자열 하나입니다. 머리글 title,viewCount로 시작해 글마다 줄바꿈 문자 \n과 한 줄을 덧붙였기 때문에, 한 번의 println이 세 줄로 출력됩니다.

이 프로그램은 바뀌는 이유에 따라 일을 나눠 맡깁니다. Board는 제목, 조회 수, 용량을 검사하고 게시글을 보관하는 일을, ExportService는 내보내기를 실행하는 일을, 각 전략은 표현 형식을 정하는 일을 맡습니다. 그래서 새 형식을 추가해도 게시글을 검사하는 규칙이나 서비스의 코드는 영향을 받지 않습니다. 아래 그림은 새 형식을 추가할 때 그대로 두는 코드와 바뀌는 코드를 나눠 보여 줍니다.

BoardStrategy는 저장·변환·조립의 변경 이유를 분리한다

BoardStrategy의 실제 책임과 같은 format(Post[]) 계약을 유지하며 새 전략을 추가할 때의 변경 경계를 비교한다.

형식 계약을 유지하면 저장·위임 코드는 그대로 둡니다

BoardStrategy에서 format(Post[]) 계약을 유지한 채 새 표현 형식을 추가하는 확장 모델입니다.

형식 계약을 유지하면 저장·위임 코드는 그대로 둡니다 같은format(Post[])계약을유지하는형식확장모델이다. main과새Exporter가변경범위이며Board검증보관과ExportService위임은유지한다. 실선은생성주입또는역할호출이며Board와서비스사이에직접호출을만들지않는다. 기존 계약 · 유지 범위 새 형식 · 변경 범위 Board 검증 · 배열과 size entries()로 새 배열 ExportService format(Post[]) 위임 main: 조립 새 구현을 생성 · 주입 새 Exporter 같은 format(Post[]) 표현 알고리즘 구현 역할에 위임 실선: 생성·주입 또는 역할 호출 · 형식 계약이 같을 때 Board.entries는 새 배열, 원소 참조는 공유
형식 계약을 유지하면 저장·위임 코드는 그대로 둡니다 같은format(Post[])계약을유지하는형식확장모델이다. main과새Exporter가변경범위이며Board검증보관과ExportService위임은유지한다. 실선은생성주입또는역할호출이며Board와서비스사이에직접호출을만들지않는다. 기존 계약 · 유지 범위 Board 검증 · 배열과 size entries()로 새 배열 ExportService format(Post[]) 위임 새 형식 · 변경 범위 main: 조립 새 구현을 생성 · 주입 새 Exporter 같은 format(Post[]) 표현 알고리즘 구현 오른쪽 선: 서비스가 역할에 위임 배열 원소 참조는 공유
변환 알고리즘과 입력·저장 정책의 변경 경계
책임 소유자현재 코드의 책임새 형식을 추가하면
Board제목·조회 수·용량을 검증하고 entries·size에 보관형식 추가만으로 검증·보관 규칙을 바꿀 필요는 없음
ExportServiceExporter 필드의 format(entries)를 호출같은 계약이면 구체 형식 분기를 추가하지 않음
구체 Exporter게시글 배열을 Text 또는 CSV 문자열로 변환새 전략에 새 표현 규칙을 구현
main구체 전략을 만들고 서비스에 주입새 전략을 생성·선택하는 조립 코드는 변경
Board
현재 코드의 책임: 제목·조회 수·용량을 검증하고 entries·size에 보관
새 형식을 추가하면: 형식 추가만으로 검증·보관 규칙을 바꿀 필요는 없음
ExportService
현재 코드의 책임: Exporter 필드의 format(entries)를 호출
새 형식을 추가하면: 같은 계약이면 구체 형식 분기를 추가하지 않음
구체 Exporter
현재 코드의 책임: 게시글 배열을 Text 또는 CSV 문자열로 변환
새 형식을 추가하면: 새 전략에 새 표현 규칙을 구현
main
현재 코드의 책임: 구체 전략을 만들고 서비스에 주입
새 형식을 추가하면: 새 전략을 생성·선택하는 조립 코드는 변경

Board.entries()는 길이 size인 새 배열을 반환하지만 원소 참조는 공유합니다. 배열 슬롯 복사는 게시글 객체의 깊은 복사가 아닙니다.

MarkdownExporterExercise는 같은 format(Post[])·서비스 위임 형태를 사용합니다. 앞의 단일 게시글 JSON 예제와 소스가 그대로 같다는 주장은 아닙니다. JSON·CSV의 일반적인 이스케이프 처리는 이 예제들에 구현되어 있지 않습니다.

entries()가 내부 배열을 그대로 돌려주지 않고 채워진 칸만 담은 새 배열을 만들어 돌려주는 데에도 이유가 있습니다. 내부 배열을 그대로 넘기면 전략이 그 배열의 칸에 다른 게시글을 넣거나 null로 지워서 Board가 검사해 둔 상태를 망가뜨릴 수 있습니다. 사본을 넘기면 전략이 받은 배열을 어떻게 바꾸든 원래 게시판의 칸은 그대로입니다. 또 크기를 size에 맞췄으므로 전략은 빈 칸의 null을 신경 쓰지 않고 배열 전체를 돌면 됩니다.

다만 이 사본은 배열의 칸만 새로 만든 얕은 복사입니다. 새 배열의 각 칸은 원래 배열과 같은 Post 객체를 가리킵니다. 이 예제가 안전한 것은 Post가 String과 int만 가진 레코드라서 만든 뒤에는 필드를 바꿀 수 없기 때문입니다. 게시글 객체가 바뀔 수 있는 클래스였다면 객체까지 복사하는 깊은 복사가 필요했을 것입니다. 사본을 돌려주는 방어적 복사는 final 참조와 객체 변경에서 배웠고, 배열 대신 크기가 자동으로 늘어나는 컬렉션은 14장부터 다룹니다.

같은 방법으로 알림 분리하기

이 설계 방법은 내보내기에만 쓰이는 것이 아닙니다. 첫 글의 알림도 같은 질문으로 정리할 수 있습니다. 게시글을 공개했다는 알림을 어디로 보낼지가 바뀔 수 있다면 PublicationNotifier 역할을 두고, 서비스는 보낼 메시지를 만드는 일만 맡습니다.

src/PublicationNotifierStrategy.java
public final class PublicationNotifierStrategy {
    public static void main(String[] args) {
        PublicationService console = new PublicationService(new ConsoleNotifier());
        PublicationService silent = new PublicationService(new SilentNotifier());

        console.publish("다형성 질문");
        silent.publish("상속 질문");
    }

    private interface PublicationNotifier {
        void send(String message);
    }

    private static final class ConsoleNotifier implements PublicationNotifier {
        @Override
        public void send(String message) {
            System.out.println("console=" + message);
        }
    }

    private static final class SilentNotifier implements PublicationNotifier {
        @Override
        public void send(String message) {
            System.out.println("silent");
        }
    }

    private static final class PublicationService {
        private final PublicationNotifier notifier;

        PublicationService(PublicationNotifier notifier) {
            this.notifier = notifier;
        }

        void publish(String title) {
            notifier.send(title + " published");
        }
    }
}
console=다형성 질문 published
silent

PublicationService.publish는 제목 뒤에 published를 붙여 메시지를 만들고 알림 객체에 보냅니다. 첫 서비스는 ConsoleNotifier가 그 메시지를 앞에 console=을 붙여 출력하고, 둘째 서비스는 SilentNotifier가 메시지와 상관없이 silent만 출력합니다. 메시지를 만드는 규칙은 서비스 한곳에 있고, 전송 방법만 전략마다 다릅니다.

PublicationService에는 콘솔이냐 무음이냐를 고르는 분기가 없으므로, EmailNotifier를 추가해도 서비스는 그대로입니다. 하지만 역할을 나눴다고 해서 오류 처리 방식까지 저절로 통일되지는 않습니다. 이메일 전송이 실패했을 때 무시할지, 다시 시도할지, 예외를 던질지처럼 모든 구현이 따라야 할 정책은 PublicationNotifier의 문서에 명시하거나 서비스 쪽에서 정해야 합니다.

개방-폐쇄 원칙을 지켰는지 확인하는 질문

설계에 패턴 이름을 붙였다고 원칙이 지켜지는 것은 아닙니다. 새 구현을 하나 추가한다고 가정하고 실제로 무엇을 고쳐야 하는지 따져 보는 편이 확실합니다. 다음 질문에 모두 '예'라고 답할 수 있다면 형식 추가에 대해서는 원칙이 지켜진 것입니다.

  • 새 구현을 추가해도 핵심 서비스의 조건문이 늘지 않는가?
  • 서비스의 필드가 구체 타입 수만큼 늘지 않는가?
  • 새 구현이 같은 역할의 의미를 지키면서 기존 구현을 대신할 수 있는가?
  • 어떤 구현을 쓸지 고르는 코드가 한곳에 모여 있는가?
  • 모든 형식에 공통인 앞뒤 처리가 구현마다 복사되어 있지 않은가?

인터페이스를 만들어 놓고도 서비스 안에서 구현의 종류를 묻는 경우가 흔한 실수입니다. 예를 들어 CSV 형식에만 머리글을 붙이려고 RoleBasedExporter의 export를 다음처럼 고쳤다고 해 봅시다.

void export(String title, int viewCount) {
    System.out.println("export:start");
    if (exporter instanceof CsvExporter) {
        System.out.println("title,viewCount"); // CSV일 때만 머리글을 붙인다
    }
    System.out.println(exporter.format(title, viewCount));
    System.out.println("export:end");
}

출력은 의도대로 나옵니다. 하지만 서비스가 다시 CsvExporter라는 구체 클래스 이름을 알게 되었고, 다른 형식에도 머리글이 필요해지면 instanceof 분기가 하나씩 늘어납니다. 결국 앞 글의 if 사슬로 돌아간 셈입니다. 형식마다 다른 부분은 형식의 일이므로 CsvExporter.format이 머리글을 포함한 문자열을 돌려주게 하는 것이 맞습니다. 반대로 구현이 두 개뿐이고 앞으로 늘어날 이유도 없다면, 인터페이스 없이 단순한 if 문 하나가 더 읽기 쉬울 수 있습니다. 추상화는 앞으로 바뀔 수도 있다는 막연한 예상이 아니라, 실제로 여러 구현이 생겨 바뀌는 방향이 드러났을 때 도입하는 것이 좋습니다.

정리

  • 바꿔 끼울 처리를 인터페이스(전략)로 정의하고, 그것을 쓰는 문맥이 생성자로 전략 하나를 받게 하는 설계가 전략 패턴입니다. 문맥은 공통 흐름을 맡고, 달라지는 부분만 전략에게 맡깁니다.
  • 꼭 필요한 전략은 생성자로 받아 null을 검사하고 final 필드에 저장합니다. 그래야 전략 없는 객체가 만들어지지 않고, 실패가 나중이 아니라 생성 시점에 드러납니다.
  • 개방-폐쇄 원칙은 새 구현을 추가할 때 핵심 서비스를 고치지 않아도 되게 하라는 원칙입니다. 새 구현과 조립 코드는 바뀌지만, 그것은 바뀌는 이유가 다른 코드를 다른 곳에 나눈 결과입니다.
  • 원칙이 지켜 주는 것은 특정 종류의 변경뿐입니다. 역할의 모양 자체가 바뀌면 모든 구현과 클라이언트를 함께 고쳐야 하므로, 자주 바뀌지 않을 메시지를 역할로 골라야 합니다.

연습 문제

문제 1: 서비스를 고치지 않고 Markdown 형식 추가하기

BoardStrategy를 복사해 클래스 이름을 MarkdownExporterExercise로 바꾼 파일을 만드세요. 그 안에 - 가입 인사: 조회 40회 형태로 글마다 한 줄씩 출력하는 MarkdownExporter를 추가합니다. 기존 Exporter를 구현하는 클래스를 만들고, main에서 new ExportService(new MarkdownExporter())로 서비스를 만들어 board.entries()를 내보냅니다. ExportService와 기존 Text·CSV 코드는 고치지 않고, 기존 두 형식도 그대로 출력되는지 확인하세요.

해설 보기

MarkdownExporterExercise 안의 다른 전략 옆에 다음 클래스를 추가합니다.

private static final class MarkdownExporter implements Exporter {
    @Override
    public String format(Post[] entries) {
        String result = "";
        for (int index = 0; index < entries.length; index++) {
            if (index > 0) result += "\n";
            Post entry = entries[index];
            result += "- " + entry.title() + ": 조회 " + entry.viewCount() + "회";
        }
        return result;
    }
}

그리고 main의 마지막에 다음 두 줄을 추가합니다.

ExportService markdown = new ExportService(new MarkdownExporter());
System.out.println(markdown.export(board.entries()));

실행하면 Text와 CSV 형식의 네 줄이 전과 똑같이 나온 뒤에 다음 두 줄이 이어집니다.

- 가입 인사: 조회 40회
- 로그인 구현: 조회 50회

고친 곳은 새 클래스 하나와 main의 두 줄뿐이고, Board, Exporter, ExportService, 기존 두 전략은 그대로입니다. TextExporter가 구분자를 두 번째 글부터 붙였던 것처럼, 줄바꿈도 두 번째 글부터 앞에 붙였기 때문에 마지막 줄 뒤에 빈 줄이 생기지 않습니다. 형식 추가에 대해 ExportService가 닫혀 있다는 것을 직접 확인한 셈입니다.

문제 2: 생성자의 null 검사를 지우면

RoleBasedExporter의 main을 다음 세 줄로 바꿔 실행하면 무엇이 출력되나요? 또 ExportService 생성자의 if (exporter == null) ... 줄을 지우고 다시 실행하면 결과가 어떻게 달라지나요?

ExportService service = new ExportService(null);
System.out.println("created");
service.export("가입 인사", 40);
해설 보기

검사가 있으면 첫 줄의 생성자에서 바로 IllegalArgumentException: exporter가 나고, created는 출력되지 않습니다. 스택 트레이스는 서비스를 만든 첫 줄을 가리킵니다.

검사를 지우면 생성이 아무 문제 없이 끝나 created가 출력되고, export가 export:start까지 출력한 다음에야 null인 필드에 format을 호출하다가 NullPointerException이 납니다. 출력의 앞부분은 다음과 같습니다.

created
export:start
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "RoleBasedExporter$Exporter.format(String, int)" because "this.exporter" is null

메시지 다음에는 예외가 난 위치를 알려 주는 스택 트레이스 줄이 이어지는데, 실수가 있던 생성 줄이 아니라 export 안과 그 호출 줄을 가리킵니다. 두 결과를 비교하면 생성자 검사가 실패 시점을 잘못이 일어난 자리로 당겨 준다는 것을 알 수 있습니다. 시작 줄이 출력되었는데도 내보내기는 실패했다는 점도 눈여겨보세요.

문제 3: instanceof 분기 없애기

앞 절의 instanceof 예제처럼 ExportService.export가 exporter instanceof CsvExporter로 머리글을 붙이고 있습니다. 출력은 그대로 유지하면서 서비스에서 이 분기를 없애려면 어떻게 고쳐야 할까요?

해설 보기

머리글은 CSV 형식의 일부이므로 CsvExporter가 만들게 합니다. 서비스의 export는 원래대로 되돌리고, CsvExporter.format이 머리글과 데이터 줄을 함께 돌려주도록 바꿉니다.

@Override
public String format(String title, int viewCount) {
    return "title,viewCount\n" + title + "," + viewCount;
}

이렇게 하면 CSV 서비스의 출력은 export:start, title,viewCount, 로그인 구현,50, export:end로 분기가 있을 때와 같습니다. 서비스는 다시 어떤 구현이 들어왔는지 묻지 않고 format만 호출하므로, 머리글이 필요한 형식이 늘어도 서비스를 고칠 필요가 없습니다. 형식마다 다른 부분은 그 형식의 전략에 두는 것, 이것이 전략 패턴에서 일을 나누는 기준입니다.

다음 글

다음 글에서는 이 글에서 미뤄 둔 질문, 곧 '어떤 전략을 쓸지는 누가 어떻게 고르는가'를 다룹니다. 사용자가 입력한 옵션 문자열로 전략을 고르다가 지원하지 않는 값이 들어오면 무슨 일이 생기는지 확인하고, 입력부터 저장과 내보내기까지 게시판 전체를 역할 기반으로 조립합니다.