테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

구조 패턴 1: 어댑터·브리지·컴포지트·데코레이터

인터페이스를 맞추고, 추상과 구현을 떼고, 트리로 묶고, 기능을 겹겹이 씌우는 법이다.

목차 (7)
  1. 구조 패턴이 하는 일
  2. 어댑터: 맞지 않는 인터페이스를 맞춘다
  3. 브리지: 두 방향으로 늘어나는 것을 떼어 놓는다
  4. 컴포지트: 하나와 여럿을 같은 방법으로 다룬다
  5. 데코레이터: 같은 모양으로 감싸 기능을 겹친다
  6. 흔한 오해
  7. 핵심 자료

구조 패턴이 하는 일

구조 패턴(structural pattern)은 이미 있는 클래스와 객체를 어떻게 엮어 더 큰 구조를 만들지 다룬다. 새 기능을 짜는 법보다 "맞지 않는 것을 맞추고, 함께 바뀌지 않아야 할 것을 떼고, 같은 것을 겹치거나 묶는 법"에 가깝다. GoF 책의 일곱 구조 패턴 가운데 이 글은 감싸고 잇는 네 가지(어댑터, 브리지, 컴포지트, 데코레이터)를, 구조 패턴 2는 창구와 공유와 대리인(퍼사드, 플라이웨이트, 프록시)을 다룬다. 패턴이라는 말이 어디서 왔고 GoF가 무엇인지는 디자인 패턴이란에, 패턴 밑에 깔린 설계 원칙(SOLID, 합성 우선)은 소프트웨어 공학 노트에 있다.

네 패턴 모두 "객체가 다른 객체를 필드로 들고 일을 넘긴다"는 같은 재료를 쓴다. 그래서 코드만 보면 비슷하고, 차이는 왜 감쌌는가(의도)에 있다. 글 끝의 "흔한 오해"와 구조 패턴 2의 맺음 표가 이 차이를 다시 모은다. 아래 클래스 그림은 모두 같은 선 모양을 쓴다.

클래스 그림 범례: 실선과 삼각형은 상속, 점선과 삼각형은 구현, 실선 화살표는 참조와 위임, 점선 화살표는 의존, 마름모는 부분을 가짐상속(extends): 삼각형 쪽이 부모구현(implements): 점선, 머리는 같다참조·위임: 필드로 들고 부른다의존: 만들거나 잠깐 쓴다부분을 가짐: 마름모 쪽이 전체강조: 이 패턴이 새로 세우는 역할상자 맨 위 작은 글자는 패턴 속 역할, 가운데는 예시 클래스 이름, 아래는 멤버다
이 글의 클래스 그림 범례. UML 클래스 그림의 선 모양 가운데 이 글에 나오는 것만 추렸다.

어댑터: 맞지 않는 인터페이스를 맞춘다

의도

이미 있는 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 바꿔, 고치지 않고 함께 쓰게 한다. 해외 여행용 전원 플러그 변환기와 같다.

문제 상황

주문 서비스는 PaymentGateway.pay(Money)로 결제한다. 새로 계약한 결제사가 준 SDK는 charge(int won, String orderId)를 쓰고 자기 결과 객체를 돌려준다. SDK는 남의 코드라 고칠 수 없고, 주문 서비스를 SDK에 맞춰 고치면 결제사를 바꿀 때마다 주문 코드가 흔들린다. 둘 사이에 번역기 하나를 세우면 양쪽 모두 그대로 둘 수 있다.

구조

어댑터 클래스 그림. 객체 어댑터는 PaymentGateway를 구현하고 PaySdk를 필드로 들고 위임한다. 클래스 어댑터는 PaySdk를 상속하고 PaymentGateway를 구현한다위임클라이언트(Client)CheckoutService대상(Target) · 인터페이스PaymentGatewaypay(Money): Receipt어댑터(Adapter)PaySdkAdapter- sdk: PaySdkpay(Money): Receipt적응 대상(Adaptee)PaySdkcharge(int, String)대상(Target) · 인터페이스PaymentGatewaypay(Money): Receipt적응 대상(Adaptee)PaySdkcharge(int, String)어댑터(Adapter)PaySdkAdapterpay(Money): Receipt객체 어댑터: 외부 객체를 필드로 들고 위임한다(합성)우리가 고칠 수 없는 코드pay() 안에서 Money를 원 단위 int로 바꿔 sdk.charge()를 부르고, 결과를 Receipt로 바꾼다클래스 어댑터: 외부 클래스를 상속하고 대상 인터페이스를 구현한다Java는 클래스를 하나만 상속하므로 적응 대상이 클래스, 대상이 인터페이스일 때만 된다.적응 대상의 하위 클래스까지 함께 맞추지는 못한다.
어댑터의 두 방식. 위의 객체 어댑터는 SDK 객체를 필드로 들고 위임하고, 아래의 클래스 어댑터는 SDK 클래스를 상속해 그 메서드를 직접 부른다.
  • 대상(Target): 클라이언트가 아는 인터페이스. 여기서는 PaymentGateway.
  • 적응 대상(Adaptee): 쓸모는 있지만 인터페이스가 다른 기존 클래스. 외부 SDK.
  • 어댑터(Adapter): 대상을 구현하고, 안에서 적응 대상을 불러 인자와 결과를 번역한다.

