JAVA 상속 문제 해결을 위한 디자인 패턴: composition and skeletal implementation

강민석·2022년 8월 29일
0

JAVA

목록 보기
2/7

오늘은 전달(forwarding)과 위임(delegate)의 형태를 가지는 두가지의 디자인 패턴을 알아보려고 한다.

각 패턴의 이름은 compositionskeletal implementation인데 전자는 Effective JAVA라는 서적에서, 후자는 인터페이스와 상속에 대한 내용을 찾던 중 알게되었다.

이 패턴들을 한번에 포스팅하는 이유는 두 패턴 모두 JAVA의 상속의 문제를 해결하기 위해 고안된 디자인 패턴들이기 때문이다.

1. JAVA 상속의 문제점

상속이란 어떤 클래스가 가지고 있는 속성과 기능을 다른 클래스에게 물려주는 것을 의미한다. 이런 특성 때문에 부모-자식의 형태로 해당 클래스들을 부르기도 한다.

상속은 코드를 재사용하기 위한 수단중의 하나로, boilerplate code를 제거하고 추상화한 개념에 따라 통일성있는 객체를 생성할 수 있게 해준다.

하지만 상속 역시 정답은 아니며, 단점은 존재한다.

1.1 단일 상속

자바에서의 상속은 한개의 클래스로부터만 받을 수 있다. 이런 특성은 상속을 꺼리게 한다.

먼저 JAVA에서의 상속은 여러 부모를 가질 수 없게 만들어져있다. 부모-자식으로 표현하니 이게 왜 잘못된 일이지 싶기도 하지만 상속의 사용이유를 생각해보면 약간 아쉬움이 든다.

상속을 사용하는 이유는 부모클래스의 속성을 자식클래스에게 주기 위함이였기 때문이다. 위와 같은 상속의 제한이 있기 때문에 다양한 클래스에게서 속성을 물려받는 것은 어려워졌다.

다만 이는 JAVA에서의 상속의 특성(JAVA에서 이런 제한을 둔 것은 diamond problem이라고 불리는 문제를 고려했기 때문이다)일 뿐 인터페이스를 사용한 구현으로 다양한 속성을 물려받는 것을 어느정도 표현 할 수는 있다.

하지만 하나의 부모클래스에게서 상속을 받으면 다른 클래스에게서 상속을 받지 못한다는 제한은 상속을 받는 것을 꺼리게 한다.

1.2 캡슐화의 어려움(구현체의 상속 한정)

알약을 먹을 때, 캡슐의 껍질로 인해 캡슐 내의 내용물은 볼 수 없게 되어있다. 캡슐 껍질의 재질이나 모양이 변한다고 해서, 알약의 기능이 달라져서는 안된다.

해당 문제는 인터페이스를 구현하거나 상속하는 인터페이스 상속은 해당되지 않는다. 또한 상위클래스와 하위클래스를 함께 개발하여 상위 클래스의 내용을 제대로 파악하고 있는 경우도 이 문제와는 관련이 없다.

해당 문제는 구현 클래스를 상위클래스로 하는 하위클래스에게서 발생할 수 있다. 주된 원인은 제대로 문서화되지 않은 상위클래스, 상속하는 클래스의 내부를 제대로 확인하지 못한 개발자의 구현등이 될 수 있다.

그렇다면 캡슐화가 되지 않는다는 건 어떤 뜻일까??

캡슐화라는 건 캡슐안의 내용을 볼 수 없다는 의미이다. 상위클래스와 하위클래스의 관계에서는 상위클래스가 하위클래스의 구현내용을 볼 수 없으며, 상위클래스의 구현이 하위클래스에 영향을 주지않아야 한다는 것을 뜻한다.

상위클래스의 구현이 하위클래스에 영향을 주지않을 수 있나? 라는 생각이 들 수 있지만, 하위클래스에서는 상위클래스를 물려받아 고유한 속성 및 기능을 정의할 것이고 이런 구현은 상위클래스와 독립적으로 동작해야한다는 의미이다.

1.2.1 자기사용 방식의 구현

자기사용 방식으로 구현된 메소드를 재정의하는 경우, 재정의한 클래스 외에 자기사용 방식에 연관된 메소드가 영향범위가 될 수 있다. 이는 기능적 오류를 만든다.

목차를 자기사용 방식의 구현이라고 적어두었지만, 결국 원인은 상위 클래스의 메소드 재사용과 내부구현 파악 숙지의 미숙이다.

HashSet을 구현상속하는 클래스를 예로 들어보자.

