3 상속보단 조립

source: categories/study/oop_programming/opp_programming_3.md

3. 상속보단 조립

3.1 상속보단 조립

  • 이 시간에는 상속보단 조립에 대해서 살펴보겠습니다.

  • 위의 클래스 다이어그램은 스프링이라는 프레임워크의 초기버전에서 앱 요청을 처리하기 위해 사용한 클래스의 계층 구조를 보여주고 있습니다.
  • 이 계층 구조에서 하위 타입의 클래스는 상위 타입의 기능을 확장하는 특징이 있습니다.
  • 예를 들어, AbstractController는 웹 요청 처리 위한 기본 기능을 제공하고 있고,
  • 하위에 있는 BaseCommandController는 웹 요청 처리 위한 기본 기능에 파라미터 처리 기능을 확장을 하고 있다.
  • 비슷하게 AbstractFormController 클래스는 파라미터 처리 기능에 추가로 폼을 보여주고 전송하는 기능을 확장하고 있다.
  • 이렇게 상속은 상위 클래스의 기능을 재사용하고 확장하는 방식으로 많이 사용하고 있다.

상속을 통한 기능 재사용시 발생할 수 있는 단점

  • 그런데 위와 같은 상속을 통한 기능 재사용은 몇가지 단점을 유발시킬 수 있다.
  • 크게 3가지 문제를 발생시킨다.

  1. 상위 클래스 변경 어려움
  2. 클래스 증가
  3. 상속 오용

상속을 통한 재사용의 단점 1

  • 상위 클래스 변경이 어려움
  • 상위 클래스를 변경하게 되면 그 변경이 모든 하위 클래스에 영향을 줄 수가 있습니다.
  • 즉, 상위 클래스를 조금만 잘못 변경해도 모든 하위 클래스가 비정상적으로 동작할 수 있게 되는 것이다.
  • 게다가 상위 클래스 입장에서는 앞으로 어떤 하위 클래스가 추가될지 모른다.
  • 그러다보니까 하위 클래스가 많아지면 많아질수록 상위 클래스를 변경하는게 매우 어렵게됩니다.

  • 게다가 상위 클래스가 어떤식으로 동작하는지 어느정도 알고 있어야 하위클래스가 기능을 재사용할 수 있게되는데,
  • 이는 결과적으로 상위 클래스는 하위 클래스에 대해서 캡슐화가 약해진다고도 볼 수 있는 것이다.
  • 캡슐화가 약해지니깐 그만큼 상위 클래스 내부 내용을 변경하기 어려워지게 되는 것이다.

상속을 통한 재사용의 단점 2

  • 클래스가 증가할 수 있다는 것이다.
  • 왼쪽에 있는 그림처럼 처음에 Storage 클래스하고 그 클래스를 상속받은 CompressedStorage, EncryptedStorage가 있었다고 해보자
  • CompressedStorage는 저장소 기능을 제공하는 스토리지를 상속받아서 거기에 압축 기능을 추가한 것이고
  • EncryptedStorage는 암호화 기능을 추가한 것이다.

  • 그런데 캐시 기능을 제공하는 스토리지가 필요해졌다.
  • 그러면 스토리지에 캐시기능을 확장한 CacheableStorage라는 그런 클래스를 만들어볼 수 있겠죠?

  • 여기에 압축과 암호화 기능을 함께 제공하는 스토리지가 필요해졌다면?
  • 그럼 또는 캐시 기능과 암호화 기능을 제공하는 스토리지가 필요해졌다면?
  • 이 경우에 위 그림에 보시는 것처럼 CacheableEncryptedStorageCacheableStorage를 상속받아서 구현할 수도 있겠죠?
  • 또는 EncryptedStorage를 상속을 받아서 구현을 할 수도 있을 것이다.
  • 비슷하게 암호화 기능과 압축 기능을 구현하는 그런 스토리지는 CompressedStorage 또는 EncryptedStorage를 상속받게해서 구현할 수 있을 것이다.

  • 만약에 압축, 암호, 캐시 기능, 이렇게 3가지를 모두 제공하는 클래스, 그런 스토리지가 필요하다면, 그땐 어떻게 해야될까요?
  • 압축, 암호, 캐시 스토리지 모두를 상속을 받아서 특정 클래스를 구현해야될까?
  • 아니면 CachableEncryptedStorage(캐시, 암호) 스토리지를 상속받고 CompressedStorage를 상속받아서 구현해야될까?
  • 이렇게 무언가 새로운 조합이 생길 때마다 하위 클래스가 증가하게 되고,
  • 심지어 그 하위 클래스가 증가하는 것도 어떤 클래스를 상속받아서 구현을 해야될지 애매한 상황이 발생하기도 한다.