예시 코드

실무에서는 거의 늘 객체 어댑터를 쓴다. 단위 변환(Money → 원 단위 int), 예외 변환(SDK 오류 코드 → 우리 예외), 결과 변환(SdkResult → Receipt)이 어댑터 한 곳에 모인다.

// 우리 코드가 아는 인터페이스(대상)
public interface PaymentGateway {
    Receipt pay(Money amount);
}

// 외부 결제 SDK(적응 대상)는 고칠 수 없다. 원 단위 int와 주문 번호를 받는다.
// public class PaySdk { public SdkResult charge(int won, String orderId) { ... } }

// 객체 어댑터: 대상을 구현하고 SDK를 필드로 감싼다
public final class PaySdkAdapter implements PaymentGateway {
    private final PaySdk sdk;

    public PaySdkAdapter(PaySdk sdk) {
        this.sdk = sdk;
    }

    @Override
    public Receipt pay(Money amount) {
        SdkResult result = sdk.charge(amount.toWon(), UUID.randomUUID().toString());
        if (!result.isSuccess()) {
            throw new PaymentFailedException(result.errorCode());
        }
        return new Receipt(result.transactionId(), amount);
    }
}

// 쓰는 쪽은 SDK를 모른다
PaymentGateway gateway = new PaySdkAdapter(new PaySdk(apiKey));
checkoutService = new CheckoutService(gateway);

클래스 어댑터는 상속으로 같은 일을 한다. 필드가 없어 짧지만, 어댑터가 SDK의 공개 메서드를 모두 물려받아 밖으로 드러내고, SDK 클래스가 final이면 쓸 수 없다.

// 클래스 어댑터: SDK 클래스를 상속해 그 메서드를 직접 부른다
public class PaySdkAdapter extends PaySdk implements PaymentGateway {
    @Override
    public Receipt pay(Money amount) {
        SdkResult result = charge(amount.toWon(), UUID.randomUUID().toString());
        return Receipt.from(result, amount);
    }
}

실제 쓰임

  • Arrays.asList: Javadoc은 "지정한 배열이 뒷받침하는 고정 크기 리스트를 돌려준다"고 적는다. 배열을 복사하지 않고 List 인터페이스로 보이게 할 뿐이라, 리스트를 고치면 배열에, 배열을 고치면 리스트에 보인다. 크기를 바꾸는 메서드는 UnsupportedOperationException을 던진다.
  • InputStreamReader: Javadoc이 스스로를 "바이트 스트림에서 문자 스트림으로 가는 다리"라고 부른다. 바이트를 읽어 문자 집합(charset)으로 해독해 Reader로 내놓는 어댑터다.
String[] names = {"kim", "lee", "park"};

// 배열을 List 인터페이스로 보이게 하는 어댑터. 복사하지 않고 배열을 그대로 쓴다
List<String> list = Arrays.asList(names);
list.set(0, "choi");
// names[0]도 "choi"가 된다. list.add(...)는 UnsupportedOperationException

// 바이트 스트림(InputStream)을 문자 스트림(Reader)으로
Reader reader = new InputStreamReader(System.in, StandardCharsets.UTF_8);

장단점

장점단점
외부 코드와 우리 코드를 둘 다 고치지 않는다클래스가 하나 늘고, 호출이 한 번 더 건너간다
외부 의존이 어댑터 한 파일에 갇혀 결제사를 바꿔도 어댑터만 새로 짠다두 인터페이스의 뜻이 다르면(동기 vs 비동기, 부분 환불 유무) 번역이 억지가 된다
테스트에서 대상 인터페이스를 가짜로 바꿔 끼우기 쉽다어댑터가 쌓이면 진짜 동작을 찾아 여러 겹을 열어 봐야 한다

주의

  • 번역만 한다: 어댑터 안에 할인 계산이나 재고 확인 같은 업무 규칙이 들어가기 시작하면 더 이상 어댑터가 아니다. 규칙은 서비스로 옮긴다.
  • 새 코드에는 필요 없다: 어댑터는 이미 있는 것끼리 맞추는 사후 처방이다. 둘 다 내가 지금 짜는 코드라면 처음부터 인터페이스를 맞춘다.
  • 외부 타입이 새어 나가지 않게: 어댑터의 공개 메서드가 SDK의 결과 타입이나 예외를 그대로 내보내면 감싼 의미가 없다.

