Decorator 패턴: 상속 없이 기능을 겹겹이 쌓는 법

Decorator 패턴이 로깅·재시도·캐싱 같은 횡단 관심사를 런타임 래핑으로 조합해 클래스 폭증을 막는 원리를 정리한다.

2026-08-13 · 최초 발행 2025-10-14

상속으로 로깅·재시도·캐싱을 조합하면 벌어지는 일

메시지 전송 기능 하나에 로깅을 붙이고, 재시도를 붙이고, 필요하면 캐싱까지 얹어야 한다고 하자. 이 조합을 전부 상속으로 풀면 로깅만 붙인 클래스, 재시도만 붙인 클래스, 둘 다 붙인 클래스가 따로 필요해져 기능이 늘어날수록 조합의 수가 기하급수로 불어난다. Decorator 패턴은 이 문제를 상속이 아니라 합성으로 푼다 — 같은 인터페이스를 구현하는 래퍼 객체를 원본 객체 위에 겹겹이 씌우고, 요청이 이 체인을 순차로 통과하며 각 데코레이터가 전처리와 후처리를 얹는 방식이다. 위임(delegate) 기반이라 클라이언트 입장에서는 몇 겹이 씌워졌는지 알 필요가 없다.

비슷해 보이는 패턴과는 목적이 갈린다. Proxy는 접근 제어가 중심이고, Adapter는 인터페이스 변환이 중심이라면, Decorator는 기능 증강이 중심이다.

체인을 이루는 조각들

Component 인터페이스는 공통 계약을 정의해 클라이언트 쪽에 다형성을 보장한다. ConcreteComponent는 핵심 기능의 기본 구현체로, 최소 책임만 지며 성능·자원 경계의 기준점이 된다. Decorator 추상 클래스는 Component 참조를 들고 위임을 수행하며 전·후처리 훅을 제공해 기능이 누적되는 구조를 만든다. 여기에 로깅·캐싱·리트라이·인증 같은 구체적인 책임을 얹는 것이 ConcreteDecorator들이고, 이들 사이의 순서 의존성은 체인을 설계할 때 부작용을 최소화하도록 신경 써야 하는 부분이다.

핵심은 런타임에 조합을 바꿀 수 있다는 점이다. 클라이언트는 몇 개의 데코레이터가 씌워졌는지 인지하지 않으며, 이 투명성 덕분에 기능 플래그와도 궁합이 좋다.

요청이 체인을 통과하는 순서

아래는 클라이언트 요청이 RetryDecorator → LoggingDecorator → ConcreteComponent 순으로 내려갔다가 응답이 역순으로 올라오는 흐름이다.

ConcreteComponentLoggingDecoratorRetryDecoratorClientConcreteComponentLoggingDecoratorRetryDecoratorClientrequest()request()request()responseresponseresponse (or retry on failure)

설계는 세 단계로 나뉜다. 먼저 핵심 기능의 최소 단위를 식별하고 공통 인터페이스를 뽑아낸 뒤, 확장 포인트를 전처리·후처리·예외 처리·정책 적용으로 분류한다. 그다음 기본 구현체(ConcreteComponent)를 확정하고 추상 Decorator를 작성한 뒤 기능별 ConcreteDecorator를 구현하는데, 이때 체인 순서는 성능→안정성→관찰성 같은 품질 속성 우선순위를 기준으로 배치한다. 마지막으로 정상 흐름에서는 최종 Decorator가 결과를 반환하고, 오류가 나면 재시도나 폴백 Decorator가 이를 흡수하거나 변환하며 정책 위반 시에는 즉시 중단한다. 상태 변경은 단일 지점(commit)에서만 일어나도록 해 멱등성을 확보하는 편이 좋다.

실무에서 자주 얹는 책임들

관찰성 쪽에서는 요청 로깅, 구조화 로그, 분산 트레이싱 컨텍스트 전파가 흔하고, 신뢰성 쪽에서는 재시도·백오프, 서킷브레이커, 타임아웃이 자주 얹힌다. 성능·비용 측면에서는 캐싱(LRU/TTL), 압축·암호화 스트림, 페이로드 슬리밍이, 보안·거버넌스 측면에서는 인증·인가 헤더 주입, 데이터 마스킹, 레이트 리밋이 데코레이터로 구현된다. Java I/O의 InputStream/OutputStream 체인도 대표적인 Decorator 활용 사례다.

로깅·재시도 체인을 Java로 짜보면

JDK 11 이상, 외부 라이브러리 없이 javac DecoratorDemo.java && java DecoratorDemo로 실행된다.

// File: DecoratorDemo.java
import java.time.Duration;

interface MessageSender {
    void send(String to, String body) throws Exception;
}

class SmtpSender implements MessageSender {
    @Override
    public void send(String to, String body) throws Exception {
        // 실제 전송 대신 데모 출력
        if (body.contains("FAIL")) throw new RuntimeException("SMTP temporary error");
        System.out.println("[SMTP] to=" + to + " body=" + body);
    }
}

