주요글: 도커 시작하기
반응형

기능의 재활용이나 기존 기능을 확장하기 위해서 사용하는 가장 손쉬운 방법은 구현 상속을 사용하는 것이다. 수화물 목록을 관리하기 위해 ArrayList를 상속받아서 구현했다고 하자.


public class LuggageCompartment extends ArrayList<Luggage> {

    private int restSpce;

    public void add(Luggage piece) {

        this.restSpace -= piece.getSize();

        super.add(piece);

    }


    public void canContain(Luggage piece) {

        return this.restSpace > piece.size();

    }


    public void extract(Luggage piece) {

        this.restSpace += piece.getSize();

        super.remove(piece);

    }


}


LuggageCompartment는 ArrayList가 제공하는 목록 관리 기능을 재사용하기 위해 ArrayList를 상속받았고, 내부적으로 짐을 넣을 수 있는 여분 공간을 관리하는 기능을 추가하였다. LuggageCompartment 클래스를 정해진 대로만 사용하면 문제가 발생하지 않는다. 하지만, LuggageCompartment는 상위 클래스인 ArrayList의 기능도 함께 제공하기 때문에 다음과 같은 코드를 작성하는 것이 가능하다.


LuggageCompoartment lc = new LuggageCompartment();

lc.add(new Luggage(10));

lc.remove(someLuggage); // 앗!! restSpace가 계산되지 않는다!

lc.extract(anyLuggage);

lc.canContain(aLuggage); // 잘못된 결과


위 코드에서 remove() 메서드는 ArrayList가 제공하는 메서드이므로 LuggageCompartment 객체에서도 호출할 수 있다. 문제는 remove() 메서드를 호출할 경우 LuggageCompartment가 지켜야 하는 규칙(여분 공백 계산)이 지켜지지 않는다는 점이다. 그렇다고 여분 공백 계산을 위해서 ArrayList의 거의 모든 메서드를 오버라이딩하는 것은 배보다 배꼽이 더 큰 상황을 만든다.


상속은 'IS A'에 대한 것


그럼, 왜 이런 문제가 발생하는 걸까? 그 이유는 역할이 같지 않은 클래스를 상속받아 재사용했기 때문이다. 상속은 'IS A' 관계일 때 의미를 갖는다. 예를 들어, ArrayList는 AbstractList이다. (ArrayList is a AbstractList). 따라서, ArrayList가 오버라이딩 하지 않은 AbstractList의 메서드를 호출하더라도 ArrayList 객체는 아무런 문제를 일으키지 않으며, ArrayList는 기대하는 바대로 동작한다.


반면 앞서 LuggageComponent는 ArrayList가 아니다. 즉, LuggageComponent is not a ArrayList 인 것이다. 이런 상황에서 ArrayList의 목록 관리 기능을 재사용하기 위해 ArrayList를 상속받아 구현하면 앞서 살펴본 것 처럼, 상위 클래스의 메서드를 호출할 때 하위 클래스의 기능이 올바르게 동작하지 않는 문제가 발생하게 된다. 즉, 'IS A' 관계가 아닌 두 클래스를 구현 상속으로 연결함으로써 원하지 않은 문제가 발생한 것이다.


따라서, 구현 상속을 이용해서 기능을 재사용하려면 반드시 두 클래스가 같은 역할을 수행하는지 확인해야 한다. 두 클래스가 생성하는 두 객체가 서로 다른 추상 타입을 위한 것이라면 기능 재사용의 방법으로 상속을 선택하면 안 된다.


조립을 통한 기능 재사용


두 클래스가 IS A 관계가 아니라면, 그 두 클래스는 서로 다른 역할을 수행한다는 의미를 갖는다. 앞서 LuggageCompart는 ArrayList처럼 범용적인 객체의 목록을 관리하는 역할을 수행하지 않는다. 단지, 짐의 목록을 관리하기 위한 목적으로 ArrayList의 기능이 필요했던 것이다. 이렇게 역할이 다른 객체의 기능을 재사용하고 싶다면, 조립(composition)을 통해서 재사용하는 것이 좋다.


조립을 통한 재사용은 다음과 같이 필드로 재사용할 객체를 정의하고 메서드 내부에서 해당 필드를 사용하는 식으로 구현된다.


public class LuggageCompartment {


    private List<Luggage> luggages = new ArrayList<Luggage>();

    private int restSpce;


    public void add(Luggage piece) {

        restSpace -= piece.getSize();

        luggages.add(piece);

    }


    public void canContain(Luggage piece) {

        return this.restSpace > piece.size();

    }


    public void extract(Luggage piece) {

        restSpace += piece.getSize();

        luggage.remove(piece);

    }


}


조립 방식으로 구현한 LuggageCompartment 클래스는 ArrayList를 상속받지 않기 때문에 LuggageCompoartment를 사용하는 코드는 remove()와 같은 메서드를 호출할 수 없다. 따라서 LuggageCompartment 클래스는 구현 상속의 경우처럼 원하지 않는 메서드가  호출되어 기능이 비정상적으로 동작하는 걱정을 하지 않아도 된다. 또한, LuggageCompartment가 목록을 관리하기 위해 ArrayList를 사용한다는 점을 클래스 외부에서 알 수 없기 때문에 캡슐화도 향상된다.


조립 방식의 또 다른 장점은 런타임에 재사용할 기능을 교체할 수 있다는 점이다. 상속은 컴파일타임에 정적으로 관계가 결정된다. 만약 ArrayList가 아닌 LinkedList를 상속받으려면 코드를 수정해서 재컴파일해야 한다. 반면 조립방식의 경우는 런타임에 재사용할 객체를 결정할 수 있다. 예를 들어, 앞서 LuggageCompartment가 생성자를 통해서 기능을 재사용할 List 구현 객체를 전달받는다고 해 보자. 이 방식은 런타임에 얼마든지 구현 객체를 변경할 수 있기 때문에, 소스 코드 수정이 필요한 정적 방식보다 더 유연하게 재사용할 객체를 변경할 수 있게 된다. ('객제 지향 기초 이야기 3, 유연함'http://javacan.tistory.com/entry/OO-Basic-3-Flexibility 을 참고하자.)


구현 상속은 정말 필요할 때 만 쓸 것!


구현 상속은 유지보수를 어렵게 만드는 요인이 된다. 예를 들어, 스프링의 AbstractController를 생각해보자. AbstractWizardFormController, SimpleFormController, MultiActionController 등이 이 클래스의 구현을 상속받고 있다. 이는 AbstractController의 기능을 수정하려면 그 하위 클래스들이 받을 영향에 대해서 고려해야 한다는 것을 뜻한다. 즉, 상속 받은 클래스가 많으면 많을수록 코드 변경시 고려해야 하는 클래스들의 숫자는 비례해서 증가하며, 이는 변경의 어려움이 증가한다는 것을 뜻한다. 따라서, 구현 상속을 통해 기능을 재사용하려거든 정말 필요한 경우에 한해서 적용해야 하며, 조립이나 메타 정보 사용 등의 방법으로 풀 수 있다면 다른 방법을 사용하는 것이 코드 유지보수에 있어 유리하다.


관련글

참고자료


제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.




+ Recent posts