다른 언어에서는

  • Go: 인터페이스를 선언 없이 메서드만 맞추면 만족하므로 어댑터가 아주 가볍다. 표준 라이브러리 net/http의 문서는 HandlerFunc를 "보통 함수를 HTTP 핸들러로 쓰게 해 주는 어댑터"라고 부른다. 함수 타입에 ServeHTTP 메서드를 달아 Handler 인터페이스를 만족시킨다.
  • Python, JavaScript: 덕 타이핑이라 "기대하는 이름의 메서드를 가진 객체"면 된다. 어댑터는 그 메서드만 가진 작은 클래스나 객체 리터럴이다.

브리지: 두 방향으로 늘어나는 것을 떼어 놓는다

의도

추상(무엇을 하나)과 구현(어떻게 하나)을 서로 다른 클래스 계층으로 나누고 필드 하나로 이어, 둘이 따로 늘어나게 한다.

문제 상황

도형(원, 사각형, 삼각형)을 화면 미리보기용 캔버스와 내보내기용 SVG 두 방식으로 그려야 한다. 상속만 쓰면 "SVG로 그리는 원", "캔버스로 그리는 원"처럼 조합마다 하위 클래스가 생긴다. 도형이 m가지, 렌더러가 n가지면 클래스가 m × n개다. 렌더러를 하나 더하면 도형 수만큼 클래스를 새로 써야 하고, 원 그리는 법을 고치면 n곳을 고친다.

도형 세 가지와 렌더러 두 가지를 상속만으로 엮으면 클래스가 6개, 브리지로 두 계층을 나누면 5개다 상속만: 3 × 2 = 6개조합마다 하위 클래스 하나원 + SVG원 + 캔버스사각형 + SVG사각형 + 캔버스삼각형 + SVG삼각형 + 캔버스렌더러를 하나 더하면 클래스가 3개 더 생긴다브리지: 3 + 2 = 5개무엇을 그리나 / 어떻게 그리나를 따로도형(추상)렌더러(구현)가진다원사각형삼각형SVG캔버스렌더러를 하나 더하면 클래스도 하나만 는다도형 5 × 렌더러 4: 상속만이면 20개, 브리지면 9개도형 10 × 렌더러 5: 상속만이면 50개, 브리지면 15개
상속만으로 엮으면 조합마다 클래스가 하나씩 생기고(왼쪽), 두 계층으로 나누면 각자 하나씩만 늘어난다(오른쪽). 수는 계산 함수로 구했다.

구조

브리지 클래스 그림. Shape는 Renderer를 필드로 가진다. Circle과 Square는 Shape를 상속하고, SvgRenderer와 CanvasRenderer는 Renderer를 구현한다브리지: 필드 하나추상(Abstraction)Shape# renderer: Rendererdraw()구현자(Implementor) · 인터페이스RendererdrawCircle(x, y, r)drawRect(x, y, w, h)구체 추상(Refined)Circledraw()구체 추상(Refined)Squaredraw()구체 구현(Concrete)SvgRendererdrawCircle(...)구체 구현(Concrete)CanvasRendererdrawCircle(...)왼쪽(무엇을 그리나)과 오른쪽(어떻게 그리나)이 서로 모른 채 따로 늘어난다Circle.draw()는 renderer.drawCircle()처럼 구현자의 기본 동작만 부른다
브리지의 클래스 그림. 왼쪽 계층(도형)과 오른쪽 계층(렌더러)을 잇는 것은 Shape의 renderer 필드 하나다.
  • 추상(Abstraction): 클라이언트가 쓰는 높은 수준의 개념. 구현자를 필드로 든다.
  • 구체 추상(Refined Abstraction): 추상의 변형. 구현자의 기본 동작을 엮어 자기 일을 한다.
  • 구현자(Implementor): 플랫폼마다 다른 낮은 수준의 기본 동작을 정한 인터페이스.

예시 코드

// 구현자: "어떻게 그리나"
public interface Renderer {
    void drawCircle(double x, double y, double radius);
}

public final class SvgRenderer implements Renderer {
    private final StringBuilder out = new StringBuilder();

    @Override
    public void drawCircle(double x, double y, double radius) {
        out.append("<circle cx='%s' cy='%s' r='%s'/>%n".formatted(x, y, radius));
    }
}

// 추상: "무엇을 그리나". 구현자를 필드로 든다(이 필드가 다리다)
public abstract class Shape {
    protected final Renderer renderer;

    protected Shape(Renderer renderer) {
        this.renderer = renderer;
    }

    public abstract void draw();
}

public final class Circle extends Shape {
    private final double x;
    private final double y;
    private final double radius;

    public Circle(Renderer renderer, double x, double y, double radius) {
        super(renderer);
        this.x = x;
        this.y = y;
        this.radius = radius;
    }

    @Override
    public void draw() {
        renderer.drawCircle(x, y, radius);
    }
}