HashSet은 Collection을 상속받으므로 add(), addAll()을 구현한다. 이런 HashSet을 상속하는 경우, 상속으로 인한 오류가 발생할 수 있다. 아래는 문제가 발생 할 수 있는 구현의 예시이다.

private int addCount = 0;

...

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

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

위의 구현이 무슨 문제가 있다는 걸까??

해당 코드를 작성할 때, size가 5인 collection을 파라미터로 addAll()을 호출하면 addCount가 5가 나오는 것을 기대했을 것이다. 이런 기대는 super.addAll()super.add()와 독립적이라는 가정에서 이루어진다.

문제는 그 가정이 틀렸다는 것이다. 아래의 HashSet의 구현을 보자.

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

HashSet의 addAll()은 내부적으로 add()를 호출한다. 이런 내부구현 방식 때문에 addCount를 추가했던 재정의 addAll()은 collection의 size만큼 addCount를 더하고, 내부구현으로 인해 재정의 add()를 호출해 중복되게 addCount를 더하게 된다. 때문에 기대했던 5의 값이 아닌 10의 값을 가지게 된다.

그림1. 자기사용 메소드 재정의로 인한 addCount값의 변경

정리를 하면 해당 문제는 상위클래스 메소드 내 자기사용과 재정의로 인해 발생했다. 상위클래스를 상속하여 구현하는 개발자는 상위클래스의 구현을 숙지하지 않은채로 재정의를 하면 안되는다는 것을 명심해야하며, 상위클래스 개발자는 @impleSpec을 사용하여 상속이 가능한 메소드들을 내부적으로 어떻게 사용하는 지 명시해주어야 한다.

1.2.2 상위클래스 메소드의 추가로 인한 문제점

상위클래스 역시 유지보수와 개발이 이루어진다. 이로인해 추가되는 메소드는 하위클래스에게는 보안 허점이 될수도 있다.

두번째 문제점도 재정의를 통해 생겨난다. HashSet을 상속받는 ExtendedHashSet이 있고, ExtendedHashSet은 특정 문자열을 값으로 가지면 안되는 상황을 가정해보자.

이를 해결하기위해 add(), addAll()등의 데이터를 추가하는 메소드들에 필터링을 추가해 재정의를 했다. 그런데 새로운 데이터를 추가하는 push()가 HashSet에 추가되면 어떻게 될까??

push()로 추가되는 데이터들은 특정 문자열을 필터링하지 않아 이슈가 생길 것이다. 만약 필터링의 내용이 보안상 중요한 내용이라면, 상위클래스의 메소드 추가만으로 큰 문제가 생길 수 있다.

이렇듯 재정의를 통해 문제를 해결하는 경우, 상위클래스의 변화에 민감해질 수 있으며 캡슐화의 관점에서 좋지않다.

1.2.3 시그니처가 같은 메소드의 구현

운이 나쁜 경우, 추가한 메소드의 시그니처가 상위 클래스의 신규 메소드와 같고 반환값만 다르다면 컴파일 이슈가 생길 수 있다

위의 두 문제가 재정의를 통한 문제이므로, 상속후에도 메소드를 추가하는 방식으로 기능을 개발할 수도 있다.

하지만 운 없게도 상위클래스에서 새롭게 추가된 메소드와 하위클래스에 개발한 메소드의 시그니처가 같고 반환값이 다른 경우, 우리가 개발한 메소드는 컴파일이 되지 않을 것이다.

2. 상속 문제 해결을 위한 디자인패턴

위와 같은 상속에서 겪을 수 있는 문제를 위임(delegation)을 통한 디자인패턴을 통해 해결 할 수 있다. 본 목차에서는 compositionskeletal implementation이라고 불리는 두가지의 디자인패턴에 대해서 소개 할 계획이다.

2.1 composition

composition(이하 컴포지션)의 사전적 의미구성이다. 컴포지션의 가장 큰 특성은 기존 클래스의 메소드를 재정의하지 않고 전달하므로서 재정의를 할 때 오는 side effect(부작용)을 예방한다.

컴포지션은 forwarding방식의 디자인패턴으로 기존의 클래스를 확장해서 사용하는 확장클래스에서 기존 클래스의 메소드를 호출해 결과를 반환받는 방식으로 구현된다.

위의 1.2.1(자기사용 방식의 구현)에서 사용한 Set예시를 통해서 컴포지션에 대해서 알아보자. 1.2.1의 주 내용은 상위 클래스에서 자기사용 방식의 메소드를 구현할 경우, 해당 메소드를 하위 클래스에서 재정의 할 때 예기치 못한 동작을 일으킬 수 있다는 것이였다. 컴포지션을 사용하면 이 부작용을 어떻게 예방할 수 있을까???

