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

안동민 개발노트

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

구체 구현 의존 비용

구현별 필드와 분기가 늘어나는 게시글 내보내기를 재현하고 역할을 도입하기 전 변경 지점을 구조적으로 측정합니다.

역할과 구현을 분리하지 않은 코드는 처음 한 구현만 있을 때 단순합니다.

문제는 두 번째·세 번째 구현이 추가될 때 나타납니다.

클라이언트가 각 구체 타입을 필드로 보관하고 종류를 분기하면 새 구현마다 기존 핵심 코드를 수정해야 합니다.


구현 교체와 잔존 상태

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

    private static final class ExportService {
        private TextExporter textExporter;
        private CsvExporter csvExporter;

        void setTextExporter(TextExporter exporter) {
            textExporter = exporter;
        }

        void setCsvExporter(CsvExporter exporter) {
            csvExporter = exporter;
        }

        void export(String title, int viewCount) {
            if (textExporter != null) {
                textExporter.export(title, viewCount);
            } else if (csvExporter != null) {
                csvExporter.export(title, viewCount);
            }
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }
}
잘못된 결과
가입 인사=40

마지막에 CsvExporter를 설정했으므로 CSV를 기대했지만 TextExporter 필드도 null이 아니어서 첫 분기가 실행됐습니다.

구현마다 필드와 setter를 추가하고 “현재 하나만 선택됨”이라는 규칙을 클라이언트 분기로 관리한 결과입니다.

새 JsonExporter를 추가하면 필드, setter, export 분기, 초기화 순서를 모두 수정해야 합니다.

구현 수가 늘수록 유효하지 않은 조합도 늘어납니다.


단일 구현의 단순성

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

    private static final class ExportService {
        private final TextExporter exporter;

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

        void export(String title, int viewCount) {
            System.out.println("export:start");
            exporter.export(title, viewCount);
            System.out.println("export:end");
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) {
            System.out.println(title + "=" + viewCount);
        }
    }
}
export:start
가입 인사=40
export:end

현재 요구만 보면 읽기 쉽고 잘 동작합니다.

모든 구체 의존을 미리 인터페이스로 바꿔야 하는 것은 아닙니다.

변경 가능성이 실제로 있고 같은 역할의 구현이 늘어나는 지점에서 분리 가치가 커집니다.

다만 ExportService의 생성자와 필드가 TextExporter를 직접 가리키므로 CsvExporter로 바꾸려면 서비스 소스를 수정해야 합니다.

“새 구현 추가”가 “기존 클라이언트 수정”을 요구한다는 결합을 확인합니다.


다중 구현의 분기 비용

src/BranchedExportService.java
public final class BranchedExportService {
    public static void main(String[] args) {
        ExportService service = new ExportService();
        service.export("text", "가입 인사", 40);
        service.export("csv", "로그인 구현", 50);
        service.export("bad", "input", 30);
    }

    private static final class ExportService {
        private final TextExporter text = new TextExporter();
        private final CsvExporter csv = new CsvExporter();

        void export(String option, String title, int viewCount) {
            System.out.println("export:start=" + option);
            if (option.equals("text")) {
                text.export(title, viewCount);
            } else if (option.equals("csv")) {
                csv.export(title, viewCount);
            } else {
                System.out.println("unknown");
            }
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }
}
export:start=text
가입 인사=40
export:start=csv
로그인 구현,50
export:start=bad
unknown

ExportService가 옵션 해석, 구현 생성, 공통 흐름, 구체 호출을 모두 압니다.

새 형식 추가 때 기존 if 사슬을 수정해야 하고 각 구현의 생성 비용도 서비스 수명과 결합됩니다.

테스트에서 특정 구현만 교체하기도 어렵습니다.


변경 축 분리의 기준

구체 의존을 발견했다고 즉시 인터페이스를 만들지 않고 다음을 확인합니다.

  • Text·CSV·JSON이 클라이언트 입장에서 같은 “내보내기” 역할인가?
  • 공통 입력과 성공·실패 계약을 정의할 수 있는가?
  • 새 형식 추가 빈도가 핵심 흐름 변경 빈도와 다른가?
  • 실행 중 구현을 교체해야 하는가?
  • 구현 선택 문자열을 해석하는 책임은 어디에 둘 것인가?

같은 역할이라면 ExportService는 공통 전후 흐름만 알고 포맷 동작은 인터페이스에 맡길 수 있습니다.

옵션 문자열에서 구현을 선택하는 공장이나 조립점은 여전히 새 구현을 알아야 할 수 있습니다.

다형성은 모든 변경을 없애는 것이 아니라 핵심 클라이언트에서 구현 변경을 격리합니다.


구성을 통한 구현 선택

src/SingleSlotExporter.java
public final class SingleSlotExporter {
    public static void main(String[] args) {
        ExportService service = new ExportService();

        service.useText(new TextExporter());
        service.export("가입 인사", 40);
        service.useCsv(new CsvExporter());
        service.export("로그인 구현", 50);
    }

    private static final class ExportService {
        private TextExporter text;
        private CsvExporter csv;

        void useText(TextExporter value) {
            text = value;
            csv = null;
        }

        void useCsv(CsvExporter value) {
            csv = value;
            text = null;
        }

        void export(String title, int viewCount) {
            if (text != null) text.export(title, viewCount);
            else if (csv != null) csv.export(title, viewCount);
            else throw new IllegalStateException("no exporter");
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }
}
가입 인사=40
로그인 구현,50

유효하지 않은 두 구현 동시 선택은 막았지만 새 구현마다 필드·메서드·분기가 계속 늘어납니다.

한 칸을 공통 인터페이스 타입으로 만들면 구조 자체가 하나의 구현만 보관하도록 표현할 수 있습니다.


연습 문제

ConsoleNotifier와 SilentNotifier를 문자열 option으로 고르는 NotificationService를 작성하고, 새 EmailNotifier 추가 시 수정할 위치를 주석으로 표시하세요.

아직 인터페이스로 개선하지 말고 결합을 관찰합니다.

해설 보기
src/ConcreteNotificationBranches.java
public final class ConcreteNotificationBranches {
    public static void main(String[] args) {
        NotificationService service = new NotificationService();
        service.notify("console", "가입 인사");
        service.notify("silent", "로그인 구현");
    }

    private static final class NotificationService {
        private final ConsoleNotifier console = new ConsoleNotifier();
        private final SilentNotifier silent = new SilentNotifier();

        void notify(String option, String title) {
            if (option.equals("console")) console.send(title);
            else if (option.equals("silent")) silent.send(title);
            else System.out.println("unknown");
        }
    }

    private static final class ConsoleNotifier {
        void send(String title) { System.out.println("console=" + title); }
    }
    private static final class SilentNotifier {
        void send(String title) { System.out.println("silent"); }
    }
}
console=가입 인사
silent

EmailNotifier를 추가하려면 서비스 필드와 if 분기를 모두 수정해야 합니다.

다음 절에서는 역할 인터페이스로 핵심 서비스를 닫습니다.