Skip to content
ioob.dev
Go back

디자인 패턴 6편 — Decorator와 Proxy

· 10분 읽기
Design Pattern 시리즈 (6/8)
  1. 디자인 패턴 1편 — 패턴을 배우는 이유
  2. 디자인 패턴 2편 — Strategy와 State
  3. 디자인 패턴 3편 — Observer와 Command
  4. 디자인 패턴 4편 — Template Method와 Chain of Responsibility
  5. 디자인 패턴 5편 — Factory Method, Abstract Factory, Builder
  6. 디자인 패턴 6편 — Decorator와 Proxy
  7. 디자인 패턴 7편 — Adapter, Facade, Composite: 구조를 정리하는 패턴들
  8. 디자인 패턴 8편 — Singleton, Iterator, Prototype: 자주 쓰지만 오해도 많은 패턴들
Table of contents

Table of contents

감싼다는 것의 의미

객체에 기능을 더하고 싶을 때 가장 먼저 떠오르는 방법은 상속이다. LoggingUserService extends UserService 같은 식으로 기존 클래스를 확장하면 된다. 간단하고 직관적이다.

그런데 상속으로 기능을 추가하다 보면 문제가 생긴다. 로깅 기능이 필요한 서비스, 캐싱 기능이 필요한 서비스, 둘 다 필요한 서비스 — 조합마다 서브클래스를 만들어야 한다. 기능이 세 가지면 조합은 최대 일곱 가지. 네 가지면 열다섯 가지. 이른바 클래스 폭발(class explosion) 문제가 터진다. 상속 계층이 깊어질수록 코드를 읽는 사람은 여러 클래스를 넘나들며 “결국 이 메서드가 뭘 하는 거지?”를 추적해야 한다.

Decorator와 Proxy는 이 문제에 대해 같은 구조적 해법을 제시한다. 원본 객체를 감싸는(wrapping) 새 객체를 만들되, 원본과 같은 인터페이스를 구현한다. 바깥에서 보면 원본이나 래퍼나 똑같이 생겼다. 다만 둘의 의도가 다르다. Decorator는 기능을 더하는 것이 목적이고, Proxy는 접근을 제어하는 것이 목적이다.

Decorator — 감싸서 기능을 더하기

패턴의 구조

Decorator 패턴은 객체를 다른 객체로 감싸서, 원본의 행동에 새로운 행동을 추가한다. 감싸는 객체(Decorator)와 감싸이는 객체(Component)가 같은 인터페이스를 구현하기 때문에, 클라이언트 입장에서는 구분이 안 된다.

classDiagram
    class Component {
        <<interface>>
        +operation()
    }
    class ConcreteComponent {
        +operation()
    }
    class Decorator {
        <<abstract>>
        -component: Component
        +operation()
    }
    class ConcreteDecoratorA {
        +operation()
    }
    class ConcreteDecoratorB {
        +operation()
    }

    Component <|.. ConcreteComponent
    Component <|.. Decorator
    Decorator <|-- ConcreteDecoratorA
    Decorator <|-- ConcreteDecoratorB
    Decorator o-- Component : wraps

핵심은 DecoratorComponent를 필드로 들고 있으면서, 자기도 Component를 구현한다는 점이다. 이 구조 덕분에 데코레이터를 여러 겹으로 감쌀 수 있다.

코드로 보자 — 알림 시스템

기본 알림을 이메일로 보내되, 필요에 따라 SMS나 Slack 알림을 덧붙이는 시나리오다.

인터페이스를 정의한다.

public interface Notifier {
    void send(String message);
}

기본 구현은 이메일 알림이다.

public class EmailNotifier implements Notifier {

    private final String email;

    public EmailNotifier(String email) {
        this.email = email;
    }

    @Override
    public void send(String message) {
        System.out.println("이메일 전송 → " + email + ": " + message);
    }
}

데코레이터의 베이스 클래스를 만든다. 이 클래스가 감싸기 구조의 뼈대를 잡는다.

public abstract class NotifierDecorator implements Notifier {

    protected final Notifier wrapped;

    protected NotifierDecorator(Notifier wrapped) {
        this.wrapped = wrapped;
    }

    @Override
    public void send(String message) {
        wrapped.send(message); // 원본에 위임
    }
}

이 클래스가 하는 일은 Notifier를 감싸고, send() 호출을 원본에 그대로 넘기는 것이다. 아직은 아무것도 추가하지 않았다.

SMS와 Slack 데코레이터를 구현한다.

public class SmsDecorator extends NotifierDecorator {

    private final String phoneNumber;

