본문 바로가기

개방-폐쇄 원칙과 전략 교체

내보내기 역할에 의존하는 게시글 서비스를 설계하고 전략 패턴과 개방-폐쇄 원칙을 실제 확장 과정으로 검증합니다.

구체 구현별 필드와 분기를 하나의 역할 타입으로 바꾸면 클라이언트는 “누가 처리하는가”보다 “무엇을 요청하는가”에 집중할 수 있습니다.

이 절에서는 게시글 내보내기를 전략으로 분리하고, 새 형식을 추가할 때 기존 서비스가 정말 닫혀 있는지 코드 변화로 확인합니다.


역할 필드의 조립 책임

인터페이스를 도입했다는 사실만으로 객체가 완성되지는 않습니다.

역할을 맡을 구현을 생성자나 setter로 전달하지 않은 채 호출하면 참조는 null이고, 실패는 실제 기능을 실행하는 시점까지 늦춰집니다.

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

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

    private static final class ExportService {
        private Exporter exporter;

        void export(String title, int viewCount) {
            exporter.export(title, viewCount);
        }
    }
}
실패 관찰
Exception in thread "main" java.lang.NullPointerException

문제는 Exporter 인터페이스가 아니라 완전하지 않은 ExportService를 만들 수 있는 생성 규칙입니다.

필수 협력자는 생성자에서 받고, null을 검사한 뒤 final 필드에 보관합니다. final은 대입 후 교체를 제한할 뿐 null 자체를 거부하지 않습니다. 아래 RoleBasedExporter의 생성자 검사가 미설정 상태를 막는 부분입니다. 뒤의 OpenClosedExportService와 BoardStrategy는 검사를 생략한 예제이므로 호출자가 실제 전략 객체를 넘긴다는 전제로 읽습니다.

setter 교체가 실제 요구라면 기본 전략을 제공하거나, 미설정 상태를 명시적인 예외로 검증해야 합니다.


역할 의존과 생성자 주입

Exporter는 게시글 항목을 외부 표현으로 바꾸는 역할입니다.

TextExporter와 CsvExporter는 같은 입력 계약을 서로 다른 방식으로 구현합니다.

ExportService는 시작·종료 로그라는 공통 흐름을 소유하고, 달라지는 변환 단계만 역할 객체에 위임합니다.

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

서비스 필드는 TextExporter도 CsvExporter도 아닌 Exporter 하나입니다.

따라서 두 구현이 동시에 선택되는 잘못된 조합을 구조적으로 만들 수 없습니다.

생성자 매개변수도 같은 역할 타입이므로 서비스의 공통 흐름을 바꾸지 않고 구현을 교체할 수 있습니다.

실행 순서도 계약의 일부입니다. export:start를 먼저 출력하고 format의 반환값을 받아 출력한 뒤 export:end를 출력합니다. format이 예외를 던지면 가운데 출력문은 인수 평가를 마치지 못하며 종료 로그에도 도달하지 않습니다. 따라서 시작 로그가 있다는 사실만으로 내보내기 성공을 판단할 수는 없습니다.

역할 계약은 구현의 공통점이 아니라 클라이언트의 요구에서 나온다

두 클래스에 우연히 같은 메서드가 있다고 곧바로 인터페이스로 묶지는 않습니다.

클라이언트가 두 구현을 같은 의미로 대체해서 사용해야 하고, 입력·출력·실패 규칙도 일관되어야 합니다.

Exporter의 format은 “제목과 조회 수를 하나의 문자열 표현으로 만든다”는 의미를 가집니다.

한 구현만 파일을 삭제하거나 네트워크를 호출한다면 같은 역할로 안전하게 대체할 수 있는지 다시 검토해야 합니다.

인터페이스 이름은 구현 기술보다 역할을 드러냅니다.

TextLike, CsvCapable보다 Exporter가 클라이언트 의도를 잘 표현합니다.

메서드도 run이나 process처럼 모호한 이름보다 format 또는 export처럼 결과와 책임을 읽을 수 있어야 합니다.

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

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 예외: 결과·종료 미도달
  • 마무리 출력은 전략의 정상 반환을 전제합니다.

