구체 구현 의존 비용
구현별 필드와 분기가 늘어나는 게시글 내보내기를 재현하고 역할을 도입하기 전 변경 지점을 구조적으로 측정합니다.
역할과 구현을 분리하지 않은 코드는 처음 한 구현만 있을 때 단순합니다.
문제는 두 번째·세 번째 구현이 추가될 때 나타납니다.
클라이언트가 각 구체 타입을 필드로 보관하고 종류를 분기하면 새 구현마다 기존 핵심 코드를 수정해야 합니다.
구현 교체와 잔존 상태
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이 아니어서 첫 분기가 실행됐습니다.
ConcreteExporterSwitchBug의 export는 textExporter부터 검사한다. 두 필드가 모두 null이면 출력 없이 끝나고, 둘 다 참조를 담으면 CSV를 마지막에 설정했어도 TextExporter를 호출한다.
ConcreteExporterSwitchBug의 export는 textExporter를 먼저 검사합니다. 표는 두 필드의 null 여부에 따른 소스 분기를 비교합니다.
| 필드 조합 | textExporter | csvExporter | 선택하는 실행 |
|---|---|---|---|
| 설정 없음 | null | null | 두 조건 모두 거짓입니다. exporter를 호출하지 않고 출력 없이 끝납니다. |
| Text만 설정 | 참조 있음 | null | 첫 if에서 TextExporter를 호출합니다. |
| CSV만 설정 | null | 참조 있음 | 첫 조건은 거짓이고, else if에서 CsvExporter를 호출합니다. |
| 둘 다 설정 원문 main | 참조 있음 | 참조 있음 | 첫 if에서 TextExporter를 호출합니다. CSV 조건은 검사하지 않습니다. |
- 설정 없음
textExporter = null,csvExporter = null. 두 조건 모두 거짓이므로 exporter 호출이나 출력 없이 끝납니다.- Text만 설정
textExporter는 참조를 담고csvExporter = null입니다. 첫if에서TextExporter를 호출합니다.- CSV만 설정
textExporter = null이고csvExporter는 참조를 담습니다. 첫 조건은 거짓이며else if에서CsvExporter를 호출합니다.- 둘 다 설정 — 원문 main
- 두 필드 모두 참조를 담습니다. 첫
if에서TextExporter를 호출하므로 CSV 조건은 검사하지 않습니다.
main은 setTextExporter(...) 다음에 setCsvExporter(...)를 호출하지만, 첫 필드를 비우지 않습니다. 이어 export("가입 인사", 40)를 호출한 실제 출력은 다음 한 줄입니다.
가입 인사=40
다른 세 조합은 원문 main에서 실행한 단계가 아니라 같은 메서드의 조건으로 판별한 경우입니다. 둘 다 설정된 상태의 선택은 모호한 것이 아니라 코드 순서상 Text 우선입니다.
구현마다 필드와 setter를 추가하고 “현재 하나만 선택됨”이라는 규칙을 클라이언트 분기로 관리한 결과입니다.
새 JsonExporter를 추가하면 필드, setter, export 분기, 초기화 순서를 모두 수정해야 합니다.
구현 수가 늘수록 유효하지 않은 조합도 늘어납니다.
단일 구현의 단순성
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로 바꾸려면 서비스 소스를 수정해야 합니다.
“새 구현 추가”가 “기존 클라이언트 수정”을 요구한다는 결합을 확인합니다.
다중 구현의 분기 비용
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
unknownExportService가 옵션 해석, 구현 생성, 공통 흐름, 구체 호출을 모두 압니다.
새 형식 추가 때 기존 if 사슬을 수정해야 하고 각 구현의 생성 비용도 서비스 수명과 결합됩니다.
테스트에서 특정 구현만 교체하기도 어렵습니다.
변경 축 분리의 기준
구체 의존을 발견했다고 즉시 인터페이스를 만들지 않고 다음을 확인합니다.
- Text·CSV·JSON이 클라이언트 입장에서 같은 “내보내기” 역할인가?
- 공통 입력과 성공·실패 계약을 정의할 수 있는가?
- 새 형식 추가 빈도가 핵심 흐름 변경 빈도와 다른가?
- 실행 중 구현을 교체해야 하는가?
- 구현 선택 문자열을 해석하는 책임은 어디에 둘 것인가?
같은 역할이라면 ExportService는 공통 전후 흐름만 알고 포맷 동작은 인터페이스에 맡길 수 있습니다.
옵션 문자열에서 구현을 선택하는 공장이나 조립점은 여전히 새 구현을 알아야 할 수 있습니다.
다형성은 모든 변경을 없애는 것이 아니라 핵심 클라이언트에서 구현 변경을 격리합니다.
구성을 통한 구현 선택
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유효하지 않은 두 구현 동시 선택은 막았지만 새 구현마다 필드·메서드·분기가 계속 늘어납니다.
SingleSlotExporter의 main은 useText 후 text만 남겨 가입 인사=40을 출력하고, useCsv 후 text를 null로 비워 로그인 구현,50을 출력한다. 여전히 구체 타입 필드 두 개이며 null 입력을 거부하지 않는다.
SingleSlotExporter의 main에서 각 선택 메서드가 끝난 뒤, 다음 export 호출이 읽는 상태입니다.
| 원문의 선택 호출 | 복귀 뒤 두 필드 | 뒤따르는 export의 출력 |
|---|---|---|
useText(...) | text: TextExporter 참조
| 가입 인사=40 |
useCsv(...) | csv: CsvExporter 참조
| 로그인 구현,50 |
useText(...)복귀 뒤text는 새TextExporter참조이고csv = null입니다. 이어export("가입 인사", 40)를 호출하면가입 인사=40을 출력합니다.useCsv(...)복귀 뒤csv는 새CsvExporter참조이고 이전text는null로 비워집니다. 이어export("로그인 구현", 50)를 호출하면로그인 구현,50을 출력합니다.
main은 useText에 new TextExporter(), useCsv에 new CsvExporter()를 전달합니다. 각 메서드는 전달받은 참조를 저장하고 다른 필드에 null을 대입합니다.
여전히 구체 타입 필드 두 개입니다. 선택 메서드는 null 입력을 거부하지 않으므로 “항상 하나가 설정됨”을 보장하지 않습니다. 두 필드가 null인 채 export를 호출하면 IllegalStateException("no exporter")가 발생합니다. 이 경계는 원문 main의 두 호출에는 나타나지 않습니다.
한 칸을 공통 인터페이스 타입으로 만들면 구조 자체가 하나의 구현만 보관하도록 표현할 수 있습니다.
연습 문제
ConsoleNotifier와 SilentNotifier를 문자열 option으로 고르는 NotificationService를 작성하고, 새 EmailNotifier 추가 시 수정할 위치를 주석으로 표시하세요.
아직 인터페이스로 개선하지 말고 결합을 관찰합니다.
해설 보기
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=가입 인사
silentEmailNotifier를 추가하려면 서비스 필드와 if 분기를 모두 수정해야 합니다.
다음 절에서는 역할 인터페이스로 핵심 서비스를 닫습니다.