// 조합은 실행할 때 고른다
Shape preview = new Circle(new CanvasRenderer(context), 10, 10, 5);
Shape export = new Circle(new SvgRenderer(), 10, 10, 5);

실제 쓰임

  • JDBC: 애플리케이션은 Connection, PreparedStatement 같은 java.sql 인터페이스만으로 SQL을 보내고, 실제 객체는 데이터베이스마다 다른 드라이버가 만든다. Driver는 "모든 드라이버 클래스가 구현해야 하는 인터페이스"이고, DriverManager는 jdbc.drivers 시스템 속성과 서비스 제공자 로딩으로 드라이버를 찾는다. 애플리케이션 쪽 API와 드라이버 구현이 따로 진화하므로 흔히 브리지의 예로 든다.
  • 로깅 API와 백엔드, 그래픽 API와 GPU 백엔드도 같은 모양이다. 위쪽 API와 아래쪽 구현이 각자 버전을 올린다.
// 애플리케이션은 java.sql의 인터페이스만 쓴다
try (Connection connection = DriverManager.getConnection(url, user, password);
     PreparedStatement statement = connection.prepareStatement("SELECT name FROM member WHERE id = ?")) {
    statement.setLong(1, memberId);
    ...
}
// 실제 Connection 객체는 클래스패스에 있는 드라이버(각 DB 회사의 Driver 구현)가 만든다

장단점

장점단점
m × n 개의 클래스가 m + n 개로 준다처음부터 두 계층을 세우므로, 한쪽만 늘어날 코드에는 과하다
구현을 실행 중에 고르거나 바꿀 수 있다(미리보기와 내보내기)구현자 인터페이스를 잘못 정하면 모든 구현을 함께 고쳐야 한다
추상 쪽 코드가 플랫폼 세부를 모른다호출이 계층을 하나 더 건너 디버깅할 때 따라갈 곳이 는다

주의

  • 구현자 인터페이스는 기본 동작으로: drawCircle처럼 작은 단위로 정해야 새 도형이 구현자를 고치지 않고 기존 동작을 엮어 만들어진다. 도형마다 구현자 메서드가 하나씩 늘면 다시 m × n에 가까워진다.
  • 어댑터와 헷갈리지 않기: 둘 다 인터페이스 뒤에 구현을 숨기지만, 어댑터는 이미 있는 것을 사후에 맞추고 브리지는 설계 단계에서 두 방향의 변화를 미리 떼어 놓는다.
  • 변화의 축이 정말 둘인지 먼저 본다: 렌더러가 앞으로도 하나뿐이라면 인터페이스 하나와 구현 하나로 충분하다.

다른 언어에서는

  • Go, Python, JavaScript: 클래스 계층이 얕거나 없어 "두 계층"보다 "인터페이스 타입(또는 덕 타이핑 객체)을 필드로 받는 구조체·객체"로 드러난다. 생성자에 렌더러를 넘기는 한 줄이 곧 브리지다. 의존성 주입과 모양이 같다.

컴포지트: 하나와 여럿을 같은 방법으로 다룬다

의도

객체들을 트리로 묶어 부분과 전체의 계층을 만들고, 클라이언트가 낱개(잎)와 묶음(복합체)을 같은 인터페이스로 다루게 한다.

문제 상황

폴더의 크기를 구하려면 안의 파일 크기를 더하고, 안에 폴더가 있으면 그 폴더의 크기를 또 구해 더해야 한다. 클라이언트가 if (node instanceof Folder)로 매번 갈라 재귀를 직접 짜면, 바로가기 같은 새 종류가 생길 때마다 그 분기를 찾아 고쳐야 한다. 파일과 폴더가 똑같이 size()에 답하게 하면 재귀는 구조 안으로 들어간다.

구조

컴포지트 클래스 그림. File과 Folder가 Node를 구현하고, Folder는 Node 여러 개를 자식으로 가진다자식들(0개 이상)구성 요소(Component) · 인터페이스Nodename(): Stringsize(): long잎(Leaf)Filesize(): long복합체(Composite)Folder- children: List<Node>add(Node)size(): longsize() = 자기 크기size() = 자식들 size()의 합클라이언트는 File인지 Folder인지 묻지 않고 size()만 부른다. 재귀는 구조가 맡는다
컴포지트의 클래스 그림. Folder는 Node를 구현하면서 동시에 Node 여러 개를 가진다. 이 자기 참조가 트리를 만든다.
  • 구성 요소(Component): 잎과 복합체가 함께 구현하는 인터페이스.
  • 잎(Leaf): 자식이 없는 끝 노드. 자기 일을 직접 한다.
  • 복합체(Composite): 자식들을 들고, 받은 요청을 자식에게 넘겨 결과를 합친다.

예시 코드

Java의 sealed 인터페이스로 잎과 복합체의 종류를 닫아 두면 새 종류를 더할 때 컴파일러가 빠진 곳을 알려 준다.

