다형성을 학습하다가 Effective Java의 "상속보다는 컴포지션을 사용하라"는 문장이 떠올랐다.
그 과정에서 implements와 컴포지션의 관계가 머릿속에서 명확히 정리되지 않아 개념을 다시 정리해본다.
다형성이란
다형성(polymorphism)은 같은 메소드 호출이 실제 객체에 따라 다르게 동작하는 성질이다.
Payment payment = new KakaoPayment();
payment.pay(1000);
위 코드에서 변수 타입은 Payment지만, 실제 실행되는 것은 KakaoPayment.pay()다.
즉 다형성의 핵심은
- 부모 타입(인터페이스/상위 클래스)으로 객체를 다룰 수 있고
- 실제 어떤 구현이 실행될지는 런타임에 결정된다는 점이다.
왜 필요한가 - 결제 예시
다형성이 없는 코드
public class OrderService {
public void checkout(String type, int amount) {
if (type.equals("kakao")) {
new KakaoPayment().pay(amount);
} else if (type.equals("naver")) {
new NaverPayment().pay(amount);
}
// 결제 수단이 추가될 때마다 분기 추가
}
}
결제 수단이 늘어날 때마다 클라이언트 코드를 매번 수정해야 한다. if / switch가 코드 곳곳에 박힌다.
다형성을 적용한 뒤
public class OrderService {
private final Payment payment;
public OrderService(Payment payment) {
this.payment = payment;
}
public void checkout(int amount) {
payment.pay(amount); // 카카오든 네이버든 신경 안 씀
}
}
클라이언트는 Payment라는 추상 타입만 의존한다. OrderService는 내부 구현체를 알 필요가 없다.
추상화와의 관계
- 클라이언트는 Payment 인터페이스에만 의존한다.
- 구체적인 구현은 인터페이스 뒤로 캡슐화된다.
- 각 구현체의 내부 동작 방식은 서로 달라도 무방하다.
클라이언트는 구현 방식과 무관하게 payment.pay()만 호출한다.
런타임에 결정된다
다형성의 핵심은 어떤 구현체가 실행될지 컴파일 타임이 아니라 런타임에 결정된다는 점이다.
Payment payment = getPayment(userChoice);
payment.pay(1000);
payment의 타입은 Payment로 고정돼 있지만, 실제 호출되는 객체는 런타임에 들어온 값에 따라 다르다.
- KakaoPayment.pay()
- NaverPayment.pay()
이것이 동적 바인딩(dynamic binding)이다.
implements / 컴포지션 / DI
처음 던졌던 질문으로 돌아가면, implements와 컴포지션은 함께 쓰이는 개념이다.
1. 인터페이스 구현 (implements) — 타입 공유
public interface Payment {
void pay(int amount);
}
public class KakaoPayment implements Payment { ... }
public class NaverPayment implements Payment { ... }
KakaoPayment와 NaverPayment는 Payment라는 타입을 공유한다.
개념적으로 implements도 넓은 의미의 상속이다.
2. 컴포지션 (Composition) — 객체를 보유
public class OrderService {
private final Payment payment;
}
OrderService는 Payment를 상속하지 않고 객체를 필드로 가진다. is-a가 아닌 has-a 관계다.
3. DI (의존성 주입) — 외부에서 주입
@Service
public class OrderService {
private final Payment payment;
public OrderService(Payment payment) {
this.payment = payment;
}
}
OrderService가 직접 객체를 생성하지 않는다.
어떤 구현체를 넣을지는 외부(스프링 컨테이너)가 결정한다.
여러 개의 구현체가 동시에 존재한다면 전략 패턴(Strategy Pattern)을 쓸 수 있다.
인터페이스에 supports() 같은 메소드를 두고, 컨테이너가 모든 구현체를 List로 주입해주면, 런타임에 조건에 맞는 구현체를 골라서 호출한다.
public interface Payment {
boolean supports(PaymentType type);
void pay(int amount);
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final List<Payment> payments; // 모든 구현체가 주입됨
public void checkout(PaymentType type, int amount) {
payments.stream()
.filter(p -> p.supports(type))
.findFirst()
.orElseThrow()
.pay(amount);
}
}
새 결제 수단이 추가돼도 @Service만 붙이면 자동으로 List에 포함된다.
"상속보다 컴포지션"의 의미
처음 이 문장을 봤을 때 의문이 들었다.
implements도 상속의 일종인데, 그러면 implements도 쓰지 말라는 뜻일까?
그것은 아니다.
여기서 말하는 “상속”은 대부분 클래스 상속(extends)을 의미한다.
- 인터페이스 상속(implements) — 타입 상속이라 권장된다.
- 클래스 상속(extends) — 구현 상속이라 강한 결합 발생 가능성이 있다.
대표적인 사례가 Stack이다.
Vector를 extends한 Stack
자바 표준 라이브러리의 Stack은 Vector를 상속한다.
public class Stack<E> extends Vector<E> { ... }
이것의 단점이 있다.
1. 의도하지 않은 동작까지 상속됨
Vector의 모든 메소드는 synchronized로 선언되어 있다. thread-safe를 보장하기 위해서다.
문제는 Stack이 Vector를 extends하는 순간, Stack도 자동으로 모든 메소드가 synchronized가 된다는 것이다.
- 새로 정의한 push(), pop()도 synchronized
- 부모(Vector)에서 내려온 get(), size()도 synchronized
Stack을 쓸 때 락이 필요 없는 상황에서도 무조건 모니터 락이 걸린다.
void doSomething() {
Stack<Integer> stack = new Stack<>(); // 지역 변수, 한 스레드만 접근
stack.push(1);
stack.pop();
// 메소드 끝나면 사라짐 — 동기화가 전혀 필요 없는데도 락 오버헤드 발생
}
2. 추상화가 깨진다
Stack은 LIFO(Last-In-First-Out)라는 약속을 가진 자료구조다.
그런데 Vector를 상속하면서 Vector의 임의 위치 접근 메소드까지 그대로 노출된다.
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.remove(0); // 가장 아래(1)를 제거 — LIFO 위반
stack.insertElementAt(99, 0); // 중간에 끼워넣기 — LIFO 위반
stack.get(1); // 임의 위치 조회 — LIFO 위반
자식이 원하지 않아도 부모의 모든 public 메소드는 그대로 자식의 API가 되는 단점이 있다.
이것이 "상속보다 컴포지션"이라는 말이 나온 이유다.
정리
- 다형성은 같은 호출이 객체에 따라 다르게 동작하는 성질이다.
- 보통 인터페이스(implements)로 공통 타입을 만들고, 클라이언트는 구현체 대신 추상 타입에 의존한다.
- 컴포지션은 객체를 상속(extends)하지 않고 필드로 조합하는 방식이다.
- DI는 객체 생성 책임을 외부로 분리하는 것이다.
- "상속보다 컴포지션"은 extends를 두고 한 말이다. implements는 타입만 공유하지만, extends는 부모의 API와 동작까지 전부 따라오기 때문이다.
'Java' 카테고리의 다른 글
| [Java] Arrays.copyOf가 얕은 복사인 이유 — 어셈블리까지 내려가기 (0) | 2026.05.19 |
|---|---|
| [Java] enum - 왜 안전한가, private 생성자로 강등되는 이유 (0) | 2026.05.14 |
| [Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작 (2) | 2026.05.12 |
| [Java] synchronized 동작 원리 - 모니터 락, CAS (0) | 2026.05.12 |