상속과 합성의 개념
- 상속(Inheritance)
- 부모 클래스와 자식 클래스 사이의 의존성은 컴파일 타임에 해결
- is-a 관계
- 부모클래스의 구현에 의존 결합도가 높음.
- 클래스 사이의 정적인 관계
- 부모 클래스 안에 구현된 코드 자체를 물려받아 재사용
- 합성(Composition)
- 두 객체 사이의 의존성은 런타임에 해결
- has-a 관계
- 구현에 의존하지 않음.
- 내부에 포함되는 객체의 구현이 아닌 인터페이스에 의존.
- 객체 사이의 동적인 관계
- 포함되는 객체의 퍼블릭 인터페이스를 재사용
상속(Inheritance)이란
클래스 상속을 통해 자식 클래스는 부모 클래스의 자원을 물려받게 되며, 부모 클래스와 다른 부분만 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있다. 그래서 상속 관계를 is-a 관계라고 표현하기도 한다.
class Mobile {
// ...
}
class Apple extends Mobile {
// ...
}
일반적인 클래스가 이미 구현이 되어 있는 상태에서 그보다 좀 더 구체적인 클래스를 구현하기 위해 사용되는 기법이며, 그로 인해 상위 클래스의 코드를 하위 클래스가 재사용할 수 있을 뿐이다.
상속을 사용하는 경우는 명확한 is-a 관계에 있는 경우, 그리고 상위 클래스가 확장할 목적으로 설계되었고 문서화도 잘되어 있는 경우에 사용하면 좋다.
상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 자식 클래스와 부모 클래스사이의 결합도가 높아질 수 밖에없다. 또한 상속 관계는 컴파일 타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에 변경할 수 없다.
따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하게 된다면 모든 조합별로 클래스를 하나하나 추가해주어야 한다.
이것을 클래스 폭발 문제라 한다.
더군다나 Java8부터는 인터페이스의 디폴트 메소드 기능이 나오면서 인터페이스 내에서 로직 구현이 가능하여 상속의 장점이 약화되었다고 할 수 있다. 그래서 더 클래스 상속보다는 인터페이스 구현을 이용하라는 풍문을 한 번쯤 들어봤을 것이다.
결과적으로 상속은 클래스 간의 관계를 한눈에 파악할 수 있고 코드를 재사용할 수 있는 쉽고 간단한 방법일지는 몰라도 우아한 방법이라고 할 수는 없다.
합성(Composition) 이란
합성 기법은 기존클래스를 상속을 통한 확장하는 대신에, 필드로 클래스의 인스턴스를 참조하게 만드는 설계이다.
자동차(Car)와 엔진종류(Engine) 간의 관계같이 아주 연관이 없지는 않지만 상속 관계로 맺기에는 애매한 것들을 다루는 것으로 볼 수도 있다.
class Car {
Engine engine; // 필드로 Engine 클래스 변수를 갖는다(has)
Car(Engine engine) {
this.engine = engine; // 생성자 초기화 할때 클래스 필드의 값을 정하게 됨
}
void drive() {
System.out.printf("%s엔진으로 드라이브~\n", engine.EngineType);
}
void breaks() {
System.out.printf("%s엔진으로 브레이크~\n", engine.EngineType);
}
}
class Engine {
String EngineType; // 디젤, 가솔린, 전기
Engine(String type) {
EngineType = type;
}
}
public class Main {
public static void main(String[] args) {
Car digelCar = new Car(new Engine("디젤"));
digelCar.drive(); // 디젤엔진으로 드라이브~
Car electroCar = new Car(new Engine("전기"));
electroCar.drive(); // 전기엔진으로 드라이브~
}
}
Car 클래스가 Engine 클래스의 기능이 필요하다고 해서 무조건 상속하지 말고, 따로 클래스 인스턴스 변수에 저장하여 가져다 쓴다는 원리이다.
이 방식을 포워딩(forwarding)이라고 하며 필드의 인스턴스를 참조해 사용하는 메소드를 포워딩 메소드(forwarding method)라고 부른다.
그래서 클래스 간의 합성 관계를 사용하는데 다른 말로 has-a 관계라고도 한다.
객체 지향에서 다른 클래스를 활용하는 기본적인 방법이 바로 합성을 활용하는 것이다.
상속 대신 합성을 이용하라
상속의 문제점
자바의 객체 지향 프로그래밍을 처음 배울 때 클래스와 상속에 대해 배우기 때문에, 마치 상속이 코드 중복을 제거하고 클래스를 묶는 다형성도 이용할 수 있어서 아주 좋은 객체 지향 기술처럼 보여 무분별하게 상속을 남발하는 경우가 있다.
하지만 현업 에서도 가능하면 extends를 지양하는 편이며 클래스 상속을 해야 할 때는 정말 개념적으로 연관 관계가 있을 때만 하는 상당히 제한적, 선택적으로 다뤄진다.
왜 상속보단 합성(composition)을 사용하라고 권고하는지 상속의 단점을 알아보자.
Java의 창시자인 제임스 고슬링(James Arthur Gosling)이 한 인터뷰에서 "내가 자바를 만들면서 가장 후회하는 일은 상속을 만든 점이다"라고 말할 정도이다.
조슈야 블로크의 Effective Java에서는 상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라는 조언을 한다.
따라서 추상화가 필요하면 인터페이스로 implements 하거나 객체 지향 설계를 할 땐 합성(composition)을 이용하는 것이 추세이다.
1. 결합도가 높아짐
결합도는 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 의존 정도를 뜻한다.
객체지향 프로그래밍에서는 결합도는 낮을수록, 응집도는 높을수록 좋다.
그래서 추상화에 의존함으로써 다른 객체에 대한 결합도는 최소화하고 응집도를 최대화하여 변경 가능성을 최소화할 수 있다.
하지만 상속을 하게 되면 부모 클래스와 자식 클래스의 관계가 컴파일 시점에 관계가 결정되어 결합도가 당연히 높아질 수밖에 없다.
컴파일 시점에 결정되는 관계는 유연성을 상당히 떨어뜨리고, 실행 시점에 객체의 종류를 변경하는 것이 불가능하여 유기적인 다형성 및 객체지향 기술을 사용할 수 없다.
예를 들어 클래스 B가 클래스 A를 상속(extends)한다고 하면, 코드 실행(런타임) 중간에 클래스 C를 상속하도록 바꿀 수 없다.
2. 불필요한 기능 상속
부모 클래스에 메소드를 추가했을 때, 자식 클래스에는 적합하지 않은 메소드가 상속되는 문제이다.
예를 들어서 아래 그림과 같이 Animal 클래스에 fly()
라는 메소드를 추가했을때, Tiger 자식 클래스에서는 동작하지 않는 메소드가 되어 버린다.
물론 메소드를 구현하고 빈칸으로 놔두거나, 클래스를 분리하고 분리하여 해결은 할 수 있지만 결국 복잡해질 뿐이다.
3. 부모 클래스의 결함이 그대로 넘어옴
만일 상위 클래스에 결함이 있다고 하면, 이를 상속받는 자식 클래스에게도 결함이 넘어오게 된다.
결국 자식 클래스에서 아무리 구조적으로 잘 설계하더라도 애초에 부모 클래스에서 결함이 있기 때문에 자식 클래스도 문제가 터지게 된다.
4. 부모 클래스와 자식 클래스의 동시 수정 문제
말 그대로 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해, 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제를 말한다.
class Food {
final int price;
Food(int price) {
this.price = price;
}
}
class Bread extends Food {
public Bread(int price) {
super(price);
}
}
public class Main {
public static void main(String[] args) {
Food bread = new Bread(1000);
}
}
class Food {
final int price;
final int count; // 코드 추가
Food(int price, int count) {
this.price = price;
this.count = count; // 코드 추가
}
}
class Bread extends Food {
public Bread(int price, int count) {
super(price, count); // 코드 수정
}
}
public class Main {
public static void main(String[] args) {
Food bread = new Bread(1000, 5); // 코드 수정
}
}
위와 같이 Food 부모 클래스에 count 변수를 추가하면 자식클래스는 물론 호출 부분까지 전부 수정해주어야 한다.
이는 OCP를 위반한다.
5. 메서드 오버라이딩의 오동작
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제이다.
부모의 public 메소드는 외부에서 사용하도록 노출한 메소드이다. 그런데 상속을 하게 된다면, 자식 클래스에서도 부모 클래스의 public 메소드를 이용할 때 의도하지 않은 동작을 수반할 수 있게 될 수 있다.
이는 캡슐화를 위반하였다고 하기도 한다.
캡슐화란, 단순히 private 변수로 Getter / Setter를 얘기하는 것이 아니다.
캡슐화(정보 은닉)는 객체가 내부적으로 기능을 어떻게 구현하는지를 감추는 것을 말한다.
그래서 우리는 클래스 자료형을 이용할 때 내부 동작을 알 필요 없이 단순히 메소드만 갖다 쓰면 된다.
단, 내부 동작을 알 필요가 없다는 말은 신뢰성이 보장되어야 한다는 말이기도 하다.
캡슐화가 깨진 건 이러한 신뢰성이 깨진 것이라고 보면 된다.
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
class CustomSet<E> extends HashSet<E> {
private int addCount = 0; // 자료형에 몇번 추가되었는지 세는 카운트 변수
@Override
public boolean add(E e) {
// 만일 add되면 카운트를 증가 시키고, 부모 클래스 HashSet의 add() 메소드를 실행한다.
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
// 만일 리스트 자체로 들어와 통쨰로 add 한다면, 컬렉션의 사이즈를 구해 카운트에 더하고, 부모 클래스 HashSet의 addAll() 메소드를 실행한다.
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
public class Main {
public static void main(String[] args) {
CustomSet<String> mySet = new CustomSet<>();
mySet.addAll(Arrays.asList("가", "나", "다", "라", "마"));
mySet.add("바");
System.out.println(mySet.getAddCount()); // ! 6이 나와야 정상이지만 11이 나오게 된다.
}
}
위의 코드는 자바의 HashSet을 상속하고 부모의 메소드를 오버라이딩하여 나만의 Set 클래스를 만들어 구축한 코드이다.
메인 메소드에서 addAll()
메소드와 add()
메소드를 통해 총 6개를 추가했다.
원소가 6개가 추가되니 당연히 addCount
변수는 6이 되어야 한다. 하지만 실행해 보면 6이 아닌 11이 나오게 된다.
문제는 부모의 addAll()
메소드를 함부로 오버라이딩 한채 super
키워드로 호출했기 때문이다.
addAll()
메소드 내부 로직을 보면, 매개변수의 길이에 따라 루프를 돌며 add()
메소드를 호출하고 있는 것을 볼 수 있다.
따라서 HashSet을 상속한 CustomSet 클래스에서 addAll()
메소드를 실행했을 때 addCount
변수에 10이 더해져 버린 것이다.
이처럼 결국은 제대로 addAll()
동작을 구현하기 위해선 상속한 부모 클래스의 내부 로직을 뒤져서 자세히 살펴봐야 할 필요성이 있게 되며, 또한 만일 상위 클래스의 내부 구현이 달라지면 코드 한 줄 건드리지 않은 하위 클래스도 오동작을 일으킬 수 있다는 잠재적 위험성이 존재한다.
6. 불필요한 인터페이스 상속 문제
부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반될 수 있다.
즉, 자식 클래스에게는 부적합한 부모 클래스의 메소드가 상속되기 때문에 자식 클래스 인스턴스의 상태가 불안정해지게 된다.
예를 들어 Stack의 대표적인 동작은 push
, pop
이지만 상속한 Vector 클래스의 add
메소드 또한 외부로 노출되게 된다.
그러면서 아래와 같이 개발자가 add
메소드도 스택 클래스에서 사용할 수 있는 메소드인줄 알고 사용했다가, 의도치 않은 동작이 실행되면서 오류를 범하게 된다.
Stack<String> stack = new Stack<>();
stack.push("one");
stack.push("two");
stack.push("three");
stack.add(0, "four"); // add 메소드를 호출함으로써 stack의 의미와는 다르게 특정 인덱스의 값이 추가
String str = stack.pop(); // three
System.out.println(str.equals("four")); // false
따라서 자바 공식 문서에 보면 애초부터 상속을 잘못하여 잘못 설계된 Stack 클래스보다 Deque 클래스를 사용하여 구현할 것을 권장하고 있다.
7. 클래스 폭발(class explosion)
상속을 남용하게 되면, 새롭게 만든 클래스에 하나의 기존 기능을 연결하기 위해 상속을 하게 될 거고, 또다시 새롭게 만든 클래스에 기능을 연결하기 위해 상속을 하고, 이렇게 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발(class explosion) 문제 또는 조합의 폭발(combinational explosion) 문제라고 부른다.
8. 단일 상속의 한계
자바에서는 클래스 다중 상속을 허용하지 않는다.
그렇기 때문에 상속이 필요한 해당 클래스가 다른 클래스를 이미 상속 중이라면 문제가 발생할 수 있다.
결국 클래스를 또 나누고 나누어 구성해야 하는데 결국 클래스 폭발로 이어지게 된다.
결국은 다중 상속 한계점 때문에 인터페이스를 사용하듯이 클래스 상속의 근본적인 문제는 단일 상속 박에 못하다는 것이다.
합성을 사용해야 하는 이유
합성은 구현에 의존하지 않는 점에서 상속과 다르다.
SOLID의 DIP원칙은 "추상화에 의존해야지, 구체화에 의존하면 안 된다"는 원칙이다.
합성은 객체의 내부는 공개되지 않고 인터페이스를 통해 코드를 재사용하기 때문에, 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경하여 결합도를 낮출 수 있다.
또한 상속 관계는 클래스 사이의 정적인 관계인데 비해 합성 관계는 객체 사이의 동적인 관계이다.
코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만(컴파일 타임), 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문이다.(런타임)
그래서 합성을 사용하고 인터페이스 타입을 사용한다면 런타임시에 외부에서 필요한 전략에 따라 교체하며 사용할 수 있으므로 좀 더 유연한 설계를 할 수 있다.
이러한 대표적인 사례가 전략 패턴이 될 수 있다.
그러나 합성에도 단점이 존재하는데, 아무래도 상속과는 달리 클래스 간의 관계를 파악하는 데 있어 시간이 걸린다는 점이다.
한마디로 코드가 복잡해질 수 있다는 점을 떠안고 있다.
a참고 자료 :
'백엔드 > Java' 카테고리의 다른 글
[Java] 프록시 패턴(Proxy Pattern) (0) | 2023.05.24 |
---|---|
[Java] JVM의 Class Loader (0) | 2023.05.23 |
[Java] 옵저버 패턴(Observer Pattern) (1) | 2023.05.17 |
[Java] 전략 패턴(Strategy Pattern) (0) | 2023.05.17 |
[Java] 템플릿 메서드 패턴(Template Method Pattern) (0) | 2023.05.17 |