객체지향 4대 요소 중 하나인 '상속'은 강력한 도구이지만, 잘못 사용하면 독이 될 수 있다.
우아한테크 테코톡의 '로키'님 발표를 바탕으로 왜 상속보다 조합을 권장하는지 정리해 보았다.
코드 재사용: 부모 클래스의 기능을 그대로 물려받아 빠르게 개발할 수 있다.
다형성: 부모 타입으로 자식 객체를 다룰 수 있어 확장에 유리하다.

상속은 부모 클래스의 내부 구현이 자식 클래스에게 노출된다.
이로 인해 부모와 자식이 강하게 결합(Strong Coupling)된다.
부모 클래스가 조금만 바뀌어도 자식 클래스가 오작동할 수 있다.
부모 클래스의 결함이 자식 클래스에게 그대로 전수된다.
public class MyHashSet<E> extends HashSet<E> {
private int addCount = 0; // 추가된 원소의 개수를 세는 변수
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
HashSet을 상속받아 추가된 원소의 개수를 세는 CounterSet을 만들었을 때, addAll() 메서드를 호출하면 예상치 못한 결과가 나올 수 있다.
원인: HashSet의 addAll() 내부에서 각 원소를 추가할 때 자기 자신의 add()를 호출하기 때문이다.
자식 클래스에서 add()를 오버라이딩했다면, addAll() 호출 시 카운트가 중복으로 올라가는 문제가 발생한다.
public class RokiSetWrapper<E> {
private final HashSet<E> set = new HashSet<>(); // 상속 대신 필드로 가짐
private int addCount = 0;
public void add(E e) {
addCount++;
set.add(e);
}
public void addAll(Collection<? extends E> c) {
addCount += c.size();
set.addAll(c); // HashSet 내부에서 무슨 짓을 하든 내 카운트엔 영향 없음!
}
public int getAddCount() {
return addCount;
}
}
조합은 기존 클래스를 상속하는 대신, 새로운 클래스의 프라이빗 필드로 기존 클래스의 인스턴스를 참조하는 방식이다.
🏗️ 조합의 구조 (Has-a 관계)
새로운 클래스는 기존 클래스의 메서드를 호출하여 결과를 반환하는 '전달(Forwarding)' 방식을 사용한다.
유연성: 기존 클래스의 내부 구현이 바뀌어도 새로운 클래스는 영향을 받지 않는다.
캡슐화 유지: 메서드 호출을 통해서만 소통하므로 내부가 노출되지 않는다.
독립성: 상속처럼 부모의 결함을 강제로 물려받지 않아도 된다.
무조건 상속이 나쁜 것은 아닙니다. 다음 조건을 만족할 때 사용을 고려해야 한다.
완벽한 IS-A 관계일 때: 자식이 부모의 진정한 하위 타입인 경우.
확장을 목적으로 설계된 경우: 부모 클래스가 상속을 고려하여 설계되고 문서화가 잘 되어 있을 때.
같은 패키지 내 관리: 부모와 자식을 동일한 개발자가 통제할 수 있는 범위에 있을 때.
우리가 수업 도중 구현한 WiseSayingService가 WiseSayingRepository를 필드로 가지고 있는 구조가 바로 조합(Composition)이라고 할 수 있다!
Service는 Repository를 상속받지 않고, 단순히 도구로써 '포함'하여 사용한다.
덕분에 Repository가 파일 저장 방식에서 DB 저장 방식으로 바뀌어도 Service의 코드는 크게 흔들리지 않는 유연한 구조를 가질 수 있게 된다.
"상속은 클래스 간의 관계를 고정시키지만, 조합은 관계를 유연하게 만든다.
때문에 확신이 없다면 조합을 사용하자!"
참고 자료: 우아한테크 - [10분 테코톡] 로키의 상속보다는 Composition
👍👍👍깔끔스무스해요!