상속(is-a)과 합성(has-a)
- [ 아키텍쳐, 방법론, 디자인패턴 등 ]
- 2022. 8. 13.
소프트웨어의 구성요소들은 확장에는 열려있고 변경에는 닫혀있어야 한다, 즉 요구사항의 변경이나 추가가 발생해도 기존 구성요소는 수정이 일어나지 않아야 한다.
흔히 OOP 5원칙에서 OCP를 만족시키기 위해서 사용하는 기법은 클래스 상속과 객체 합성을 통한 코드 재사용이라고 말한다.
상속이 어떤 문제가 있길래
그러면 상속은 언제 사용해야 되나요?
먼저 상속을 사용하면, 자식클래스에 부모클래스를 덧붙이는 것으로 부모의 정의를 물려받은 후 코드를 추가하고 확장할 수 있다는 장점이 있다. 하지만 상속을 이용해서 코드를 재사용하기 위해서는 자식 클래스에서 부모 클래스의 구현법에 대한 지식을 가져야 하고, 그것은 부모 객체의 캡슐화를 약화시키는 문제를 가져오고, 결국 기능을 추가하는데 기존 구성요소를 수정하게 되므로 OCP의 위반이라는 결과를 가져올 수도 있다.
상속의 주된 목적은 코드의 재사용성을 올리는 것이 아니다. 구현 클래스를 "재사용"할 목적으로 상속을 사용한다면 변경에 취약한 코드를 낳게 될 가능성이 높다. SOLID 원칙의 LSP는 "자식 클래스는 부모 클래스를 대체할 수 있어야 한다" 라고 말하고 있다. 이렇게 LSP에 해당하는 케이스, 즉 상속받는 자식 클래스가 부모 클래스를 대체할 수 있는 경우(is-a)에만 상속을 사용해야 한다.
개는 동물이다 (O) -> 모든 개는 동물이다. (is-a)
뇌는 동물이다 (X) -> 뇌는 동물을 구성할 뿐, 동물이 아니다. (has-a)
위와 같이 사람(Human)은 동물(Human)의 한 종류(is-a)이기 때문에 상속관계로 설정하는 것이 바람직하다. 사람은 동물이 갖는 특징을 모두 그대로 가지기에 모든 메소드를 대체할 수 있으며, 공통 로직을 그대로 사용할 수 있다.
public class Animal {
protected void eat(Food food) {...}
}
public class Human extends Animal {...}
Human human = new Human();
human.eat(new Food());
상속을 사용할 때는 주의하자
하지만 상속을 사용할 경우 유의해야 될 점들이 있다. 먼저 상속을 사용하면 부모 클래스가 자식 클래스에 노출되기에 두 클래스간의 결합도가 증가하기 때문에 부모 객체의 캡슐화를 약화시킬 가능성이 많다. 결국 과도한 상속의 사용은 오히려 코드의 재사용성을 낮출 수도 있다는 말이다. 아래 예시를 보자.
@Getter @Setter
public class Animal {
private int height;
private int weight;
protected void eat(Food food) {...}
}
public class Cow extends Animal {
public int getCowPrice() {
return super.getWeight()*4; // 부모 클래스의 메소드를 사용하고 있다.
}
}
public class Pig extends Animal {
public int getPigPrice() {
return super.getWeight()*2; // 부모 클래스의 메소드를 사용하고 있다.
}
}
소와 돼지의 가격을 구하기 위해 각각 getPrice() 함수를 만들었다고 하자. 여기서 문제는 부모객체의 변경이 발생했을 때 자식 객체까지 영향이 끼친다는 사실이다. 정책상 필드명이 변경될 가능성이 있거나, 자식 객체에서 공통으로 쓰일 가능성이 있는 메소드는 부모 객체에 만들어주는 것이 좋다. 따라서 아래와 같이 수정하였다.
public class Animal {
private int height;
private int weight;
// 자식 객체에서 공통으로 사용되는 메소드를 부모 클래스에 구현
public int getPrice(Animal animal) {
if(animal instanceOf Cow) return weight*4;
else return weight*2;
}
}
public class Cow extends Animal {...}
public class Pig extends Animal {...}
하지만 여기서도 문제가 있다. Animal 클래스의 getPrice() 함수에서는 인자로 들어온 animal의 타입을 instanceOf 연산자를 사용하고 있는데, 만약 새로운 동물 Chicken이 추가되었다고 하자. 그러면 Animal클래스의 getPrice()메소드의 로직을 다음과 같이 수정해주어야 한다.
public int getPrice(Animal animal) {
if(animal instanceOf Cow) return weight*4;
else if(animal instanceOf Chicken) return weight*3;
else return weight*2;
}
새로운 자식클래스의 추가로 인해서 부모클래스가 수정되는 문제가 발생했다.
SOLID 원칙에서 OCP는 "변경에는 닫혀있고 확장에는 열려있어야 한다"라고 말하고 있는데, 변경으로 인해서 기존 객체의 코드의 수정이 필수적이게 되는 문제가 발생했다. 이 외에도 instanceOf를 사용하게 됨으로서 Animal클래스가 Cow, Chicken과 같은 다른 클래스의 로직에 대한 책임을 가지게 되어 SRP 위반이 이루어졌다라고도 말할 수 있다.
public abstract class Animal {
private int height;
private int weight;
protected void eat(Food food) {...}
public abstract int getPrice(Animal animal); // 구현 위임
}
public class Cow extends Animal {
@Override
public int getPrice(Animal animal) {
return animal.getweight*4;
}
}
public class Pig extends Animal {
@Override
public int getPrice(Animal animal) {
return animal.getweight*2;
}
}
Animal pig = new Pig(100, 500);
Animal cow = new Cow(150, 400);
pig.getPrice();
cow.getPrice();
따라서 Animal에서 getPrice() 메소드를 abstract 메소드로 만들어 자식 클래스에게 구현을 위임한 뒤, 자식 클래스는 추상메소드를 오버라이딩한다. 각 클래스는 각각의 클래스에 대한 책임을 가지게 되었고(SRP), 변경이 이루어지더라도 기존 코드의 수정을 필요로 하지 않게 되었다(OCP).
재사용을 위해서는 상속보다 합성을
방금까지는 is-a 관계일 때 상속을 적절하게 사용하는 방법을 알아보았다. 하지만 일반적으로 객체 간 관계는 is-a 관계보다는 has-a 관계인 경우가 많다. '개는 동물이다'의 예시는 is-a 관계가 맞지만, 동물과 뇌의 관계를 살펴보자. 뇌는 동물을 구성할 뿐, 동물의 범주에 속하지 않는다. 뇌는 동물을 구성하고 있을 뿐이다. (has-a)
합성은 객체가 다른 객체의 참조를 얻어서 객체의 기능을 이용하는 방식으로 의존성의 주입이 런타임에 동적으로 이루어지며, 구현체가 아닌 인터페이스에 의존한다. 따라서 인터페이스의 호환이 이루어지기만 한다면 코드를 까볼 필요도 없이 인자로 가져다 사용할 수 있으며, 객체간에 오로지 인터페이스만 이용한 의존성 주입이 이루어질 수 있으므로 결국 응집도 높은 재사용이 가능해진다. 그뿐 아니라, 새로운 기능을 추가하더라도 DI의 대상이 될 클래스만 추가해주면 해결된다.
+ 합성을 사용한 전략 패턴 예시
// 인터페이스
interface Brain {
void work();
}
// 구현체
class BrainV1 implements Brain {
@Override
void work() {
System.out.println("1번 뇌 일하는 중");
}
}
public class Animal {
private Brain brain;
public Animal(Brain brain) {
this.brain = brain; // 의존성 주입
}
public void think() {
brain.work(); // 인터페이스에 의존
}
}
Animal myComposedAnimal = new Animal(new BrainV1()); // 생성자를 이용한 의존성 주입
myComposedAnimal.think(); // BrainV1에 구현한 think메소드 동작
정리
지금까지 재사용의 목적으로 상속을 사용할 경우 생길 수 있는 문제점 위주로 알아보았다. 한줄로 정리하면 아래와 같이 정리할 수 있다.
1. 상속을 사용하면 객체 간 결합도가 높아지고, 캡슐화를 약화시키는 결과를 낳는다.
하지만 재사용을 위해 상속을 사용할 경우 발생할 수 있는 문제는 이것뿐만이 아니다.
2. 자식 클래스가 필요로 하는 기능 이상의 불필요한 기능을 상속하게 될 수도 있다.
3. 부모 클래스의 메서드를 사용할 경우 side effect가 발생할 수도 있다. 이를 막기 위해서는 자식 클래스에서 부모 클래스의 동작방식을 알아야 한다.
4. '클래스 폭발'이라고 부르는, 부모 클래스를 상속하는 자식 클래스가 너무 많아지는 문제도 발생할 수 있다.
결론은, 재사용을 위해서는 합성을 사용하고, 불가피하게 상속을 사용할 경우에도 인터페이스를 이용한 추상화를 곁들여 상속해야 한다. 이는 합성을 통해 재사용성을 높이고, 인터페이스를 통해 추상화와 다형성을 이용한 OCP를 지킬 수 있게 돕는다.
참고자료
'[ 아키텍쳐, 방법론, 디자인패턴 등 ]' 카테고리의 다른 글
OOP와 다형성, 의존 역전 (0) | 2023.09.29 |
---|---|
Git 브랜치 전략 - Git Flow, Github Flow (0) | 2023.09.08 |
다양한 디자인 패턴 - (1) 구조 패턴 (0) | 2023.08.16 |
객체지향 프로그래밍과 SOLID (0) | 2022.08.07 |
MVC, MVP, MVVM (0) | 2022.07.07 |