public sealed interface Node permits File, Folder {
    String name();

    long size();
}

// 잎: 자식이 없다
public record File(String name, long size) implements Node {
}

// 복합체: 자식 Node들을 들고, 같은 질문을 자식에게 넘겨 합친다
public final class Folder implements Node {
    private final String name;
    private final List<Node> children = new ArrayList<>();

    public Folder(String name) {
        this.name = name;
    }

    public Folder add(Node child) {
        children.add(child);
        return this;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public long size() {
        return children.stream()
                .mapToLong(Node::size)
                .sum();
    }
}

Node project = new Folder("project")
        .add(new File("README.md", 4))
        .add(new Folder("src").add(new File("Main.java", 12)));
long total = project.size();

아래 단계 그림은 project.size() 한 번이 트리를 어떻게 타고 내려갔다 올라오는지 보여 준다. 폴더는 자식에게 묻고, 파일은 바로 답하고, 답이 모이면 폴더가 합을 위로 돌려준다.

project 폴더의 크기를 재귀로 더하는 단계 그림. 파일은 자기 크기를, 폴더는 자식 합을 돌려준다트리돌려준 값호출 스택(위가 지금)project/70 KBREADME.md4 KBsrc/18 KBMain.java12 KButil/6 KBStrings.java6 KBassets/48 KBlogo.png48 KBproject.size()자식에게 묻는 중project.size()README.md.size()4 KB를 돌려줌project.size()src.size()자식에게 묻는 중project.size()src.size()Main.java.size()12 KB를 돌려줌project.size()src.size()util.size()자식에게 묻는 중project.size()src.size()util.size()Strings.java.size()6 KB를 돌려줌project.size()src.size()util.size()6 KB를 돌려줌project.size()src.size()18 KB를 돌려줌project.size()assets.size()자식에게 묻는 중project.size()assets.size()logo.png.size()48 KB를 돌려줌project.size()assets.size()48 KB를 돌려줌project.size()70 KB를 돌려줌크기 단위는 KB(예시 값)
1 / 12

project.size(): 폴더라서 자식들에게 차례로 size()를 묻는다.

Folder.size()는 자식마다 size()를 부르고 그 합을 돌려준다. 파일이든 폴더든 같은 메서드라서 재귀가 저절로 깊이를 따라간다. 순서와 값은 계산 함수로 구했다.

실제 쓰임

  • AWT·Swing: java.awt.Container는 Component를 상속하면서 "다른 AWT 컴포넌트를 담을 수 있는 컴포넌트"다. 버튼(잎)과 패널(복합체)을 같은 Component로 배치하고 그린다.
  • UI 컴포넌트 트리 전반: 화면은 컴포넌트 안에 컴포넌트가 든 트리이고, 그리기와 크기 계산과 이벤트 전달이 트리를 따라 내려간다. 파일 시스템, 조직도, 메뉴와 하위 메뉴, 수식의 구문 트리도 같은 모양이다.

장단점

장점단점
클라이언트 코드에서 종류 분기와 재귀가 사라진다공통 인터페이스가 너무 일반적이 되어, 특정 종류만 지켜야 할 제약을 타입으로 막기 어렵다
새 종류의 노드를 더해도 클라이언트를 고치지 않는다트리가 깊으면 재귀 호출이 깊어지고, 같은 값을 매번 다시 계산할 수 있다

주의

  • add()를 어디에 둘지: 자식 관리 메서드를 구성 요소 인터페이스에 두면 잎과 복합체를 완전히 같게 다루지만(투명성), 파일에도 add()가 생겨 실행 중에 실패한다. 복합체에만 두면 타입은 안전하지만 클라이언트가 둘을 구분해야 한다. Wikipedia가 이 두 설계를 "투명성을 위한 설계"와 "타입 안전성을 위한 설계"로 나눠 설명한다. 위 예시는 안전한 쪽을 골랐다.
  • 순환을 막는다: 폴더를 자기 자식 폴더 안에 넣으면 size()가 끝나지 않는다. add()에서 조상인지 확인하거나 트리를 불변으로 만든다.
  • 큰 트리는 결과를 캐시한다: 매번 전체를 다시 더하지 말고, 자식이 바뀔 때 부모의 캐시를 지운다.

다른 언어에서는