abstract class SenderDecorator implements MessageSender {
    protected final MessageSender delegate;
    protected SenderDecorator(MessageSender delegate) { this.delegate = delegate; }
}

class LoggingDecorator extends SenderDecorator {
    public LoggingDecorator(MessageSender delegate) { super(delegate); }
    @Override
    public void send(String to, String body) throws Exception {
        long start = System.nanoTime();
        try {
            System.out.println("[LOG] sending start to=" + to);
            delegate.send(to, body);
            long tookMs = (System.nanoTime() - start) / 1_000_000;
            System.out.println("[LOG] success in " + tookMs + "ms");
        } catch (Exception e) {
            System.err.println("[LOG] error: " + e.getMessage());
            throw e;
        }
    }
}

class RetryDecorator extends SenderDecorator {
    private final int maxAttempts;
    private final long backoffMillis;
    public RetryDecorator(MessageSender delegate, int maxAttempts, Duration backoff) {
        super(delegate);
        this.maxAttempts = Math.max(1, maxAttempts);
        this.backoffMillis = Math.max(0, backoff.toMillis());
    }
    @Override
    public void send(String to, String body) throws Exception {
        int attempt = 0;
        while (true) {
            try {
                attempt++;
                delegate.send(to, body);
                return;
            } catch (Exception e) {
                if (attempt >= maxAttempts) throw e;
                Thread.sleep(backoffMillis);
            }
        }
    }
}

public class DecoratorDemo {
    public static void main(String[] args) {
        MessageSender sender =
            new RetryDecorator(
                new LoggingDecorator(
                    new SmtpSender()
                ),
                3, Duration.ofMillis(200)
            );

        try {
            sender.send("user@example.com", "HELLO");
            sender.send("user@example.com", "FAIL then retry succeeds?"); // 데모상 실패 유지
        } catch (Exception e) {
            System.err.println("Final failure: " + e.getMessage());
        }
    }
}

Decorator는 가급적 무상태로 설계하는 편이 안전하다. 공유 상태가 필요하다면 동기화·CAS·불변 객체로 경쟁 조건을 막아야 한다. 이 예제에서도 Metrics → Retry → Logging처럼 순서를 명시적으로 정해 의도를 드러내는 편이 낫다.

상속·Proxy와 비교하면

항목 Decorator 상속(Subclassing) Proxy
성능 래핑 오버헤드(호출/객체) 소폭 증가 가상 호출 비용 수준 조건부 체크 중심, 오버헤드 낮음
확장성 런타임 조합 및 기능 누적에 강점 조합 시 클래스 폭증, 컴파일타임 고정 접근 제어/원격지 대리 특화
일관성 공통 인터페이스 준수, 투명성 확보 부모 계약 위반 리스크 인터페이스 투명, 기능 증강 제한
안정성 실패 격리·재시도·폴백 구현 용이 공통 부모 결함 전파 가능 네트워크/지연 등 별도 리스크
운영 편의 기능 플래그·체인 재배치 용이 재배포 필요 정책 스위칭 용이하나 기능 확장 제한

얼마나 얻고 무엇을 감수하는가

인터페이스는 최소한으로 유지하고 단일 책임을 지키며, 전처리·후처리 역할을 분리하는 것이 기본 원칙이다. 체인 순서는 명시적으로 정하고 테스트로 고정하는 편이 좋고, 조합(Composition) 자체는 DI 컨테이너에 맡기는 것이 관리하기 쉽다. 재시도·타임아웃·서킷브레이커 같은 실패 격리 로직은 바깥쪽 레이어에 두는 편이 안전하다.

다만 중첩이 과도해지면 추적이 어려워지고 성능도 떨어지므로 코릴레이션 ID나 구조화 로그 같은 관측성 장치는 필수에 가깝다. 상태를 들고 있는 Decorator를 섞으면 경쟁 조건이나 교착 상태 위험이 생기니 불변·멱등 패턴을 우선하는 편이 낫다. 횡단 관심사를 대량으로 적용해야 한다면 프록시·AOP가 더 단순할 수 있고, 세밀한 조합이나 부분 적용이 필요할 때 Decorator가 우위를 갖는다.

상속 조합과 비교하면 기능 조합에 필요한 클래스 수를 50% 이상 줄일 수 있고, 배포당 변경량도 평균 2040% 줄어드는 것으로 파악된다. 레이어 하나가 추가하는 오버헤드는 수 µs 단위이고, 35단 정도로 체인을 구성해도 전체 오버헤드는 수% 내에서 관리할 수 있는 수준이다. 변경을 격리하고 테스트하기 쉬워지는 효과, 기능 플래그와 묶어 점진적으로 롤아웃할 수 있는 효과는 정량 수치보다 체감이 더 크다.

디자인 패턴구조 패턴Decorator 패턴횡단 관심사객체지향 설계