상속을 통한 재사용의 단점 3

  • 상속 자체를 오용할 수 있다는 것이다.


public class Container extends ArrayList<Luggage> {
    private int maxSize;
    private int currentSize;

    public Container(int maxSize) {
        this.maxSize = maxSize;
    }
    public void put(Luggage lug) throws NotEnoughSpaceException {
        if (!canContain(lug)) throw new NotEnoughSpaceException();
        super.add(lug);
        currentSize += lug.size();
    }
    public void extract(Luggage lug) {
        super.remove(lug);
        this.currentSize -= lug.size();
    }
    public boolean canContain(Luggage lug) {
        return maxSize >= currentSize + lug.size();
    }
}


  • 지금 위에서 보시는 Container 클래스는 Luggage(수하물) 목록을 관리하는 클래스이다.
  • Container 클래스는 목록 관리 기능을 직접 구현하지 않고 ArrayList를 상속 받아서 구현하고 있다.
  • put 메소드를 보면 상위 클래스(super)의 add 메소드를 이용해서 수하물을 목록에 추가하고 있고,
  • extract 메소드를 보면 상위 클래스(super)의 remove 메소드를 이용해서 수하물을 목록에서 제거하고 있다.

  • put 메소드는 Container가 최대 크기를 넘겨서 Luggage를 보관하지 않도록 하려고 currentSize를 사용해 관리하고 있고,
  • canContain 메소드를 보시면 새로운 Luggage를 넣을 수 있는지 판단하기 위해서 currentSize 값을 사용하고 있다.

  • 지금 이 코드만 보시면 크게 문제가 없는 것처럼 보인다. 잘 만든 것 같다.
  • 실제로 잘 만든게 맞을까?

  • 올바른 사용법을 왼쪽 위에 표시했다.
  • Container 클래스에 정의된 canContain, put 메소드를 사용해서 수하물을 추가하는 코드이다.
  • 그런데 이클립스나 인텔리제이 같은 개발 도구에서 .을 누르면 Container에 정의되어 있는 메소드만 나오는 것이 아니고,
  • Container 클래스가 상속받은 ArrayList에 정의된 메소드 목록도 표시된단 말이죠?
  • 그래서 지금 여기서는 add 메소드가 첫번째로 목록에 나온다. (위의 사진 참고)
  • add 메소드는 이름만 보면 마치 ContainerLuggage를 추가하는 기능처럼 보이잖아?

  • 그래서 위와 같이 ContainerLuggage 추가에 대한 올바른 사용법을 제대로 숙지하지 않으면
  • 위의 오른쪽 아래처럼 add 메소드를 사용해서 수하물을 추가하는 코드를 작성할 수 있다.
  • add 메소드를 사용하면 currentSize가 올바르게 계산되지 않잖아?
  • 그러다보니까 Luggage를 최대 크기보다 넘치게 추가할 수 있는 그런 논리적인 오류가 발생할 수 있게된다.

  • 그러면 이는 add 메소드를 잘못 사용한 사람의 문제일까?
  • 아니다.
  • 사실 이건 잘못 사용할 가능성이 높은 Container 클래스를 구현한 사람이 더 잘못한겁니다.

  • 그럼 재사용 방법으로 상속을 사용할 때 발생하는 단점은 어떻게 없앨 수 있을까?
  • 그것은 바로 상속 대신에 조립을 사용하는 방식을 사용하는 것이다.