  • Python: sum(child.size() for child in self.children) 한 줄이 복합체의 몸통이다. 타입 힌트의 Protocol로 구성 요소를 적어 둘 수 있다.
  • Go: type Node interface { Size() int64 } 하나에 파일과 폴더 구조체가 각자 Size를 단다. 폴더는 []Node를 필드로 든다.
  • 함수형 언어: 대수적 데이터 타입(합 타입)과 패턴 매칭으로 같은 트리를 표현한다. Java의 sealed와 record도 이쪽으로 다가간 것이다.

데코레이터: 같은 모양으로 감싸 기능을 겹친다

의도

객체를 같은 인터페이스의 다른 객체로 감싸, 원래 클래스를 고치거나 상속하지 않고 실행 중에 책임을 더한다. 감싼 것도 같은 인터페이스라 또 감쌀 수 있다.

문제 상황

HTTP 클라이언트에 로깅, 실패 시 재시도, 응답 캐시를 붙이고 싶다. 상속으로 풀면 LoggingRetryClient, RetryCachingClient, LoggingRetryCachingClient처럼 조합마다 클래스가 생긴다(기능 세 개면 켜고 끄는 조합이 2³ = 8가지). 게다가 상속은 컴파일 때 묶여, 설정에 따라 캐시를 켜고 끄는 일을 할 수 없다. 기능 하나를 객체 하나로 만들고 필요한 것만 겹쳐 씌우면 클래스는 기능 수만큼만 생긴다.

구조

데코레이터 클래스 그림. BasicHttpClient와 HttpClientDecorator가 HttpClient를 구현하고, 데코레이터는 안쪽 HttpClient를 필드로 가진다. LoggingClient, RetryClient, CachingClient가 데코레이터를 상속한다inner: 감싼 대상구성 요소(Component) · 인터페이스HttpClientget(url): Response실제 객체(Concrete)BasicHttpClientget(url)데코레이터(Decorator) · 추상HttpClientDecorator# inner: HttpClientget(url) → inner.get구체 데코레이터LoggingClient구체 데코레이터RetryClient구체 데코레이터CachingClient데코레이터도 HttpClient라서 또 감싸일 수 있다. 감싸는 순서가 실행 순서다new LoggingClient(new RetryClient(new CachingClient(new BasicHttpClient())))
데코레이터의 클래스 그림. 데코레이터는 HttpClient를 구현하는 동시에 HttpClient를 필드로 가진다. 이 겹침 덕에 데코레이터끼리도 감쌀 수 있다.
  • 구성 요소(Component): 감싸는 쪽과 감싸이는 쪽이 함께 구현하는 인터페이스.
  • 실제 객체(Concrete Component): 기본 일을 하는 맨 안쪽 객체.
  • 데코레이터(Decorator): 안쪽 구성 요소를 들고, 요청을 넘기기 전이나 후에 자기 일을 더한다.

예시 코드

public interface HttpClient {
    Response get(String url);
}

// 모든 데코레이터의 뼈대: 안쪽 HttpClient에 그대로 넘긴다
public abstract class HttpClientDecorator implements HttpClient {
    protected final HttpClient inner;

    protected HttpClientDecorator(HttpClient inner) {
        this.inner = inner;
    }

    @Override
    public Response get(String url) {
        return inner.get(url);
    }
}

public final class LoggingClient extends HttpClientDecorator {
    private static final Logger log = LoggerFactory.getLogger(LoggingClient.class);

    public LoggingClient(HttpClient inner) {
        super(inner);
    }

    @Override
    public Response get(String url) {
        long started = System.nanoTime();
        Response response = inner.get(url);
        log.info("GET {} -> {} ({} ms)", url, response.status(), (System.nanoTime() - started) / 1_000_000);
        return response;
    }
}

public final class RetryClient extends HttpClientDecorator {
    private final int maxAttempts;

    public RetryClient(HttpClient inner, int maxAttempts) {
        super(inner);
        this.maxAttempts = maxAttempts;
    }

