디자인 패턴에서의 위임(Delegation)

6 분 소요


RECOMMEND POSTS BEFORE THIS

1. Delegation

요즘 스프링 시큐리티(spring security)를 주제로 책을 집필하면서 프레임워크 내부를 탐구하고 있다. 스프링 프레임워크 내부에는 Delegating-이라는 키워드가 붙은 클래스들이 많다는 사실을 알았다. 이름에서 알 수 있듯 이런 클래스들의 인스턴스는 자신이 처리해야 하는 요청을 다른 인스턴스에게 위임(delegation)한다. 스프링 프레임워크가 위임을 많이 사용하는 이유가 궁금하여 관련된 내용을 정리해봤다.

1.1. Delegation Pattern

위키피디아(wikipedia)에선 위임 패턴은 객체 합성을 사용해 상속처럼 코드를 재사용할 수 있다고 소개한다.

In software engineering, the delegation pattern is an object-oriented design pattern that allows object composition to achieve the same code reuse as inheritance.

디자인 패턴으로 유명한 Gangs of Four (GoF) Design Patterns 책에는 위임 패턴이 별도로 존재하지 않는다. 위임은 패턴이 아닌 패턴을 위한 하나의 방법으로 소개한다.

위임(delegation)은 합성을 상속만큼 강력하게 만드는 방법입니다. 위임에서는 두 객체가 하나의 요청을 처리합니다. 수신 객체가 연산의 처리를 위임자(delegate)에게 보냅니다. 이는 서브 클래스가 부모 클래스에게 요청을 전달하는 것과 유사한 방식입니다.

책에서처럼 위임은 디자인 패턴이라기보단 중복 코드 문제를 해결하는 디자인 패턴들이 사용하는 하나의 프로그래밍 구현 테크닉이다. 한 객체가 모든 것을 담당하는 것이 아니라 다른 대상에게 책임을 넘기는 행위이다. 대표적으로 프록시 패턴(proxy pattern)이나 전략 패턴(strategy pattern)은 위임을 사용하는 디자인 패턴이다.

2. Inheritance and Composition

Gangs of Four (GoF) Design Patterns 책에선 객체 지향 시스템에서 코드 재사용을 위한 대표적인 기법으로 상속(inheritance)와 객체 합성(object composition)을 소개한다. 위임은 객체 합성을 위해 사용되는 테크닉이다. 위임을 통한 객체 합성이 어떤 장점을 갖는지 상속과 비교해보겠다.

2.1. Inheritance

상속은 Is-a 관계로 부모 클래스에 정의된 코드를 서브 클래스(sub class)가 재사용하는 방법이다. 이런 방식을 화이트 박스 재사용(white box reuse)이라고도 한다. 상속을 받으면 부모 클래스의 내부가 서브 클래스에게 공개되기 때문이다. 상속은 다음과 같은 특징을 가진다.

  • 컴파일 시점에 정적으로 정의된다.
  • 프로그래밍 언어가 직접 지원하기 때문에 그대로 사용할 수 있다.
  • 상속받은 부모 클래스의 구현을 런타임에 변경할 수 없다.
  • 부모 클래스의 구현이 서브 클래스에 모두 공개되기 때문에 캡슐화를 파괴한다.
  • 부모 클래스 구현에 변경이 생기면 서브 클래스도 변경이 필요하다.

부모 클래스는 서브 클래스를 재사용할 때 방해가 될 수 있다. 상속한 구현이 새로운 문제에 맞지 않을 때 부모 클래스를 재작성해야 하거나 다른 것으로 대체하는 일이 생길 수 있다. 상속으로 인해 발생하는 강한 결합도는 코드의 유연성과 재사용성을 떨어뜨린다. 추상 클래스를 사용하면 종속성 문제를 어느 정도 완화할 수 있다. 간단한 예제 코드로 상속 구조의 유연성이 떨어지는 상황을 살펴보겠다.

아래는 창문을 추상화한 Window 클래스이다.

  • 영역의 넓이를 반환하는 getArea 메서드를 제공한다.
  • 영역 넓이를 출력하는 printArea 메서드를 제공한다.
package inheritance;

import java.math.BigDecimal;

public abstract class Window {

    private final int width;
    private final int height;

    public Window(int width, int height) {
        this.width = width;
        this.height = height;
    }

    protected BigDecimal getArea() {
        return BigDecimal.valueOf((long) width * height);
    }

    public abstract void printArea();
}

아래는 정사각형 모양인 창문을 정의한 SquareWindow 클래스다.

  • 생성자에 전달된 값은 부모 클래스에 너비, 높이 값으로 전달한다.
  • 부모 클래스에 정의된 getArea 메서드를 재사용한다.
