반응형

오류를 내기 쉬운 상속

 상속은 코드 중복을 줄이고, 기능을 확장하는 강력한 수단이지만, 잘못 사용하면 오류를 내기 쉽기 때문에 주의를 요한다.

 

 HashSet이 처음 생성된 이후 원소가 몇개 더해졌는지(HashSet의 크기와는 다른 개념이다.) 를 확인하는 기능을 상속을 통해 추가해보았다.

 


잘못 사용된 상속

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

    private int addCount = 0;

    public InstrumentedHashSet(){

    }

    @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;
    }
}

class Main{
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(Set.of("가","나","다"));

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

 

 

 HashSet을 상속받는 InstrumentedHashSet 클래스를 생성한 후 추가된 원소의 수를 나타내는 addCount 멤버필드를 생성하고, HashSet 클래스에 원소를 추가하는 메서드를 재정의하여 addCount를 증가시키도록 구현하였다.

 

 겉으로 봐선 문제가 없어보이지만 실제로 main 메서드를 실행하면 getAddCount()은 예상했던 3이 아닌 6을 리턴한다.

 


6을 리턴하는 이유

HashSet에서 사용하는 addAll 메서드는 내부적으로 add 메서드를 호출하기 때문이다. 이 add 메서드는 addCount를 증가시키도록 재정의되었기 때문에 3이 아닌 6이 증가하는 것이다.

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

 


상속을 유지하고 문제를 해결하는 방법

 

하나, addAll 메서드를 재정의하지 않는다.

addAll 메서드를 재정의하지 않으면 HashSet에 존재하는 addAll 메서드를 사용하게 된다. 이때에는 addCount 값을 증가시키지 않고, 재정의된 add 메서드를 호출할 때만 증가시키게되므로 당장은 제대로 동작하게 된다.

 

하지만 이는 HashSet의 addAll이 add 메서드를 이용해 구현했음을 가정한 해법이다. addAll의 내부 구현에 종속된 것이다. 이런 상황에서 HashSet의 addAll 메서드 내부 구현이 add 메서드를 호출하게 아닌 다른 방식으로 변경된다면 어떨까? 재정의한 add 메서드는 호출되지 않아 버그가 발생할 것이다.

 

 이처럼 상위 클래스의 내부 구현에 의존하는 메서드는 항상 잠재적인 위험요소가 된다.

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

    private int addCount = 0;

    public InstrumentedHashSet(){

    }

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

class Main{
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(Set.of("가","나","다"));

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

 

둘, addAll 메서드를 다른 식으로 재정의한다.

 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한번만 호출하는 것이다. 이 방식은 상위 클래스의 addAll 메서드를 호출하지 않으니 그 메서드의 내부 구현이 어떻게 동작하는지 신경쓸 필요가 없다.

 하지만 상위 클래스의 메서드 동작을 다시 구현하는 것은 단순 호출하는 것보다 많은 시간과 노력이 필요하다. 자칫 성능을 떨어뜨릴 수도 있다.

 

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

    private int addCount = 0;

    public InstrumentedHashSet(){

    }

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

    public boolean addValues(Collection<? extends E> c) {
        for(E e : c){
            add(e);
        }
        return true;
    }

    public int getAddCount(){
        return addCount;
    }
}

class Main{
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addValues(List.of("가","나","다"));

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

 


캡슐화를 깨뜨리는 상속

 두번째 방법을 사용하면 문제가 해결되긴 하지만, 객체지향적인 문제가 발생하는데 바로 캡슐화가 무너지게 되는 것이다.

 

캡슐화
객체의 속성과 행위를 하나로 묶고, 실제 구현 내용 일부를 내부에 감추어 은닉한다.

 

 첫번째 방법의 경우 부모 클래스의 addAll 메서드를 호출했다. 이는 HashSet의 addAll 메서드가 내부적으로 add 메서드를 호출한다는 사실에 근거하였다는 점에서 캡슐화를 무너뜨린다.

 

두번째 방법은 어떨까? addAll 의 메서드를 호출하지도 않고 HashSet의 구현부를 꼭 알아야하는 부분도 당장은 없어보인다. 하지만 InstrumentedHashSet에 메서드를 추가할 때 HashSet의 메서드 시그니처를 알고 피해야한다는 점에서 캡슐화를 무너뜨린다.

 이를 반대로 생각하면 기존 자식 클래스에서 사용하는 메서드 시그니처와 똑같은 메서드가 부모 클래스에 새로 추가될 때 문제가 발생할 수 있다.

 

 아래 코드는 들어오는 정수 값에 대해서는 반드시 0 이상의 양수만 받을 수 있도록 하는 HashSet을 관리하기 위해 integerValidation 이라는 private 메서드를 추가한 상황이다.

