역할과 구현의 분리
협력에서 역할과 구현을 구분하고 구체 클래스 의존이 대체 가능성과 변경 범위를 어떻게 제한하는지 게시글 알림으로 판단합니다.
좋은 객체 지향 설계는 클래스를 많이 만드는 것이 아니라 객체들이 책임을 나누고 메시지로 협력하게 합니다.
역할은 클라이언트가 기대하는 계약이고 구현은 그 계약을 실제로 수행하는 구체 객체입니다.
역할과 구현을 분리하면 클라이언트가 세부 구현을 몰라도 대체 가능한 객체와 협력할 수 있습니다.
전략은 같은 목적을 수행하는 여러 구현 가운데 실행할 하나를 객체로 전달하는 방식입니다.
다음 최소 예제에서 Notifier가 역할이고 ConsoleNotifier가 전략 구현입니다.
interface Notifier {
void send(String message);
}
class ConsoleNotifier implements Notifier {
public void send(String message) {
System.out.println(message);
}
}
Notifier notifier = new ConsoleNotifier();
notifier.send("게시글 공개");사용하는 쪽은 Notifier의 send만 알고 실제 출력 방식은 구현 객체가 결정합니다.
전략 패턴은 이 교체 지점을 의도적으로 설계한 형태이며 ch8-3에서 생성자 주입과 함께 완성합니다.
구체 타입과 구현 제한
public final class ConcreteNotifierDependency {
public static void main(String[] args) {
BoardService service = new BoardService(new SilentNotifier());
service.publish("가입 인사");
}
private static final class BoardService {
private final ConsoleNotifier notifier;
BoardService(ConsoleNotifier notifier) {
this.notifier = notifier;
}
void publish(String title) {
notifier.notify(title);
}
}
private static final class ConsoleNotifier {
void notify(String title) {
System.out.println("console=" + title);
}
}
private static final class SilentNotifier {
void notify(String title) {
System.out.println("silent");
}
}
}error: incompatible types: SilentNotifier cannot be converted to ConsoleNotifier두 알림 클래스에 우연히 같은 notify 메서드가 있어도 자바 타입 관계는 없습니다.
BoardService 생성자가 ConsoleNotifier라는 구현 타입을 요구하므로 SilentNotifier를 대체할 수 없습니다.
새 알림을 추가할 때 서비스 필드·생성자·호출 코드를 바꾸거나 타입별 분기를 넣게 됩니다.
공통 역할을 인터페이스로 정의하면 클라이언트가 구현 대신 계약에 의존합니다.
interface PostNotifier {
void notify(String title);
}ConsoleNotifier와 SilentNotifier가 역할을 구현하고 BoardService가 PostNotifier를 받으면 둘을 교체할 수 있습니다.
역할의 메시지 계약
알림 역할을 만드는 기준은 구현 클래스의 공통 메서드를 기계적으로 추출하는 것이 아닙니다.
BoardService가 공개 사실을 전달하기 위해 어떤 메시지를 필요로 하는지에서 시작합니다.
public final class RoleBasedNotification {
public static void main(String[] args) {
BoardService console = new BoardService(new ConsoleNotifier());
BoardService silent = new BoardService(new SilentNotifier());
console.publish("가입 인사");
silent.publish("로그인 구현");
}
private interface PostNotifier {
void notifyPublished(String title);
}
private static final class BoardService {
private final PostNotifier notifier;
BoardService(PostNotifier notifier) {
this.notifier = notifier;
}
void publish(String title) {
System.out.println("published=" + title);
notifier.notifyPublished(title);
}
}
private static final class ConsoleNotifier implements PostNotifier {
@Override public void notifyPublished(String title) {
System.out.println("console=" + title);
}
}
private static final class SilentNotifier implements PostNotifier {
@Override public void notifyPublished(String title) {
System.out.println("silent");
}
}
}published=가입 인사
console=가입 인사
published=로그인 구현
silentBoardService는 알림이 콘솔인지 무음인지 알지 못합니다.
notifyPublished 역할만 호출합니다.
구현 선택은 main 같은 조립점이 담당합니다.
서비스의 공개 저장 순서는 알림 구현과 분리됩니다.
역할과 구현의 협력
운전자는 자동차 역할의 시동·가속·정지 방법을 알고, K3나 Model3 같은 구현 내부 엔진 구조를 알 필요가 없습니다.
게시글에서도 클라이언트는 저장소의 add, 내보내기의 export, 알림의 notify 역할을 사용하고 구체 형식이나 전송 방식을 구현에 맡깁니다.
클라이언트 → 역할 계약 ← 구현 A / 구현 B / 구현 C역할이 안정적이면 구현을 추가하거나 교체해도 클라이언트의 핵심 흐름을 유지할 수 있습니다.
역할 자체가 자주 바뀌면 모든 구현과 클라이언트가 함께 영향을 받으므로 인터페이스를 만드는 것만으로 안정성이 생기지는 않습니다.
의미를 지키는 대체 가능성
PostNotifier 구현은 다음 의미를 지켜야 합니다.
- 공개 알림 요청을 받아 처리합니다.
- 알림 방식이 달라도 BoardService의 저장 상태를 임의로 바꾸지 않습니다.
- 한 구현으로 교체해도 서비스가 기대하는 성공·실패 계약을 유지합니다.
메서드 서명만 같아도 의미가 다르면 대체할 수 없습니다.
예를 들어 notifyPublished가 어떤 구현에서는 프로그램을 종료하고 다른 구현에서는 단순 출력한다면 클라이언트의 기대가 깨집니다.
인터페이스 이름, 매개변수, 반환값, 예외 정책이 역할 의미를 함께 표현해야 합니다.
다형성은 구현을 숨기는 기술이지만 모든 차이를 지울 수는 없습니다.
특정 알림만 지원하는 예약 시간이나 재시도 정책이 필요하면 공통 역할을 비대하게 만들지 말고 별도 능력 인터페이스나 구성 객체로 분리합니다.
객체 생성과 사용의 분리
public final class BoardApplicationComposition {
public static void main(String[] args) {
Board board = new Board(2);
PostNotifier notifier = new ConsoleNotifier();
BoardApplication app = new BoardApplication(board, notifier);
app.publish("가입 인사", 40);
app.publish("로그인 구현", 50);
System.out.println(board.summary());
}
private static final class BoardApplication {
private final Board board;
private final PostNotifier notifier;
BoardApplication(Board board, PostNotifier notifier) {
this.board = board;
this.notifier = notifier;
}
void publish(String title, int viewCount) {
if (board.add(title, viewCount)) notifier.notifyPublished(title);
}
}
private interface PostNotifier { void notifyPublished(String title); }
private static final class ConsoleNotifier implements PostNotifier {
@Override public void notifyPublished(String title) {
System.out.println("published=" + title);
}
}
private static final class Board {
private final Post[] entries;
private int size;
Board(int capacity) { entries = new Post[capacity]; }
boolean add(String title, int viewCount) {
if (size == entries.length || title.isBlank() || viewCount <= 0) return false;
entries[size++] = new Post(title, viewCount);
return true;
}
String summary() {
int total = 0;
for (int index = 0; index < size; index++) {
total += entries[index].viewCount();
}
return "count=" + size + ", total=" + total;
}
}
private record Post(String title, int viewCount) {}
}published=가입 인사
published=로그인 구현
count=2, total=90main이 구체 ConsoleNotifier를 생성하지만 BoardApplication은 PostNotifier 역할만 압니다.
구현 생성 책임과 사용 책임을 분리하면 조립점에서 정책을 교체할 수 있습니다.
연습 문제
PostFormatter 역할과 TextFormatter, CsvFormatter 구현을 만들고 같은 print(PostFormatter) 함수에 전달하세요.
제목이 가입 인사이고 조회 수가 40인 게시글을 서로 다른 문자열로 표현합니다.
해설 보기
public final class FormatterRole {
public static void main(String[] args) {
Post entry = new Post("가입 인사", 40);
print(new TextFormatter(), entry);
print(new CsvFormatter(), entry);
}
private static void print(PostFormatter formatter, Post entry) {
System.out.println(formatter.format(entry));
}
private interface PostFormatter { String format(Post entry); }
private record Post(String title, int viewCount) {}
private static final class TextFormatter implements PostFormatter {
@Override public String format(Post entry) {
return entry.title() + "=" + entry.viewCount();
}
}
private static final class CsvFormatter implements PostFormatter {
@Override public String format(Post entry) {
return entry.title() + "," + entry.viewCount();
}
}
}가입 인사=40
가입 인사,40print는 역할만 사용하고 형식 차이는 구현이 책임집니다.
새 형식도 같은 계약으로 전달할 수 있습니다.