상속의 단점 해결 방법 -> 조립

  • 조립은 기능을 재사용하고 싶은 클래스가 있으면, 그 클래스의 객체를 필드나 필요한 시점에 생성해서 사용하는 것이다.

  • 조립(Composition)
    • 여러 객체를 묶어서 더 복잡한 기능을 제공
    • 보통 필드로 다른 객체를 참조하는 방식으로 조립 또는 객체를 필요 시점에 생성/구함

  • 예를 들어, 암호화 기능이 필요하다면, 아래 보시는 코드처럼
  • 암호화 기능을 제공하는 Encryptor 클래스를 상속 받아서 구현하는 것이 아니고 필드나 메소드에서 Encryptor 객체를 생성하고 재사용하는 것이다.
  • 즉, 다양한 기능을 제공하는 객체를 조립해서 더 복잡한 기능을 제공하는 것이다.


public class FlowController {
    private Encryptor encryptor = new Encryptor(); // 필드로 조립
    
    public void process() {
        // ...
        byte[] encryptedData = encryptor.encrypt(data);
        // ...
    }
}


조립을 통한 기능 재사용

  • 조립을 통해 기능을 재사용하면, 앞에서 봤던 클래스가 증식하는 문제가 사라지게된다.
  • 특정 기능을 재사용하고 싶으면, 오른쪽 그림에서 보는 것처럼, 해당 기능을 제공하는 클래스 객체를 만들어서 사용하면 되는 것이다.

  • 예를 들어, 서명 기능을 제공하는 기능이 추가로 필요하다면,
  • 서명 기능을 제공하는 클래스를 만드는 것이다.
  • Signer 이렇게 서명 기능을 제공하는 클래스를 만들고, 그 클래스에 객체를 조립해서 재사용 하는 것이다.

  • 조립하는 방식을 사용하면 Storage 클래스에 하위 클래스가 없잖아?
  • 그러니까 Storage 클래스의 내부를 변경하는 것도 상대적으로 더 수월해진다.

조립을 통한 기능 재사용



public class Container extends ArrayList<Luggage> {
  private int maxSize;
  private int currentSize;
  // ...
  public void put(Luggage lug) {
    if (!canContain(lug))
      throw new NotEnoughSpaceException();
    super.add(lug);
    currentSize += lug.size();
  }
  // ...
}


  • 위 코드가 아래처럼..


public class Container {
  private int maxSize;
  private int currentSize;
  private List<Luggage> luggages = new ArrayList();
  // ...
  public void put(Luggage lug) {
    if (!canContain(lug))
      throw new NotEnoughSpaceException();
    luggages.add(lug);
  }
  // ...
}


  • 위와 같이하면 상속을 오용하는 문제도 줄어든다.
  • 위에 조립으로 변경한 코드를 보시면 더 이상 ArrayListadd 메소드를 제공하지 않는다.
  • 그래서 앞에서 봤던 불필요한 기능까지 상속하면서 발생했던 문제가 해결된다.

상속보다는 조립(Composition over inheritance)

  • 상속하기에 앞서 조립으로 풀 수 없는지 검토
  • 진짜 하위 타입인 경우에만 상속 사용

  • 이렇게 기능 재사용을 위해서 상속을 사용하지 않고 조립을 사용하는 것이 보통 장점이 더 많다.
  • 그래서 상속 보다는 조립을 사용하라는 규칙이 있는 것이다.
  • 지금 코드를 재사용하려고 상속을 고민하고 있다면, 그러지마시고 조립으로 맞출 수 없는지 검토를 해야됩니다.
  • 진짜로 하위 타입인 경우에만 상속으로 구현을 해야된다.

  • 예를 들어, 앞에서 봤던 Container 클래스의 경우 Container 클래스는 ArrayList의 한 종류가 아니잖아?
  • 단지, 목록을 관리하는 기능이 필요해서 ArrayList를 상속을 했단말야?
  • 이렇게 진짜로 하위 타입이 아닌데, 기능 재사용 때문에 상속을 오용하면 문제가 발생한다.

  • 여러분이 상속을 고려하고 있다면, 어떤 기능을 구현하기 위해서 상속을 고려하고 있다면, 꼭 조립하는 방법으로 해결할 수 없는지, 먼저 검토해보시기 바랍니다.