티스토리 뷰
객체지향 프로그래밍에서 코드를 재사용하기 위한 방법으로 크게 상속과 합성이 있습니다. 대부분의 경우 상속보다 합성을 이용하는 것이 좋은데, 이번에는 왜 합성을 사용해야 하는지에 대해 알아보도록 하겠습니다.
아래의 내용은 클린코드, 이펙티브 자바, 오브젝트 등을 참고하여 작성하였습니다.
1. 상속(Inheritance)과 합성(Composition)
개발을 할 때 가장 신경써야 하는 것 중 하나가 중복을 제거하여 변경을 쉽게 만드는 것이다. 객체지향 프로그래밍의 장점 중 하나는 코드를 재사용하여 중복을 제거하기에 용이하다는 것인데, 이를 위한 방법에는 크게 상속과 합성 두 가지가 있다.
[ 상속(Inheritance) 이란? ]
상속은 상위 클래스에 중복 로직을 구현해두고 이를 물려받아 코드를 재사용하는 방법이다. 흔히 상속은 Is-a 관계라고 많이 불린다.
예를 들어 요리사 클래스와 사람 클래스가 있을 때 요리사는 사람이므로 이러한 관계를 Is-a 관계라고 한다.
public class Person {
public void walk() {
System.out.println("걷는다");
}
public void talk() {
System.out.println("말한다");
}
}
public class Chef extends Person {
}
요리사 클래스는 사람 클래스를 상속받았으므로 사람 클래스에 정의된 메소드들을 다음과 같이 재사용할 수 있다.
Person person = new Chef();
person.walk();
person.talk();
이렇게 부모 클래스에 정의된 메소드를 물려받아 재사용하는 것을 상속이라고 부른다.
[ 합성(Composition) 이란? ]
합성은 중복되는 로직들을 갖는 객체를 구현하고, 이 객체를 주입받아 중복 로직을 호출함으로써 퍼블릭 인터페이스를 재사용하는 방법이다. 흔히 합성은 Has-a 관계라고 많이 불린다.
예를 들어 요리사가 음식의 가격을 계산해야 하는 상황이라고 하자. 그러면 요리사는 자신이 만든 음식들을 가지고 있으므로 이러한 관계를 Has-a 관계라고 한다.
public class Chef {
private List<Food> foodList;
public Chef(List<Food> foodList) {
this.foodList = foodList;
}
public int calculatePrice() {
return foodList.stream()
.mapToInt(v -> v.getPrice())
.sum();
}
}
상속으로 코드를 재사용하는 것과 합성으로 퍼블릭 인터페이스를 재사용하는 것은 근본적으로 다르다. 왜냐하면 합성을 이용하면 객체의 내부는 공개되지 않고 인터페이스를 통해 코드를 재사용하기 때문에 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경하여 결합도를 낮출 수 있기 때문이다.
또한 합성을 이용하면 여러가지 단점들이 부각되는데, 상속을 이용하면 어떠한 단점들이 생기고 왜 합성을 이용해야 하는지에 대해 알아보도록 하자.
2. 상속(Inheritance)보다 합성(Composition)을 사용해야 하는 이유
[ 상속의 단점 및 한계점 ]
상속은 중복을 제거하기에 아주 좋은 객체지향 기술처럼 보이고, 그에 따라 상속을 무분별하게 남발하는 경우를 자주 볼 수 있다. 하지만 상속을 이용해야 하는 경우는 상당히 선택적이며, 상속이 갖는 단점은 상당히 치명적이기 때문에 상속보다는 합성을 이용할 것을 권장한다.
상속은 대표적으로 다음과 같은 단점을 가지고 있다.
- 캡슐화가 깨지고 결합도가 높아짐
- 유연성 및 확장성이 떨어짐
- 다중상속에 의한 문제가 발생할 수 있음
- 클래스 폭팔 문제가 발생할 수 있음
캡슐화가 깨지고 결합도가 높아짐
결합도는 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 정도이다. 객체지향 프로그래밍에서는 결합도는 낮을수록, 응집도는 높을수록 좋다. 그리고 객체지향의 장점 중 하나는 추상화에 의존함으로써 다른 객체에 대한 결합도는 최소화하고 응집도를 최대화하여 변경 가능성을 최소화하는 것이다.
하지만 상속을 이용하면 캡슐화가 깨지고 결합도가 높아지는데, 그 이유는 부모 클래스와 자식 클래스의 관계가 컴파일 시점에 결정되어 구현에 의존하기 때문이다. 컴파일 시점에 결정되는 관계는 유연성을 상당히 떨어뜨리고, 실행 시점에 객체의 종류를 변경하는 것이 불가능하여 다형성 등과 같은 좋은 객체지향 기술을 사용할 수 없다. (이해가 잘 안갈 수 있으므로 뒤에서 다시 한번 짚고 넘어가도록 하겠습니다.)
또한 상속에서 자식 클래스는 부모 클래스의 코드를 직접 호출가능하므로 부모 클래스의 내부 구조를 잘 알고 있어야 하기까지 한다.
예를 들어 다음과 같은 Food 추상 클래스와 이를 구현한 Steak 클래스가 있다고 하자.
@RequiredArgsConstructor
public abstract class Food {
private final int price;
public int calculatePrice() {
return price;
}
}
public class Steak extends Food {
public Steak(final int price) {
super(price);
}
}
그리고 Steak와 Salad로 구성된 세트 메뉴를 추가해야하며, Steak가 제공되는 경우에는 10000원 할인이 되어야 하는 상황이라고 하자. 이를 구현하기 위해 우리는 Steak 클래스에 할인해야 하는 금액을 계산하는 discountAmount()를 추가하고 Steak 클래스를 상속받아 세트메뉴로 만들어진 SteakWithSaladSet 클래스와 부모 Food 클래스의 calculatePrice() 메소드를 오버라이딩하여 상속으로 구현하였다고 하자.
public class Steak extends Food {
public Steak(final int price) {
super(price);
}
protected int discountAmount() {
return 10000;
}
}
public class SteakWithSaladSet extends Steak {
public SteakWithSaladSet(final int price) {
super(price);
}
@Override
public int calculatePrice() {
// 원래 금액 - 스테이크를 주문한 경우 할인받을 금액
return super.calculatePrice() - super.discountAmount();
}
}
위의 예시에서 보변 SteakWithSalad 세트 메뉴는 할인 금액을 위해 구체 클래스인 Steak 클래스에 의존하고 있다. SteakWithSaladSet에서 할인 금액을 포함한 가격을 계산하기 위해서는 부모 클래스인 Steak 클래스에서 discountAmount를 제공해야 함을 알고 있어야 한다. 이렇게 애플리케이션이 실행되는 시점이 아닌 컴파일 시점에 SteakWithSaladSet가 Steak라는 구체 클래스(구현)에 의존하는 것을 컴파일 타임 의존성이라 부르고, 이는 다형성을 사용할 수 없어 객체지향적이지 못하다. 심지어 해당 메소드의 이름이 변경되면 자식 클래스의 메소드도 변경해주어야 하므로 문제가 발생할 여지가 많다.
즉, 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다. 왜냐하면 부모 클래스를 기반으로 자식 클래스의 코드를 구현해야 하기 때문이다. 자식 클래스에서 super를 이용해 부모 클래스의 메소드를 호출하는 상황이라면 부모 클래스의 구현은 자식 클래스에게 노출되어 캡슐화가 약해지고, 자식 클래스와 부모 클래스는 강하게 결합되어 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 가능성이 높아진다.
유연성 및 확장성이 떨어짐
상속은 위에서 설명하였듯 부모 클래스와 자식 클래스가 강하게 결합되므로 유연성과 확장성이 상당히 떨어진다.
예를 들어 Food 추상 클래스에 음식의 개수를 반환하는 새로운 메소드를 추가해야 하는 상황이라고 하자. 우리는 이를 해결하기 위해 Food 클래스에 음식의 개수를 나타내는 count 변수와 getFoodCount 메소드를 추가하였다고 하자.
@RequiredArgsConstructor
public abstract class Food {
private final int price;
private final int count;
public int calculatePrice() {
return price;
}
public int getFoodCount() {
return count;
}
}
문제는 이러한 변경 사항이 자식 클래스까지 전파된다는 것이다. 우리는 당장 Steak 클래스와 SteakWithSaladSet 클래스의 생성자에 count 파라미터를 받도록 추가해주어야 한다.
public class Steak extends Food {
public Steak(final int price, final int count) {
super(price, count);
}
protected int discountAmount() {
return 10000;
}
}
public class SteakWithSaladSet extends Steak {
public SteakWithSaladSet(final int price, final int count) {
super(price, count);
}
@Override
public int calculatePrice() {
// 원래 금액 - 스테이크가 포함된 세트메뉴인 경우 할인받을 금액
return super.calculatePrice() - super.discountAmount();
}
}
그리고 자식 클래스 뿐만 아니라 자식 클래스가 선언되어 객체를 생성한는 부분들 역시 모두 수정해주어야 한다.
// count 파라미터 없이 생성된 객체를 모두 수정해야 함
// Food food = new SteakWithSaladSet(15000);
Food food = new SteakWithSaladSet(15000, 2);
이렇듯 상속으로 구현하면 변경에 대한 범위가 상당히 커지므로 유연성과 확장성이 떨어지는 것을 확인할 수 있다. 위의 예제에서는 다행히 추가되는 부모 클래스의 메소드를 이용하면 되었지만 만약 자식 클래스마다 메소드의 구현이 달라져야 하는 상황이라면 변경의 포인트가 자식 클랫들 만큼 추가되는 것이다.
클래스 폭발 문제가 발생할 수 있음
상속을 남용하게 되면 필요 이상으로 많은 수의 클래스를 추가해야 하는 클래스 폭발(Class Explosion) 문제가 발생할 수 있다.
만약 새로운 요구사항이 생겨 Steak와 Salad 그리고 Pasta로 구성된 세트 메뉴를 추가해야 하는 상황이라고 하자. 그러면 우리는 이를 해결하기 위해 다음과 같은 SteakWithSaladSetAndPasta 클래스를 또 추가해야 할 것이다.
public class SteakWithSaladSetAndPasta extends SteakWithSaladSet {
...
}
그리고 새로운 메뉴가 또 개발된다면 계속해서 해당 클래스를 추가해야 할 것이고 지나치게 많은 클래스가 생겨야 하는 클래스 폭발 문제가 발생할 수 있다.
클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현과 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생한다. 컴파일 타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수 만큼 새로운 클래스를 추가하는 것 뿐이다. 클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때에도 동일하게 발생한다.
따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우 별로 클래스를 추가해야 한다. 그러므로 이러한 문제를 방지하기 위해서라도 상속보다는 합성을 이용해야 한다.
다중 상속에 의한 문제가 발생할 수 있음
자바에서는 다중 상속을 허용하지 않는다. 그렇기 때문에 상속이 필요한 해당 클래스가 다른 클래스를 이미 상속중이라면 문제가 발생할 수 있다. 다중 상속과 관련된 문제를 피하기 위해서도 상속의 사용을 지양해야 한다.
[ 합성을 사용하기 ]
상속은 컴파일 시점에 부모 클래스와 자식 클래스의 코드가 강하게 결합되는 반면에 합성을 이용하면 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 객체를 교체하는 것이 비교적 쉬우므로 설계가 유연해진다. 왜나하면 상속은 클래스를 통해 강하게 결합되지만 합성은 메세지를 통해 느슨하게 결합되기 때문이다. 따라서 코드 재사용을 위해서는 상속보다 합성을 선호하는 것이 더 좋은 방법이다.
위에서 살펴본 예시 상황들을 합성으로 풀어보도록 하자. 우리는 다음 음식에 대한 인스턴스 변수가 추가된 Food 클래스와 Steak 클래스를 추가하였고, 이번에는 합성을 이용하기 위해 새롭게 Salad 클래스를 추가하였다.
@RequiredArgsConstructor
public abstract class Food {
private final int price;
private final Food next;
public int calculatePrice() {
return next == null
? price
: price + next.calculatePrice();
}
}
public class Steak extends Food {
public Steak(final int price, final Food next) {
super(price, next);
}
@Override
public int calculatePrice() {
return next == null
? price
: price - 10000 + next.calculatePrice();
}
}
public class Salad extends Food {
public Salad(final int price, final Food next) {
super(price, next);
}
}
위와 같이 작성된 Food는 스테이크와 샐러드로 구성된 세트메뉴를 추가해달라는 요구사항을 구현하기 위해 다음과 같이 선언할 것이다. 그리고 Chef가 해당 메뉴의 가격을 계산하기 위해서는 다음과 같이 처리가 될 것이다.
Food setMenu = new Steak(20000, new Salad(15000));
int price = setMenu.calculatePrice();
위와 같은 합성의 구조에서 달라진 점은 가격을 계산하기 위해 더 이상 클래스의 구현(Steak클래스의 discountAmount)에 의존하지 않는다는 것이다. 우리는 해당 기능을 구현하기 위해서 구체 클래스에 어떠한 구현이 있는지 살펴볼 필요 없이 그저 calculatePrice()를 호출하면 된다.
이제 새로운 요구사항이 생겨, 세트 메뉴가 갖는 음식의 개수를 세어야 하는 상황이라고 하자. 우리는 이번에도 다음 음식인 Food 클래스의 next 객체가 존재하는지 메세지를 보내서 파악을 해야 한다. 이를 해결하기 위해 추상화된 클래스에 Food에 getFoodCount()를 이용해 내부를 구현하고, 다음 Food가 존재할 경우 동일하게 getFoodCount()를 호출하여 메소드를 재사용하도록 구현할 수 있다.
@RequiredArgsConstructor
public abstract class Food {
...
public int getFoodCount() {
return next == null
? 1
: 1 + next.getFoodCount();
}
}
또한 이러한 합성에 의존하는 개발은 추가적인 세트 메뉴를 개발한다고 하여도 새로운 클래스를 추가할 필요가 없을 것이다.
이렇듯 컴파일 시점에 코드 레벨에서 어느 클래스의 코드에 의존하는지 알 수 있었던 컴파일 의존성과 달리 현재 Food 객체에서 또 다른 Food 객체인 next에 의존하면서 컴파일 타임에 어떠한 구체 클래스에 의존하지 않고 추상화에 의존하는 것이 런타임 의존성이다. 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성하여 추상화에 의존할 수 있다는 것은 합성의 가장 커다란 장점이다.
물론 컴파일 타임 의존성과 런타임 의존성과 거리가 멀수록 설계의 복잡도가 상승하여 코드를 이해하기 어려워지지만, 유지보수를 위해서는 반드시 필요하다.
[ 상속을 사용해야 하는 경우 ]
상속의 용도는 크게 두 가지이다.
- 타입 계층을 구현하는 것
- 코드를 재사용하는 것
하지만 위에서 설명하였듯 2번, 코드의 재사용을 위해 상속을 사용하는 것은 제약이 많기 때문에 사용하지 않는 것이 좋다. 상속을 사용해야 하는 경우는 상당히 제약적인데, 다음의 경우를 모두 만족한다면 상속을 고려할만하다. 그렇지 않은 경우라면 거의 합성을 사용하는 것이 좋다.
- 부모와 자식 클래스가 Is-A 관계인 경우
- 행동 호환성이 만족하는 경우
부모와 자식 클래스가 Is-A 관계인 경우
우선 두 클래스가 어휘적으로 '타입 S는 타입 T이다'라고 표현할 수 있을 때 상속을 이용해야 한다. 일반적으로 '자식클래스는 부모클래스이다' 라고 말해도 이상하지 않다면 상속을 사용할 후보로 간주할 수 있다.
예를 들어 우리가 펭귄과 새의 관계를 구현하는 상황이라고 하자. '펭귄은 새이다' 라고 전혀 이상하지 않으므로 새와 펭귄의 관계를 구현하고자 할 때 상속을 고려할 수 있다.
public class Bird {
}
public class Penguin extends Bird {
}
하지만 아직 검토해야 할 조건이 남아 있으므로 살펴보도록 하자.
행동호환성
행동호환성이란 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 하며, 부모 클래스의 타입으로 자식 클래스를 사용해도 무방함을 의미한다. 클라이언트의 관점에서 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 묶을 수 있다. 만약 그렇지 않다면 두 타입을 하나의 타입계층으로 묶어서는 안된다.
이제 다시 펭귄과 새의 관계를 살펴보도록 하자. 일반적으로 '새는 날 수 있다'라고 생각을 한다. 그리고 이를 실제로 구현하면 다음과 같을 것이다.
public class Bird {
public void fly() {
...
}
}
하지만 실제로 펭귄은 날 수 없다. 우리는 펭귄이 날 수 없는 경우를 처리하기 위해 여러 방법들 중 하나를 고려할 것이다.
- fly 함수를 비워둔다.
- fly 함수 호출 시 예외를 던진다.
- instance of로 타입을 검사해 펭귄이 아닌 경우에만 fly를 호출한다.
하지만 결국 펭귄이라는 객체가 fly 함수를 이해할 수 있다는 근본적인 사실은 변하지 않는다. 그리고 이러한 부분은 클라이언트가 Bird 객체를 사용할 때 문제가 발생한다. 왜냐하면 일반적이라면 클라이언트는 모든 새가 날 수 있다고 가정을 할 것이고, 우리의 조치들에 따라 에러를 만나는 등의 예상치 못한 상황에 직면할 수 있기 때문이다.
Bird bird = findBird();
// findBird()로 펭귄 객체가 찾아진 경우 예상치 못한 상황이 발생
bird.fly();
결국 새 클래스에 '날다' 라는 함수를 만들면 펭귄 객체가 '날다'라는 메세지를 이해할 수 있어 문제가 발생하므로 펭귄 객체가 fly()를 이해할 수 없도록 변경이 필요하다. 현재 상황을 해결하기 위해서는 새의 서브타입으로 날 수 있는 새를 추가하고, 해당 클래스에 fly() 메소드를 추가하는 것이 가장 바람직하다.
public class Bird {
}
public class FlyingBird extends Bird {
public void fly() { ... }
}
public class Penguin extends Bird {
}
이제 findBird가 FlyingBird 타입을 반환함을 명시함으로써 이를 사용하는 클라이언트는 이제 이와 같은 경우를 다음과 같이 처리할 수 있다.
FlyingBird flyingBird = findBird();
flyingBird.fly();
이를 통해 모든 클래스들의 행동 호환성을 만족시킬 수 있고, 잘못된 객체와 협력하는 문제를 해결할 수 있다. 물론 이러한 문제를 상속으로 해결하는 것 보다는 인터페이스로 분리하는게 더 좋다. 그러므로 역시 상속을 사용하는 경우는 상당히 제한적이다.
새와 펭귄의 관계에서 살펴보았듯 Is-A 관계라고 하더라도 상속을 사용하면 안되는 경우가 분명히 있다. 그래서 Is-A 관계에 더해 행동호환성을 고려해야 한다.
상속을 사용하면 손쉽게 코드를 재사용할 수 있다. 하지만 상속 관계가 추가될수록 시스템의 결합도가 높아져 변경이 점차 어려워지고, 캡슐화를 깨뜨리는 등의 문제가 발생한다. 특히 새로운 변수가 추가되는 경우에는 더욱 결합도에 의한 문제가 부각된다.
하지만 상속을 반드시 사용해야 한다면 유사한 두 메소드를 추출하여 동일한 형태로 만들고, 부모 클래스의 코드를 자식으로 옮기는 것이 아니라 자식 클래스의 코드를 부모로 옮기도록 하자. 이는 결국 자식 클래스들이 추상화에 의존하도록 하는 것으로, 이를 통해 재사용성과 응집도를 더욱 높일 수 있다.
하지만 그럼에도 불구하고 완벽히 결합도를 낮출 수는 없다. 합성을 이용하면 조금 번거롭긴 하지만 변경하기 쉽고 유연한 설계를 얻을 수 있으므로 가급적이면 합성이라는 더 좋은 방법을 이용하도록 하자.
상속의 진정한 목적은 코드 재사용이 아니라, 다형성을 활용하기 위한 서브타입 계층을 구축하는 것이다. 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고, 유지보수하기 버거운 코드가 만들어질 뿐이다.
블로그에 최대한 적은 양으로 내용을 설명하려다보니 위에서 설명한 예시들이 깔끔하지 못하다고 느낄 수 있을 것 같습니다. 해당 예시와 관련해서는 오브젝트 책에 자세히 나와있으니 위의 내용으로 꺼림직하시다면 책의 내용을 보시는 것을 추천드립니다:)
'나의 공부방' 카테고리의 다른 글
[GitHub] GitHub PR(Pull Request) 머지 후에 브랜치 자동 삭제하기 (0) | 2022.03.01 |
---|---|
[오픈소스] SpringBoot 오픈 소스 프로젝트에 컨트리뷰트 하기 (4) | 2022.01.30 |
[OOP] 객체지향 캡슐화(Encapsulation), 응집도(Cohension)와 결합도(Coupling) (0) | 2021.11.09 |
[OOP] 객체지향 프로그래밍의 5가지 설계 원칙, 실무 코드로 살펴보는 SOLID (38) | 2021.11.02 |
[개발서적] 클린 코더(Clean Coder) 핵심 요약 및 정리 (10) | 2021.09.04 |