Effective Java에서는 상속보다 컴포지션을 사용하라고 하고 있다.
책 내용이 조금 복잡하고 이해를 했더라도 실제 설계를 어떤 방향으로 할 지 감이 안잡힌다.
비슷한 주제로 검색 하더라도 설명이 부실한 경우가 많았다.
추후에 나도 다시 읽을 목적으로 초등학생한테 설명해주는 것 처럼 하나하나 설명 해볼 예정이다!
일단 주제에 대해 다시 짚고 넘어가고 싶다.
상속보다는 컴포지션을 이용하라는 뜻이 상속이라는 기능 자체에 문제가 있어서는 아닐 것이다.
그렇다면 상속은 항상 좋지 않은 선택일까?
상속이 올바른 경우
상속이 올바른 경우는 아래와 같다.
- 같은 프로그래머가 하위, 상위 클래스를 통제
- 문서화가 잘된 클래스
- 확장 목적으로 설계 된 경우
- is-a 관계가 확실할 경우
위의 조건에 부합하는 상속은 문제없다.
하지만 상속을 초기 설계부터 위의 조건을 생각하지 못하고 만들면 클래스의 캡슐화를 깨뜨릴 수도 있다.
캡슐화란 관련이 있는 변수와 함수를 하나의 클래스로 묶고 외부에서 쉽게 접근하지 못하도록 은닉하는 것을 말한다.
상속을 할 때 캡슐화가 깨지는 경우
class Animal {
public void makeSound() {
System.out.println("Animal is making a sound.");
}
}
class Dog extends Animal {
public void makeSound() {
System.out.println("Dog is barking.");
}
}
class Main {
public static void main(String[] args) {
Animal animal = new Dog();
animal.makeSound(); // 출력: "Dog is barking."
}
}
위와 같은 코드가 있다고 가정하자.
class Dog extends Animal {
Dog는 Animal을 상속받는다.
Animal이라는 클래스는 makeSound라는 소리를 내는 기능("Animal is making a sound.")이 존재한다.
Dog는 Animal 클래스를 상속받고 Dog 클래스에서 부모 메서드를 재정의(Override)하면 기존의 부모 메소드 기능은 사라진다.
이런 경우 하나의 캡슐로 만들어놓은 부모 클래스의 기능이 새로운 기능으로 대체되었기 때문에 캡슐화가 깨졌다고 볼 수 있다.
캡슐화가 깨졌을 때 어떠한 문제가 생길까?
캡슐화가 깨지는 건 알겠는데, 그래서 뭐? 그게 상속 시스템이잖아? 라고 생각이 들었다.
만약에 Animal 클래스의 makeSound 메서드가 Animal 클래스에 있는 메소드 중 또 다른 메서드를 호출한다고 상상해 보자.
class Animal {
public void makeSound() {
System.out.println("Animal is making a sound.");
eatFood();
}
public void eatFood() {
System.out.println("Animal eat Food");
}
}
예를 들어 makeSound는 소리를 내는 기능이니 소리를 쳤으면 배가 고파서 바로 밥을 먹는다라는 기능으로 자연스럽게 연결되는 캡슐화 클래스라고 가정해 보자.
그리고 개발자는 Dog에서 상속을 통해 makeSound 메서드를 재정의 했다고 가정하자.
class Dog extends Animal {
public void makeSound() {
System.out.println("Dog is barking.");
}
}
그러면 makeSound 메소드는 "Dog is barking"만 출력될 뿐 eatFood 메서드 실행이 안되는 것이다.
만약에 부모 클래스에서 eatFood라는 메서드가 무조건 실행되었어야 하는 필수 메서드였다면 개발자가 에러가 발생할 여지가 있는 코드를 짠 것이라고 볼 수 있다.
이렇게 상상해 볼 수도 있다.
Animal 클래스의 다른 메서드에서 makeSound 메서드를 사용하는 경우라고 생각해 보자.
이때도 역시 makeSound는 하위 메서드에서 재정의 되었으니 의도한 바와는 다르게 실행될 수 있다.
그래서 책에 나온대로 보통 상속에서 문제가 생기는 부분은 메서드 재정의 때문이다.
재정의로 인해 캡슐화가 깨지는 것이며 이러한 부분을 개발자가 직접 관여해서 생각하는 것보다는 컴포지션 방식으로 객체를 구현하면 자연스럽게 부모 클래스의 캡슐화를 지킬 수 있다.
컴포지션
상속 관계를 보통 is-a라고 표현하고 컴포지션은 has-a라고 표현한다
- 상속 - A is a B(A는 B다)
- 컴포지션 - A has a B(A는 B를 갖고 있다)
상속은 클래스 하나가 다른 클래스의 기능을 확장하여 기능을 갖는 것을 말하며 강결합의 특성을 갖고 있다.
컴포지션은 하나의 클래스가 다른 클래스를 갖고 있으므로 약결합의 특성을 갖고 있다.
우선 "다른 클래스의 기능을 갖는다"라는 표현 자체는 같다.
그런데 왜 상속은 강결합이고 컴포지션은 약결합일까?
상속은 부모 메서드의 기능을 완전히 내 것처럼 가져와서 재정의가 가능하다는 특성이 있다.
자식 클래스에서 메서드를 재정의했는데 추후에 자식 클래스의 기능을 변경하기 위해 재정의한 메소드를 수정해보려 하니 부모 클래스까지 기능을 변경해야 하는 불상사가 생길 수도 있다는 것이다.
그래서 부모 클래스를 수정하려고 했더니 이 부모 클래스는 또 다른 하위 클래스에서도 상속 받고 있었다면 관련된 파일들을 전체적으로 수정해야하기 때문에 강결합의 특성을 갖고 있는 것이다.
반면에 컴포지션은 부모 클래스의 기능을 그대로 둔다.
재정의가 절대 불가능하기 때문에 추후에 고쳐도 하위 클래스만 고치면 되는 것이지 부모의 기능이 바뀔 이유는 없는 것이다.
이러한 측면에서 컴포지션은 약결합 방식이라고 보면 된다.
결론적으로 컴포지션을 구현하면 상속 관계에서의 캡슐화가 깨지는 현상을 막을 수 있다.
컴포지션 구현 방법
상속은 상위 클래스에 있는 기능을 하위 클래스에서 재정의가 가능한 확장의 개념이었다면
컴포지션에서는 상속이 아닌 위임을 사용한다.
위임은 마치 하위 클래스에서 상위 클래스의 책임을 대신 위임받아 사용하는 것을 말한다.
이 때는 상위 클래스가 재정의되거나 하는 일이 없어서 상위 클래스의 캡슐화가 지켜진다.
위임 방식 1 - 의존성 주입(DI)
class Animal {
public String makeSound() {
return "일반 동물 소리";
}
public String animalType() {
return "동물";
}
}
class Dog {
private Animal animal;
public Dog() {
this.animal = new Animal();
}
public String makeSound() { // makeSound를 재정의했지만 Animal의 기능이 바뀌지 않는다.
return "멍멍";
}
public String animalType() { // Animal의 기능이 필요하다면 아래와 같이 정의한다.
return animal.animalType();
}
}
의존성 주입(DI) 방식의 위임 방식을 알아보자.
DI 방식의 위임 방식은 하나의 클래스가 다른 하나의 클래스를 갖고 있는 방식이다.
Dog extends Animal로 상속하지 않고 Dog 클래스 안에 Animal을 직접 선언하여 Animal 클래스를 직접 갖고 있는(has-a) 컴포지션 방식으로 처리하였다.
이렇게 하면 메서드를 재정의 할 수가 없으니 부모 클래스의 캡슐화가 지켜진다.
하지만 위의 방식에는 문제점이 하나 있다.
만약에 Animal이 abstract 클래스라고 가정해 보자
abstract class Animal {
String animalName();
public String makeSound() {
return "일반 동물 소리";
}
public String animalType() {
return "동물";
}
}
abstract 클래스에 있는 내용 중 추상 메서드가 포함되어있다면 자식 클래스에서 오버라이드를 해서 기능을 만들어줘야 한다는 룰이 존재한다.
즉, 위의 코드에서는 자식 클래스인 Dog에서는 Animal 클래스를 상속받을 때 animalName()을 꼭 오버라이드 해줘야하는 강제성이 존재한다.
일단 abstract는 직접 객체 생성이 불가능하다는 문제는 제외하더라도 단순 DI 방식에서는 오버라이드를 해서 메소드를 강제화하는 기능이 존재하지 않는다.
추상 메소드를 만들고 싶다고 Animal 클래스에 abstract를 이용하면 Dog에서는 extends를 해야하니 다시 상속의 늪으로 빠져들어가서 난처해진다.
그렇다면 animalName()이라는 메소드를 강제하도록 오버라이드 하려면 어떻게 할까?
위임 방식 2 - Interface 구현
interface AnimalFeature {
void makeSound();
String animalName();
}
class Animal implements AnimalFeature {
public void makeSound() {
System.out.println("Animal is making a sound.");
}
public String animalName() {
return "동물";
}
}
class Dog implements AnimalFeature {
public void makeSound() {
System.out.println("Dog is barking.");
}
public String animalName() {
return "멍멍이";
}
}
class Main {
public static void main(String[] args) {
Dog animal = new Dog();
animal.makeSound(); // 출력: "Dog is barking."
}
}
Dog와 Animal이 공통으로 구현해야 할 Interface인 Sound를 만들어서 서로 animalName()이라는 추상 메소드를 구현하도록 만들었다.
Dog는 Animal을 상속받는 관계였지만 상속을 제거하고 Interface로 서로를 묶었다고 보면 된다.
근데 Interface 위임 방식에도 문제가 있다.
의존성 주입 위임 방식에서는 Dog 클래스에서 Animal의 메서드들을 전부 이용할 수 있었지만 Interface 위임 방식에서는 Animal 메서드에 있는 기능을 가져다 쓰지 못하기 때문이다.
Animal 클래스에 animalType()이라는 동물의 종류를 가져오는 메서드가 추가 되었다고 생각해보자.
Dog animal = new Dog();
animal.animalType(); // 구현된게 없어서 실행 불가.
하지만 Dog에서는 Animal과 직접적인 연관관계가 사라졌기 때문에 animalType()이라는 메소드를 실행시키지 못한다.
각각의 위임 방식에 대한 특징
이쯤에서 요약을 해보자.
의존성 주입 위임 방식
- 부모 클래스의 캡슐화를 지켜준다.
- 추상 메소드 설정이 불가능하다.
- 부모 클래스의 기능을 이용할 수 있다.
Interface 위임 방식
- 부모 클래스의 캡슐화를 지켜준다.
- 추상 메소드 설정이 가능하다.
- 부모 클래스의 기능을 이용할 수 없다.
위임 방식을 따로따로 특징을 설명하기 위해서 적어본 것이다.
실제로는 두 가지 방식을 같이 쓰면 단점은 보완하고 장점만을 가져다 사용할 수 있다.
두가지 방식을 한꺼번에 사용한 위임 방식을 확인해 보자.
위임 방식 2개 같이 사용하기
interface AnimalFeature {
void makeSound();
String animalType();
}
class Animal implements AnimalFeature {
public void makeSound() {
System.out.println("Animal is making a sound.");
}
public String animalType() {
return "동물";
}
}
class Dog implements AnimalFeature {
private Animal animal;
public Dog() {
this.animal = new Animal();
}
public void makeSound() {
System.out.println("Dog is barking.");
}
public String animalType() {
return animal.animalType();
}
}
class Main {
public static void main(String[] args) {
Dog animal = new Dog();
animal.makeSound();
System.out.println(animal.animalType()); // 부모(Animal) 클래스 기능 이용
}
}
Dog 클래스에서는 Animal 기능을 이용할 수도 있는 의존성 주입 위임 방식의 장점과 Interface를 서로 구현해서 추상 메서드를 활용한 Interface 위임 방식의 장점을 이용한 코드다.
이제 컴포지션 위임 방식에 대한 설명이 끝났다.
마지막으로 Animal 클래스를 상속을 받는 것을 불가능 하게 만들어야 더 좋은 설계가 된다.
클래스를 상속 불가능으로 만들기
방법 1
final class Animal
클래스를 final로 설정하면 상속이 불가능하다.
방법 2
private Animal() {
}
클래스의 생성자를 private으로 설정한다.
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class Animal {
혹은 위와 같이 간단하게 롬복 생성자를 이용해서 만들 수도 있다.
클래스의 생성자를 private으로 만들면 하위 클래스에서 Animal 클래스를 상속받으려 할 때 하위 클래스에서의 생성자는 Animal 클래스의 생성자를 super()를 통해서 부르는 자바의 원리가 존재한다.
해당 원리를 잘 모른다면 기본 생성자(Default Constructor)가 필요한 이유를 봐보자.
super()를 호출했을 때 상위 클래스의 생성자는 private으로 잠겨있기 때문에 상속을 받지 못하는 원리다.
클래스의 생성자를 private으로 잠가두면 외부에서 생성자를 통해 객체를 만들지 못하니 정적 팩토리 메소드를 통해 객체를 만드는 방식으로 할당받아야 한다. 객체를 상속이 아니라 만드는 방식으로 가져오면 그것이 컴포지션이 되는 것이다.
두가지 방식 중 원하는 것으로 선택적으로 사용하면 된다.
상속을 통한 공통이 필요했던 이유
마지막으로 다시 근원적인 질문으로 돌아가보고 싶다.
우리는 어떨 때 상속의 구조를 만들어야 했던 것인가가 중요하다.
상속을 통해 공통 클래스를 만들려고 했던 이유는 보통 아래와 같다.
그 중에 대부분의 이유는 1번이다.
1. 공통 기능에 대해 재활용되는 클래스가 필요했던 경우
이런 경우에는 상속이 아니라 유틸리티 클래스를 사용하는 것 처럼 컴포지션 방식으로 클래스를 포함해서 사용하면 된다.
2. 로그 찍기와 같은 기능이 필요한데 서비스 로직에 포함하고 싶지 않을 경우
public List<User> getUsers() {
long start = before();
List<User> users = findAll();
after(start);
return users;
}
기존 로직 앞뒤로 기능을 포함하고 싶지 않은 경우가 있을 수 있다.
이런 경우 상속은 '기능 확장'의 개념으로 쓰일 수 있다.
위와 같은 코드가 알맞은 예시인데 findAll이라는 서비스 로직에 앞뒤로 로그를 찍는 기능을 추가하여 기능을 확장한 코드다.
이런 경우는 상속이 아닌 AOP를 이용한다.
3. "이동"이라는 추상적 기능을 "걷기", "달리기"로의 구현이 필요한 경우
상속이 아닌 Interface의 implement로 대체 가능하며 위의 컴포지션 방식을 참고하며 만드는 것이 좋다.
'📘 Effective Java' 카테고리의 다른 글
[Item 9] try-finally보다는 try-with-resources를 사용하라 (0) | 2022.09.03 |
---|---|
[Item 7~8] 다 쓴 객체 참조를 해제하라, finalizer와 cleaner 사용을 피하라. (0) | 2022.09.03 |
[Item 6] 불필요한 객체 생성을 피하라 (0) | 2022.09.02 |
[Item 5] 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) | 2022.09.02 |
[Item 4] 인스턴스화를 막으려거든 private 생성자를 사용하라. (0) | 2022.09.02 |