    public SmsDecorator(Notifier wrapped, String phoneNumber) {
        super(wrapped);
        this.phoneNumber = phoneNumber;
    }

    @Override
    public void send(String message) {
        super.send(message); // 기존 알림 실행
        System.out.println("SMS 전송 → " + phoneNumber + ": " + message);
    }
}

public class SlackDecorator extends NotifierDecorator {

    private final String channel;

    public SlackDecorator(Notifier wrapped, String channel) {
        super(wrapped);
        this.channel = channel;
    }

    @Override
    public void send(String message) {
        super.send(message); // 기존 알림 실행
        System.out.println("Slack 전송 → #" + channel + ": " + message);
    }
}

데코레이터를 겹겹이 감싸는 코드는 이렇다.

Notifier notifier = new EmailNotifier("dev@example.com");
notifier = new SmsDecorator(notifier, "010-1234-5678");
notifier = new SlackDecorator(notifier, "alerts");

notifier.send("서버 장애 발생");

이 코드를 실행하면 이메일, SMS, Slack 알림이 순서대로 발송된다. 각 데코레이터가 자기 알림을 보내고, super.send()로 안쪽 데코레이터(또는 원본)에도 전달한다. 상속 계층을 여러 개 만들 필요 없이, 조합만으로 기능이 확장되는 구조다.

Java I/O — Decorator의 교과서적 사례

Java의 java.io 패키지는 Decorator 패턴의 가장 유명한 실제 적용 사례다.

// 파일에서 바이트를 읽는 기본 스트림
InputStream raw = new FileInputStream("data.txt");

// 버퍼링 기능을 추가
InputStream buffered = new BufferedInputStream(raw);

// GZIP 압축 해제 기능을 추가
InputStream decompressed = new GZIPInputStream(buffered);

FileInputStream, BufferedInputStream, GZIPInputStream이 전부 InputStream을 구현한다. BufferedInputStreamFileInputStream을 감싸서 버퍼링을 더하고, GZIPInputStreamBufferedInputStream을 감싸서 압축 해제를 더한다. 겉에서 보면 전부 InputStream이다.

이 구조가 왜 강력한가? 각 기능이 독립적인 데코레이터니까, 원하는 조합을 자유롭게 만들 수 있다. 버퍼링만 쓸 수도 있고, GZIP만 쓸 수도 있고, 둘 다 쓸 수도 있다. 새로운 스트림 기능(암호화, Base64 디코딩 등)을 추가할 때 기존 클래스를 건드릴 필요가 없다.

단점도 분명하다. Java I/O 코드를 처음 보면 new BufferedReader(new InputStreamReader(new FileInputStream(data.txt), UTF-8)) 같은 중첩이 눈을 어지럽힌다. 감싸는 순서가 중요한데 컴파일러가 순서를 강제하지 않으니, 잘못된 순서로 감쌌을 때 런타임에야 문제가 드러나기도 한다.

Kotlin에서의 Decorator

Kotlin의 by 키워드(위임)를 쓰면 데코레이터 보일러플레이트가 줄어든다.

interface Notifier {
    fun send(message: String)
}

class EmailNotifier(private val email: String) : Notifier {
    override fun send(message: String) {
        println("이메일 전송 → $email: $message")
    }
}

class SmsDecorator(
    private val wrapped: Notifier,
    private val phone: String
) : Notifier by wrapped {  // wrapped에 위임

    override fun send(message: String) {
        wrapped.send(message)
        println("SMS 전송 → $phone: $message")
    }
}

by wrappedNotifier의 모든 메서드를 wrapped에 자동 위임한다. 오버라이드할 메서드만 직접 구현하면 되니까, 인터페이스에 메서드가 열 개여도 관심 있는 메서드 하나만 건드리면 된다. 베이스 데코레이터 클래스를 따로 만들 필요가 없어지는 셈이다.

Proxy — 감싸서 접근을 제어하기

패턴의 구조

Proxy 패턴도 원본 객체를 감싸는 구조다. Decorator와 클래스 다이어그램이 거의 동일하다. 하지만 목적이 다르다. Proxy는 원본에 대한 접근을 제어한다. 기능을 더하는 게 아니라, 원본에 도달하기 전에 무언가를 검사하거나 최적화하는 것이 핵심이다.

흔히 쓰이는 Proxy의 종류는 세 가지다.

코드로 보자 — 지연 로딩 프록시

데이터베이스 연결은 무거운 자원이다. 애플리케이션 시작 시 바로 연결을 맺는 대신, 첫 쿼리가 날아올 때 비로소 연결하는 지연 로딩 프록시를 만들어보자.

