해당 포스트는 "Effective Java" 책의 내용을 요약한 것이다.



※ extends 대신 composition을 사용해라

: 상속을 제대로 사용하지 못하면 소프트웨어는 많이 불안정해진다. 상속은 단일 패키지 안에서만 해야 안전하다. 하위 클래스는 상위 클래스에 많이 의존하게 된다. 상위 클래스의 코드가 수정되면 하위 클래스의 코드도 수정해야하는 경우가 많다. 상속은 중요하게 캡슐화를 위반하기 때문이다. 구체적인 사례는 다음과 같다. 다음 코드는 HashSet을 상속해 삽입된 요소의 수를 추적하는 클래스이다. 

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;
  }
}
//메인 함수
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println(s.getAddCount());
위 예제에서 s 객체의 getAddCount()를 하면 3을 나와 정상이지만 6이 나온다. addAll에서 addCount를 3을 더한 후 super.addAll(c)가 호출된다. super.addAll에서는 원소를 삽입할 때마다 add메서드를 호출한다. 즉 add를 추가로 3번 더 호출해 addCount를 3번 더 더한다. 그래서 6이 나온다. 이렇게 하위 클래스는 상위 클래스에 의존적일 수 밖에 없다. 또한 상위 클래스에서 어떠한 변경이 있을 시 하위 클래스도 변경을 해야될 수 있다. 이렇게 하위 클래스는 깨지기 쉬운 클래스일 수 밖에 없다. 위와 같은 문제는 메서드 재정의 때문에 발생한 것이기에 메서드를 재정의를 하지 않고 새로운 메서드를 만들 수도 있다. 하지만 상위 클래스 릴리즈 때 같은 이름의 메서드가 추가 되면 곤란한 상황이 된다. 

이러한 문제들을 해결한 방법이 하나 있다. 바로 상위 클래스를 상속하는 대신에 상위 클래스를 참조하는 private 필드를 하나 만드는 것이다. 이러한 방법을 구성(composition)이라고 한다. 새로운 클래스의 메서드들은 기존 클래스에서 필요한 메서드를 호출하면 된다. 이를 전달이라고 하고 해당 메서드를 전달 메서드라고 한다. composition은 기존 클래스의 세부 구현과 상관 없기 때문에 기존 클래스가 수정되도 영향을 미치지 않는다. 다음은 위 예제를 composition 방법으로 바꾼 것이다.

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; } } public class ForwardingSet<E> implements Set<E> { // Set 객체 private final Set<E> s; public ForwardingSet(Set<E> s) { this.s = s; } public void clear(){ s.clear(); } public boolean contains(Object o){return s.contains(o); } public boolean isEmpty(){ return s.isEmpty(); } public int size(){ return s.size(); } public Iterator<E> iterator(){ return s.iterator(); } public boolean add(E e){ return s.add(e); } public boolean remove (Object o){ return s.remove(o); } public boolean containsAll(Collection<?> c){ return s.containsAll(c); } public boolean addAll(Collection<? extends E> c){ return s.addAll(c); } public boolean removeAll(Collection<?> c){ return s.removeAll(c); } public boolean retainAll(Collection<?> c){ return s.retainAll(c); } public Object[] toArray() { return new Object[0]; } public <T> T[] toArray(T[] a){ return s.toArray(a); } @Override public boolean equals(Object o){ return s.equals(o); } @Override public int hashCode(){ return s.hashCode(); } @Override public String toString(){ return s.toString(); } }

InstrumentedSet 클래스는 계승 대신 구성을 사용하는 포장(wrapper) 클래스이다. 다른 Set 객체를 포장하고 있기 때문이다. ForwardingSet 클래스는 재사용 가능한 전달(forwarding) 클래스이다. 위와 같이 구현하면 안정적이고 유연성도 좋다. 또한 Set 인터페이스가 있어 더 안정적이다. InstrumentedSet 클래스는 어떤 Set 객체를 인자로 받아 필요한 기능을 갖춘 다른 Set 객체로 변환할 수 있다. 이러한 접근법은 계승의 경우 한 클랫에만 적용이 가능하고 별도의 생성자를 만들어야 한다. 하지만 포장 클래스를 사용하면 어떤 Set 구현도 원하는 대로 수정할 수 있고 이미 있는 생성자도 그대로 사용할 수 있다. 다음과 같이 말이다.

Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp)); Set<E> s1 = new InstrumentedSet<E>(new HashSet<E>(ca));

또한 이미 사용 중인 객체에 일시적으로 원하는 기능을 넣을 수도 있다.