package inheritance;

public class SquareWindow extends Window {

    public SquareWindow(int side) {
        super(side, side);
    }

    @Override
    public void printArea() {
        System.out.printf("Square window area is %s\n", getArea());
    }
}

아래는 사각형 창문을 정의한 RectangleWindow 클래스다.

  • 생성자에 전달된 값은 부모 클래스에 너비, 높이 값으로 전달한다.
  • 부모 클래스에 정의된 getArea 메서드를 재사용한다.
package inheritance;

public class RectangleWindow extends Window {

    public RectangleWindow(int width, int height) {
        super(width, height);
    }

    @Override
    public void printArea() {
        System.out.printf("Rectangle window area is %s\n", getArea());
    }
}

사각형 모양이 아닌 다른 모양의 창문이 추가되면 어떻까? 예를 들어, 정사각형, 직사각형 창문만 존재하던 시스템에 원형 창문이 포함될 예정이라고 가정해보자. 시스템 구성상 원형 창문도 반드시 Window 클래스를 상속해야만 한다. 하지만 Window 클래스에 정의된 미리 정의된 getArea 메서드는 원형 창문에 적합하지 않다. 어쩔 수 없이 상속하지만, 코드는 자연스럽게 지저분해진다. 예제는 아주 작은 코드 조각이지만, 큰 시스템일수록 불필요한 상속은 코드의 복잡성을 높일 확률이 크다.

  • width, height 필드는 부모 클래스에 캡슐화되어 있으므로 radius 필드를 새로 정의해야 한다.
    • 불필요한 필드 사용
  • 넓이를 구하는 방식이 다르기 때문에 getArea 메서드를 재정의한다.
    • 불필요한 메서드 재정의
package inheritance;

import java.math.BigDecimal;

public class CircleWindow extends Window {

    private final int radius;

    public CircleWindow(int radius) {
        super(radius, radius);
        this.radius = radius;
    }

    @Override
    public BigDecimal getArea() {
        var pi = new BigDecimal(String.valueOf(Math.PI));
        return pi.multiply(
                BigDecimal.valueOf((long) radius * radius)
        );
    }

    @Override
    public void printArea() {
        System.out.printf("Circle window area is %s\n", getArea());
    }
}

각 윈도우의 넓이를 출력한다.

import inheritance.CircleWindow;
import inheritance.RectangleWindow;
import inheritance.SquareWindow;
import inheritance.Window;

public class Main {

    public static void main(String[] args) {

        Window rectangleWindow = new RectangleWindow(10, 5);
        Window squareWindow = new SquareWindow(5);
        Window circleWindow = new CircleWindow(10);

        rectangleWindow.printArea();
        squareWindow.printArea();
        circleWindow.printArea();
    }
}

다음과 같은 로그가 출려된다.

Rectangle window area is 50
Square window area is 25
Circle window area is 314.159265358979300

2.2. Composition

객체 합성은 Has-a 관계로 다른 객체를 여러 개 붙여서 새로운 기능 혹은 객체를 구성하는 방법이다. 객체를 합성하기 위해 객체들의 인터페이스를 명확히 정의하는 것이 중요하다. 이런 방식을 블랙 박스 재사용(black box reuse)이라고도 한다. 객체의 내부가 공개되지 않고 인터페이스만을 통해 재사용되기 때문이다. 객체 합성은 다음과 같은 특징을 가진다.

  • 인터페이스로 추상화된 변수를 사용하면 런타임 시점에 동적으로 정의할 수 있다.
    • 팩토리 패턴을 사용하거나 프레임워크의 도움을 통해 런타임 시점에 정의 가능하다.
  • 객체들은 인터페이스에서만 접근하므로 캡슐화를 유지할 수 있다.

객체 합성은 위임을 통해 이뤄진다. 위임과 인터페이스를 통한 객체 합성이 상속과 동일한 문제 상황을 어떻게 유연하게 대처하는지 살펴보겠다.

Boundary 인터페이스는 다음과 같은 책임을 가진다.

  • 이름 제공
  • 영역 넓이 제공
package composition;

import java.math.BigDecimal;

public interface Boundary {

    String getName();

    BigDecimal getArea();
}

RectangleBoundary 클래스는 자신의 넓이와 이름을 제공한다.

package composition;

import java.math.BigDecimal;

public class RectangleBoundary implements Boundary {

    private final int width;
    private final int height;

