본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 다형성과 객체지향 설계

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

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

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

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


역할 필드의 조립 책임

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

역할을 맡을 구현을 생성자나 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를 만들 수 있는 생성 규칙입니다.

필수 협력자는 생성자에서 받고 final 필드로 보관하면 null 상태와 실행 전 교체 가능성을 함께 줄일 수 있습니다.

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 하나입니다.

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

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

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

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

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

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

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

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

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

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


새 구현과 개방-폐쇄 원칙

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

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

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}

JsonExporter를 추가했지만 ExportService의 필드, 생성자, export 메서드는 수정하지 않았습니다.

이것이 서비스가 형식 확장에 닫혔다는 구체적인 증거입니다.

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

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

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

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

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

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


알림 전송의 역할 분리

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

게시글 공개 알림을 어디로 보낼지 달라진다면 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 효과는 거의 없습니다.

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

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


연습 문제

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

두 항목을 내보내고, 기존 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를 편집하지 않고 새 클래스와 조립 코드만 추가했습니다.

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