제육's 휘발성 코딩
article thumbnail
반응형

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

상속은 코드를 재사용하는 강력한 수단이지만, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.

같은 프로그래머가 통제하는 패키지 안에서 상속한다면 안전할 수 있지만, 다른 패키지의 구체 클래스를 상속하는 것을 위험하다.

(인터페이스 상속이 아닌 클래스 상속)

 

상속의 문제점

상속은 상위 클래스의 설계에 따라 캡슐화를 깨뜨리는 등의 하위 클래스에 문제를 일으킬 수 있다.

예를 들어 HashSet을 사용하는 프로그램에서 성능을 높이기 위해 처음 생성된 이후 원소가 몇개 더해졌는지 등의 기능을 추가했다고 생각해보자.

 

import java.util.Collection;
import java.util.HashSet;
import java.util.List;

public class InstrumentedHashSet<E> extends HashSet<E> {

  // 추가된 원소의 수
  private int addCount = 0;

  public InstrumentedHashSet(){}

  public InstrumentedHashSet(int initCap, float loadFactor) {
    super(initCap, loadFactor);
  }

  @Override
  public boolean add(E e) {
    addCount++;
    return super.add(e);
  }

  @Override
  public boolean addAll(Collection<? extends E> c) {
    addCount += c.size();
    return super.addAll(c);
  }

  public int getAddCount() {
    return addCount;
  }
}
  • 다음과 같이 addaddAll 을 재정의하여 추가된 원소의 수를 계산할 수 있도록 구현해보자.

 

class InstrumentedHashSetTest {
  public static void main(String[] args) {
    InstrumentedHashSet<String> hs = new InstrumentedHashSet<>();
    hs.addAll(List.of("1", "2", "3", "4"));
    System.out.println(hs.getAddCount()); // 8 반환
  }
}
  • addAll 을 통해 현재 추가된 원소의 수를 반환하고자 했지만 기대했던 4가 아닌 8이 반환된다.

 

image

  • 위의 코드가 정상적으로 동작하지 않은 이유는 addAll 에서 add 를 호출하기 때문에 원소 1개 당 2번의 카운팅이 이루어지기 때문이다. 따라서 정상 동작하게 만드려면 addAll 메서드를 호출하지 않거나, 직접 구현해야 한다.

 

지금 같은 상황을 자신의 다른 부분을 사용하는 자기사용(self-use) 방식이라고 한다. 이 기능은 다음 릴리즈에서도 유지될지는 모르기 때문에, addAll을 재정의하지 않는다고 현재 프로그램이 계속 동작할 것이라는 보장이 없다. 또한 하위 클래스에서 접근할 수 없는 private 필드라면 별도의 구현도 불가능하다.

상속의 문제점은 더 있다. 예를 들어 상위 클래스에 특정 조건을 지정했지만, 하위 클래스에서 재정의할 경우 새로운 변수가 생길 수 있다.

 

상속 대신 컴포지션 사용

컴포지션이란 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하도록 하는 것을 의미한다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이 설계를 컴포지션(구성)이라 한다.

 

새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들은 전달 메서드 (forwarding method)라 부른다.

 

import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

// 기존의 Set 인터페이스 메서드를 ForwardingSet으로 전달 
public class ForwardingSet<E> implements Set<E> {
  private final Set<E> s; // private 으로 Set 인스턴스를 참조

  public ForwardingSet(Set<E> s) {
    this.s = s;
  }

  @Override
  public int size() {
    return s.size();
  }

  @Override
  public boolean isEmpty() {
    return s.isEmpty();
  }

  @Override
  public boolean contains(Object o) {
    return s.contains(o);
  }

  @Override
  public Iterator<E> iterator() {
    return s.iterator();
  }

  @Override
  public Object[] toArray() {
    return s.toArray();
  }

  @Override
  public <T> T[] toArray(T[] a) {
    return s.toArray(a);
  }

  @Override
  public boolean add(E e) {
    return s.add(e);
  }

  @Override
  public boolean remove(Object o) {
    return s.remove(o);
  }

  @Override
  public boolean containsAll(Collection<?> c) {
    return s.containsAll(c);
  }

  @Override
  public boolean addAll(Collection<? extends E> c) {
    return s.addAll(c);
  }

  @Override
  public boolean retainAll(Collection<?> c) {
    return s.retainAll(c);
  }

  @Override
  public boolean removeAll(Collection<?> c) {
    return s.removeAll(c);
  }

  @Override
  public void clear() {
    s.clear();
  }

  @Override
  public int hashCode() {
    return s.hashCode();
  }

  @Override
  public boolean equals(Object obj) {
    return s.equals(obj);
  }

  @Override
  public String toString() {
    return s.toString();
  }
}
  • 재사용할 수 있는 전달 클래스인 ForwardingSet 클래스를 만들자. 이 때 Set을 인터페이스 상속받아 private 필드로 인스턴스를 생성한 후 전달 메서드를 구현하자.

 

import java.util.*;

public class InstrumentedSet<E> extends ForwardingSet<E> {

  // 추가된 원소의 수
  private int addCount = 0;

  public InstrumentedSet(Set<E> s) {
    super(s);
  }

  @Override
  public boolean add(E e) {
    addCount++;
    return super.add(e);
  }

  @Override
  public boolean addAll(Collection<? extends E> c) {
    addCount += c.size();
    return super.addAll(c);
  }

  public int getAddCount() {
    return addCount;
  }
}
  • InstrumentedHashSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해서 설계되어 아주 유연하다. 구체적으로는 Set 인터페이스를 구현해서 인수로 받는 생성자를 제공한다. 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
  • 상속 방식은 구체 클래스 각각 따로 확장해서 지원하고 싶은 상위 클래스의 생성자를 별도로 정의해줘야 하지만, 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.

 

class InstrumentedSetTest {
  public static void main(String[] args) {
    InstrumentedSet<String> hs = new InstrumentedSet<>(new HashSet<>());
    hs.addAll(List.of("1", "2", "3", "4"));
    System.out.println(hs.getAddCount());

    InstrumentedSet<String> ts = new InstrumentedSet<>(new TreeSet<>());
    ts.addAll(List.of("1", "2", "3", "4"));
    System.out.println(ts.getAddCount());
  }
}
  • 다음 예시와 같이 Set의 모든 구현체를 계측할 수 있다. *모든 Set 인스턴스를 wrap 하고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라고 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. *
  • 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다. 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.

 

상속을 사용하는 상황

상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 한다. 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. 즉 B는 A이다 (아반떼는 자동차다 (O), 거미는 사람이다(X)) 가 성립하는 경우에만 사용한다.

자바 API에서도 이 원칙을 위반한 클래스들이 있다. 대표적으로 Stack과 Vector이다. Stack은 Vector이다가 성립하지 않는데, Stack은 Vector를 확장해서 구현되어 있다.

 

추가적으로 Properties의 경우이다. 이 클래스는 HashTable을 상속 받았는데, 기본 기능인 properties.getProperty(key) 와 HashTable의 기능인 properties.get(key)의 결과가 HashTable이 변경된다면 달라질 수 있다.

정리

상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. 이 관계가 성립하여도, 하위 클래스의 패키지가 상위 클래스의 패키지와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다.

 

상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자. 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 강력하다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^