static void walk(Set<Dogs> dogs){ InstrumentedSet<Dog> iDogs = new InstrumentedSet<Dog>(dogs); .... // 이 메서드 안에서는 dogs가 아닌 iDogs를 사용한다 }


상속은 "IS-A" 관계가 확실할 때만 사용해야 한다. 그렇지 않으면 구성을 사용해야 한다. 구성 대신 상속을 사용할 때 계승할 클래스에 문제가 있는 지 확인해야 하고 그렇다면 그 문제들이 계속 새 API의 일부가 되어도 상관 없는지 확인해야 한다. 상위 클래스의 문제는 하위 클래스에 전파된다. 반면 구성은 그런 약점을 감추는 새로운 API를 만들 수 있다. 


계승은 캡슐화 원칙을 깨 IS-A 관계가 확실할 때만 사용해야 한다. IS-A 관계가 성립하더라도 하위 클래스가 상위 클래스와 다른 패키지에 있거나 상위 클래스가 상속을 고려해 만든 클래스가 아니라면 구성을 사용하는 게 좋다. 또한 포장 클래스 구현에 인터페이스까지 있다면 더 좋다.



※ extends을 위한 설계와 문서를 갖추거나 ,그럴 수 없다면 상속을 금지하라

: 재정의 가능 메서드(public이나 protected로 선언된 모든 메서드)에 대해서 내부적으로 어떻게 사용하는 지(내부 동작 원리) 반드시 문서에 남겨야 한다. 다음은 java.util.AbstractCollection remove 메서드의 주석이다.

This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator's remove method.

위 주석을 보면 iterate 메서드를 재정의하면 remove가 영향을 받는 다는 걸 알 수 있다. 반면 해당 포스트의 첫 번째 예제에서 HashSet의 하위 클래스를 만드는데 add를 재정의하면 addAll에 무슨 일이 생길지 알 수 없다. 따라서, 하위 클래스를 안전하게 만들기 위해서 문서에 반드시 구현 세부사항을 기술해야 한다. 


계승을 허용하라면 반드시 따라야할 규칙 사항이 있다. 생성자는 재정의 가능 메서드를 호출해서는 안 된다. 상위 클래스의 생성자는 하위 클래스의 생성자보다 먼저 실행이 되 상위 클래스에서 하위 클래스의 재정의 메서드가 호출된다. 이는 심각한 오류를 유발한다. 클래스가 Serializable 인터페이스를 사용할 경우 readObject에서 재정의 가능한 메서드를 호출 하지 않도록 해야 한다. 또한 클래스 내부적으로 재정의 가능 메서드를 사용하는 경우를 완전히 제거해야 한다. 그러면 클래스는 계승해도 안전한 클래스가 된다. 메서드를 재정의해도 다른 메서드에는 영향이 없기 때문이다. 만약 재정의 가능 메서드를 내부적으로 사용하는 코드는 다음과 같이 해라. 재정의 가능 메서드의 내부 코드를 private로 선언된 도움 메서드 안으로 옮기고 각각의 재정의 가능 메서드가 해당 메서드를 호출하게 하라. 그런 다음 재정의 가능 메서드를 호출하는 내부 코드는 전부 해당 private 도움 메서드 호출로 바꾸면 된다.


※ 추상 클래스 대신 인터페이스를 사용하라

: 인터페이스는 다양한 구현이 가능한 자료형을 정의할 수 있고 포장 클래스를 통해 안전하면서도 강력한 기능 개선이 가능하다. 추상 클래스는 상속 이외에 수단이 없다. 물론 인터페이스는 함수 구현을 하지 못하는 단점을 가지고 있다. 이런 단점을 해결하는 방안으로 추상 골격 구현 클래스가 있다. 추상 골격 구현 클래스에 대한 설명은 해당 문장 url에 있다. 추상 골격 구현 클래스는 인터페이스와 추상 클래스의 장점을 결합해 놓은 것이다. 골격 구현 클래스를 API에 포함시킬 수 있다면 제공하는 게 좋다. 

인터페이스보다 추상 클래스로 정의하면 좋은 점은 추상 클래스가 더 발전시키기 쉽다는 거다. 만약 API에 새로운 메서드를 추가하고 싶다면 추상 클래스는 기본적인 구현 메서드를 담으면 된다. 하지만 인터페이스의 경우 메서드를 추가하면 해당 API를 구현한 다른 클라이언트 코드들은 오류를 발생시킬 것이다. 따라서 유연하고 강력한 API를 만들 때는 인터페이스를 사용하는 게 좋고, 개선이 쉬운 API를 만들 때는 추상클래스를 만드는 게 좋다. 또한 인터페이스가 공개되고 수정이 거의 불가능하기 때문에 public 인터페이스는 조심해서 사용해야 한다.

참고로 인터페이스는 구현한 클래스의 기능을 담당하는 것이다. 인터페이스 안에 상수만 정의해 놓는 상수 인터페이스는 절대 사용해서는 안 된다. 사용해야 한다면 가장 관련된 클래스 내부에 public static final로 선언해 놓아야 한다.


+ Recent posts