2.1.1 컴포지션의 특징

컴포지션을 통해 Set을 확장하는 확장클래스에서 add(), addAll()을 부작용없이 확장할 수 있다. 이번 목차에서는 컴포지션의 특징 위주로 구현방법을 알아보자.

  • 기존 클래스의 기능을 전달하는 클래스를 가진다.
    첫번째 특징은 전달이라고 표현했던 컴포지션의 특성이다. 컴포지션에서는 상위클래스와 확장클래스 간에 전달 및 위임의 기능을 하는 클래스를 가진다. 이를 Forwarding class라고 하며 클래스 간 관계는 다음과 같이 형성된다.
    그림2. 확장, 포워딩, 기존 클래스 간의 관계
    위와 같은 형태가 만들어지므로 전달의 기능을 한다고 표현하며, 넓은 범위로는 위임이라고도 표현하기도 한다.

  • 기존 클래스를 필드로 가진다.
    앞서 말한 기능전달의 구현을 위해 포워딩 클래스에서는 기존 클래스의 인스턴스를 참조한다. 제네릭으로 구현을 하면 자료형마다 구현을 하는 번거로움을 줄일 수 있다.

    public class ForwardingSet<E> implements Set<E> {
       private final Set<E> set;
       public ForwardingSet(Set<E> set) {
           this.set = set;
       }
  • 기존 클래스의 메소드를 그대로 반환한다.
    세번째 특성 또한 기능전달을 위한 특성이다. 포워딩 클래스는 기존 클래스의 메소드를 재정의 함에 있어 추가적인 로직 없이 그대로 반환을 한다. 다음의 예시를 보자.

    // 위의 ForwardingSet내의 구현
       @Override
       public boolean add(E e) {
           return set.add(e);
       }
    
       @Override
       public boolean addAll(Collection<? extends E> c) {
           return set.addAll(c);
       }
    

    composition의 구현은 위의 특징을 토대로 구현된 포워딩 클래스를 확장클래스가 extends하는 방식으로 구현된다. 기존 클래스를 구성요소로 가지며 감싸는 형태 때문에 wrapper class라고 불리기도 한다.

그럼 이와 같이 포워딩 클래스를 사용해 기존 클래스를 참조하면 자기사용 메소드를 재정의 할 시 어떤 결과가 나올까??

아래와 같이 포워딩 클래스 없이 확장을 할때와 동일하게 재정의를 해보았다.

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

    private int addCount = 0;

...

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

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

해당 클래스를 통해 addAll()을 호출하면 어떤 결과가 나올지 예상해보자.
그림3. 포워딩 클래스를 사용한 자기사용 메소드 재정의 시 addCount값의 변경

이전과 동일한 내용으로 확장클래스의 메소드를 작성하였지만, super.addAll()시 포워딩 클래스의 메소드를 호출하므로 다른 결과를 가지게 될 것이라고 예상할 수 있다.

위의 그림3을 봤을 때, 포워딩 클래스를 사용하면 확장 클래스에서 재정의를 하더라도 기존 클래스의 구현에 영향을 받지 않게 된다. 이는 상속의 문제점에서 언급했던 캡슐화의 관점에서 큰 개선이라고 볼 수 있다.

composition을 사용하면 재정의로 생길 수 있는 side effect를 피해갈 수 있다. 하지만 상위클래스와 하위클래스가 모두 통제되는 상황이라면 필요없는 추가 구현이 될 수 있으니 상황에 맞게 적용하도록 하자.

2.2 skeletal implementation

skeletal implementation은 다른 말로 Abstract interface라고 불린다. skeletal implementation은 interface의 장점과 abstract class의 장점을 혼합한 디자인 패턴이다.

앞선 소개에서 본 디자인 패턴은 위임의 방식으로 구현된다고 소개했다. composition 역시 일종의 위임(전달)방식으로 구현되었기 때문에 앞선 2.1 composition을 이해한 독자에게는 어렵지 않은 이야기가 될 것이라고 생각한다.

skeletal implemetation은 위임방식을 사용해서 구현체가 상속(extends)가 아닌 구현(implements)의 방식으로 속성을 물려받도록 한다. 이 때 구현이지만 추상클래스를 상속받는 것과 같은 효과를 볼 수 있다.

구현부는 크게 세가지로 나눌 수 있다.

  1. 인터페이스
  2. 1의 인터페이스를 구현하는 추상 클래스
  3. 1의 인터페이스를 구현하는 구현체

1, 2는 인터페이스를 사용한 구현에 있어 특별한 일이 아닐 것이다. 우리는 3번 구현부에서 본 디자인 패턴의 특성을 알 수 있다. 추상클래스의 기능을 물려받는 inner class를 구현하고 이에 기능을 위임하는 식으로 구현을 하게 된다.

먼저 1번부터 천천히 구현해보자.

/* 1. 인터페이스 */
package com.example.skeletal;

public interface Machine {
    void start();
    void stop();
    void operate();
    void process();
}

기계의 기능을 나타내는 간단한 인터페이스이다. 다음으로 기계의 공통된 기능을 구현한 추상 클래스를 구현해보자.

/* 2. `1`의 인터페이스를 구현하는 추상 클래스 */
package com.example.skeletal;

public abstract class AbstractMachine implements Machine {

    @Override
    public void start() {
        System.out.println("기계를 작동합니다.");
    }

    @Override
    public void stop() {
        System.out.println("기계를 끕니다.");
    }

    @Override
    public void operate() {
        System.out.println("기계를 공회전 시킵니다.");
    }

    @Override
    public void process() {
        start();
        operate();
        stop();
    }
}

보편적으로 AbstractMachine의 기능을 물려받는 구현을 하려면 AbstractMachine을 extends받는 구현체를 생성하면 된다. 하지만 본 디자인패턴에서 원하는 결과는 이와 다르다.

마지막으로 3번의 구현을 확인해보자.

/* 3. `1`의 인터페이스를 구현하는 구현체 */
package com.example.skeletal;

public class Car implements Machine{

    private static class AbstractMachineDelegator extends AbstractMachine {
        @Override
        public void operate() {
            System.out.println("엔진이 작동 됩니다.");
            System.out.println("엑셀을 밟으면 속도가 올라갑니다.");
        }
    }

    AbstractMachineDelegator abstractMachineDelegator = new AbstractMachineDelegator();

    @Override
    public void start() {
        abstractMachineDelegator.start();
    }

    @Override
    public void stop() {
        abstractMachineDelegator.stop();
    }

    @Override
    public void operate() {
        abstractMachineDelegator.operate();
    }

    @Override
    public void process() {
        abstractMachineDelegator.process();
    }
}

skeletal implementation에 대해서 알기 위해서는 3번인 구현체에 대해서 자세히 보아야한다.

해당 클래스를 보면 인터페이스를 구현하고 있는 것을 볼 수 있다. 하지만 인터페이스를 구현하는 경우, 손수 인터페이스에 정의된 메소드들을 구현해주어야 한다.

우리는 여기서 위임의 개념을 사용할 것 이다. 여기서 inner class인 AbstractMachineDelegator에 주목해보자.

해당 inner class는 우리가 구현했던 추상 클래스인 AbstractMachine을 상속받고 있다. 그리고 구현체 Car로 부터 인터페이스의 구현을 위임받는다.

이를 통해 1.1 단일상속의 문제로 부터 Car는 자유롭게 된다. 공통된 구현은 추상 클래스가 맡되 이 기능을 위임받아 구현하여 bolierplate를 없앤 구조를 가질 수 있게 된다.

해당 구조에서는 구현체인 Car는 인터페이스를 구현하므로, diamond ploblem에서 자유롭고 여러 인터페이스와 추상클래스로부터 기능을 물려받을 수 있는 확장성을 가진다.

마지막으로 구현한 클래스의 결과를 확인해보자.

/* Car의 결과를 확인하기 위한 테스트 main() */
public static void main(String[] args) {
    Car car = new Car();
    car.process();
}

그림4. Car.process()의 결과

3. 글을 마치며

이번 시간에는 위임과 전달로 상속의 문제를 해결하는 디자인 패턴에 대해서 알아보았다.

업무를 진행하며 위의 디자인패턴을 적용해 본 적은 없지만(상위 클래스를 통제하지 않는 상태에서의 상속을 아직은 경험해본 적이 없다.) 문제해결에 대한 다양한 시각을 경험하게 되어 좋았다.

개발과 운영을 하면서 느끼게 되는 것 중 하나는, 개발자로서 코드의 품질보다도 좋은 구조의 구현을 하는 것이 먼저라는 점이였다. 앞으로도 다양한 디자인패턴에 대해서 공부하면서 염두해두고 업무에 임하면 좋겠다는 생각이 든다.

0개의 댓글