    @Override
    public Response get(String url) {
        Response response = inner.get(url);
        for (int attempt = 2; attempt <= maxAttempts && response.isServerError(); attempt++) {
            response = inner.get(url);
        }
        return response;
    }
}

// CachingClient도 같은 모양: 캐시에 있으면 inner를 부르지 않고, 성공 응답만 저장한다

// 필요한 기능만 골라 겹겹이 씌운다. 순서가 곧 실행 순서다
HttpClient client = new LoggingClient(
        new RetryClient(
                new CachingClient(new BasicHttpClient()),
                3));

아래 단계 그림은 같은 요청 두 번이 층을 지나는 길이다. 첫 요청은 캐시에 없어 서버까지 가고, 서버가 한 번 실패하자 재시도 층이 다시 보낸다. 두 번째 요청은 캐시 층에서 끝나 서버에 가지 않는다.

로깅, 재시도, 캐시 데코레이터가 실제 HTTP 클라이언트를 겹겹이 감싼 모습과, 호출이 층을 지나는 단계 그림LoggingClientRetryClientCachingClientBasicHttpClient실제 네트워크 요청GET /users/11번째 호출LoggingClient들어감 → 안쪽에 넘김실제 HTTP 요청: 0번캐시: 비어 있음1번째 호출RetryClient들어감 → 안쪽에 넘김실제 HTTP 요청: 0번캐시: 비어 있음1번째 호출CachingClient캐시에 없음 → 안쪽에 넘김실제 HTTP 요청: 0번캐시: 비어 있음1번째 호출BasicHttpClient서버 응답 503(실패)실제 HTTP 요청: 1번캐시: 비어 있음1번째 호출CachingClient← 결과를 밖으로실제 HTTP 요청: 1번캐시: 비어 있음1번째 호출RetryClient실패를 받고 다시 시도실제 HTTP 요청: 1번캐시: 비어 있음1번째 호출CachingClient캐시에 없음 → 안쪽에 넘김실제 HTTP 요청: 1번캐시: 비어 있음1번째 호출BasicHttpClient서버 응답 200실제 HTTP 요청: 2번캐시: 비어 있음1번째 호출CachingClient← 성공 응답을 캐시에 저장실제 HTTP 요청: 2번캐시: /users/1 있음1번째 호출RetryClient← 결과를 밖으로실제 HTTP 요청: 2번캐시: /users/1 있음1번째 호출LoggingClient← 결과를 밖으로실제 HTTP 요청: 2번캐시: /users/1 있음2번째 호출LoggingClient들어감 → 안쪽에 넘김실제 HTTP 요청: 2번캐시: /users/1 있음2번째 호출RetryClient들어감 → 안쪽에 넘김실제 HTTP 요청: 2번캐시: /users/1 있음2번째 호출CachingClient캐시 적중: 안쪽을 안 부름실제 HTTP 요청: 2번캐시: /users/1 있음2번째 호출RetryClient← 결과를 밖으로실제 HTTP 요청: 2번캐시: /users/1 있음2번째 호출LoggingClient← 결과를 밖으로실제 HTTP 요청: 2번캐시: /users/1 있음재시도는 최대 3번,캐시는 성공만 저장한다
1 / 16

1번째 호출. LoggingClient: 요청을 받아 할 일을 하고 안쪽 객체의 get()을 부른다.

바깥 층부터 안쪽으로 요청이 들어가고, 결과는 거꾸로 나온다. 같은 GET /users/1을 두 번 보낸다. 지나는 길은 계산 함수로 구했다.

실제 쓰임

  • Java I/O: FilterInputStream은 다른 입력 스트림을 품고 요청을 그대로 넘기는 뼈대이고, 하위 클래스가 기능을 더한다. 그 하위 클래스인 BufferedInputStream은 감싼 스트림에 버퍼와 mark·reset을 더한다. 위 그림의 HttpClientDecorator와 같은 자리다.
  • 컬렉션 감싸개: Collections의 unmodifiableList, synchronizedList는 같은 List 인터페이스로 원래 리스트를 감싸 "고치기 막기", "동기화"를 더한다. 문서는 이것들을 "지정한 컬렉션이 뒷받침하는 새 컬렉션을 돌려주는 래퍼"라고 부른다.
// FileInputStream(실제 객체)에 버퍼를 씌우고, 그 위에 기본형 읽기를 씌운다
try (DataInputStream in = new DataInputStream(
        new BufferedInputStream(
                new FileInputStream("scores.bin")))) {
    int count = in.readInt();
}

장단점

장점단점
기능 n개의 조합이 클래스 2ⁿ개가 아니라 n개로 된다작은 객체가 많아지고, 스택 트레이스와 디버거에서 같은 메서드가 여러 겹으로 보인다
실행 중에 설정에 따라 붙이고 뗄 수 있다감싸는 순서가 결과를 바꾼다. 잘못된 순서가 버그가 된다
원래 클래스를 고치지 않아 개방 폐쇄 원칙에 맞는다감싼 객체는 원래 객체와 다른 객체라 ==나 구체 타입 검사가 깨진다

주의

  • 순서를 의도적으로 고른다: 캐시를 로깅 바깥에 두면 캐시 적중은 로그조차 남지 않는다. 재시도를 캐시 안쪽에 두면 실패 응답까지 캐시될 수 있다. 순서를 정하는 조립 코드를 한 곳에 모은다.
  • 인터페이스 전체를 전달한다: 인터페이스에 메서드가 많으면 데코레이터마다 모두 넘겨야 한다. 위 예처럼 "그대로 넘기는" 추상 데코레이터를 하나 두면 구체 데코레이터는 바꿀 메서드만 덮어쓴다.
  • 상속 대신 합성: 데코레이터는 "상속보다 합성"을 보여 주는 대표 예다. 원칙 자체의 설명은 소프트웨어 공학 노트에 있다.

다른 언어에서는