    public RectangleBoundary(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public String getName() {
        return "Rectangle";
    }

    @Override
    public BigDecimal getArea() {
        return BigDecimal.valueOf((long) width * height);
    }
}

SquareBoundary 클래스는 자신의 넓이와 이름을 제공한다.

package composition;

import java.math.BigDecimal;

public class SquareBoundary implements Boundary {

    private final int side;

    public SquareBoundary(int side) {
        this.side = side;
    }

    @Override
    public String getName() {
        return "Square";
    }

    @Override
    public BigDecimal getArea() {
        return BigDecimal.valueOf((long) side * side);
    }
}

CircleBoundary 클래스는 인터페이스에 정의된 책임을 자신에게 맞도록 재구현한다. 이 과정에서 상속과 다르게 불필요한 필드나 메서드 구현이 추가되지 않는다.

package composition;

import java.math.BigDecimal;

public class CircleBoundary implements Boundary {

    private final int radius;

    public CircleBoundary(int radius) {
        this.radius = radius;
    }

    @Override
    public String getName() {
        return "Circle";
    }

    @Override
    public BigDecimal getArea() {
        var pi = new BigDecimal(String.valueOf(Math.PI));
        return pi.multiply(
                BigDecimal.valueOf((long) radius * radius)
        );
    }
}

Window 클래스는 클라이언트의 요청을 다른 인스턴스에게 위임한다.

  • 팩토리 메서드를 통해 필요한 인스턴스를 명시적으로 생성한다.
    • private 생성자로 정의하여 Window 인스턴스 생성을 캡슐화한다.
    • 어떤 모양 창문인지 명시적으로 선언할 수 있다.
  • printArea 메서드
    • 윈도우 이름과 영역 넓이 값은 boundary 인스턴스에게 위임한다.
    • 창문 형태가 Boundary 인터페이스로 추상화되어 있기 때문에 적합한 인스턴스를 주입받으면 코드 변경이 발생하지 않는다.
package composition;

public class Window {

    private final Boundary windowBoundary;

    private Window(Boundary windowBoundary) {
        this.windowBoundary = windowBoundary;
    }

    public static Window createCircle(int radius) {
        return new Window(new CircleBoundary(radius));
    }

    public static Window createRectangle(int width, int height) {
        return new Window(new RectangleBoundary(width, height));
    }

    public static Window createSquare(int side) {
        return new Window(new SquareBoundary(side));
    }

    public void printArea() {
        System.out.printf("%s window area is %s\n", windowBoundary.getName(), windowBoundary.getArea());
    }
}

각 윈도우의 넓이를 출력한다.

import composition.Window;

public class Main {

    public static void main(String[] args) {

        Window rectangleWindow = Window.createRectangle(5, 10);
        Window squareWindow = Window.createSquare(5);
        Window circleWindow = Window.createCircle(10);

        rectangleWindow.printArea();
        squareWindow.printArea();
        circleWindow.printArea();
    }
}

다음과 같은 로그가 출력된다.

Rectangle window area is 50
Square window area is 25
Circle window area is 314.159265358979300

3. Summary

객체 합성 방법에서는 getArea 메서드를 재사용하지 못하지만, printArea 메서드를 재사용할 수 있다. 인터페이스를 통해 인스턴스들을 참조하기 때문에 실제 구현체가 무엇인지 판단할 필요 없이 비즈니스 로직을 구성할 수 있다. 앞으로 새로운 기능이 추가되더라도 Boundary 인터페이스를 상속받은 클래스가 추가될 뿐 Window 클래스의 비즈니스 로직은 변경되지 않는다. 수정에는 닫혀 있고, 확장에는 열린 구조를 가진다.

간단한 요약으로 이번 글을 마무리하겠다.

  • 위임은 객체 합성에 사용되는 프로그래밍 테크닉이다.
  • 객체 합성은 상속에 비해 기능 확장에 용이하다. 상속은 컴파일 시점에 코드가 이미 굳어지기 때문에 확장이 필요한 비즈니스에 유연하게 대응하기 어렵다.

스프링 프레임워크가 위임을 많이 사용하는 이유는 프레임워크로써 확장성을 고려한 설계를 해야 하기 때문이라고 생각된다. 물론 스프링 프레임워크는 상속도 많이 사용한다. 주로 발견되는 템플릿 메서드 패턴(template method pattern)은 상속을 사용하는 대표적인 예이다.

CLOSING

iOS 진영은 컴포넌트 사이의 통신을 위해 콜백 함수를 사용한다. 이때 위임을 사용하는데 이런 방식이 하나의 패턴으로 굳어져 위임 패턴으로 불리는 것 같다.

TEST CODE REPOSITORY

REFERENCE

댓글남기기