새 구현과 개방-폐쇄 원칙

개방-폐쇄 원칙은 코드를 한 번 작성한 뒤 절대 수정하지 않는다는 뜻이 아닙니다.

새로운 구현을 추가하는 확장에는 열려 있고, 이미 검증한 핵심 정책을 매번 고치는 일에는 닫혀 있도록 변경 경계를 잡는 원칙입니다.

src/OpenClosedExportService.java
public final class OpenClosedExportService {
    public static void main(String[] args) {
        ExportService service = new ExportService(new JsonExporter());
        System.out.println(service.export(new Post("polymorphism", 60)));
    }

    private record Post(String title, int viewCount) {
        Post {
            if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
        }
    }

    private interface Exporter {
        String format(Post entry);
    }

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

    private static final class ExportService {
        private final Exporter exporter;

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

        String export(Post entry) {
            return exporter.format(entry);
        }
    }
}
{"title":"polymorphism","viewCount":60}

이 예제의 Exporter.format(Post) 계약을 유지하며 형식을 추가한다면, 새 구현과 조립점을 바꾸고 ExportService의 역할 필드와 위임 메서드는 유지할 수 있습니다.

앞의 RoleBasedExporter는 format(String, int)와 시작·종료 로그를 사용하므로 두 예제의 서비스 소스가 그대로 같다는 뜻은 아닙니다.

반면 JSON의 따옴표 정책이나 필드 순서를 바꾸면 JsonExporter는 수정해야 합니다.

위의 단순 문자열 연결은 제목이 polymorphism일 때의 결과를 보여 줍니다. 제목에 따옴표가 들어오면 그대로 붙인 JSON은 유효하지 않습니다. CSV 예제도 제목에 쉼표가 들어오면 한 제목이 두 필드로 나뉩니다. Post의 공백 검사는 이런 문자를 거부하지 않으므로 형식 구현이 직접 처리해야 합니다.

예시

제목 가입 "인사", 다음을 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

이 예제는 정상적인 유니코드 제목 한 개의 문자열 이스케이프를 다룹니다. 전체 객체 직렬화나 모든 CSV 방언을 구현한 라이브러리는 아닙니다. 여러 CSV 레코드를 파일로 만들 때에는 레코드 구분도 별도로 정해야 합니다. JSON의 문자열 규칙과 CSV의 필드·레코드 규칙을 확인할 수 있습니다.

직접 확인

제목에 역슬래시나 줄바꿈을 하나 넣고 결과를 예상해 보세요. JSON의 줄바꿈은 문자열 안의 실제 줄바꿈 대신 \u000a가 되며, CSV의 줄바꿈은 따옴표로 감싼 필드 안에 남습니다. 두 형식이 같은 문자를 같은 방법으로 바꾸지는 않습니다.

모든 코드를 모든 변경에서 보호하는 것이 아니라 서로 다른 변경 이유를 서로 다른 타입에 둡니다.

조립 코드는 새 구현을 알아야 합니다.

main에서 new JsonExporter()를 선택하는 부분은 변경됐습니다.

이 변경은 실패가 아니라 의도된 경계입니다.

핵심 업무 규칙과 구현 선택을 분리하면 형식 추가 때 수정 범위가 조립점과 새 구현으로 제한됩니다.


전략 패턴의 구조

Exporter는 내보내기 알고리즘의 공통 계약이고 각 구현은 전략입니다.

ExportService는 전략을 사용하는 문맥입니다.

문맥은 전략의 구체 클래스 이름을 모르며, 실행 시점에 주입된 객체에게 일을 위임합니다.

전략을 생성자에서 받으면 서비스 수명 동안 하나의 정책을 안정적으로 유지합니다.

실행 중 바꿔야 하는 편집기나 시뮬레이터라면 setExporter를 둘 수 있지만, 동시 실행·null·예상하지 못한 교체를 별도로 관리해야 합니다.