  • Python의 @ 데코레이터는 이 패턴과 다른 것이다: PEP 318에 따르면 @dec은 함수를 정의한 뒤 func = dec(func)로 이름을 바꿔 매는 문법일 뿐이다. 함수를 감싸 앞뒤에 일을 더하는 데 흔히 써서 데코레이터 패턴과 닮았지만, 감싸지 않고 함수를 등록만 하거나 아예 다른 객체를 돌려줘도 된다. 같은 인터페이스를 지킨다는 보장도 없다. PEP 318도 이 이름이 GoF 책의 쓰임과 맞지 않는다는 불만이 있었고, 이름은 오히려 구문 트리에 표시를 다는 컴파일러 쪽 쓰임에서 왔을 것이라고 적었다. 감싼 함수의 이름과 설명을 지키려면 functools.wraps를 쓴다.
import functools
import time


def timed(function):
    """함수를 감싸 걸린 시간을 출력한다."""

    @functools.wraps(function)
    def wrapper(*args, **kwargs):
        started = time.perf_counter()
        try:
            return function(*args, **kwargs)
        finally:
            print(f"{function.__name__}: {time.perf_counter() - started:.3f}s")

    return wrapper


@timed
def fetch_user(user_id):
    ...

# 위 @timed는 fetch_user = timed(fetch_user)와 같다
  • Go의 인터페이스 임베딩: 구조체에 인터페이스를 이름 없이 넣으면(임베딩) 그 메서드가 바깥 구조체의 메서드가 되어, 바꾸고 싶은 메서드만 다시 쓰면 나머지는 자동으로 안쪽에 넘어간다. 위 Java의 "그대로 넘기는 추상 데코레이터"를 언어가 대신 해 주는 셈이다. Effective Go는 임베딩이 상속과 다르다고 강조한다. 넘어간 메서드가 불릴 때 받는 쪽(receiver)은 바깥이 아니라 안쪽 값이다.
  • Go의 미들웨어: http.Handler를 받아 http.Handler를 돌려주는 함수가 Go식 데코레이터다. 표준 라이브러리의 http.StripPrefix와 http.TimeoutHandler도 이 모양이다.
// 같은 인터페이스를 받아 같은 인터페이스를 돌려주는 함수 = Go식 데코레이터(미들웨어)
func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        started := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(started))
    })
}

handler := Logging(http.StripPrefix("/api", apiHandler))

흔한 오해

  • "파이썬 @ 데코레이터가 곧 데코레이터 패턴이다" — @은 f = dec(f)의 문법 설탕이다. 감싸는 데 자주 쓰일 뿐 같은 인터페이스를 지키는 객체 감싸기와는 다른 개념이고, PEP 318도 이름이 어긋난다고 적었다.
  • "어댑터와 데코레이터는 같다, 둘 다 래퍼다" — 어댑터는 인터페이스를 바꾸고, 데코레이터는 인터페이스를 그대로 두고 기능을 더한다. 그래서 데코레이터만 여러 겹 쌓을 수 있다.
  • "브리지는 어댑터를 미리 만들어 둔 것이다" — 브리지는 두 방향의 변화를 떼어 놓는 설계 결정이고, 어댑터는 이미 맞지 않는 둘을 잇는 사후 처방이다. 구조가 비슷해도 시점과 목적이 다르다.
  • "컴포지트는 트리 자료 구조다" — 트리는 재료이고, 패턴의 핵심은 잎과 묶음이 같은 인터페이스로 답해 클라이언트가 둘을 가르지 않는다는 점이다.
  • "데코레이터를 쓰면 상속은 필요 없다" — 데코레이터 자체도 인터페이스 구현(넓은 뜻의 상속)에 기대고, 뼈대 추상 클래스를 상속한다. 버리는 것은 "기능 조합마다 하위 클래스"다.
  • "패턴 이름을 붙여야 좋은 설계다" — 클래스 이름에 Adapter나 Decorator를 붙이는 것보다, 왜 감쌌는지가 코드와 테스트에서 드러나는 것이 중요하다. 남용의 함정은 디자인 패턴이란의 안티패턴 절에 있다.

핵심 자료

요지는 직접 정리한 한 줄이다.

자료한 줄 요지
Wikipedia — Adapter pattern객체 어댑터는 실행 중에 위임하고, 클래스 어댑터는 컴파일 때 상속한다.
Wikipedia — Bridge pattern추상과 구현을 떼어 둘이 따로 바뀌게 한다는 GoF의 정의와 예.
Wikipedia — Composite pattern자식 연산을 어디에 둘지: 투명성을 고르면 타입 안전성을 잃는다.
Wikipedia — Decorator pattern하위 클래스는 컴파일 때 묶이고, 데코레이터는 실행 중에 붙였다 뗀다.
Oracle — FilterInputStreamJava I/O 데코레이터의 뼈대. 감싼 스트림에 요청을 넘기고 하위 클래스가 기능을 더한다.
PEP 318파이썬 @ 문법의 유래. 이름이 GoF 책과 어긋난다는 불만을 PEP가 직접 적었다.
Effective Go — Embedding임베딩은 전달 메서드를 대신 써 주지만, 받는 쪽은 안쪽 값이다. 상속이 아니다.