개발

[디자인 패턴] 6. 데코레이터 패턴

2026년 01월 31일
70

디자인 패턴 글을 안쓴지 오래 됐네요. 다시 한번 이어서 작성해보겠습니다. 지난 글에서는 어댑터 패턴을 사용하여 서로 다른 인터페이스를 가진 외부 시스템을 우리 코드에 매끄럽게 연결하는 방법을 알아보았습니다. 덕분에 우리는 기존 코드를 수정하지 않고도 외부 결제 서비스를 통합할 수 있게 되었습니다.

그런데 개발을 진행하다 보니, 이번에는 기존 기능에 '부가 기능'을 추가해달라는 요구사항이 들어왔습니다.

"결제 요청 데이터를 암호화해서 보내주세요." "모든 결제 시도가 언제 일어났는지 로그를 남겨야 합니다."

상속의 함정: 클래스 폭발 문제

가장 먼저 떠오르는 방법은 '상속'입니다. 기존 CreditCardStrategy를 상속받아 EncryptedCreditCardStrategy를 만들고, 또 그걸 상속받아 LoggedEncryptedCreditCardStrategy를 만드는 식이죠.

하지만 이런 방식은 기능을 조합할수록 클래스 개수가 기하급수적으로 늘어나는 '클래스 폭발(Class Explosion)' 문제를 일으킵니다. 만약 '압축' 기능까지 추가된다면, 우리는 온갖 조합의 클래스를 다 만들어야 할지도 모릅니다.

이때 필요한 것이 바로 **데코레이터 패턴(Decorator Pattern)**입니다. 객체에 기능을 마치 옷을 입히듯 겹겹이 덧붙여 나가는 방식입니다.

데코레이터 패턴 구현하기

데코레이터 패턴의 핵심은 **"기본 객체와 장식(Decorator) 객체가 같은 인터페이스를 공유한다"**는 점입니다. 이를 통해 클라이언트는 자신이 다루는 객체가 기본 객체인지, 장식된 객체인지 신경 쓰지 않고 동일하게 사용할 수 있습니다.

1. 추상 데코레이터 정의

먼저, 장식자들의 부모가 될 추상 클래스를 만듭니다. 이 클래스는 PaymentStrategy 인터페이스를 구현하면서, 동시에 내부에 또 다른 PaymentStrategy를 가지고 있어야 합니다. 이것이 바로 '감싸기(Wrapping)'의 핵심입니다.

public abstract class PaymentDecorator implements PaymentStrategy {
    
    // 감싸질 대상 (기본 객체일 수도, 다른 데코레이터일 수도 있음)
    protected PaymentStrategy wrappedStrategy;

    public PaymentDecorator(PaymentStrategy strategy) {
        this.wrappedStrategy = strategy;
    }

    @Override
    public void pay(int amount) {
        // 기본적으로는 자신이 감싸고 있는 객체에 행동을 위임
        wrappedStrategy.pay(amount);
    }
}

2. 구체적인 장식(기능) 구현

이제 원하는 부가 기능을 구현합니다. 결제 전후에 원하는 로직을 추가하고, 실제 결제는 super.pay()가 아닌 wrappedStrategy.pay()를 호출하여 다음 단계로 넘깁니다.

// 로깅 기능을 추가하는 데코레이터
public class LoggingDecorator extends PaymentDecorator {

    public LoggingDecorator(PaymentStrategy strategy) {
        super(strategy);
    }

    @Override
    public void pay(int amount) {
        System.out.println("[Log] 결제 요청 발생: " + amount + "원");
        // 다음 로직(실제 결제 혹은 다른 데코레이터) 실행
        wrappedStrategy.pay(amount); 
    }
}

// 암호화 기능을 추가하는 데코레이터
public class EncryptionDecorator extends PaymentDecorator {

    public EncryptionDecorator(PaymentStrategy strategy) {
        super(strategy);
    }

    @Override
    public void pay(int amount) {
        System.out.println("[Security] 결제 데이터를 암호화합니다.");
        // 다음 로직 실행
        wrappedStrategy.pay(amount); 
    }
}

사용 예시: 마트료시카 인형처럼 감싸기

이제 클라이언트 코드는 필요한 기능을 레고 블록처럼 조립해서 사용할 수 있습니다.

public class PaymentClient {

    public static void main(String[] args) {
        
        // 1. 기본 객체 생성
        PaymentStrategy rawCard = new CreditCardStrategy();

        // 2. 로깅 기능 추가 (기본 객체를 감쌈)
        PaymentStrategy loggedCard = new LoggingDecorator(rawCard);

        // 3. 암호화 기능 추가 (로깅된 객체를 다시 감쌈)
        PaymentStrategy secureLoggedCard = new EncryptionDecorator(loggedCard);

        // 실행
        System.out.println("--- 결제 진행 ---");
        secureLoggedCard.pay(50000);
    }
}

실행 결과:

--- 결제 진행 ---
[Security] 결제 데이터를 암호화합니다.
[Log] 결제 요청 발생: 50000원
50000원을 신용카드로 결제합니다.

보시다시피 EncryptionDecorator가 먼저 실행되고, 그 안의 LoggingDecorator가 실행된 뒤, 마지막으로 핵심 로직인 CreditCardStrategy가 실행됩니다. 순서를 바꾸고 싶다면 생성할 때 감싸는 순서만 바꾸면 됩니다.

무엇이 좋아졌을까요?

가장 큰 장점은 유연한 확장이 가능하다는 점입니다. 상속을 사용했다면 기능이 추가될 때마다 코드를 뜯어고치거나 수많은 자식 클래스를 만들어야 했겠지만, 데코레이터 패턴을 사용하면 기존 코드를 전혀 건드리지 않고 새로운 데코레이터 클래스를 하나 추가하는 것만으로 기능을 확장할 수 있습니다. 이는 **OCP(개방-폐쇄 원칙)**를 완벽하게 준수하는 설계입니다.

또한, 런타임에 동적으로 기능을 추가하거나 뺄 수 있어 상황에 따라 다양한 객체 조합을 만들어낼 수 있습니다. java.io 패키지의 BufferedReaderInputStream이 대표적으로 이 패턴을 사용하고 있습니다.

마치며

지금까지 우리는 객체의 구조를 유연하게 만드는 다양한 패턴들을 살펴보았습니다.

그런데 결제 시스템이 고도화되면서, 결제 요청을 생성할 때 필요한 데이터가 너무 많아지기 시작했습니다. 상품 정보, 할인 정보, 배송지 정보, 결제 수단 정보 등... 생성자의 파라미터가 10개가 넘어가니, 순서가 헷갈리고 코드 가독성이 엉망이 되어가고 있습니다.

다음 글에서는 복잡한 객체를 단계별로 명확하고 깔끔하게 생성할 수 있도록 도와주는 **빌더 패턴(Builder Pattern)**에 대해 알아보겠습니다.


댓글을 불러오는 중...