상속이란 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것이다.
상속은 아래와 같은 장점을 가지고 있다.
출처 : 자바의신
하지만, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드를 한줄도 건드리지 않은 하위클래스가 오작동할 수 있다.
이러한 이유로 상위 클래스 설계자가 확장을 충분히 고려하지 않는다면 하위 클래스는 상위 클래스의 변화에 맞춰 수정되어야한다.
구체적인 예를 살펴보자.
상위클래스의 자기사용(self-use) 메서드
Tv
라는 클래스를 사용하는 CountTv
프로그램이 있다. Tv
생성 이후로 몇번의 SW가 설치되었는지 내구성을 실시간으로 알 수 있도록 업그레이드된 CountTv
를 만들었다.
public class Tv <E>{
List<E> softwareList;
public Tv() {
softwareList = new ArrayList<>();
}
public void install(E e){
softwareList.add(e);
}
public void installAll(List<E> softwareList){
softwareList.stream().forEach(e -> this.install(e));
}
}
public class CountTv<E> extends Tv<E>{
int installNum; // 설치횟수
@Override
public void install(E e) {
installNum++;
super.install(e);
}
@Override
public void installAll(List<E> list){
installNum+=list.size();
super.installAll(list);
}
}
이 클래스는 잘 구현된 것 처럼 보이지만, 총 3번의 sw설치가 있었음에도 불구하고 설치횟수로 5를 반환한다. 그 원인은 Tv
의 installAll
메서드가 install
메서드를 사용해 구현되었기 때문이다.
따라서 CountTv
의 installAll
메서드는 Override된 install
메서드를 사용하여 중복으로 값이 더해진 것이다.
public static void main(String[] args) {
CountTv<Integer> tv = new CountTv();
tv.install(1);
System.out.println(tv.installNum); // 1
tv.installAll(List.of(2, 3));
System.out.println(tv.installNum); // 5
}
이 경우 2가지 방법이 있다.
1.하위 클래스에서 installAll
메서드를 재정의하지 않는다.
Tv
의 installAll
메서드가 자기사용(self-use)이용해 구현했음을 가정한 해법이다. 다음 릴리즈에서도 유지될지 바뀔지는 아무도 모른다.2.하위 클래스에서 installAll
메서드를 상위 클래스의 메서드를 사용하지 않고 새로 구현한다.
@Override
public void installAll(List<Integer> list){
installNum+=list.size();
list.stream().forEach(i -> this.install(i));
}
private
필드를 써야하는 경우 구현이 불가능하다.상위클래스의 새로운 메서드 추가
다음 릴리즈에서 Tv
클래스에 새로운 메서드가 추가된다면 어떻게 될까?
1.보안 상 sw의 특정조건을 만족해야만 TV에 설치가 가능한 상황을 가정해보자. sw를 설치하는 모든 상위클래스의 메서드를 하위클래스에서 재정의해 필요한 조건을 검사해야한다.
2.다음 릴리즈에서 새로운 메서드가 추가됐는데, 하위클래스에 정의된 메서드와 시그니처가 같고 반환타입이 다르다면 컴파일 에러가 난다.
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private
field로 기존 클래스의 인스턴스를 참조하는 Composition을 사용하자.
새로운 클래스의 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 Forwarding이라 하며, 기존 클래스의 내부 구현 방식의 영향에서 벗어날 수 있다. 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
Composition과 Forwarding으로 리팩토링한 예를 살펴보자.
public interface TvInterface<E>{
void install(E e);
void installAll(List<E> softwareList);
}
public class Tv <E> implements TvInterface<E>{
List<E> softwareList;
public Tv() {
softwareList = new ArrayList<>();
}
public void install(E e){
softwareList.add(e);
}
public void installAll(List<E> softwareList){
softwareList.stream().forEach(e -> this.install(e));
}
}
Tv의 기능을 정의한 TvInterface
를 활용해 ForwardingTV
를 만들었다. TvInterface
를 멤버 변수로 가지고 TvInterface
구현체를 파라미터로 받는 생성자를 만들어 상속이 아닌 Composition으로 구현했다.
public class ForwardingTv<E> implements TvInterface<E>{
private final TvInterface<E> tv;
public ForwardingTv(TvInterface<E> tv){
this.tv = tv;
}
public void install(E e){
tv.install(e);
}
public void installAll(List<E> eList){
tv.installAll(eList);
}
}
그리고 새로운 기능을 추가한 CountTv
는 ForwardingTv
를 상속하여 추가 기능을 정의해 조합해준다.
public class CountTv<E> extends ForwardingTv<E> {
int installNum; // 설치횟수
public CountTv(TvInterface<E> tv) {
super(tv);
}
@Override
public void install(E e) {
installNum++;
super.install(e);
}
@Override
public void installAll(List<E> list){
installNum+=list.size();
super.installAll(list);
}
}
public static void main(String[] args) {
CountTv<Integer> countTv = new CountTv(new Tv<>());
countTv.install(1);
System.out.println(countTv.installNum); // 1
countTv.installAll(List.of(2, 3));
System.out.println(countTv.installNum); // 3
}
결과적으로 임의의 Tv에 Count기능을 덧붙여 새로운 Tv를 만드는 것이 이 예제의 핵심이다. 또한 이 컴포지션 방식은 어떠한 TvInterface의 구현체와도 결합할 수 있다.
TvInterface
인스턴스를 감싸고 있다는 뜻에서 CountTv
와 같은 클래스를 래퍼(Wrapper) 클래스라고하며, 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.
데코레이터 패턴이란, 객체의 결합을 통해 기능을 동적으로 유연하게 확장할 수 있는 패턴이다. 즉, 기본기능에 각 추가 기능을 Decorator클래스로 정의한 후 필요한 Decorator 객체를 조합함으로써 추가 기능의 조합을 설계하는 방식이다.
- Component =
TvInterface
- ConcreteComponent =
Tv
- Decorator =
ForwardingTv
- ConcreteDecorator =
CountTv
출처 : [Design Pattern] 데코레이터 패턴이란
하지만 래퍼 클래스가 자신에 대한 참조를 다른 객체에 넘겨서 다음 호출(콜백)에 사용하는 콜백프레임워크에 적합하지 않다는 점을 주의하자.
참고 : stackoverflow - Wrapper Classes are not suited for callback frameworks
상속은 강력하지만 캡슐화를 해치고 변화에 유연하지 못하다는 단점이 있다. 따라서 아래와 같은 조건을 고려하고 만족하는 상황에서 쓰여야한다.
1.하위클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. 즉, 순수한 is-a
관계일 때만 써야한다.
2.상위 클래스의 API에 아무런 결함이 없는지 확인하고 써야하며, 만약 결함이 있다면 하위 클래스까지 전파되도 괜찮은지 확인하자.
하지만 상위클래스가 확장을 고려해 설계되지 않았다면,, 또 위에서 만난 문제가 생긴다.
따라서 상황에 맞게 적절히 선택해 사용하도록 하자.
그리고 웬만하면 상속 대신 Composition과 Forwarding을 사용하자.
특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더더욱.