변경 가능성을 실제 요구보다 크게 열어 두면 상태 공간만 늘어납니다.

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

Board는 항목의 유효성과 보관을 책임지고 ExportService는 내보내기 흐름을 책임집니다.

각 전략은 표현 형식만 책임집니다.

entries()가 실제 보관 배열이 아닌 크기에 맞춘 사본을 반환하므로 전략은 원본 배열 구조를 바꾸지 못합니다.

전략을 추가해도 게시글 불변식과 서비스 조정 로직은 영향을 받지 않습니다.

가변 크기 컬렉션은 14~17장에서 이 배열 저장소와 비교합니다.

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의 일반적인 이스케이프 처리는 이 예제들에 구현되어 있지 않습니다.


알림 전송의 역할 분리

내보내기와 알림은 목적이 다르지만 설계 질문은 같습니다.

게시글 공개 알림을 어디로 보낼지 달라진다면 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에 console·silent 분기가 없으므로 EmailNotifier를 추가해도 서비스는 그대로입니다.

다만 전송 실패를 무시할지 재시도할지처럼 모든 구현이 지켜야 할 정책은 PublicationNotifier 계약이나 상위 조정 서비스에서 명시해야 합니다.

역할 분리는 오류 정책까지 저절로 통일하지 않습니다.


개방-폐쇄 원칙의 평가

설계 이름보다 실제 변경 범위를 확인합니다.

  • 새 구현을 추가할 때 기존 핵심 서비스의 조건문이 늘지 않는가?
  • 클라이언트 필드가 구체 타입 수만큼 늘지 않는가?
  • 새 구현이 같은 계약으로 기존 구현을 대체할 수 있는가?
  • 구현 선택은 한 조립점에 모여 있는가?
  • 공통 전후 정책이 각 구현에 복사되지 않는가?

인터페이스가 있어도 서비스가 instanceof로 모든 구현을 분기하면 OCP 효과는 거의 없습니다.

반대로 구현이 두 개뿐이고 앞으로 바뀔 이유가 없다면 단순한 분기가 더 읽기 쉬울 수 있습니다.

예상 변화가 아니라 실제 변화 축을 근거로 추상화합니다.


배열 반환의 범위

Board.entries()는 새 배열을 만들어 채워진 칸만 돌려줍니다. 반환 배열의 0번 칸을 다른 Post로 바꾸어도 원래 게시판의 칸은 바뀌지 않습니다. 다만 배열 안의 객체 참조는 공유합니다. 이 예제의 Post는 불변 String과 int를 가진 레코드라 필드 상태를 수정할 수 없으며, 객체까지 새로 만드는 깊은 복사와는 구분합니다.

연습 문제

BoardStrategy의 ExportService를 수정하지 않고 - 가입 인사: 조회 40회 형식의 MarkdownExporter를 추가하세요.

수정 대상은 위 BoardStrategy입니다. 그 클래스 안에 기존 Exporter를 구현하는 MarkdownExporter를 추가하고, main에서 new ExportService(new MarkdownExporter())를 조립해 board.entries()를 전달합니다. 기존 Text·CSV 호출은 그대로 유지합니다. 아래 해설의 독립 파일은 Markdown의 출력 모양을 확인하는 예제이므로, 그 파일을 실행하는 것과 원래 서비스에 새 전략을 끼우는 작업을 구분해 보세요.

두 항목을 내보내고, 기존 TextExporter도 여전히 같은 서비스에서 실행되는지 확인하세요.

해설 보기
src/MarkdownExporterExercise.java
public final class MarkdownExporterExercise {
    public static void main(String[] args) {
        Post[] entries = {
                new Post("가입 인사", 40),
                new Post("로그인 구현", 50)
        };

        ExportService service = new ExportService(new MarkdownExporter());
        System.out.println(service.export(entries));
    }

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

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

    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;
        }
    }

    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회

기존 ExportService를 편집하지 않고 새 클래스와 조립 코드만 추가했습니다.

이 차이가 형식 확장에 열린 구조를 보여 줍니다.