
상속 vs 조합은 객체지향 설계에서 가장 자주 나오는 비교 주제 중 하나입니다. 이번 글에서는 inheritance와 composition의 차이를 알림 발송 구조 예시, 실제 코드, 다이어그램으로 자세히 설명하면서 어떤 구조가 변경에 더 잘 버티는지 정리해보겠습니다.
상속 vs 조합에서 inheritance가 먼저 떠오르는 이유
상속은 공통 코드를 부모 클래스에 모으고 자식 클래스에서 차이만 구현할 수 있기 때문에 처음에는 매우 매력적으로 보입니다. 비슷한 기능을 한 줄 계층으로 정리하는 느낌을 주기 때문입니다.
상속 구조는 처음엔 왜 이렇게 편해 보일까
예시 도메인은 알림 발송 구조입니다. 이메일, SMS, 푸시 알림이 있고, 모든 알림은 제목과 본문을 갖는다고 가정하면 상속 구조는 아주 자연스럽게 보입니다.
abstract class Notification {
protected String title;
protected String message;
public Notification(String title, String message) {
this.title = title;
this.message = message;
}
public void validate() {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title is required");
}
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("message is required");
}
}
public abstract void send();
}
class EmailNotification extends Notification {
private final String email;
public EmailNotification(String title, String message, String email) {
super(title, message);
this.email = email;
}
@Override
public void send() {
validate();
System.out.println("Send email to " + email);
}
}
class SmsNotification extends Notification {
private final String phoneNumber;
public SmsNotification(String title, String message, String phoneNumber) {
super(title, message);
this.phoneNumber = phoneNumber;
}
@Override
public void send() {
validate();
System.out.println("Send sms to " + phoneNumber);
}
}
class PushNotification extends Notification {
private final String deviceToken;
public PushNotification(String title, String message, String deviceToken) {
super(title, message);
this.deviceToken = deviceToken;
}
@Override
public void send() {
validate();
System.out.println("Send push to " + deviceToken);
}
}이 구조는 처음 보면 꽤 좋아 보입니다. 제목과 본문 검증은 부모에 한 번만 두고, 자식은 채널별 전송 방식만 구현하면 되기 때문입니다. 즉, inheritance가 매력적인 이유는 비슷한 것들을 한 줄로 세워서 관리하는 느낌을 주기 때문입니다.

문제는 요구사항이 늘어날 때 시작된다
- 어떤 알림은 이메일과 푸시를 동시에 보내야 한다
- 특정 알림은 실패하면 재시도해야 한다
- 야간에는 SMS를 막고 푸시만 보내야 한다
- VIP 사용자 알림은 감사 로그를 남겨야 한다
- 어떤 알림은 제목 없이 메시지만 보내야 한다
이제부터는 시스템이 단순히 이메일인가 SMS인가만으로 설명되지 않습니다. 채널 외에도 재시도 정책, 로깅 정책, 시간대 정책, 메시지 포맷 정책 같은 여러 축이 등장합니다. 이 순간부터 상속 구조는 점점 답답해집니다.
상속 구조가 실제로 어디서 답답해질까
예를 들어 재시도 가능한 이메일 알림과 재시도 가능한 푸시 알림이 필요해지면 RetryableEmailNotification, RetryablePushNotification 같은 클래스가 생기기 시작합니다. 여기에 감사 로그까지 붙으면 LoggedRetryablePushNotification 같은 식으로 클래스 조합이 폭발하기 쉽습니다.
즉, 기능 조합 하나가 클래스 하나가 되는 구조입니다. inheritance는 종류 분류에는 강하지만 기능 합성에는 자주 불리합니다.
inheritance가 불편해지는 핵심 이유
- 부모 변경이 자식 전체에 퍼진다
- 원하지 않는 기능까지 함께 물려받는다
- 기능 조합에 약하다
예를 들어 부모 클래스의 검증 규칙이 일부 채널에는 맞지 않게 되면, 처음에는 공통이라고 생각했던 규칙이 오히려 자식을 제한하는 전제가 됩니다. 즉, 부모는 자식을 편하게 해주는 기반이 아니라 자식을 불편하게 만드는 제약이 될 수 있습니다.
변경 영향 비교: 왜 상속은 흔들리고 조합은 버티는가
상속과 조합의 차이는 클래스 수보다 변경이 어디까지 퍼지는가에서 더 분명하게 드러납니다. 예를 들어 푸시에만 재시도를 붙이려 할 때, 상속 구조에서는 새 하위 클래스를 만들어야 할지, 기존 계층을 다시 나눠야 할지부터 고민하게 됩니다. 반면 조합 구조에서는 재시도 정책 객체만 교체하면 되는 경우가 많습니다.

