상속보다는 Composition을 사용하라

Jeonghwa·2023년 1월 25일
0

상속의 장점

상속이란 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것이다.
상속은 아래와 같은 장점을 가지고 있다.

  • 적은 양의 코드로 새로운 클래스를 작성할 수 있다. (시간단축)
  • 코드를 공통적으로 관리할 수 있기 때문에 코드의 추가 및 변경이 용이하다.
  • 코드의 재사용성을 높이고 코드의 중복을 제거하여 프로그램의 생산성과 유지보수성을 높인다.

출처 : 자바의신

하지만, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.


상속의 단점 : 캡슐화를 깨뜨린다.

상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드를 한줄도 건드리지 않은 하위클래스가 오작동할 수 있다.
이러한 이유로 상위 클래스 설계자가 확장을 충분히 고려하지 않는다면 하위 클래스는 상위 클래스의 변화에 맞춰 수정되어야한다.

구체적인 예를 살펴보자.

상위클래스의 자기사용(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를 반환한다. 그 원인은 TvinstallAll메서드가 install메서드를 사용해 구현되었기 때문이다.
따라서 CountTvinstallAll메서드는 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메서드를 재정의하지 않는다.

  • 하지만 이는 TvinstallAll메서드가 자기사용(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를 설치하는 모든 상위클래스의 메서드를 하위클래스에서 재정의해 필요한 조건을 검사해야한다.

  • 하지만 이를 간과하고 다음 릴리즈에서 새로 추가된 메서드를 재정의 하지 않는다면 허용되지 않는 sw가 설치될 수 있다.

2.다음 릴리즈에서 새로운 메서드가 추가됐는데, 하위클래스에 정의된 메서드와 시그니처가 같고 반환타입이 다르다면 컴파일 에러가 난다.

  • 반환타입마저 같다면 상위클래스를 재정의한셈이므로 앞의 문제를 마주할 수 있다.

해결법 : Composition을 사용하자(feat. Decorator pattern)

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 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);
    }
}

그리고 새로운 기능을 추가한 CountTvForwardingTv를 상속하여 추가 기능을 정의해 조합해준다.

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 객체를 조합함으로써 추가 기능의 조합을 설계하는 방식이다.

하지만 래퍼 클래스가 자신에 대한 참조를 다른 객체에 넘겨서 다음 호출(콜백)에 사용하는 콜백프레임워크에 적합하지 않다는 점을 주의하자.
참고 : stackoverflow - Wrapper Classes are not suited for callback frameworks


결론

상속은 강력하지만 캡슐화를 해치고 변화에 유연하지 못하다는 단점이 있다. 따라서 아래와 같은 조건을 고려하고 만족하는 상황에서 쓰여야한다.

1.하위클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. 즉, 순수한 is-a 관계일 때만 써야한다.

  • 아니라면 불필요하게 상위클래스의 내부 구현을 노출하는 꼴이 된다.

2.상위 클래스의 API에 아무런 결함이 없는지 확인하고 써야하며, 만약 결함이 있다면 하위 클래스까지 전파되도 괜찮은지 확인하자.

하지만 상위클래스가 확장을 고려해 설계되지 않았다면,, 또 위에서 만난 문제가 생긴다.

따라서 상황에 맞게 적절히 선택해 사용하도록 하자.
그리고 웬만하면 상속 대신 Composition과 Forwarding을 사용하자.

특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더더욱.

참고 :
이펙티브 자바 3/E
tecoble - 상속보다는 조합(Composition)을 사용하자.

profile
backend-developer🔥

0개의 댓글