 테스트를 해보면 -1 과 같은 값이 들어갈 경우 예외가 발생하게 된다. 

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

    private int addCount = 0;

    public InstrumentedHashSet(){

    }

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

    @Override
    public boolean addAll(Collection<? extends E> c) {
        for(E e : c){
            add(e);
        }
        return true;
    }

    public int getAddCount(){
        return addCount;
    }

    private void integerValidation(E e){

        if(Integer.class.isInstance(e)){
            Integer a = (Integer)e;

            if(a.intValue() < 0){
                throw new IllegalArgumentException("Integer 타입은 0 이상 값만 추가할 수 있습니다.");
            }
        }
    }
}

class Main{
    public static void main(String[] args) {
        InstrumentedHashSet<Integer> s = new InstrumentedHashSet<>();
        s.addAll(List.of(Integer.valueOf(0),Integer.valueOf(1),Integer.valueOf(-1))); // 예외발생

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

 

 그런데 몇년 후 HashSet에 똑같은 메서드 시그니처를 갖고 반환만 다른 integerValidation이 추가된다면, 부모 클래스와 자식 클래스의 메서드가 충돌하게 되어 컴파일 에러가 발생하게 된다.

 

 결국 캡슐화가 무너지기 시작하면 결합도가 강해지게 되고, 강해진 결합도는 어떤 클래스의 변경이 일어났을 때 결합된 클래스에도 영향을 미치게 된다.

 


상속말고 컴포지션

 이러한 문제를 피해가는 방법이 바로 컴포지션이다.

컴포지션
다른객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 메서드를 호출하는 기법.

 

위 상속을 컴포지션 방식으로 변경하면 아래와 같다.

 

 기존 인스턴스를 확장하는게 아닌 독립적인 InstrumentedHashSet 클래스를 만들고, private 필드로 기존 클래스의 인스턴스를 멤버필드로 추가하였다. 이 방식은 HashSet의 내부 구현을 새롭게 재정의할 필요가 없고, Set 타입 인스턴스를 외부로부터 주입받는다는 점에서 약한 결합도를 갖게 된다.

 또한 Set 인스턴스의 캡슐화도 무너뜨리지 않는다. 앞서 HashSet에 같은 시그니처를 갖는 메서드가 추가되도 이 클래스에는 아무런 영향을 끼치지 않는다.

public class InstrumentedHashSet<E> {

    private Set<E> set;
    private int addCount = 0;

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

    public boolean add(E e){
        addCount++;
        set.add(e);
        return true;
    }

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

    public int getAddCount(){
        return addCount;
    }
}

class Main{
    public static void main(String[] args) {
        Set set = new HashSet<String>();
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>(set);
        s.addAll(Set.of("가","나","다"));
        System.out.println(s.getAddCount());
    }
}

 


그럼 상속은 언제써야해?

 상위 클래스와 하위 클래스와 관계가 정말 is-a 관계일 때만 사용해야한다. 예를들어 Dog과 Animal의 경우 Dog is Animal 이라는 관계가 성립한다. 하지만 이를 확신할 수 없다면 컴포지션을 사용하자.


 

이를 위반한 Properties와 Hashtable

 자바 라이브러리에서 이 원칙을 위반한 예시로 Properties가 있다. 이 클래스는 String 타입의 key, value를 관리하는 클래스로 Hashtable을 상속받고 있다.

 

 Properties에 값을 추가하려면 String 값만 받는 setProperty 메서드를 사용하면 된다.

public synchronized Object setProperty(String key, String value) {
    return put(key, value);
}

 

그런데 문제는 부모 클래스의 put 메서드를 사용할 경우 Object를 받을 수 있어 아무 타입의 값을 넣을 수 있다.

@Override
public synchronized Object put(Object key, Object value) {
    return map.put(key, value);
}

 

이 결과 Properties의 store 메서드와 같은 공개 API를 더이상 사용할 수 없다. 아래와 같이 object 타입의 값을 String 으로 변환하는 부분에서 Cast 예외가 발생하기 때문이다.

for (Map.Entry<Object, Object> e : entrySet()) {
    String key = (String)e.getKey();
    String val = (String)e.getValue();
    key = saveConvert(key, true, escUnicode);
    /* No need to escape embedded and trailing spaces for value, hence
     * pass false to flag.

 


정리

 상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위 클래스가 is-a 관계일때만 사용해야 하지만 이를 확신할 수 없다면 컴포지션을 사용하자. 

 


참고

https://iyoungman.github.io/designpattern/Inheritance-and-Composition/ - 컴포지션

반응형

+ Recent posts