Table of contents
상속이 자연스러워 보이는 순간
알림 시스템을 만든다고 하자. 이메일 알림이 기본이고, SMS 알림을 추가해야 한다. 가장 먼저 떠오르는 설계는 이렇다.
공통 로직을 부모 클래스에 두고, 각 채널은 하위 클래스로 구현한다.
public abstract class Notification {
protected String recipient;
protected String message;
public Notification(String recipient, String message) {
this.recipient = recipient;
this.message = message;
}
public void send() {
validate();
log();
doSend();
}
protected void validate() {
if (recipient == null || message == null) {
throw new IllegalArgumentException("수신자와 메시지는 필수");
}
}
protected void log() {
System.out.println("알림 전송: " + recipient);
}
protected abstract void doSend();
}
public class EmailNotification extends Notification {
public EmailNotification(String recipient, String message) {
super(recipient, message);
}
@Override
protected void doSend() {
// SMTP로 이메일 전송
}
}
public class SmsNotification extends Notification {
public SmsNotification(String recipient, String message) {
super(recipient, message);
}
@Override
protected void doSend() {
// SMS API 호출
}
}
깔끔해 보인다. 공통 로직(validate, log)은 부모에, 채널별 전송 로직은 자식에. 여기까지는 문제가 없다.
상속이 깨뜨리는 것들
깨지기 쉬운 기반 클래스 문제
Fragile Base Class Problem. 부모 클래스의 작은 수정이 자식 클래스를 예상치 못한 방식으로 깨뜨리는 현상이다.
알림 시스템에 재전송 기능이 추가되었다고 하자. 부모 클래스의 send() 메서드를 수정한다.
부모 클래스에 재전송 로직을 추가한다.
public abstract class Notification {
// ... 기존 필드, 생성자
public void send() {
validate();
log();
doSend();
}
public void sendWithRetry(int maxRetries) {
for (int i = 0; i <= maxRetries; i++) {
try {
send();
return;
} catch (Exception e) {
if (i == maxRetries) throw e;
// 재시도 대기
}
}
}
// ...
}
겉보기에는 안전한 변경이다. 하지만 SmsNotification이 이미 send()를 오버라이드하고 있었다면 어떻게 될까?
public class SmsNotification extends Notification {
@Override
public void send() {
// SMS는 validate 로직이 다르다
if (!recipient.matches("\\d{10,11}")) {
throw new IllegalArgumentException("유효하지 않은 번호");
}
doSend();
}
@Override
protected void doSend() {
// SMS API 호출
}
}
SmsNotification이 send()를 오버라이드했기 때문에 sendWithRetry()가 호출하는 send()는 자식의 것이다. 부모의 log()가 호출되지 않는다. 부모 클래스 개발자는 log()가 항상 실행될 거라 가정했지만, 자식 클래스가 send()를 재정의하면서 그 가정이 깨졌다.
이것이 깨지기 쉬운 기반 클래스 문제의 핵심이다. 부모의 구현을 자식이 알아야 하고, 부모가 바뀌면 자식이 깨진다. 상속은 캡슐화를 파괴한다.
조합의 폭발
요구사항이 복잡해지면 상속은 더 위험해진다. 알림 시스템에 다음 기능이 추가된다고 하자.
- 로깅 여부를 선택할 수 있어야 한다
- 암호화 전송이 가능해야 한다
- 예약 전송이 가능해야 한다
상속으로 해결하면 조합이 폭발한다. EncryptedEmailNotification, ScheduledSmsNotification, EncryptedScheduledEmailNotification — 채널 2개에 옵션 3개만으로도 경우의 수가 기하급수적으로 늘어난다.
flowchart TB
subgraph inheritance["상속 기반 — 조합 폭발"]
direction TB
BASE["Notification"]
EMAIL["EmailNotification"]
SMS["SmsNotification"]
EE["EncryptedEmail<br/>Notification"]
SE["ScheduledEmail<br/>Notification"]
ESE["EncryptedScheduled<br/>EmailNotification"]
ES["EncryptedSms<br/>Notification"]
SS["ScheduledSms<br/>Notification"]
ESS["EncryptedScheduled<br/>SmsNotification"]
BASE --> EMAIL
BASE --> SMS
EMAIL --> EE
EMAIL --> SE
EMAIL --> ESE
SMS --> ES
SMS --> SS
SMS --> ESS
end
style ESE fill:#ff6b6b,color:#fff
style ESS fill:#ff6b6b,color:#fff
8개 클래스. 옵션이 하나 더 추가되면 16개가 된다. 이 구조를 유지보수할 수 있는 사람은 없다.
다이아몬드 문제
Java는 다중 상속을 허용하지 않는다. 이유가 있다. A를 B와 C가 상속하고, D가 B와 C를 동시에 상속하면 A의 메서드를 D가 호출할 때 B의 것인지 C의 것인지 모호해진다. 이것이 다이아몬드 문제(Diamond Problem)다.
Java는 이를 원천 차단했지만, 그 대가로 이메일 전송 기능과 로깅 기능을 동시에 상속받는 것이 불가능하다. 상속은 단일 경로로만 확장되기 때문에, 횡단 관심사(cross-cutting concern)를 다루기에 구조적으로 불리하다.
조합으로 같은 기능을 달성하기
상속이 만드는 문제들을 조합(composition)이 어떻게 해결하는지 보자.
핵심 아이디어는 간단하다. 기능을 부모로부터 물려받는 대신, 부품으로 조립한다.
먼저 전송 채널을 인터페이스로 분리한다.
public interface MessageSender {
void send(String recipient, String message);
}
public class EmailSender implements MessageSender {
@Override
public void send(String recipient, String message) {
// SMTP 전송
}
}
public class SmsSender implements MessageSender {
@Override
public void send(String recipient, String message) {
// SMS API 호출
}
}
부가 기능도 인터페이스로 분리한다. 데코레이터 패턴을 적용한다.
MessageSender를 감싸서 기능을 추가하는 데코레이터다.
public class EncryptingSender implements MessageSender {
private final MessageSender delegate;
public EncryptingSender(MessageSender delegate) {
this.delegate = delegate;
}
@Override
public void send(String recipient, String message) {
String encrypted = encrypt(message);
delegate.send(recipient, encrypted);
}
private String encrypt(String message) {
// 암호화 로직
return "encrypted:" + message;
}
}
public class LoggingSender implements MessageSender {
private final MessageSender delegate;
public LoggingSender(MessageSender delegate) {
this.delegate = delegate;
}
@Override
public void send(String recipient, String message) {
System.out.println("전송 시작: " + recipient);
delegate.send(recipient, message);
System.out.println("전송 완료: " + recipient);
}
}
조립은 호출하는 쪽에서 한다.
// 이메일 + 로깅
MessageSender sender = new LoggingSender(new EmailSender());
// SMS + 암호화 + 로깅
MessageSender sender = new LoggingSender(new EncryptingSender(new SmsSender()));
// 이메일 + 암호화만
MessageSender sender = new EncryptingSender(new EmailSender());
8개의 클래스가 필요했던 상속 구조가, 4개의 클래스(2개 채널 + 2개 데코레이터)로 모든 조합을 표현한다. 새 기능이 추가되면 데코레이터 하나만 만들면 된다. 기존 코드를 수정할 필요가 없다. OCP와도 잘 맞는다.
flowchart LR
subgraph composition["조합 기반 — 부품 조립"]
direction LR
IF["MessageSender<br/>인터페이스"]
EMAIL2["EmailSender"]
SMS2["SmsSender"]
ENC["EncryptingSender<br/>데코레이터"]
LOG["LoggingSender<br/>데코레이터"]
EMAIL2 -->|"구현"| IF
SMS2 -->|"구현"| IF
ENC -->|"구현"| IF
LOG -->|"구현"| IF
ENC -->|"위임"| IF
LOG -->|"위임"| IF
end
style IF fill:#339af0,color:#fff
모든 클래스가 MessageSender 인터페이스에 의존한다. 데코레이터는 같은 인터페이스를 구현하면서 내부에 다른 MessageSender를 갖는다. 이 구조 덕분에 데코레이터를 자유롭게 겹칠 수 있다.
Kotlin의 by 위임
Kotlin은 조합을 언어 차원에서 지원한다. by 키워드를 사용하면 인터페이스 구현을 다른 객체에게 위임할 수 있다.
Java로 데코레이터를 만들면 인터페이스의 모든 메서드를 오버라이드해야 하는 번거로움이 있다. 메서드가 10개인 인터페이스라면 10개를 모두 위임 코드로 채워야 한다. Kotlin의 by는 이 보일러플레이트를 제거한다.
by를 사용한 데코레이터 구현이다.
interface MessageSender {
fun send(recipient: String, message: String)
fun validate(recipient: String): Boolean
fun getStatus(): String
}
class LoggingSender(
private val delegate: MessageSender
) : MessageSender by delegate {
// send만 오버라이드하고, 나머지는 delegate에게 자동 위임
override fun send(recipient: String, message: String) {
println("전송 시작: $recipient")
delegate.send(recipient, message)
println("전송 완료: $recipient")
}
}
validate()와 getStatus()는 명시적으로 구현하지 않아도 delegate에게 자동으로 위임된다. 코드가 간결해지면서도 조합의 유연함은 그대로다.
by를 활용하면 상속처럼 보이면서도 실제로는 조합으로 동작하는 코드를 만들 수 있다.
여러 인터페이스의 구현을 조합하는 예다.
interface Logger {
fun log(message: String)
}
interface Validator {
fun validate(input: String): Boolean
}
class ConsoleLogger : Logger {
override fun log(message: String) = println("[LOG] $message")
}
class RegexValidator : Validator {
override fun validate(input: String): Boolean = input.matches(Regex(".+@.+\\..+"))
}
// 두 인터페이스의 구현을 조합
class NotificationService(
logger: Logger,
validator: Validator
) : Logger by logger, Validator by validator {
fun notify(email: String, message: String) {
if (validate(email)) {
log("전송: $email")
// 실제 전송 로직
}
}
}
NotificationService는 Logger와 Validator를 상속받은 것이 아니다. 생성 시 주입받은 구현체에 위임하는 것이다. 언제든 ConsoleLogger를 FileLogger로, RegexValidator를 StrictValidator로 교체할 수 있다.
Java에서는 다중 상속이 불가능하지만, Kotlin의 by 위임으로는 여러 인터페이스의 구현을 하나의 클래스에서 자유롭게 조합할 수 있다. 이것이 상속보다 조합을 언어 차원에서 실현한 모습이다.
상속이 적절한 경우
상속보다 조합이 상속을 쓰지 말라는 뜻은 아니다. 상속이 올바른 선택인 경우가 분명히 있다.
is-a 관계가 진짜일 때
상속의 정당성은 is-a 관계의 진위에 달려 있다. Cat is an Animal — 진짜다. 고양이는 동물이다. ArrayList is a List — 진짜다. EmailNotification is a Notification — 이건 좀 생각해 봐야 한다.
is-a 관계를 판단하는 기준은 리스코프 치환 원칙(LSP)이다. 2편에서 다뤘던 내용이다. 부모 타입이 쓰이는 모든 곳에서 자식 타입으로 바꿔도 프로그램이 올바르게 동작하는가? 그렇다면 is-a 관계가 진짜인 것이고, 상속이 적절하다.
프레임워크가 상속을 요구할 때
현실적으로 프레임워크가 상속을 강제하는 경우가 있다. Android의 Activity, Spring의 AbstractRoutingDataSource 같은 것이 그 예다. 이런 경우에는 프레임워크의 설계를 따르는 게 맞다. 조합으로 바꾸려고 무리하면 오히려 코드가 복잡해진다.
구현 상속이 아닌 인터페이스 상속
Java의 interface나 Kotlin의 interface를 구현하는 것은 조합과 대립하지 않는다. 문제는 구현 상속(concrete class를 extends하는 것)이다. 인터페이스 상속은 타입 계약을 정의하는 것이지, 구현을 물려받는 게 아니다. 조합 기반 설계에서도 인터페이스는 핵심 도구다.
판단 기준 요약
상속과 조합을 선택하는 기준을 정리하면 다음과 같다.
// 상속이 적절한 경우 — 진짜 is-a, LSP 충족
abstract class Shape {
abstract fun area(): Double
}
class Circle(private val radius: Double) : Shape() {
override fun area(): Double = Math.PI * radius * radius
}
class Rectangle(private val width: Double, private val height: Double) : Shape() {
override fun area(): Double = width * height
}
// 조합이 적절한 경우 — has-a, 기능 조립
class NotificationService(
private val sender: MessageSender, // has-a
private val logger: Logger // has-a
) {
fun notify(recipient: String, message: String) {
logger.log("전송: $recipient")
sender.send(recipient, message)
}
}
Circle은 Shape다 — is-a 관계가 명확하다. NotificationService는 MessageSender를 가진다 — has-a 관계이므로 조합이 맞다.
상속을 쓰기 전에 한 가지만 자문하면 된다. “이 클래스가 부모 클래스의 모든 행위를 대체할 수 있는가?” 답이 아니오이거나 확신이 없다면, 조합을 먼저 고려해야 한다.
시리즈 정리
다섯 편에 걸쳐 객체지향 설계의 주요 원칙들을 살펴봤다.
| 편 | 원칙 | 핵심 |
|---|---|---|
| 1편 | SRP, OCP | 변경의 이유를 하나로, 확장에 열린 구조 |
| 2편 | LSP, ISP | 대체 가능한 다형성, 클라이언트 기준의 인터페이스 |
| 3편 | DIP | 의존의 방향을 추상화로 |
| 4편 | TDA, 디미터 법칙, CQS | 객체 간 대화의 규칙 |
| 5편 | Composition over Inheritance | 상속의 한계와 조합의 유연함 |
이 원칙들은 독립적으로 존재하지 않는다. SRP로 책임을 나누고, OCP로 확장 포인트를 만들고, LSP로 다형성의 정확성을 지키고, ISP로 인터페이스를 분리하고, DIP로 의존 방향을 잡는다. 그 위에서 TDA·디미터 법칙·CQS가 객체 간 메시지의 품질을 높이고, 조합이 유연한 구조의 기반이 된다.
원칙을 아는 것과 적용하는 것은 다른 문제다. 실제 코드에서 원칙들이 충돌하는 상황을 마주하고, 트레이드오프를 따져보며, 결국 더 나은 쪽을 선택하는 경험이 쌓여야 비로소 자기 것이 된다.




Loading comments...