상속과 합성
📌 상속(Inheritance)이란?
기존의 클래스를 재사용하여 새로운 클래스를 작성하는 자바의 문법 요소를 말한다. 상속을 통해 우리는 객체지향 프로그래밍에서 다형성을 구현할 수 있으며, 다형성을 통해 재사용성 및 확장성을 높이고 중복된 코드를 제거하는 이점을 얻을 수 있다.
이처럼 상속은 좀 더 구체적인 클래스를 구현하기 위해 사용되는 기법이며, 그로 인해 부모 클래스의 코드를 자식 클래스가 재사용할 수 있다.
하지만 상속에 대해서 다음과 같은 견해가 있었다.
"내가 자바를 만들면서 가장 후회하는 일은 상속을 만든 점이다."
- James Arthur Gosling의 인터뷰
"상속을 위한 설계와 문서를 갖추거나 그럴 수 없다면 상속을 금지하라"
- Effective Java by 조슈아 블로크
"An object is dead if it allows other objects to inherit its encapsulated code and data"
- David West (Object Thinking 저자)의 인터뷰
그래서 상속에 어떤 문제들이 있는지 알아보고자 한다.
💥 상속의 단점
🚨 높은 결합도
결합도란 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 의존 정도를 말한다.
이러한 높은 결합도는 다음과 같은 단점들을 초래한다.
좋은 객체지향 설계의 원칙 중 의존 관계 역전 원칙이 있다. 프로그래머는 추상화에 의존하여 코드를 유연하게 변경할 수 있도록 해야 한다고 설명한다. 이것은 다형성만으로는 지켜지지 않는다.
그래서 프레임워크 중 스프링은 의존 관계 주입을 제공하는데, 이것은 런타임 시점에 해당 클래스가 의존하는 클래스가 결정되며, 인터페이스를 사용하면 그 대상을 유연하게 변경할 수 있다. 반면, 상속은 컴파일 시점에 관계가 결정된다.
따라서 유연성을 상당히 떨어뜨리고 의존 관계 주입과 같은 동작을 수행할 수 없어 객체지향 설계 원칙을 위배할 가능성이 있다.
🚨 캡슐화 위반
캡슐화는 객체의 속성과 행위를 하나로 묶고, 구현 내용을 접근 제어자를 통해 은닉하는 것을 말한다.
자식 클래스에서 발생한 문제를 해결하기 위해서 부모 클래스의 구현을 알아야 하는 경우가 생긴다. 이러한 불필요한 구현 내용 노출은 캡슐화를 깨트리는 행위다. 아래 코드는 의도한 대로 동작하지 않는다.
class CountingApp 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();
}
}
CountingApp ca = new CountingApp();
ca.addAll(List.of("one", "two", "three"));
데이터를 3개 넣었으므로 기대했던 결과는 addCount가 3이 되는 것이다. 하지만 add는 총 6번 수행된다. 이것은 HashSet의 내부를 들여다보면 알 수 있다.
...
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e)) //원인
modified = true;
return modified;
}
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
위 코드를 보면 부모 클래스의 내부 구조를 살펴보지 않은 것이 원인이다. c.size()를 통해 크기만큼 카운트를 올린 후 부모 클래스의 addAll()을 호출하여 add는 3번 더 동작한다. 따라서 총 6번 카운팅 되는 것이다.
여기서 문제점은 이러한 문제를 해결하려면 부모 클래스의 구현 내용을 알아야 하고, 이를 해결에 반영하면 그 내용을 노출할 가능성이 발생한다.
한편으로는 addAll()을 원하는 방식으로 오버라이딩하여 사용할 수도 있지만, 상속 관계에서 부모 클래스의 메서드 동작을 다시 구현하는 것은 시간도 많이 걸리며 오류 가능성을 높인다.
이렇게 상속은 자식 클래스가 부모 클래스에 강하게 결합되어 있기 때문에 변화에 유연하게 대처하기 어렵다.
🚨 부모 클래스의 결함은 자식 클래스에도 그대로 전달됨
🚨 부모 클래스와 자식 클래스의 동시 수정 문제
예를 들어 부모 클래스의 생성자를 수정하면 자식 클래스의 생성자도 수정해야 한다. 게다가 부모 클래스와 자식 클래스는 개념적으로 결합되어 있어 자식 클래스의 'is-a'에 적합한 내용인지 고려할 필요가 있다.
🚨 불필요한 인터페이스 상속 문제
자바의 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Properties와 java.util.Stack이 있다.
Stack의 사례를 살펴보자.
Stack은 LIFO 구조를 구현한 자료구조다. 그런데 Stack의 부모 인터페이스인 Vector는 add(index, element)라는 메서드를 갖고 있다. 상속 관계이기 때문에 이것을 Stack에서 사용할 수 있음을 의미한다. 하지만 이 메서드는 인덱스를 가진 메서드이므로, LIFO 구조에는 필요 없다. 즉, 불필요한 인터페이스의 상속으로 인해 사용할 필요가 없는 기능을 상속받았고, 이것은 의도치 않은 동작의 원인이 될 수 있다.
A more complete and consistent set of LIFO stack operations is provided by the Deque interface and its implementations, which should be used in preference to this class.
더욱 완전하고 일관된 LIFO 스택 작업은 Deque 인터페이스 및 해당 구현을 사용하여 구현하는 것이다.
ref: https://docs.oracle.com/javase/8/docs/api/
🚨 클래스 폭발(Class explosion)
상속은 특정 기능을 추가하기 위해 새로운 클래스를 생성한다. 이처럼 기능마다 추가를 위해 새로운 클래스를 만들게 되면 필요 이상으로 많은 수의 클래스를 추가하게 되는데, 이를 클래스 폭발(class explosion) 문제 또는 조합의 폭발(combinational explosion) 문제라고 부른다.
상속 관계는 컴파일 타임에 관계가 결정되기 때문에 코드를 실행하는 도중에는 의존성을 변경할 수 없다. 그래서 다양한 조합이 필요한 상황이 오면 그 조합만큼 새로운 클래스를 생성하고 상속받는 방법을 택해야 한다.
🚨 단일 상속의 한계
자바에서는 2개 이상의 상속을 허용하지 않는다. 그래서 이미 상속받은 클래스가 다른 클래스를 상속받아야 한다면 문제가 발생할 수 있다. 이를 위해 클래스를 나누다 보면 클래스 폭발 문제로 이어지게 된다.
📌 합성(Composition)이란?
합성 기법은 기존 클래스를 상속을 통해 확장하는 대신, 필드로 클래스의 인스턴스를 참조하게 만드는 설계를 말한다.
public class Student {
Subject subject;
Store(Subject subject) {
this.subject = subject;
}
void course() {
System.out.printf("%s과목 수강\n", subject.name);
}
}
class Subject {
String name;
Subject(String name) {
this.name = name;
}
}
Student student = new Student(new Subject("Java프로그래밍"));
student.course(); //Java프로그래밍과목 수강
위 예시의 Student(학생)과 Subject(과목)처럼 'Has-A' 관계로 볼 수 있는 수평적인 관계들을 합성 관계로 맺는다.
위 객체 생성 코드는 new 생성자에 new 생성자를 받는 형식을 사용했다. 이처럼 무조건 상속하지 않고, 따로 클래스 인스턴스 변수에 저장하여 가져다 쓰는 방식을 포워딩(forwarding)이라고 하며 필드의 인스턴스를 참조해 사용하는 메서드를 포워딩 메서드(forwarding method)라고 부른다.
이 외에도 추상 클래스, 인터페이스로도 합성을 이용할 수 있다.
📝 합성을 사용해야 하는 이유
합성은 구현에 의존하지 않아 결합도가 낮다.
합성을 이용하면 의존하는 객체의 내부를 알지 못해도 인터페이스를 통해 코드를 재사용할 수 있다. 이처럼 인터페이스라는 추상화에 의존하여 결합도를 낮출 수 있다.
반면, 상속은 extends를 덧붙이는 것만으로도 코드 재사용과 부모 클래스 확장을 쉽게 해결할 수 있다. 하지만 상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세히 알아야 하기 때문에 결합도가 높아질 수밖에 없다.
상속은 컴파일 타임에 관계가 결정되지만, 합성은 런타임에 관계가 결정되므로 동적으로 클래스 간의 관계를 맺을 수 있다. 다시 말해서 클래스에서 필요한 기능 조합이 다양해도 유연하게 대처할 수 있는 것이다. 이러한 대표적인 사례가 디자인 패턴 중 전략 패턴이 될 수 있다.
💡 전략 패턴(Strategy Pattern)
실행(런타임) 중에 알고리즘 전략을 선택하여 객체 동작을 실시간으로 바뀌도록 할 수 있게 하는 행위 디자인 패턴
여기서 '전략'이란 일종의 알고리즘이 될 수도 있으며, 기능이나 동작이 될 수도 있는 특정한 목표를 수행하기 위한 행동 계획을 말한다.
즉, 어떤 일을 수행하는 알고리즘이 여러 가지일 때, 동작들을 미리 전략으로 정의함으로써 손쉽게 전략을 교체할 수 있는 알고리즘 변형이 빈번하게 필요한 경우에 적합한 패턴이다.
합성은 변경에 유연하게 대처할 수 있다.
합성은 구현 클래스에 의존하지 않기 때문에 구현 내용이 변경되어도 영향을 최소화할 수 있고 변화에 유연하게 대처할 수 있다.
하지만 수평적인 관계이기 때문에 클래스 간의 관계를 파악하는데 시간이 걸릴 수 있고, 코드 가독성이 떨어질 수 있다는 단점이 존재한다.
❓ 상속은 언제 사용해야 하나
상속이 적절한 경우는 클래스의 행동을 확장하는 것이 아니라 정제(refine)할 때이다.
확장이란 새로운 행동을 덧붙여 기존의 행동을 부분적으로 보완하는 것을 의미하고, 정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미한다.
✅ 상속에 대한 고려사항
✍ 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황인가? (is-a 관계)
✍ 상속 대신 합성을 사용할 수는 없나?
다양한 기능 조합이 필요하다면 합성을 사용하는 것을 권장한다.
✍ 상속으로 인해 내부 구현을 불필요하게 노출하지는 않나?
오버라이딩에서 내부 구현을 알아야 하는 경우 내부 구현 노출이 발생하여 캡슐화를 깨트릴 수 있다.
✍ 확장하려는 클래스에 결함이 없는가? 있다면 결함이 전파되어도 괜찮은가?
상속은 결함까지도 상속하므로 신중하게 선택해야 한다.
상속은 객체 지향 초기에 재사용성을 위해 만들어졌지만, 현대의 시스템은 다양한 기능 조합이나 변화에 대한 대처와 같은 유연성이 중요한 키워드가 되었다.
따라서 개념적으로 수직 구조로 만들어야 하는 경우가 아니라면 합성을 사용하는 방향으로 무게가 실리는 것으로 파악된다.
References:
https://programmer-ririhan.tistory.com/408
https://hoons-dev.tistory.com/106