본문 바로가기

JAVA

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

1. 상속과 컴포지션


 

상속 하위 클래스가 상위 클래스의 특성을 재정의 한것 > (IS-A) 관계
컴포지션 기존 클래스가 새로운 클래스의 구성요소가 되는것 > (HAS-A) 관계

 

자바에서는 부모 클래스의 메소드를 오버라이딩(Overriding) 할 수 있는 상속(Inheritance) 기능을 제공한다.

 

어찌보면 유용하게 사용될 기능처럼 느껴지겠지만 실제 개발을 하면서 느껴지는 상속의 불편함과 단점에 대해,

그리고 이를 극복하기위한 composite 의 개념을 다뤄보고자 한다.

 

 

2. 상속의 단점


 

상속은 코드를 재사용할 수 있는 강력한 수단이지만, 잘못 사용하면 객체의 유연성을 해치는 결과를 초래한다.

 

  1. 상속은 캡슐화를 위반한다.           
  2. 상속은 설계에 유연하지 못한다.
  3. 다중상속이 불가능하다.

 

여기서 말하는 상속이란, 클래스가 다른 클래스를 확장하는 구현상속을 의미하고, 클래스가 인터페이스를 구현하거나 인터페이스끼리 확장하는 인터페이스 상속이 아니다.

 

 

오류의 가능성이 있는 상속

 

 

/*
* HashSet 의 요소를 몇번 삽입했는지 갯수를 출력하기 위해만든 클래스
* */
public class CustomHashSet<E> extends HashSet {
    private int addCount = 0;

    public CustomHashSet(){}

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

    @Override
    public boolean add(Object o) {
        addCount++;
        return super.add(o);
    }

    @Override
    public boolean addAll(Collection c) {
        addCount+=c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

}

public class Main {
    public static void main(String[] args) {
        CustomHashSet<String> customHashSet = new CustomHashSet<>();
        List<String> test = Arrays.asList("아","이","스");
        customHashSet.addAll(test);

        System.out.println(customHashSet.getAddCount());
    }
}

 

다음의 코드를 살펴보면 기존의 HashSet 에서는 전체 요소의 갯수를 출력하는 size() 메소드를 제공하지만, 요소를 몇번 삽입했는지를 알기위해서 작성한 코드이다.

 

main 메소드를 보면 우리는 3개의 요소가 담긴 List를 추가하였고, 제대로 작성된 클래스라면 addCount의 갯수는 3개가 나와야 하지만, 출력결과를 보면 6이 나온것을 알 수 있다.

 

그이유는 HashSet 의 부모 클래스를 살펴보면 알 수 있다.

 

public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    for (E e : c)
        if (add(e))
            modified = true;
    return modified;
}

 

AbstractCollection 클래스에서 addAll 메소드는 Collection 객체의 수만큼 add 메소드를 수행한다고 작성되어있는데,

우리가 작성한 CutomHashSet 클래스에서도 add 메소드를 overriding 했으므로 총 6번의 add가 수행되면서

addCount의 값이 6이되는것을 확인할 수 있다.

 

우리가 원하는 결과를 얻으러면 overriding한 add 메소드를 없애던가, addAll 메소드를 없애면 되지만 이는 추후에 HashSet 클래스가 변경되면 치명적인 오류를 발생할 수 있다.

 

즉, 상속의 개념은 우리가 편하게 사용하기 위해서 등장했지만, 객체지향 프로그래밍의 캡슐화에 위배될 가능성이 있는것이며, 코드의 유연성도 저하된다.

 

3. 컴포지션


 

위와같은 상속의 단점으로 기존의 클래스를 확장한는 것이 아니라, 

새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게하는 컴포지션 방식을 사용한다.

 

이러한 방식을 forwarding(전달) 한다 라고 하며, 새로운 클래스는 기존의 클래스의 구현방식을 벗어나고, 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향을 받지 않는다.

 

HashSet 클래스는 기능을 규정하는 Set 인터페이스가 있으므로 CustomHashSet 클래스를 Forwarding 방식으로 구현할 수 있다.

 

/*
* HashSet 의 전체 요소의 갯수를 출력하기 위해만든 클래스
* */
public class CustomHashSet<E> extends ForwardingSet {
    private int addCount = 0;

    public CustomHashSet(Set<E> set){
        super(set);
    }

    @Override
    public boolean add(Object o) {
        addCount++;
        return super.add(o);
    }

    @Override
    public boolean addAll(Collection c) {
        addCount+=c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

}

 

public class ForwardingSet<E> implements Set {

    private final Set<E> set;

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

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

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

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

    @Override
    public Iterator iterator() {
        return set.iterator();
    }

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

    @Override
    public boolean add(Object o) {
        return set.add((E) o);
    }

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

    @Override
    public boolean addAll(Collection c) {
        return set.addAll(c);
    }

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

    @Override
    public boolean removeAll(Collection c) {
        return set.removeAll(c);
    }

    @Override
    public boolean retainAll(Collection c) {
        return set.retainAll(c);
    }

    @Override
    public boolean containsAll(Collection c) {
        return set.containsAll(c);
    }

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

 

public class Main {
    public static void main(String[] args) {
        CustomHashSet<String> customHashSet = new CustomHashSet<>(new HashSet<>());
        List<String> test = Arrays.asList("아","이","스");
        customHashSet.addAll(test);

        System.out.println(customHashSet.getAddCount());
    }
}

 

Set 인터페이스를 구현하는 전달 클래스(Forwarding Class)를 상속하며 CustomHashSet 클래스는 Set 인터페이스를 구현하며 Set객체를 인자로 받는 생성자를 가지고 있다. 

 

ForwardingSet 클래스에서는 private final 필드로 Set 객체를 생성하였는데 이유는 아래의 포스팅에서 확인이 가능하다.

 

즉 CustomHashSet 클래스는 어떤 Set 객체를 인자로 받아, 필요한 기능을 가지고있는 Set 객체로 변환시켜주는 역할을 한다. 이러한 클래스를 래퍼(Wrapper) 클래스라고 한다. 다른 Set 객체를 포장하고 있다라는 의미이다.

 

또한 이러한 디자인 패턴을 decorator 패턴이라고 한다.

 

 

결국 우리는 간편하게 사용하기 위해 상속이라는 기능을 사용할 수 있지만, LSP 의 원칙에 따라 IS-A 의 관계가 성립할때만 사용한는 것이 좋다.

하지만 우리가 고객의 요구사항을 만족시키며 작업을 할때에는 변할수 있는 환경이 많아 변수가 많기 때문에 IS-A 의 관계를 함부로 지정할 수 없다고 생각한다.

이러한 문제를 피하기위해 composite & forwarding 기법을 사용하는것이 설계에 유연하며 객체지향의 원칙을 위배하지 않는것이다.

 

 

출처 : https://gyoogle.dev/blog/computer-language/Java/Composition.html