sequenceDiagram
    participant Client
    participant Proxy as DatabaseProxy
    participant Real as RealDatabase

    Client->>Proxy: query("SELECT ...")
    alt 아직 연결되지 않음
        Proxy->>Real: connect()
        Real-->>Proxy: 연결 완료
    end
    Proxy->>Real: query("SELECT ...")
    Real-->>Proxy: 결과
    Proxy-->>Client: 결과
    Note over Proxy: 두 번째 query부터는<br/>connect() 생략

인터페이스를 정의한다.

public interface Database {
    List<Map<String, Object>> query(String sql);
    void close();
}

실제 구현체는 생성 시 DB 연결을 맺는다.

public class RealDatabase implements Database {

    private final Connection connection;

    public RealDatabase(String url, String user, String password) {
        // 이 시점에 실제 DB 연결이 맺어진다 (무거운 작업)
        this.connection = DriverManager.getConnection(url, user, password);
        System.out.println("DB 연결 완료: " + url);
    }

    @Override
    public List<Map<String, Object>> query(String sql) {
        // 실제 쿼리 실행 로직
        // ...
        return results;
    }

    @Override
    public void close() {
        connection.close();
    }
}

프록시는 첫 사용 시점까지 RealDatabase 생성을 미룬다.

public class LazyDatabaseProxy implements Database {

    private final String url;
    private final String user;
    private final String password;
    private RealDatabase realDatabase; // null로 시작

    public LazyDatabaseProxy(String url, String user, String password) {
        this.url = url;
        this.user = user;
        this.password = password;
        // 여기서는 연결하지 않는다!
    }

    private RealDatabase getRealDatabase() {
        if (realDatabase == null) {
            realDatabase = new RealDatabase(url, user, password);
        }
        return realDatabase;
    }

    @Override
    public List<Map<String, Object>> query(String sql) {
        return getRealDatabase().query(sql); // 첫 호출 시 연결
    }

    @Override
    public void close() {
        if (realDatabase != null) {
            realDatabase.close();
        }
    }
}

이 프록시가 하는 일은 RealDatabase를 자기 안에 숨겨두고, query()가 처음 호출될 때 비로소 실제 객체를 만드는 것이다. 두 번째부터는 이미 만들어진 객체를 재사용한다.

클라이언트는 프록시인지 실제 객체인지 구분하지 못한다.

Database db = new LazyDatabaseProxy("jdbc:mysql://localhost/mydb", "root", "pass");
// 이 시점에는 아직 DB 연결이 없다

// ...앱 초기화 작업...

List<Map<String, Object>> users = db.query("SELECT * FROM users");
// 이 시점에 비로소 DB 연결이 맺어진다

캐싱 프록시 예시

같은 쿼리를 반복 실행하는 상황에서 캐싱 프록시를 끼우면 DB 부하를 줄일 수 있다.

public class CachingDatabaseProxy implements Database {

    private final Database wrapped;
    private final Map<String, List<Map<String, Object>>> cache = new HashMap<>();

    public CachingDatabaseProxy(Database wrapped) {
        this.wrapped = wrapped;
    }

    @Override
    public List<Map<String, Object>> query(String sql) {
        if (cache.containsKey(sql)) {
            System.out.println("캐시 히트: " + sql);
            return cache.get(sql);
        }

        List<Map<String, Object>> result = wrapped.query(sql);
        cache.put(sql, result);
        return result;
    }

    @Override
    public void close() {
        cache.clear();
        wrapped.close();
    }
}

이 프록시가 하는 일은 SQL 문자열을 키로 사용해 결과를 캐시하는 것이다. 같은 쿼리가 오면 DB를 거치지 않고 캐시된 결과를 반환한다. 실전에서는 TTL(Time-To-Live)이나 캐시 크기 제한을 추가해야 하겠지만, 패턴의 핵심 구조는 이것이다.

캐싱 프록시와 지연 로딩 프록시를 조합하는 것도 가능하다.

Database db = new CachingDatabaseProxy(
    new LazyDatabaseProxy("jdbc:mysql://localhost/mydb", "root", "pass")
);

바깥에서 보면 그냥 Database다. 내부적으로는 캐시를 확인하고, 캐시 미스면 지연 로딩 프록시를 거쳐 실제 DB에 접근한다.

Spring AOP와 Proxy의 관계

Spring 프레임워크를 쓰고 있다면, 이미 Proxy 패턴의 세례를 받고 있는 것이다. Spring의 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 동적 프록시(Dynamic Proxy)로 구현된다.