즉, inheritance와 composition의 차이는 문법이 아니라 변경을 흡수하는 방식의 차이입니다.
조합 구조로 다시 생각해보자
같은 문제를 composition으로 다시 보면 핵심 아이디어는 단순합니다. 알림 시스템을 하나의 큰 계층으로 만들지 말고, 역할을 나누고 필요한 기능을 조합하는 것입니다. 즉, 채널, 재시도 정책, 로깅 정책을 각각 독립적인 역할로 분리합니다.
interface NotificationChannel {
void send(String title, String message, String target);
}
class EmailChannel implements NotificationChannel {
@Override
public void send(String title, String message, String target) {
System.out.println("Send EMAIL to " + target + " / title=" + title);
}
}
class SmsChannel implements NotificationChannel {
@Override
public void send(String title, String message, String target) {
System.out.println("Send SMS to " + target + " / message=" + message);
}
}
class PushChannel implements NotificationChannel {
@Override
public void send(String title, String message, String target) {
System.out.println("Send PUSH to " + target + " / message=" + message);
}
}
interface RetryPolicy {
void execute(Runnable action);
}
class NoRetryPolicy implements RetryPolicy {
@Override
public void execute(Runnable action) {
action.run();
}
}
class FixedRetryPolicy implements RetryPolicy {
private final int maxRetry;
public FixedRetryPolicy(int maxRetry) {
this.maxRetry = maxRetry;
}
@Override
public void execute(Runnable action) {
for (int i = 0; i < maxRetry; i++) {
try {
action.run();
return;
} catch (RuntimeException e) {
if (i == maxRetry - 1) {
throw e;
}
}
}
}
}
interface NotificationLogger {
void log(String channelType, String target, String message);
}
class ConsoleNotificationLogger implements NotificationLogger {
@Override
public void log(String channelType, String target, String message) {
System.out.println("LOG :: channel=" + channelType + ", target=" + target + ", message=" + message);
}
}
class Notifier {
private final NotificationChannel channel;
private final RetryPolicy retryPolicy;
private final NotificationLogger logger;
public Notifier(
NotificationChannel channel,
RetryPolicy retryPolicy,
NotificationLogger logger
) {
this.channel = channel;
this.retryPolicy = retryPolicy;
this.logger = logger;
}
public void notify(String title, String message, String target) {
logger.log(channel.getClass().getSimpleName(), target, message);
retryPolicy.execute(() -> channel.send(title, message, target));
}
}이 구조에서 중요한 것은 Notifier가 더 이상 구체 채널 종류가 아니라, 채널, 재시도 정책, 로거를 조합하는 객체라는 점입니다. composition은 무엇의 자식인가보다 무엇을 가지고 협력하는가에 더 집중합니다.

