ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 클래스와 인터페이스 - 아이템 18. 상속보다는 컴포지션을 사용하라
    Study/Effective Java 2022. 12. 20. 17:17

    상속보다는 컴포지션을 사용하라

    상속의 문제점

    상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다. 일반적인 구체 클래스의 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다. 이번 게시글의 '상속'은 특정 클래스가 다른 클래스를 확장하는 구현 상속을 의미한다.

    메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말하면, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작할 수 있다는 말이다.

    또한, 상위 클래스와 하위 클래스의 관계가 컴파일 시점에 결정되어 구현에 의존하기 때문에, 실행 시점에 객체의 종류를 변경하는 것이 불가능하며 다형성과 같은 객체지향의 이점을 활용할 수 없다.

    정리하자면, 하위 클래스가 상위 클래스에 강하게 의존 및 결합이 되는 설계가 되는 것이다.

     

    아래 코드를 통해 구체적인 예시를 살펴보도록 하겠다.

     

    public class Lotto {
        protected List<Integer> lottoNumbers;
        
        public Lotto(List<Integer> lottoNumbers) {
            this.lottoNumbers = new ArrayList<>(lottoNumbers);
        }
        
        public boolean contains(Integer lottoNumber) {
            return this.lottoNumbers.contains(lottoNumber);
        }
        
        ...
    }

    Lotto 클래스는 로또 번호를 List로 가지고 있는 클래스이다.

     

    public class WinningLotto extends Lotto {
        private final LottoNumber bonusBall;
        
        public WinningLotto(List<Integer> lottoNumbers, LottoNumber bonusBall) {
            super(lottoNumbers);
            this.bonusBall = bonusBall;
        }
        
        public long calculateMatchCount(Lotto otherLotto) {
            return lottoNumbers.stream()
                    .filter((number) -> otherLotto.contains(number))
                    .count();
        }
        
        ...
    }

    WinningLotto 클래스는 당첨 로또번호를 가지고 있는 클래스이며, Lotto를 상속하는 클래스다. 아직까지는 특별한 문제사항이 없다.

     

    Lotto 클래스의 List<Integer>를 List<LottoNumber>로 변경하는 요구사항이 추가되었다고 하자.

     

    public class Lotto {
        protected List<LottoNumber> lottoNumbers;
    
        public Lotto(List<LottoNumber> lottoNumbers) {
            this.lottoNumbers = new ArrayList<>(lottoNumbers);
        }
    
        public boolean contains(LottoNumber lottoNumber) {
            return this.lottoNumbers.contains(lottoNumber);
        }
        
        ...
    }

    요구사항에 따라, List<Integer> 부분을 List<LottoNumber>로 변경하였다.

     

    public class WinningLotto extends Lotto {
        private final LottoNumber bonusBall;
    
        public WinningLotto(List<Integer> lottoNumbers, LottoNumber bonusBall) {
            super(lottoNumbers);   // Compile Error
            this.bonusBall = bonusBall;
        }
    
        public long calculateMatchCount(Lotto otherLotto) {
            return lottoNumbers.stream()
                    .filter((number) -> otherLotto.contains(number))
                    .count();
        }
        
        ...
    }

    WinningLotto 클래스는 Lotto 클래스를 상속하고 있어 의존적이기 때문에, 상위 클래스의 변경으로 인해 생성자 부분에 컴파일 에러가 발생한다. 이러한 문제를 해결하기 위해서는 WinningLotto 클래스를 상속받는 모든 하위 클래스를 개발자가 일일이 수정해줘야 한다.

    이처럼, 상속은 하위 클래스가 상위 클래스에 강하게 의존하는 구조를 띄기 때문에 변화에 유연하게 대처하기 어렵다.

     

    컴포지션의 사용

    컴포지션이란 기존 클래스가 새로운 클래스의 구성요소로 사용되는 것을 말한다.

     

    public class WinningLotto  {
        private Lotto lotto;
        private LottoNumber bonusBall;
    
        public WinningLotto(Lotto lotto, LottoNumber bonusBall) {
            this.lotto = lotto;
            this.bonusBall = bonusBall;
        }
    
        public long calculateMatchCount(Lotto otherLotto) {
            return lotto.getLottoNumbers().stream()
                    .filter((number) -> otherLotto.contains(number))
                    .count();
        }
    }

    위 코드는 앞에서 보았던 WinningLotto 클래스를 상속이 아닌, 컴포지션 구조로 변경한 것이다. Winning 클래에서는 인스턴스 변수로 Lotto 클래스를 가지고 있다는 것을 알 수 있다.

    이처럼, WinningLotto 클래스에서 인스턴스 변수로 Lotto 클래스를 가지고 있는 것이 컴포지션이다. WinningLotto 클래스에서 Lotto 클래스를 사용하고 싶으면, Lotto 클래스의 메서드를 호출하는 방식으로 사용하면 된다.

    컴포지션을 사용함으로써, 상위 클래스의 내부 구현 방식의 영향에서 벗어나 Lotto 클래스의 인스턴스 변수가 List<Integer>에서 List<LottoNumber>로 변경되더라도 영향을 받지 않게 된다.

     

    주의 사항

    상위 클래스에 의존하게 되어 종속적이고 변화에 유연하지 못하다고 판단되는 경우, 상속보다는 컴포지션을 사용하는 것이 좋다. 하지만, 컴포지션이 상속보다 무조건 좋은 것은 아니다. 상속이 적절하게 사용되면 조합보다 강력하고 개발하기도 편한 부분도 있다.

     

    상속은 아래와 같은 조건을 만족한 상태에서 사용하는 것을 추천한다.

     

    1. 확장을 고려하고 설계한 확실한 'is - a' 관계일 경우

    2. API에 아무런 결합이 없는 경우, 결함이 있다면 하위 클래스까지 전파돼도 괜찮은 경우

     

    public class 포유류 extends 동물 {
    
        protected void 숨을_쉬다() {
            ...
        }
        
        protected void 새끼를_낳다() {
            ...
        }
    }

    위와 같은 경우가 확실한 'is - a' 관계라고 할 수 있다. 포유류가 동물이라는 사실은 변할 가능성이 없고, 포유류가 숨을 쉬고 새끼를 낳는다는 행동이 변할 가능성은 거의 없다. 이처럼, 확실한 'is - a'의 관계일 경우, 상위 클래스는 변할 일이 거의 없다.

    향후 상속을 사용할 경우 확실한 'is - a' 관계인지 꼼꼼하게 고민해보고, 상위 클래스가 변화에 의해서 결함이 발생한 경우, 하위 클래스까지 영향이 가도 괜찮다고 판단된 경우 상속을 사용해도 좋다.

     

    최종 정리

    상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위 클래스가 순수한 'is - a' 관계일 때만 써야 한다. 'is - a' 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다. 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용해야 한다.


    출처

     이펙티브 자바 Effective Java 3/E. 조슈아 블로크 저자(글) · 개앞맵시(이복연) 번역

     

    728x90

    댓글

Designed by Tistory.