@Transactional을 예로 들면, Spring은 해당 빈(Bean)의 프록시 객체를 만들어서 컨테이너에 등록한다. 클라이언트가 메서드를 호출하면, 실제 객체가 아닌 프록시가 먼저 호출을 받는다. 프록시는 트랜잭션을 시작하고, 실제 메서드를 호출하고, 예외 없이 끝나면 커밋하고, 예외가 나면 롤백한다.

@Service
public class OrderService {

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 이 메서드는 직접 호출되지 않는다.
        // Spring이 만든 프록시가 트랜잭션을 열고,
        // 이 메서드를 호출하고,
        // 결과에 따라 커밋/롤백한다.
        orderRepository.save(request.toOrder());
        paymentService.charge(request.getPaymentInfo());
    }
}

Spring은 두 가지 프록시 방식을 쓴다.

  1. JDK Dynamic Proxy: 인터페이스가 있는 경우. java.lang.reflect.Proxy를 사용해서 런타임에 프록시 객체를 생성한다.
  2. CGLIB Proxy: 인터페이스가 없는 경우. 바이트코드를 조작해서 대상 클래스의 서브클래스를 런타임에 만든다.

어떤 방식이든 클라이언트는 프록시인지 실제 객체인지 모른다. 이것이 Proxy 패턴의 핵심이다 — 투명하게 끼어들기.

이 구조를 모르면 Spring에서 자주 당하는 함정이 하나 있다. 같은 클래스 내부에서 @Transactional 메서드를 호출하면 트랜잭션이 안 걸린다. this.placeOrder()는 프록시를 거치지 않고 실제 객체를 직접 호출하기 때문이다. 프록시 패턴의 구조를 이해하면 이런 문제의 원인이 바로 보인다.

Decorator vs Proxy — 구조는 같은데 의도가 다르다

클래스 다이어그램만 놓고 보면 Decorator와 Proxy는 구분이 안 된다. 둘 다 원본과 같은 인터페이스를 구현하고, 원본 객체를 필드로 들고 있다. 차이는 왜 감싸는가에 있다.

기준DecoratorProxy
목적기능 추가접근 제어
원본과의 관계원본 위에 새 행동을 쌓는다원본에 도달하기 전에 가로챈다
겹쳐 쓰기핵심 사용법 (여러 겹 감싸기)보통 한 겹
원본 객체 생성클라이언트가 원본을 만들어서 넘긴다프록시가 원본 생성을 관리할 수 있다
대표 사례Java I/O, 알림 데코레이터Spring AOP, 지연 로딩, 캐싱
의도 질문이 객체에 기능을 더해야 한다이 객체에 대한 접근을 제어해야 한다

가끔은 경계가 모호해진다. 캐싱은 기능 추가처럼 보이기도 하고 접근 제어처럼 보이기도 한다. 로깅도 마찬가지다. 이런 경우 “이 감싸기의 주된 목적이 뭔가?”를 기준으로 판단하면 된다. 캐싱의 주된 목적이 원본 호출을 줄이는 것이면 Proxy에 가깝고, 응답에 캐시 헤더를 추가하는 것이면 Decorator에 가깝다.

실전에서의 판단 기준

Decorator와 Proxy 앞에서 고민될 때 물어볼 질문은 이것이다.

“감싸는 이유가 ‘더하기’인가, ‘막기/미루기’인가?”

더하기라면 Decorator다. 이메일 알림에 SMS를 더하고, 거기에 Slack을 더하는 식. 기능이 누적된다. 감쌀수록 객체가 할 수 있는 일이 늘어난다.

막기 또는 미루기라면 Proxy다. 권한 없는 사용자를 차단하거나, 무거운 객체의 생성을 미루거나, 같은 요청에 캐시를 돌려주는 식. 원본에 도달하기 전에 무언가를 결정하는 것이 핵심이다.

두 패턴 모두 같은 인터페이스로 감싼다는 원리 위에 서 있다. 이 원리를 이해하고 나면, 감싸는 의도만 명확히 하면 어떤 상황에서 어떤 패턴을 쓸지 자연스럽게 결정된다. 구조가 단순한 패턴일수록 의도를 정확히 이름 붙이는 것이 중요한 법이다.


다음 편에서는 인터페이스를 맞추고, 복잡함을 숨기고, 부분과 전체를 같은 방식으로 다루는 세 패턴을 살펴본다. 기존 인터페이스를 원하는 형태로 변환하는 Adapter, 복잡한 서브시스템 앞에 단순한 창구를 세우는 Facade, 트리 구조를 재귀적으로 다루는 Composite를 다룬다.

7편: Adapter, Facade, Composite


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
디자인 패턴 5편 — Factory Method, Abstract Factory, Builder
Next Post
디자인 패턴 7편 — Adapter, Facade, Composite: 구조를 정리하는 패턴들