조합 구조에서는 무엇이 쉬워질까
예를 들어 푸시에만 재시도를 붙이고 싶다면 PushChannel은 그대로 두고 RetryPolicy만 바꾸면 됩니다.
Notifier pushNotifier = new Notifier(
new PushChannel(),
new FixedRetryPolicy(3),
new ConsoleNotificationLogger()
);이건 새로운 계층을 만든 것이 아니라, 기존 채널에 정책을 붙인 것입니다. 즉, 기능을 계층으로 만들지 않고 조합으로 부착합니다.
이메일과 푸시를 동시에 보내고 싶다면 복수 채널 조합체를 추가하면 됩니다.
class MultiChannelNotifier {
private final List<Notifier> notifiers;
public MultiChannelNotifier(List<Notifier> notifiers) {
this.notifiers = notifiers;
}
public void notifyAllChannels(String title, String message, String target) {
for (Notifier notifier : notifiers) {
notifier.notify(title, message, target);
}
}
}
MultiChannelNotifier notifier = new MultiChannelNotifier(List.of(
new Notifier(new EmailChannel(), new NoRetryPolicy(), new ConsoleNotificationLogger()),
new Notifier(new PushChannel(), new FixedRetryPolicy(3), new ConsoleNotificationLogger())
));여기서 중요한 것은 클래스 계층이 늘어나지 않았다는 점입니다. composition에서는 필요한 역할을 가진 객체를 묶기만 하면 됩니다.
로그 정책만 바꾸고 싶어도 채널이나 재시도 정책을 건드릴 필요가 없습니다.
class AuditNotificationLogger implements NotificationLogger {
@Override
public void log(String channelType, String target, String message) {
System.out.println("AUDIT :: channel=" + channelType + ", target=" + target);
}
}
Notifier vipPushNotifier = new Notifier(
new PushChannel(),
new FixedRetryPolicy(3),
new AuditNotificationLogger()
);이 예시는 composition의 장점을 더 잘 보여줍니다. 로깅 정책이 바뀌었다고 해서 채널 클래스나 재시도 정책까지 흔들리지 않습니다.
composition이 더 유연하다고 하는 이유
- 필요한 기능만 붙일 수 있다
- 변경 전파를 줄이기 쉽다
- 기능 추가가 클래스 폭발로 이어지지 않는다
상속은 부모가 준 구조 안에서 확장하고, 조합은 필요한 부품을 붙여서 구성합니다. 이 차이 때문에 기능 축이 늘어나고 요구사항이 섞일수록 composition이 더 유리해지는 경우가 많습니다.
그렇다고 상속이 항상 나쁜 건 아니다
- is-a 관계가 정말 분명할 때
- 부모의 규칙이 매우 안정적일 때
- 확장 방향이 제한적일 때
예를 들어 템플릿 메서드 패턴처럼 전체 흐름은 고정하고 일부 단계만 바꾸는 구조에서는 상속이 여전히 유용할 수 있습니다. 즉, 상속은 나쁜 도구가 아니라 강한 관계를 표현하는 도구입니다.
실무에서는 어떤 기준으로 고르면 좋을까
- 이 관계는 정말 is-a 관계인가
- 기능 조합 요구가 자주 생기는가
- 부모 변경이 자식 전체를 흔들 가능성이 큰가
- 지금 필요한 것이 재사용인가 유연성인가
즉, inheritance와 composition은 무엇이 더 멋진가의 문제가 아니라 지금 내 구조가 무엇에 더 민감한가의 문제입니다.
이 글에서 꼭 기억하면 좋은 한 문장
상속은 관계를 고정하고, 조합은 역할을 조립한다. 이 문장을 기억하면 많은 판단이 쉬워집니다. 변경이 자주 생기고 기능 조합이 필요한 구조에서는 조합이 상속보다 더 잘 버티는 경우가 많습니다.
마무리
상속과 조합은 둘 다 객체지향의 도구입니다. 중요한 것은 한쪽을 교조적으로 옳다고 보는 것이 아니라, 어떤 구조가 지금 요구사항과 앞으로의 변경에 더 잘 맞는지 판단하는 것입니다.
상속은 처음 구조를 빠르게 잡을 때 꽤 편하고 매력적입니다. 하지만 기능 축이 늘어나고 변경이 자주 생기면 inheritance는 종종 부모-자식 관계 안에 너무 많은 책임을 억지로 담게 됩니다. 반대로 composition은 처음에는 조금 더 번거로워 보여도, 역할을 나누고 필요한 기능을 조합하는 방식이기 때문에 시스템이 자라날수록 더 유연하게 대응할 가능성이 큽니다.
이전 글인 객체지향 설계에서 결합도가 중요한 이유를 먼저 읽고 오면, 왜 상속보다 조합이 더 안전한 선택이 되는지 더 자연스럽게 연결해서 이해할 수 있습니다.