기능을 추가하고 싶은데 상속은 부담스러울 때
알림을 보내는 기능에 로깅을 추가하고 싶다. 그래서 LoggingNotificationSender를 만들었다.
다음에는 재시도 기능도 필요해져서 RetryNotificationSender를 만들었다.
로깅과 재시도를 동시에 하려면? LoggingRetryNotificationSender를 또 만들어야 한다.
기능 조합이 늘어날수록 클래스 수가 폭발적으로 증가한다. 데코레이터 패턴은 이 문제를 상속 대신 조합(composition)으로 해결한다. 기존 객체를 감싸는 래퍼(wrapper)를 만들어서, 원래 객체의 인터페이스는 유지하면서 기능을 덧붙인다.
데코레이터 패턴의 구조
구성 요소는 네 가지다.
- Component: 기본 기능을 정의하는 인터페이스
- ConcreteComponent: Component의 기본 구현
- Decorator: Component를 구현하면서 내부에 Component 참조를 갖는 추상 클래스
- ConcreteDecorator: Decorator를 확장하여 부가 기능을 추가하는 클래스
핵심은 Decorator가 Component와 동일한 인터페이스를 구현한다는 점이다. 덕분에 데코레이터를 여러 겹으로 감쌀 수 있고, 클라이언트는 감싸진 객체와 원본 객체를 구분할 필요가 없다.
로깅과 재시도를 조합해보기
알림 발송 기능에 로깅과 재시도를 데코레이터로 추가해보자.
먼저 Component와 ConcreteComponent를 정의한다.
public interface NotificationSender {
void send(String recipient, String message);
}
class BasicNotificationSender implements NotificationSender {
@Override
public void send(String recipient, String message) {
System.out.println(recipient + "에게 알림: " + message);
}
}
Decorator 추상 클래스를 만든다. 내부에 Component 참조를 갖고, 기본적으로 위임한다.
abstract class NotificationDecorator implements NotificationSender {
protected final NotificationSender delegate;
protected NotificationDecorator(NotificationSender delegate) {
this.delegate = delegate;
}
@Override
public void send(String recipient, String message) {
delegate.send(recipient, message);
}
}
로깅 데코레이터와 재시도 데코레이터를 각각 구현한다.
class LoggingDecorator extends NotificationDecorator {
LoggingDecorator(NotificationSender delegate) {
super(delegate);
}
@Override
public void send(String recipient, String message) {
System.out.println("[LOG] 알림 발송 시작: " + recipient);
delegate.send(recipient, message);
System.out.println("[LOG] 알림 발송 완료: " + recipient);
}
}
class RetryDecorator extends NotificationDecorator {
private final int maxRetries;
RetryDecorator(NotificationSender delegate, int maxRetries) {
super(delegate);
this.maxRetries = maxRetries;
}
@Override
public void send(String recipient, String message) {
Exception lastException = null;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
delegate.send(recipient, message);
return; // 성공하면 종료
} catch (Exception e) {
lastException = e;
System.out.println("재시도 " + attempt + "/" + maxRetries);
}
}
throw new RuntimeException("최대 재시도 횟수 초과", lastException);
}
}
데코레이터를 조합하는 부분이 이 패턴의 핵심이다.
NotificationSender sender = new BasicNotificationSender();
// 로깅만 추가
sender = new LoggingDecorator(sender);
// 재시도도 추가 (로깅 + 재시도 조합)
sender = new RetryDecorator(sender, 3);
sender.send("user@example.com", "주문이 완료되었습니다.");
LoggingRetryNotificationSender 같은 별도 클래스를 만들 필요가 없다.
기존 데코레이터를 원하는 순서로 조합하면 된다.
감싸고 또 감싸는 자바 I/O
InputStream 체이닝
Java I/O는 데코레이터 패턴의 대표적인 사례다.
InputStream을 기반으로 BufferedInputStream, DataInputStream 등이 겹겹이 감싸는 구조다.
InputStream input = new FileInputStream("data.bin");
input = new BufferedInputStream(input); // 버퍼링 추가
DataInputStream dataInput = new DataInputStream(input); // 데이터 타입 읽기 추가
int value = dataInput.readInt();
각 래퍼가 하나의 기능만 담당하고, 조합으로 원하는 기능 셋을 만들어낸다. 처음 접하면 생성자에 스트림을 계속 넘기는 모양이 낯설 수 있지만, 데코레이터 패턴을 알고 나면 구조가 명확하게 보인다.
Collections.unmodifiableList()
Collections.unmodifiableList()는 기존 리스트를 수정 불가능한 리스트로 감싸는 데코레이터다.
List 인터페이스를 그대로 구현하면서, 수정 메서드(add, remove 등)에서 예외를 던진다.
List<String> original = new ArrayList<>(Arrays.asList("a", "b", "c"));
List<String> readOnly = Collections.unmodifiableList(original);
readOnly.add("d"); // UnsupportedOperationException
요청 객체를 꾸미는 법
HttpServletRequestWrapper
서블릿 API의 HttpServletRequestWrapper는 HttpServletRequest를 감싸는 데코레이터다.
Spring에서 필터(Filter)를 구현할 때, 요청 객체에 부가 정보를 추가하거나 특정 메서드의 동작을 바꾸고 싶을 때 활용한다.
class CustomRequestWrapper extends HttpServletRequestWrapper {
public CustomRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String getHeader(String name) {
if ("X-Custom".equals(name)) {
return "custom-value";
}
return super.getHeader(name);
}
}
원본 요청 객체를 수정하지 않으면서, 특정 헤더 값만 오버라이드할 수 있다.
BeanPostProcessor
Spring의 BeanPostProcessor는 빈이 생성된 뒤 추가적인 가공을 적용하는 메커니즘이다.
엄밀히 데코레이터 패턴 그 자체는 아니지만, 원본 빈을 감싸서 프록시나 래퍼를 반환하는 방식으로 동작할 때
데코레이터와 같은 효과를 낸다.
데코레이터 vs 프록시
데코레이터와 프록시는 둘 다 원본 객체와 같은 인터페이스를 구현하고 내부에서 위임한다는 점에서 구조가 거의 동일하다. 차이는 의도에 있다.
- 데코레이터: 클라이언트가 명시적으로 감싸서 기능을 추가한다. Java I/O의
new BufferedInputStream(new FileInputStream(...))처럼 조합 자체가 드러난다. - 프록시: 클라이언트가 프록시의 존재를 모르는 것이 이상적이다. Spring의
@Transactional처럼 프레임워크가 알아서 끼워 넣는 경우가 대표적이다.
정리하며
데코레이터 패턴은 상속 없이 객체에 기능을 동적으로 조합하는 방법을 제공한다. Java I/O의 스트림 체이닝이 가장 널리 알려진 사례이고, Spring에서도 요청 래핑이나 빈 후처리에서 같은 원리를 활용한다.
데코레이터를 3겹 이상 감싸야 하는 상황이 온다면, 그 조합이 자주 쓰이는지 확인해보자. 자주 쓰이는 조합은 별도 클래스로 묶는 편이 오히려 읽기 쉽고 디버깅할 때 스택 트레이스도 짧아진다.