인터페이스로 역할과 구현 분리하기
구체 클래스를 직접 받는 코드가 왜 구현을 바꾸기 어렵게 만드는지 확인하고, 인터페이스로 역할을 정의해 게시글 알림 구현을 바꿔 끼우는 방법을 배웁니다.
게시판에 글이 공개되면 그 사실을 어딘가에 알려야 한다고 해 봅시다. 처음에는 콘솔에 한 줄 출력하는 것으로 충분하지만, 테스트할 때는 아무것도 출력하지 않는 알림이 필요하고, 나중에는 이메일이나 메신저로 보내는 알림이 추가될 수 있습니다. 게시글을 공개하는 과정은 그대로인데 알리는 방법만 계속 바뀌는 셈입니다. 이때 게시글 서비스가 특정 알림 클래스의 이름을 코드에 직접 적어 두면, 알림 방법을 바꿀 때마다 서비스 코드까지 고쳐야 합니다.
이 글에서는 먼저 그 문제를 컴파일 오류로 확인하고, 인터페이스로 해결합니다. 핵심은 사용하는 쪽이 기대하는 일인 역할(role)과, 그 일을 실제로 해내는 클래스인 구현(implementation)을 나누는 것입니다. 7장에서 부모 타입 변수에 자식 객체를 담았던 것처럼, 역할을 나타내는 인터페이스 타입 변수에 구현 객체를 담으면 됩니다.
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가 역할이고, new로 만든 ConsoleNotifier 객체가 구현입니다. notifier.send(...)를 호출하는 코드는 Notifier에 send가 있다는 것만 알고, 실제로 콘솔에 찍을지 다른 곳으로 보낼지는 오른쪽에 넣은 객체가 정합니다. 인터페이스의 메서드는 따로 적지 않아도 public이므로, 구현하는 send에도 public을 붙여야 합니다. 빼면 접근 범위를 좁힐 수 없다는 컴파일 오류가 납니다.
이 조각은 마지막 두 문장이 클래스 밖에 있어서 .java 파일로는 바로 실행할 수 없습니다. JDK에 들어 있는 대화형 실행 도구인 JShell(jshell 명령)을 열고 위에서부터 차례로 입력하면 실행해 볼 수 있습니다. 이 글에서 제목에 파일 경로가 붙은 나머지 예제는 모두 완전한 프로그램입니다. 파일 이름을 public 클래스 이름과 같게 저장하고 첫 장에서 익힌 javac와 java 명령으로 실행하면 됩니다. 한 파일로 실행할 수 있도록 필요한 클래스는 모두 바깥 클래스 안에 중첩해 두었습니다.
구체 클래스를 받는 서비스는 구현을 바꿀 수 없다
인터페이스나 추상 클래스와 달리 모든 메서드가 구현되어 있어 new로 바로 객체를 만들 수 있는 클래스를 구체 클래스(concrete class)라고 합니다. 다음 프로그램의 BoardService는 생성자에서 구체 클래스 ConsoleNotifier를 받는데, main은 그 자리에 SilentNotifier를 넣으려고 합니다. 두 알림 클래스에는 이름과 매개변수가 똑같은 notify 메서드가 있습니다. 컴파일이 될지 먼저 예상해 보세요.
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가 있어도, 자바는 메서드가 닮았다는 이유로 두 클래스를 같은 타입으로 취급하지 않습니다. 타입 사이의 관계는 extends나 implements로 선언해야만 생깁니다. SilentNotifier는 ConsoleNotifier를 상속하지도 않고 둘이 같은 인터페이스를 구현하지도 않으므로, ConsoleNotifier를 요구하는 생성자에 넣을 수 없습니다.
이 구조에서 알림을 바꾸려면 BoardService의 필드 타입과 생성자 매개변수 타입을 SilentNotifier로 고쳐야 합니다. 그러면 이번에는 ConsoleNotifier를 넣을 수 없게 됩니다. 두 알림을 모두 쓰려면 필드를 두 개 두고 if 문으로 골라야 하는데, 다음 글에서 보듯 이 방법은 구현이 늘어날수록 금방 복잡해집니다. 어느 쪽이든 새 알림을 추가하는 일이 기존 서비스를 고치는 일로 이어집니다.
SilentNotifier가 ConsoleNotifier를 상속하게 만드는 방법도 떠올릴 수 있습니다. ConsoleNotifier의 final을 빼고 SilentNotifier extends ConsoleNotifier로 바꾸면 실제로 컴파일되고 silent가 출력됩니다. 하지만 '무음 알림은 콘솔 알림의 한 종류다'라는 관계는 사실이 아닙니다. 나중에 ConsoleNotifier에 콘솔 전용 필드나 메서드가 생기면 SilentNotifier도 원치 않게 물려받고, 이메일 알림까지 콘솔 알림의 자식이 되어야 합니다. 상속은 'A는 B의 한 종류다'가 참일 때 쓰는 도구이고, 여기서 필요한 것은 '공개된 게시글을 알릴 수 있다'는 공통 역할입니다.
더 깊이: 메서드 모양이 같아도 타입이 맞지 않는 이유
자바는 명목적 타입(nominal typing)을 쓰는 언어입니다. 어떤 클래스가 다른 타입의 하위 타입이 되려면 선언에 그 타입의 이름을 extends나 implements로 적어야 합니다. 반면 TypeScript는 객체의 구조가 맞으면 같은 타입으로 받아 주는 구조적 타입(structural typing)을 쓰고, Go의 인터페이스도 메서드만 갖추면 따로 선언하지 않아도 충족됩니다. 자바 방식은 코드를 조금 더 쓰게 만들지만, 이름이 우연히 같은 메서드가 엉뚱한 자리에 끼어드는 일을 컴파일러가 막아 준다는 장점이 있습니다. 앞의 컴파일 오류가 바로 그 경우입니다.
인터페이스로 역할을 정의하기
해결 방법은 '공개된 게시글을 알린다'는 역할을 인터페이스로 정의하고, 서비스가 구체 클래스 대신 그 인터페이스를 받게 하는 것입니다. 다음 프로그램에서 PostNotifier가 역할이고 ConsoleNotifier와 SilentNotifier가 구현입니다. main은 서로 다른 알림을 넣은 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=로그인 구현
silentconsole.publish("가입 인사")를 호출하면 BoardService.publish가 먼저 published=가입 인사를 출력하고, 이어서 필드에 담긴 알림 객체에 notifyPublished를 요청합니다. 이 서비스에는 ConsoleNotifier가 들어 있으므로 둘째 줄 console=가입 인사가 나옵니다. silent.publish("로그인 구현")도 같은 순서로 실행되어 published=로그인 구현을 출력한 뒤, 이번에는 SilentNotifier가 silent를 출력합니다.
두 서비스는 같은 BoardService 클래스로 만든 객체이고, 다른 것은 생성자로 넣어 준 알림 객체뿐입니다. BoardService의 코드 어디에도 ConsoleNotifier나 SilentNotifier라는 이름이 없으므로, 새 알림 클래스를 만들어도 PostNotifier만 구현하면 서비스를 고치지 않고 넣을 수 있습니다. 어떤 구현을 쓸지는 main이 정합니다. 참고로 published=는 콘솔에 찍는 메시지일 뿐이고, 이 예제는 게시글을 어디에도 저장하지 않습니다. 저장과 알림을 함께 다루는 예제는 이 글의 뒷부분에 있습니다.
아래 그림은 앞의 컴파일 오류와 이 예제를 나란히 놓고, 서비스 생성자가 요구하는 타입에 따라 무엇이 달라지는지 비교합니다.
생성자 계약이 바꾸는 알림 구현의 대체 가능성
구체 타입 예제의 컴파일 실패와 별도 Console/Silent 서비스의 published 출력→notify 호출을 비교합니다. 실행되지 않은 경로를 유지하면서 역할 계약의 실제 호출 결과를 대응 단계로 볼 수 있습니다.
구체 타입을 요구하는 ConcreteNotifierDependency는 SilentNotifier 인수를 컴파일할 수 없다. 별도 RoleBasedNotification은 PostNotifier를 요구하여 두 구현을 받으며, console과 silent 서비스가 각기 원문 메시지를 출력한다.
역할 계약을 받으면 두 알림 구현을 교체할 수 있습니다
ConcreteNotifierDependency와 RoleBasedNotification은 서로 다른 프로그램입니다.
| 비교 대상 | 생성자가 요구하는 타입 | 호출과 결과 |
|---|---|---|
| 구체 타입 예제
| ConsoleNotifier | 컴파일 실패
|
| 역할 예제
| PostNotifier
| console.publish("가입 인사") |
| 역할 예제
| PostNotifier
| silent.publish("로그인 구현") |
- 구체 타입 예제 ·
SilentNotifier전달생성자는
ConsoleNotifier를 요구합니다. 컴파일 실패로publish는 실행되지 않습니다. - 역할 예제 ·
ConsoleNotifier전달생성자는
PostNotifier를 받으며,notifyPublished(String title)계약을 사용합니다.console.publish("가입 인사")published=가입 인사 console=가입 인사 - 역할 예제 ·
SilentNotifier전달생성자는
PostNotifier를 받으며,notifyPublished(String title)계약을 사용합니다.silent.publish("로그인 구현")published=로그인 구현 silent
역할 예제의 main이 두 서비스에 구현을 각각 전달하고 console, silent 순서로 호출합니다. 각 서비스는 먼저 published=를 출력한 뒤 역할에 요청합니다. 이 예제에는 게시글 저장 코드가 없습니다.
이 예의 조건
- 구체 타입 컴파일 실패와 역할 예제는 별도 프로그램이며 저장 코드가 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 구체 타입 예제는 ConsoleNotifier를 요구하는 자리에 SilentNotifier를 전달하므로 실행 전에 컴파일이 실패한다. 별도의 역할 예제에서 console과 silent 서비스는 각각 published 출력 뒤 주입된 PostNotifier에 요청한다. 두 서비스 사이 화살표는 없으며 저장 코드도 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 구체 타입 예제는 ConsoleNotifier를 요구하는 자리에 SilentNotifier를 전달하므로 실행 전에 컴파일이 실패한다. 별도의 역할 예제에서 console과 silent 서비스는 각각 published 출력 뒤 주입된 PostNotifier에 요청한다. 두 서비스 사이 화살표는 없으며 저장 코드도 없다. |
| 전체 읽기 정보 | 생성자 계약이 바꾸는 알림 구현의 대체 가능성 생성자 계약이 바꾸는 알림 구현의 대체 가능성 구체 타입을 요구하는 ConcreteNotifierDependency는 SilentNotifier 인수를 컴파일할 수 없다. 별도 RoleBasedNotification은 PostNotifier를 요구하여 두 구현을 받으며, console과 silent 서비스가 각기 원문 메시지를 출력한다. 역할 계약을 받으면 두 알림 구현을 교체할 수 있습니다 ConcreteNotifierDependency 와 RoleBasedNotification 은 서로 다른 프로그램입니다. 두 프로그램의 BoardService 생성자 계약과 원문 호출 결과 비교 대상 생성자가 요구하는 타입 호출과 결과 구체 타입 예제 SilentNotifier 전달 ConsoleNotifier 컴파일 실패 SilentNotifier 는 요구 타입으로 변환할 수 없습니다. publish 는 실행되지 않습니다. 역할 예제 ConsoleNotifier 전달 PostNotifier notifyPublished(String title) 계약 console.publish("가입 인사") published=가입 인사 console=가입 인사 역할 예제 SilentNotifier 전달 PostNotifier notifyPublished(String title) 계약 silent.publish("로그인 구현") published=로그인 구현 silent 구체 타입 예제 · SilentNotifier 전달 생성자는 ConsoleNotifier 를 요구합니다. 컴파일 실패로 publish 는 실행되지 않습니다. 역할 예제 · ConsoleNotifier 전달 생성자는 PostNotifier 를 받으며, notifyPublished(String title) 계약을 사용합니다. console.publish("가입 인사") published=가입 인사 console=가입 인사 역할 예제 · SilentNotifier 전달 생성자는 PostNotifier 를 받으며, notifyPublished(String title) 계약을 사용합니다. silent.publish("로그인 구현") published=로그인 구현 silent 역할 예제의 main 이 두 서비스에 구현을 각각 전달하고 console , silent 순서로 호출합니다. 각 서비스는 먼저 published= 를 출력한 뒤 역할에 요청합니다. 이 예제에는 게시글 저장 코드가 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 구체 타입 거절 ConsoleNotifier 요구에 SilentNotifier를 전달합니다. |
|
|
| Console 역할 서비스 PostNotifier 계약 서비스가 published 뒤 요청을 위임합니다. |
|
|
| Silent 역할 서비스 별도 서비스가 로그인 구현을 같은 역할에 요청합니다. |
|
|
생성자 계약이 바꾸는 알림 구현의 대체 가능성
구체 타입 예제의 컴파일 실패와 별도 Console/Silent 서비스의 published 출력→notify 호출을 비교합니다. 실행되지 않은 경로를 유지하면서 역할 계약의 실제 호출 결과를 대응 단계로 볼 수 있습니다.
구체 타입을 요구하는 ConcreteNotifierDependency는 SilentNotifier 인수를 컴파일할 수 없다. 별도 RoleBasedNotification은 PostNotifier를 요구하여 두 구현을 받으며, console과 silent 서비스가 각기 원문 메시지를 출력한다.
역할 계약을 받으면 두 알림 구현을 교체할 수 있습니다
ConcreteNotifierDependency와 RoleBasedNotification은 서로 다른 프로그램입니다.
| 비교 대상 | 생성자가 요구하는 타입 | 호출과 결과 |
|---|---|---|
| 구체 타입 예제
| ConsoleNotifier | 컴파일 실패
|
| 역할 예제
| PostNotifier
| console.publish("가입 인사") |
| 역할 예제
| PostNotifier
| silent.publish("로그인 구현") |
- 구체 타입 예제 ·
SilentNotifier전달생성자는
ConsoleNotifier를 요구합니다. 컴파일 실패로publish는 실행되지 않습니다. - 역할 예제 ·
ConsoleNotifier전달생성자는
PostNotifier를 받으며,notifyPublished(String title)계약을 사용합니다.console.publish("가입 인사")published=가입 인사 console=가입 인사 - 역할 예제 ·
SilentNotifier전달생성자는
PostNotifier를 받으며,notifyPublished(String title)계약을 사용합니다.silent.publish("로그인 구현")published=로그인 구현 silent
역할 예제의 main이 두 서비스에 구현을 각각 전달하고 console, silent 순서로 호출합니다. 각 서비스는 먼저 published=를 출력한 뒤 역할에 요청합니다. 이 예제에는 게시글 저장 코드가 없습니다.
이 예의 조건
- 구체 타입 컴파일 실패와 역할 예제는 별도 프로그램이며 저장 코드가 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 구체 타입 예제는 ConsoleNotifier를 요구하는 자리에 SilentNotifier를 전달하므로 실행 전에 컴파일이 실패한다. 별도의 역할 예제에서 console과 silent 서비스는 각각 published 출력 뒤 주입된 PostNotifier에 요청한다. 두 서비스 사이 화살표는 없으며 저장 코드도 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 구체 타입 예제는 ConsoleNotifier를 요구하는 자리에 SilentNotifier를 전달하므로 실행 전에 컴파일이 실패한다. 별도의 역할 예제에서 console과 silent 서비스는 각각 published 출력 뒤 주입된 PostNotifier에 요청한다. 두 서비스 사이 화살표는 없으며 저장 코드도 없다. |
| 전체 읽기 정보 | 생성자 계약이 바꾸는 알림 구현의 대체 가능성 생성자 계약이 바꾸는 알림 구현의 대체 가능성 구체 타입을 요구하는 ConcreteNotifierDependency는 SilentNotifier 인수를 컴파일할 수 없다. 별도 RoleBasedNotification은 PostNotifier를 요구하여 두 구현을 받으며, console과 silent 서비스가 각기 원문 메시지를 출력한다. 역할 계약을 받으면 두 알림 구현을 교체할 수 있습니다 ConcreteNotifierDependency 와 RoleBasedNotification 은 서로 다른 프로그램입니다. 두 프로그램의 BoardService 생성자 계약과 원문 호출 결과 비교 대상 생성자가 요구하는 타입 호출과 결과 구체 타입 예제 SilentNotifier 전달 ConsoleNotifier 컴파일 실패 SilentNotifier 는 요구 타입으로 변환할 수 없습니다. publish 는 실행되지 않습니다. 역할 예제 ConsoleNotifier 전달 PostNotifier notifyPublished(String title) 계약 console.publish("가입 인사") published=가입 인사 console=가입 인사 역할 예제 SilentNotifier 전달 PostNotifier notifyPublished(String title) 계약 silent.publish("로그인 구현") published=로그인 구현 silent 구체 타입 예제 · SilentNotifier 전달 생성자는 ConsoleNotifier 를 요구합니다. 컴파일 실패로 publish 는 실행되지 않습니다. 역할 예제 · ConsoleNotifier 전달 생성자는 PostNotifier 를 받으며, notifyPublished(String title) 계약을 사용합니다. console.publish("가입 인사") published=가입 인사 console=가입 인사 역할 예제 · SilentNotifier 전달 생성자는 PostNotifier 를 받으며, notifyPublished(String title) 계약을 사용합니다. silent.publish("로그인 구현") published=로그인 구현 silent 역할 예제의 main 이 두 서비스에 구현을 각각 전달하고 console , silent 순서로 호출합니다. 각 서비스는 먼저 published= 를 출력한 뒤 역할에 요청합니다. 이 예제에는 게시글 저장 코드가 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 구체 타입 거절 ConsoleNotifier 요구에 SilentNotifier를 전달합니다. |
|
|
| Console 역할 서비스 PostNotifier 계약 서비스가 published 뒤 요청을 위임합니다. |
|
|
| Silent 역할 서비스 별도 서비스가 로그인 구현을 같은 역할에 요청합니다. |
|
|
역할은 사용하는 쪽의 필요에서 정한다
역할과 구현의 관계는 운전자와 자동차에 빗대어 볼 수 있습니다. 운전자는 자동차의 시동, 가속, 정지 방법만 알면 K3에서 Model3로 차를 바꿔도 운전할 수 있고, 엔진이 휘발유 엔진인지 전기 모터인지는 몰라도 됩니다. 여기서 '자동차'가 역할이고 K3와 Model3가 구현입니다. 다만 이 비유가 맞지 않는 부분도 있습니다. 사람은 새 차의 낯선 점을 눈치채고 적응할 수 있지만, 프로그램의 클라이언트는 그러지 못하므로 구현이 달라도 역할이 약속한 대로 움직여야 합니다. 이 점은 다음 절에서 다시 다룹니다.
게시판에서도 마찬가지입니다. 역할을 사용하는 쪽을 클라이언트(client)라고 하는데, 게시판의 클라이언트는 저장소에 게시글을 추가하고, 내보내기 객체에 출력 형식을 맡기고, 알림 객체에 공개 사실을 알립니다. 클라이언트가 아는 것은 각 역할의 메서드뿐이고, 어떤 형식으로 바꾸는지나 어떤 방법으로 전송하는지는 구현이 정합니다.
클라이언트 → 역할(인터페이스) ← 구현 A / 구현 B / 구현 C화살표는 '알고 있다', 곧 코드에서 그 이름을 사용한다는 뜻입니다. 클라이언트는 역할만 알고, 구현들도 implements로 역할만 압니다. 클라이언트와 구현 사이에는 화살표가 없으므로 구현을 추가하거나 바꿔도 클라이언트의 코드는 그대로입니다.
그렇다면 역할의 메서드는 어떻게 정할까요? 구현 클래스들에 우연히 있는 공통 메서드를 뽑아내는 것이 아니라, 클라이언트가 보내야 하는 메시지에서 출발합니다. BoardService가 알리려는 것은 '게시글이 공개되었다'는 사실이므로 메서드 이름이 notifyPublished(String title)가 되었습니다. 이렇게 정하면 콘솔, 무음, 이메일 같은 구현 방식이 이름에 섞이지 않고, 나중에 보내는 방식이 바뀌어도 이름이 어색해지지 않습니다.
앞 예제의 notify라는 이름은 피하는 편이 좋은 이유가 하나 더 있습니다. 모든 객체는 스레드끼리 신호를 주고받는 Object의 notify()를 물려받는데, 매개변수가 있는 notify(String)은 그 메서드의 오버로딩이라 컴파일은 되지만 읽는 사람을 헷갈리게 합니다. 매개변수 없는 void notify()를 인터페이스에 선언하면 Object의 final 메서드를 재정의하려 한다는 컴파일 오류가 납니다.
역할은 구현보다 덜 바뀌어야 의미가 있습니다. 구현을 추가하거나 바꾸는 일은 클라이언트에 영향을 주지 않지만, 인터페이스의 메서드 이름이나 매개변수가 바뀌면 모든 구현과 모든 클라이언트를 함께 고쳐야 합니다. 그래서 인터페이스를 만들었다는 사실만으로 코드가 안정되지는 않습니다. 자주 바뀌지 않을 메시지를 골라 역할로 삼아야 분리한 효과가 납니다.
메서드 모양이 같아도 바꿔 쓸 수 없는 구현
인터페이스를 구현했다고 해서 언제나 안심하고 바꿔 끼울 수 있는 것은 아닙니다. 컴파일러가 확인하는 것은 메서드의 이름과 매개변수, 반환 타입까지이고, 그 메서드가 무슨 일을 하는지는 확인하지 않습니다. BoardService가 어떤 구현을 받든 같은 결과를 기대하려면, PostNotifier의 모든 구현이 다음과 같은 약속을 지켜야 합니다.
- 공개 알림 요청을 받으면 자기 방식대로 알리고 정상적으로 돌아옵니다.
- 알리는 방법이 달라도
BoardService나 게시글의 상태를 마음대로 바꾸지 않습니다. - 실패를 알리는 방법(예외를 던질지 말지)이 다른 구현과 같습니다.
다음 구현은 컴파일러가 아무 문제 없이 받아들이지만 첫 번째 약속을 어깁니다.
final class ExitingNotifier implements PostNotifier {
@Override
public void notifyPublished(String title) {
System.exit(1); // 알리지 않고 프로그램 전체를 끝낸다
}
}이 객체를 받은 BoardService는 게시글 하나를 공개하는 순간 프로그램 전체와 함께 멈춥니다. 메서드 모양은 같지만 의미가 다르므로 ConsoleNotifier 대신 쓸 수 없는 구현입니다. 이렇게 하위 타입은 상위 타입이 약속한 동작을 깨지 않고 그 자리에 대신 쓰일 수 있어야 한다는 원칙을 리스코프 치환 원칙(Liskov Substitution Principle)이라고 부릅니다. 그래서 역할을 설계할 때는 인터페이스 이름, 매개변수, 반환 타입, 던질 수 있는 예외를 모두 동원해 역할의 의미를 드러내고, 코드로 표현하기 어려운 약속은 문서 주석으로 적어 둡니다.
반대로 모든 구현에 공통이지 않은 기능은 역할에 넣지 않습니다. 예를 들어 이메일 알림만 예약 발송을 지원한다고 PostNotifier에 schedule(...) 메서드를 추가하면, 콘솔 알림과 무음 알림은 의미 없는 메서드를 억지로 구현해야 합니다. 이런 기능은 인터페이스 다중 구현에서 본 것처럼 별도의 작은 인터페이스로 나누거나, 예약 정책을 담은 객체를 따로 만들어 이메일 알림이 필드로 갖게 합니다. 이처럼 다른 객체를 필드로 가지고 협력하는 방식을 구성(composition)이라고 합니다.
객체를 만드는 곳과 쓰는 곳 나누기
지금까지는 main이 서비스에 알림 객체를 넣어 주었습니다. 이번에는 게시글을 실제로 배열에 저장하는 Board까지 더해서, 객체를 만드는 코드와 쓰는 코드가 각각 어디에 있는지 살펴봅니다. Board.add는 저장에 성공하면 true를 돌려주고, 배열이 가득 찼거나 제목이 비었거나 조회 수가 0 이하이면 저장하지 않고 false를 돌려줍니다. BoardApplication은 저장에 성공했을 때만 알림을 보냅니다.
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이 실행되는 동안 Board의 상태는 다음과 같이 바뀝니다. 두 번의 publish는 모두 저장 조건을 통과하므로, 매번 배열에 게시글을 넣고 size를 늘린 뒤 알림을 출력합니다.
| 실행한 코드 | add 결과 | 0번 칸 | 1번 칸 | size | 출력 |
|---|---|---|---|---|---|
app.publish("가입 인사", 40) | true | 가입 인사, 40 | null | 1 | published=가입 인사 |
app.publish("로그인 구현", 50) | true | 가입 인사, 40 | 로그인 구현, 50 | 2 | published=로그인 구현 |
board.summary() | 없음 | 그대로 | 그대로 | 2 | count=2, total=90 |
마지막 줄의 total=90은 summary가 채워진 두 칸의 조회 수 40과 50을 더한 값입니다. 이 프로그램에서 ConsoleNotifier라는 구체 클래스 이름을 아는 곳은 main뿐이고, BoardApplication은 PostNotifier만 압니다. 이렇게 객체를 만들고 서로 연결하는 코드를 모아 둔 곳을 조립점이라고 부릅니다. static 메서드를 배울 때 main을 조립점으로 썼던 것과 같은 구조입니다. 필요한 협력 객체를 밖에서 만들어 생성자로 넣어 주는 방식은 생성자 주입(constructor injection)이라고 하며, 이 장의 나머지 글에서도 계속 사용합니다. 알림 방법을 바꾸고 싶다면 main의 new ConsoleNotifier() 한 곳만 고치면 됩니다.
publish의 if 문은 저장과 알림의 순서도 정합니다. 저장에 실패하면 알림을 보내지 않으므로, summary 출력 전에 app.publish("추가 인사", 30)을 한 번 더 호출해도 출력은 달라지지 않습니다. 배열의 두 칸이 이미 차 있어 add가 false를 돌려주고, 배열에 대입하는 문장과 알림 호출을 모두 건너뛰기 때문입니다. 요약도 count=2, total=90 그대로입니다.
반대로 저장은 성공했는데 알림이 실패하면 어떻게 될까요? 알림 구현이 예외를 던져도 add는 이미 끝났으므로 배열에 들어간 게시글은 그대로 남습니다. 이 예제에는 저장을 되돌리거나 알림을 다시 시도하는 코드가 없습니다. 저장과 알림을 '둘 다 성공하거나 둘 다 없던 일로' 묶어야 한다면 그 정책은 따로 설계해야 하며, 실패를 다루는 방법은 12장에서 예외를 배우며 이어 갑니다. 아래 그림은 두 번의 정상 호출과 가득 찬 저장소에 보낸 세 번째 요청에서 저장과 알림이 어떤 순서로 일어나는지 보여 줍니다.
조립된 앱은 저장 성공 뒤에만 알림을 요청한다
Board.add 성공은 저장·size 증가 뒤 알림을 호출하고 거절은 알림을 건너뛰며 알림 예외도 앞선 저장을 되돌리지 않습니다. 실제 저장과 부수 효과의 전후 경계가 중요합니다.
BoardApplicationComposition의 main이 Board와 ConsoleNotifier를 생성하여 앱에 전달한다. 앱은 Board와 PostNotifier 필드로 협력하며, 실제 두 게시글을 저장한 뒤 각각 알림을 요청하고 최종 count=2, total=90을 출력한다.
배열 저장에 성공한 뒤 알림을 요청합니다
BoardApplicationComposition에서 main은 new Board(2)와 new ConsoleNotifier()를 만든 뒤 new BoardApplication(board, notifier)에 전달합니다.
앱의 필드 타입은 각각 Board와 PostNotifier입니다. publish는 board.add(title, viewCount)가 true를 반환한 뒤에만 notifier.notifyPublished(title)을 호출합니다.
| main의 호출 순서 | Board의 저장 | 알림 출력 |
|---|---|---|
app.publish("가입 인사", 40) | entries[0]에 제목 "가입 인사", 조회 수 40 저장 후 true 반환 | published=가입 인사 |
app.publish("로그인 구현", 50) | entries[1]에 제목 "로그인 구현", 조회 수 50 저장 후 true 반환 | published=로그인 구현 |
app.publish("가입 인사", 40)Board가entries[0]에 제목"가입 인사", 조회 수40을 저장하고true를 반환합니다.그 뒤
ConsoleNotifier가published=가입 인사를 출력합니다.app.publish("로그인 구현", 50)Board가entries[1]에 제목"로그인 구현", 조회 수50을 저장하고true를 반환합니다.그 뒤
ConsoleNotifier가published=로그인 구현을 출력합니다.
두 호출 뒤 main이 board.summary()의 결과 count=2, total=90을 출력합니다.
위 두 입력은 모두 저장에 성공합니다. add가 false이면 알림을 호출하지 않는다는 것은 소스의 if 조건이며, 이 main에서 거절 입력을 실행한 결과가 아닙니다.
이 예의 조건
- 알림 예외가 저장을 되돌리는 코드는 없다.
- notifyPublished는 저장 성공 뒤에만 호출된다.
- 원 main은 두 추가 호출로 count=2, total=90까지입니다. 마지막 장면은 본문의 확장 실습에서 같은 가득 찬 저장소에 추가 인사,30을 세 번째 요청하는 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 조건 통과 시 배열에 저장하고 true 반환 뒤 알림을 요청한다. 거절이면 대입과 알림 호출을 건너뛴다. 두 정상 호출의 count는 2, total은 90. 알림 예외는 이미 저장한 상태를 되돌리지 않는다. |
| 13 | 2회 저장: count=2 |
| 14 | total=90 |
| 16 | 가득 참 · 빈 제목 · 조회≤0 |
| 구분 | 내용 |
|---|---|
| 설명 | 조건 통과 시 배열에 저장하고 true 반환 뒤 알림을 요청한다. 거절이면 대입과 알림 호출을 건너뛴다. 두 정상 호출의 count는 2, total은 90. 알림 예외는 이미 저장한 상태를 되돌리지 않는다. |
| 전체 읽기 정보 | 조립된 앱은 저장 성공 뒤에만 알림을 요청한다 조립된 앱은 저장 성공 뒤에만 알림을 요청한다 BoardApplicationComposition의 main이 Board와 ConsoleNotifier를 생성하여 앱에 전달한다. 앱은 Board와 PostNotifier 필드로 협력하며, 실제 두 게시글을 저장한 뒤 각각 알림을 요청하고 최종 count=2, total=90을 출력한다. 배열 저장에 성공한 뒤 알림을 요청합니다 BoardApplicationComposition 에서 main 은 new Board(2) 와 new ConsoleNotifier() 를 만든 뒤 new BoardApplication(board, notifier) 에 전달합니다. 앱의 필드 타입은 각각 Board 와 PostNotifier 입니다. publish 는 board.add(title, viewCount) 가 true 를 반환한 뒤에만 notifier.notifyPublished(title) 을 호출합니다. 원문의 두 publish 호출에서 저장이 먼저 완료되고 알림이 이어지는 관계 main의 호출 순서 Board의 저장 알림 출력 app.publish("가입 인사", 40) entries[0] 에 제목 "가입 인사" , 조회 수 40 저장 후 true 반환 published=가입 인사 app.publish("로그인 구현", 50) entries[1] 에 제목 "로그인 구현" , 조회 수 50 저장 후 true 반환 published=로그인 구현 app.publish("가입 인사", 40) Board 가 entries[0] 에 제목 "가입 인사" , 조회 수 40 을 저장하고 true 를 반환합니다. 그 뒤 ConsoleNotifier 가 published=가입 인사 를 출력합니다. app.publish("로그인 구현", 50) Board 가 entries[1] 에 제목 "로그인 구현" , 조회 수 50 을 저장하고 true 를 반환합니다. 그 뒤 ConsoleNotifier 가 published=로그인 구현 을 출력합니다. 두 호출 뒤 main 이 board.summary() 의 결과 count=2, total=90 을 출력합니다. 위 두 입력은 모두 저장에 성공합니다. add 가 false 이면 알림을 호출하지 않는다는 것은 소스의 if 조건이며, 이 main 에서 거절 입력을 실행한 결과가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 저장 Board.add가 첫 슬롯에 조회 수가 40인 게시글을 저장합니다. |
|
|
| 첫 알림 성공을 확인한 application이 알림 역할을 호출합니다. |
|
|
| 둘째 성공 두 번째 저장과 알림이 같은 순서로 이어집니다. |
|
|
| 확장 실습의 세 번째 요청 본문 확장 실습에서 추가 인사,30을 같은 가득 찬 저장소에 세 번째로 요청합니다. |
|
|
조립된 앱은 저장 성공 뒤에만 알림을 요청한다
Board.add 성공은 저장·size 증가 뒤 알림을 호출하고 거절은 알림을 건너뛰며 알림 예외도 앞선 저장을 되돌리지 않습니다. 실제 저장과 부수 효과의 전후 경계가 중요합니다.
BoardApplicationComposition의 main이 Board와 ConsoleNotifier를 생성하여 앱에 전달한다. 앱은 Board와 PostNotifier 필드로 협력하며, 실제 두 게시글을 저장한 뒤 각각 알림을 요청하고 최종 count=2, total=90을 출력한다.
배열 저장에 성공한 뒤 알림을 요청합니다
BoardApplicationComposition에서 main은 new Board(2)와 new ConsoleNotifier()를 만든 뒤 new BoardApplication(board, notifier)에 전달합니다.
앱의 필드 타입은 각각 Board와 PostNotifier입니다. publish는 board.add(title, viewCount)가 true를 반환한 뒤에만 notifier.notifyPublished(title)을 호출합니다.
| main의 호출 순서 | Board의 저장 | 알림 출력 |
|---|---|---|
app.publish("가입 인사", 40) | entries[0]에 제목 "가입 인사", 조회 수 40 저장 후 true 반환 | published=가입 인사 |
app.publish("로그인 구현", 50) | entries[1]에 제목 "로그인 구현", 조회 수 50 저장 후 true 반환 | published=로그인 구현 |
app.publish("가입 인사", 40)Board가entries[0]에 제목"가입 인사", 조회 수40을 저장하고true를 반환합니다.그 뒤
ConsoleNotifier가published=가입 인사를 출력합니다.app.publish("로그인 구현", 50)Board가entries[1]에 제목"로그인 구현", 조회 수50을 저장하고true를 반환합니다.그 뒤
ConsoleNotifier가published=로그인 구현을 출력합니다.
두 호출 뒤 main이 board.summary()의 결과 count=2, total=90을 출력합니다.
위 두 입력은 모두 저장에 성공합니다. add가 false이면 알림을 호출하지 않는다는 것은 소스의 if 조건이며, 이 main에서 거절 입력을 실행한 결과가 아닙니다.
이 예의 조건
- 알림 예외가 저장을 되돌리는 코드는 없다.
- notifyPublished는 저장 성공 뒤에만 호출된다.
- 원 main은 두 추가 호출로 count=2, total=90까지입니다. 마지막 장면은 본문의 확장 실습에서 같은 가득 찬 저장소에 추가 인사,30을 세 번째 요청하는 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 조건 통과 시 배열에 저장하고 true 반환 뒤 알림을 요청한다. 거절이면 대입과 알림 호출을 건너뛴다. 두 정상 호출의 count는 2, total은 90. 알림 예외는 이미 저장한 상태를 되돌리지 않는다. |
| 13 | 2회 저장: count=2 |
| 14 | total=90 |
| 16 | 가득 참 · 빈 제목 · 조회≤0 |
| 구분 | 내용 |
|---|---|
| 설명 | 조건 통과 시 배열에 저장하고 true 반환 뒤 알림을 요청한다. 거절이면 대입과 알림 호출을 건너뛴다. 두 정상 호출의 count는 2, total은 90. 알림 예외는 이미 저장한 상태를 되돌리지 않는다. |
| 전체 읽기 정보 | 조립된 앱은 저장 성공 뒤에만 알림을 요청한다 조립된 앱은 저장 성공 뒤에만 알림을 요청한다 BoardApplicationComposition의 main이 Board와 ConsoleNotifier를 생성하여 앱에 전달한다. 앱은 Board와 PostNotifier 필드로 협력하며, 실제 두 게시글을 저장한 뒤 각각 알림을 요청하고 최종 count=2, total=90을 출력한다. 배열 저장에 성공한 뒤 알림을 요청합니다 BoardApplicationComposition 에서 main 은 new Board(2) 와 new ConsoleNotifier() 를 만든 뒤 new BoardApplication(board, notifier) 에 전달합니다. 앱의 필드 타입은 각각 Board 와 PostNotifier 입니다. publish 는 board.add(title, viewCount) 가 true 를 반환한 뒤에만 notifier.notifyPublished(title) 을 호출합니다. 원문의 두 publish 호출에서 저장이 먼저 완료되고 알림이 이어지는 관계 main의 호출 순서 Board의 저장 알림 출력 app.publish("가입 인사", 40) entries[0] 에 제목 "가입 인사" , 조회 수 40 저장 후 true 반환 published=가입 인사 app.publish("로그인 구현", 50) entries[1] 에 제목 "로그인 구현" , 조회 수 50 저장 후 true 반환 published=로그인 구현 app.publish("가입 인사", 40) Board 가 entries[0] 에 제목 "가입 인사" , 조회 수 40 을 저장하고 true 를 반환합니다. 그 뒤 ConsoleNotifier 가 published=가입 인사 를 출력합니다. app.publish("로그인 구현", 50) Board 가 entries[1] 에 제목 "로그인 구현" , 조회 수 50 을 저장하고 true 를 반환합니다. 그 뒤 ConsoleNotifier 가 published=로그인 구현 을 출력합니다. 두 호출 뒤 main 이 board.summary() 의 결과 count=2, total=90 을 출력합니다. 위 두 입력은 모두 저장에 성공합니다. add 가 false 이면 알림을 호출하지 않는다는 것은 소스의 if 조건이며, 이 main 에서 거절 입력을 실행한 결과가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 저장 Board.add가 첫 슬롯에 조회 수가 40인 게시글을 저장합니다. |
|
|
| 첫 알림 성공을 확인한 application이 알림 역할을 호출합니다. |
|
|
| 둘째 성공 두 번째 저장과 알림이 같은 순서로 이어집니다. |
|
|
| 확장 실습의 세 번째 요청 본문 확장 실습에서 추가 인사,30을 같은 가득 찬 저장소에 세 번째로 요청합니다. |
|
|
정리
- 역할은 클라이언트가 기대하는 메시지와 그 의미이고, 구현은 그 메시지를 실제로 처리하는 클래스입니다. 자바에서는 인터페이스로 역할을 표현합니다.
- 클라이언트가 구체 클래스를 필드나 매개변수 타입으로 쓰면, 같은 모양의 메서드를 가진 다른 클래스로도 바꿀 수 없습니다. 타입 관계는
extends나implements로 선언해야만 생깁니다. - 역할의 메서드는 구현들의 공통점이 아니라 클라이언트의 필요에서 정합니다. 메서드 모양뿐 아니라 동작의 의미까지 모든 구현이 지켜야 서로 바꿔 쓸 수 있습니다.
- 구체 클래스를 고르고 만드는 일은
main같은 조립점에 모으고, 그 객체를 쓰는 쪽은 생성자로 역할 타입을 받습니다.
연습 문제
문제 1: 같은 함수에 다른 형식 넘기기
PostFormatter 역할과 이를 구현하는 TextFormatter, CsvFormatter를 만드세요. print(PostFormatter formatter, Post entry) 함수 하나에 두 구현을 차례로 넘겨, 제목이 '가입 인사'이고 조회 수가 40인 게시글을 가입 인사=40과 가입 인사,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는 매개변수 타입이 PostFormatter이므로 어떤 형식인지 모른 채 format을 호출하고 결과를 출력하기만 합니다. 첫 호출에는 TextFormatter가 들어가 =로 이은 문자열이, 둘째 호출에는 CsvFormatter가 들어가 쉼표로 이은 문자열이 나옵니다. 새 형식이 필요하면 PostFormatter를 구현한 클래스를 하나 더 만들어 넘기면 되고, print는 고칠 필요가 없습니다.
문제 2: 가득 찬 게시판에 세 번째 글 공개하기
BoardApplicationComposition의 main에서 System.out.println(board.summary()); 바로 앞에 app.publish("추가 인사", 30);을 넣고 실행하면 무엇이 출력될까요? 또 new Board(2)를 new Board(3)으로 바꾸면 출력이 어떻게 달라질까요? 실행하기 전에 알림 줄의 개수와 count, total을 먼저 예상해 보세요.
해설 보기
new Board(2)일 때는 처음 두 줄의 알림과 count=2, total=90이 그대로 나오고, 세 번째 게시글에 대한 출력은 없습니다. 세 번째 호출에서 size가 2이고 배열 길이도 2이므로 add의 첫 조건 size == entries.length가 참이 되어 false를 돌려줍니다. 배열에 대입하는 문장까지 가지 않았고, publish의 if 조건이 거짓이니 알림도 호출되지 않습니다.
new Board(3)으로 바꾸면 세 번째 칸이 비어 있으므로 저장에 성공합니다. 출력은 다음과 같습니다.
published=가입 인사
published=로그인 구현
published=추가 인사
count=3, total=120문제 3: 알림을 먼저 보내면 생기는 일
누군가 BoardApplication.publish를 다음처럼 바꿨습니다. 문제 2처럼 new Board(2)인 게시판에 세 번째 글 "추가 인사", 30까지 공개하면 무엇이 출력되나요? 원래 코드와 비교해 무엇이 잘못되었는지 설명하세요.
void publish(String title, int viewCount) {
notifier.notifyPublished(title);
board.add(title, viewCount);
}해설 보기
published=가입 인사
published=로그인 구현
published=추가 인사
count=2, total=90바뀐 코드는 저장 결과와 상관없이 알림을 먼저 보냅니다. 세 번째 글은 배열이 가득 차 add가 false를 돌려주므로 저장되지 않았지만, 알림은 이미 published=추가 인사를 출력한 뒤입니다. 그래서 알림을 받은 사람은 공개되었다고 믿는데 게시판에는 글이 두 개뿐인 모순이 생깁니다. 원래 코드처럼 add의 결과를 확인한 뒤에 알려야 실패한 저장을 성공으로 알리는 일이 없습니다.
다음 글
다음 글에서는 역할을 나누지 않은 채 게시글 내보내기 형식을 하나씩 늘려 봅니다. 구현이 하나일 때는 단순하던 서비스에 필드와 if 문이 어떻게 쌓이는지 확인하면, 이 글에서 만든 역할이 왜 필요한지 더 분명해집니다.