확장 가능한 게시글 설계
구현 선택 경계를 안전하게 만들고 사용자 입력부터 게시글 저장·내보내기까지 역할 기반 객체를 조립하며 객체 지향 설계를 종합합니다.
역할과 구현을 분리한 뒤에도 프로그램은 어느 순간 구체 구현을 선택해야 합니다.
핵심 서비스에서 분기를 제거했다고 선택 책임까지 사라지는 것은 아닙니다.
이 절에서는 선택 실패를 재현하고, 조립점·기본 구현·등록소를 이용해 변경 범위를 통제한 다음 게시글 전체 흐름을 완성합니다.
잘못된 옵션과 실행 실패
문자열 옵션을 구현으로 바꾸는 메서드가 찾지 못한 경우 null을 반환하면, 실패 원인은 선택 경계에 있지만 예외는 나중의 서비스 호출에서 발생합니다.
호출 스택이 길어질수록 어떤 입력이 문제였는지 찾기 어려워집니다.
public final class NullExporterSelection {
public static void main(String[] args) {
Exporter exporter = select("xml");
ExportService service = new ExportService(exporter);
service.export("가입 인사", 40);
}
private static Exporter select(String option) {
if (option.equals("text")) return new TextExporter();
if (option.equals("csv")) return new CsvExporter();
return null;
}
private interface Exporter {
String format(String title, int viewCount);
}
private static final class TextExporter implements Exporter {
public String format(String title, int viewCount) {
return title + "=" + viewCount;
}
}
private static final class CsvExporter implements Exporter {
public String format(String title, int viewCount) {
return title + "," + viewCount;
}
}
private static final class ExportService {
private final Exporter exporter;
ExportService(Exporter exporter) {
this.exporter = exporter;
}
void export(String title, int viewCount) {
System.out.println(exporter.format(title, viewCount));
}
}
}Exception in thread "main" java.lang.NullPointerException해결 위치는 ExportService가 아니라 select입니다.
지원하지 않는 값이면 즉시 IllegalArgumentException으로 거부하거나, 아무 일도 하지 않는 기본 구현을 반환하거나, 사용 가능한 선택지를 다시 보여 줄 수 있습니다.
어떤 정책이 맞는지는 사용자 입력인지 내부 설정인지에 따라 달라지지만 null을 정상 전략처럼 흘려보내지 않는 원칙은 같습니다.
조립점과 안전한 기본 구현
콘솔 프로그램에서는 잘못된 선택 때문에 전체 기록을 잃는 것보다 안내 문자열을 반환한 뒤 다시 입력받는 정책이 적절할 수 있습니다.
UnsupportedExporter는 Exporter 계약을 지키면서 오류를 값으로 표현하는 기본 구현입니다.
public final class SafeExporterSelection {
public static void main(String[] args) {
ExportService known = new ExportService(select("csv"));
ExportService unknown = new ExportService(select("xml"));
System.out.println(known.export("가입 인사", 40));
System.out.println(unknown.export("가입 인사", 40));
}
private static Exporter select(String option) {
return switch (option) {
case "text" -> new TextExporter();
case "csv" -> new CsvExporter();
default -> new UnsupportedExporter(option);
};
}
private interface Exporter {
String format(String title, int viewCount);
}
private static final class TextExporter implements Exporter {
public String format(String title, int viewCount) {
return title + "=" + viewCount;
}
}
private static final class CsvExporter implements Exporter {
public String format(String title, int viewCount) {
return title + "," + viewCount;
}
}
private static final class UnsupportedExporter implements Exporter {
private final String option;
UnsupportedExporter(String option) {
this.option = option;
}
public String format(String title, int viewCount) {
return "unsupported=" + option;
}
}
private static final class ExportService {
private final Exporter exporter;
ExportService(Exporter exporter) {
if (exporter == null) throw new IllegalArgumentException("exporter");
this.exporter = exporter;
}
String export(String title, int viewCount) {
return exporter.format(title, viewCount);
}
}
}가입 인사,40
unsupported=xmlswitch는 남아 있지만 한 조립 함수에 모였습니다.
새 MarkdownExporter를 도입하면 select와 새 구현은 바뀌지만 ExportService는 바뀌지 않습니다.
OCP 판단의 기준은 조건문이 세상에서 완전히 사라졌는지가 아니라 핵심 정책에서 구현 선택 변화가 격리됐는지입니다.
기본 구현을 쓸 때는 조용히 실패를 숨기지 않습니다.
UnsupportedExporter처럼 어떤 옵션이 잘못됐는지 반환하거나 기록해야 합니다.
정말 아무 동작도 필요 없는 SilentExporter라면 이름과 사용 위치로 의도를 드러냅니다.
결제 시스템의 확장 구조
게시글 내보내기만의 특별한 기법은 아닙니다.
결제 서비스가 현금·카드의 구체 클래스를 직접 분기하면 새 결제 수단마다 핵심 흐름을 수정합니다.
PaymentMethod.pay(amount) 역할을 만들고 CashPayment와 CardPayment가 구현하면, PaymentService는 양수 금액 검증과 승인 흐름만 소유합니다.
새 계좌이체 수단은 PaymentMethod를 구현해 조립점에 연결합니다.
금액의 공통 유효성은 서비스 한 곳에 유지하고, 수단별 수수료는 각 구현이 계산합니다.
이 구분이 무너지면 서비스가 모든 수단의 세부 규칙을 다시 분기하게 됩니다.
구현 선택 배열의 책임
구현이 많아지면 긴 switch 대신 Exporter[] 배열에 전략 객체를 모으고 각 객체의 option() 값을 차례로 비교할 수 있습니다.
배열은 ch3에서 배운 문법이므로 새로운 컨테이너 없이도 역할 기반 선택을 연습할 수 있습니다.
같은 이름이 두 번 들어가지 않는지 조립할 때 확인하고, 찾지 못한 이름은 즉시 거부합니다.
이 장에서는 상태가 없는 전략 인스턴스를 배열에 보관합니다.
가변 크기 등록소와 이름 기반 자료구조는 컬렉션을 배운 뒤에 확장합니다.
작은 프로그램에서는 여전히 switch가 더 단순할 수 있으며, 선택 방식이 ExportService의 실행 책임 안으로 새어 들어가지 않는지가 핵심입니다.
게시글 조립 과정
다음 프로그램은 Scanner로 사용자 입력을 읽고, Board가 게시글 불변식을 지키며, ExportService가 선택된 전략을 실행합니다.
예제를 반복 실행할 수 있도록 문자열 입력을 사용하지만 실제 콘솔에서는 new Scanner(System.in)으로 바꿀 수 있습니다.
import java.util.Scanner;
public final class BoardExportCli {
public static void main(String[] args) {
Scanner scanner = new Scanner("csv\n가입 인사\n40\n로그인 구현\n50\n");
String option = scanner.nextLine();
Board board = new Board();
board.add(scanner.nextLine(), Integer.parseInt(scanner.nextLine()));
board.add(scanner.nextLine(), Integer.parseInt(scanner.nextLine()));
Exporter exporter = select(option);
ExportService service = new ExportService(exporter);
System.out.println(service.export(board.snapshot()));
}
private static Exporter select(String option) {
return switch (option) {
case "text" -> new TextExporter();
case "csv" -> new CsvExporter();
default -> throw new IllegalArgumentException("unknown=" + option);
};
}
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[] snapshot() {
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 {
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 {
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);
}
}
}title,viewCount
가입 인사,40
로그인 구현,50입력 해석은 main, 게시글 검증은 Board, 표현 변환은 Exporter, 실행 조정은 ExportService에 있습니다.
클래스가 많다는 사실보다 각 변경 이유가 겹치지 않는지가 중요합니다.
입력 형식을 파일로 바꿔도 Exporter는 영향을 받지 않고, Markdown 형식을 추가해도 Board의 검증은 바뀌지 않습니다.
객체 지향 설계 판단 흐름
객체 지향 설계는 인터페이스를 많이 만드는 기술이 아니라 상태와 행동을 책임 단위로 배치하고, 변하는 곳이 안정된 곳을 흔들지 않게 만드는 과정입니다.
- 서로 함께 쓰이는 데이터와 행동을 객체 책임으로 묶습니다.
- 생성자에서 필수 상태와 협력자를 받아 유효한 객체만 만듭니다.
- 패키지와 접근 제어로 외부에 공개할 계약을 제한합니다.
- static은 인스턴스가 아닌 공통 정책과 무상태 유틸리티에만 사용합니다.
- final은 재대입 금지와 불변 객체를 구분해 적용합니다.
- 상속은 진짜 타입 관계와 대체 가능성이 있을 때 공통 계층으로 사용합니다.
- 다형적 참조와 오버라이딩으로 구체 종류 분기를 공통 계약 호출로 바꿉니다.
- 독립적인 능력과 변경 전략은 작은 인터페이스로 분리합니다.
- 구현 선택은 조립점에 모으고 핵심 서비스는 역할에 의존시킵니다.
- 새 요구를 실제로 추가해 기존 코드의 수정 범위를 측정합니다.
좋은 설계는 모든 미래 요구를 예측하지 않습니다.
현재 중복과 결합을 관찰하고, 두 번째 구현이 등장했을 때 공통 역할을 확인하며, 세 번째 구현을 추가해 확장 경계를 검증합니다.
추상화가 오히려 흐름을 숨기면 단순한 구체 코드로 돌아갈 수 있어야 합니다.
다음 확장 주제
이제 한 프로세스 안의 객체 협력은 설계할 수 있습니다.
다음 단계에서는 예외를 계층화해 실패 계약을 만들고, 제네릭으로 타입 안전한 컬렉션과 역할을 표현하며, 파일·데이터베이스·네트워크 같은 외부 경계를 다룹니다.
단위 테스트를 배울 때도 구체 구현 대신 작은 역할을 주입하는 구조가 테스트 대역을 자연스럽게 만듭니다.
동시성이나 웹 애플리케이션으로 범위가 커져도 핵심 질문은 유지됩니다.
누가 상태를 소유하는가, 어떤 객체가 변경을 승인하는가, 실패는 어느 경계에서 변환되는가, 새 구현이 기존 정책을 수정하게 만드는가를 계속 묻습니다.
연습 문제
ExporterRegistry의 전략 배열에 JsonExporter를 추가해 {"title":"가입 인사","viewCount":40}을 출력하세요.
ExportService는 수정하지 말고, 배열 조립 코드와 새 구현만 변경합니다.
같은 이름을 가진 전략이 두 개 들어오면 생성 시 거부해야 합니다.
해설 보기
public final class JsonRegistryExercise {
public static void main(String[] args) {
Registry registry = new Registry(new Exporter[]{new JsonExporter()});
ExportService service = new ExportService(registry.find("json"));
System.out.println(service.export("가입 인사", 40));
}
private interface Exporter {
String option();
String format(String title, int viewCount);
}
private static final class JsonExporter implements Exporter {
@Override
public String option() {
return "json";
}
@Override
public String format(String title, int viewCount) {
return "{\"title\":\"" + title + "\",\"viewCount\":" + viewCount + "}";
}
}
private static final class Registry {
private final Exporter[] exporters;
Registry(Exporter[] exporters) {
this.exporters = exporters.clone();
for (int left = 0; left < exporters.length; left++) {
for (int right = left + 1; right < exporters.length; right++) {
if (exporters[left].option().equals(exporters[right].option())) {
throw new IllegalArgumentException(
"duplicate=" + exporters[left].option());
}
}
}
}
Exporter find(String option) {
for (Exporter exporter : exporters) {
if (exporter.option().equals(option)) return exporter;
}
throw new IllegalArgumentException("unknown=" + option);
}
}
private static final class ExportService {
private final Exporter exporter;
ExportService(Exporter exporter) {
this.exporter = exporter;
}
String export(String title, int viewCount) {
return exporter.format(title, viewCount);
}
}
}{"title":"가입 인사","viewCount":40}추가된 변경은 JsonExporter와 전략 배열의 한 원소뿐입니다.
중복 검증과 알 수 없는 이름 검증은 Registry의 공통 정책이므로 새 형식에도 그대로 적용됩니다.
ch11에서 컬렉션을 배우면 고정 배열을 가변 등록 구조로 바꿀 수 있습니다.