디자인 패턴 세미나에서 데코레이터 패턴을 맡게 돼서 예제를 하나 만들고 정리해 봤다. 처음에는 클래스 다이어그램만 보고 "감싼다"는 말이 잘 와닿지 않았는데, 호출이 실제로 어떤 순서로 흘러가는지 하나씩 따라가 보니 그제야 이해가 됐다. 그 과정을 그대로 적어둔다.
예제는 Java 17, Gradle, JUnit 5로 작성했다.
예제 상황
외부 날씨 API를 호출하는 클라이언트가 있다. 여기에 세 가지 기능을 붙이고 싶다.
- 로깅: 요청과 결과, 걸린 시간을 남긴다
- 재시도: 서버가 잠깐 응답하지 않으면 몇 번 더 시도한다
- 캐시: 같은 도시를 다시 물어보면 저장해 둔 결과를 돌려준다
일단 한 클래스에 다 넣어보면
패턴 없이 만들면 보통 이렇게 된다.
public class AllInOneWeatherClient implements WeatherClient {
private final WeatherClient api;
private final boolean logging; // 로그 켜기/끄기
private final int maxAttempts; // 재시도 횟수
private final Duration cacheTtl; // null이면 캐시 안 씀
private final Map<String, Weather> cache = new HashMap<>();
private final Map<String, Instant> expiresAt = new HashMap<>();
@Override
public Weather fetch(String city) {
if (cacheTtl != null) {
Instant expiry = expiresAt.get(city);
if (expiry != null && Instant.now().isBefore(expiry)) {
return cache.get(city);
}
}
WeatherApiException lastFailure = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
long start = System.nanoTime();
try {
Weather weather = api.fetch(city);
if (logging) {
System.out.println("[all-in-one] " + city + " → " + weather);
}
if (cacheTtl != null) {
cache.put(city, weather);
expiresAt.put(city, Instant.now().plus(cacheTtl));
}
return weather;
} catch (WeatherApiException e) {
if (logging) {
System.out.println("[all-in-one] " + city + " → 실패: " + e.getMessage());
}
lastFailure = e;
}
}
throw lastFailure;
}
}
logging, cacheTtl 같은 필드는 기능을 켜고 끄는 플래그다. 만들 때 new AllInOneWeatherClient(api, true, 3, Duration.ofMinutes(10))처럼 넘겨주고, 코드 안에서는 if (logging), if (cacheTtl != null)로 매번 확인한다.
동작은 하지만 문제가 있다.
- 기능을 추가할 때마다 이 클래스를 고쳐야 한다. 인증 같은 기능이 하나 더 생기면 플래그가 늘고
if가 또 끼어든다. - 순서가 코드에 박혀 있다. 지금은 시도할 때마다 로그가 찍히는데, "요청당 한 번만 찍고 싶다"고 하면 구조를 다시 짜야 한다.
- 따로 테스트하기 어렵다. 로깅만 확인하고 싶어도 캐시와 재시도가 같이 따라온다.
fetch() 하나 안에 캐시, 재시도, 로깅, 실제 호출이 전부 섞여 있는 게 근본 원인이다.
심부름으로 생각해 보기
데코레이터 패턴을 이해할 때 가장 도움이 됐던 건 심부름 비유였다.
"서울 날씨 알아와"라고 시키면, 사람 네 명이 줄줄이 일을 넘긴다.
나: "서울 날씨?"
↓
[캐시 담당] 메모해 둔 답이 있으면 바로 알려준다. 없으면 다음 사람에게 넘긴다.
↓
[재시도 담당] 다음 사람에게 넘긴다. 실패하면 정해진 횟수까지 다시 시킨다.
↓
[기록 담당] 다음 사람에게 넘기고, 결과를 수첩에 적는다.
↓
[기상청] "서울 맑음 18도." (실제로 일하는 사람)
- 기상청은 자기 일만 한다. 캐시나 기록은 모른다.
- 나머지 세 명은 각자 일 하나만 하고 나머지는 다음 사람에게 넘긴다.
- 나는 맨 앞사람에게만 물어본다. 뒤에 몇 명이 있는지 몰라도 답은 받는다.
새 기능이 필요하면 사람을 한 명 더 세우면 되고, 기존 사람들은 손댈 필요가 없다. 이게 데코레이터 패턴이다.
그리고 방향은 딱 두 가지다. 질문은 뒤로 내려가고, 답은 성공이든 실패든 앞으로 올라온다.
클래스 구성
| 패턴에서의 역할 | 예제 클래스 | 비유 |
|---|---|---|
| Component | WeatherClient |
"날씨를 알려줄 수 있다"는 명찰 |
| ConcreteComponent | FakeWeatherApi |
기상청 |
| Decorator | WeatherClientDecorator |
담당자들이 공통으로 받는 기본 교육 |
| ConcreteDecorator | LoggingClient, RetryingClient, CachingClient |
기록, 재시도, 캐시 담당 |
WeatherClient
public interface WeatherClient {
Weather fetch(String city);
}
메서드 하나짜리 인터페이스다. 기상청도, 담당자 세 명도 전부 이걸 구현한다. 모두 같은 명찰을 달고 있으니 앞사람은 뒤에 누가 있든 fetch만 부르면 된다.
FakeWeatherApi
실제 API 대신 쓰는 가짜 기상청이다. 발표 중에 결과가 매번 달라지면 곤란해서 결과를 조절할 수 있게 만들었다.
api.failNext(2); // 다음 2번은 일부러 실패
api.callCount(); // 지금까지 실제로 호출된 횟수
실패는 두 종류로 나눴다.
WeatherApiException: 서버가 잠깐 응답하지 않는 경우. 다시 하면 될 수도 있다.IllegalArgumentException: 도시 이름이 비어 있는 경우. 몇 번을 다시 해도 실패한다.
이 구분은 재시도에서 쓰인다.
WeatherClientDecorator
public abstract class WeatherClientDecorator implements WeatherClient {
protected final WeatherClient delegate; // 다음 사람
protected WeatherClientDecorator(WeatherClient delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
@Override
public Weather fetch(String city) {
return delegate.fetch(city); // 기본은 그냥 넘기기
}
}
볼 건 두 가지다.
implements WeatherClient: 데코레이터 자신도WeatherClient다. 그래서 데코레이터를 다른 데코레이터 안에 또 넣을 수 있다.delegate: 안쪽의WeatherClient, 즉 다음 사람을 들고 있다.
사실 이 클래스가 없어도 패턴은 동작한다. 세 데코레이터가 각자 delegate를 들고 있으면 된다. 다만 같은 코드를 세 번 쓰게 되니까 공통 부분을 부모로 뺀 것이다. abstract라서 이걸로 직접 객체를 만들 수는 없고, 줄에 서는 건 이걸 상속한 세 클래스뿐이다.
LoggingClient: 한 번 넘긴다
@Override
public Weather fetch(String city) {
long start = System.nanoTime();
try {
Weather weather = delegate.fetch(city);
logger.accept("[" + name + "] " + city + " → " + weather + " (" + elapsedMillis(start) + "ms)");
return weather;
} catch (RuntimeException e) {
logger.accept("[" + name + "] " + city + " → 실패: " + e.getMessage() + " (" + elapsedMillis(start) + "ms)");
throw e;
}
}
시간을 재기 시작하고, 다음 사람에게 넘기고, 돌아오면 적는다. 여기서 헷갈렸던 부분이 하나 있는데, 로그는 먼저 적지 않는다. 넘긴 다음 결과가 돌아올 때까지 기다렸다가 그때 적는다.
실패해도 throw e로 예외를 그대로 다시 던진다. 로그가 실패를 삼켜버리면 앞에 있는 재시도 담당이 실패한 줄 모르게 된다. 기록 담당은 보기만 하고 결과는 바꾸지 않는다.
name은 로그 앞에 붙는 이름표([api], [request])이고, logger는 어디에 적을지다. 평소에는 System.out::println을 쓰고, 테스트에서는 logs::add로 리스트에 모아서 몇 줄 찍혔는지 센다.
RetryingClient: 여러 번 넘긴다
@Override
public Weather fetch(String city) {
WeatherApiException lastFailure = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return delegate.fetch(city);
} catch (WeatherApiException e) {
lastFailure = e;
}
}
throw lastFailure;
}
성공하면 바로 돌려주고, WeatherApiException이면 다시 넘긴다. 끝까지 실패하면 마지막 예외를 던진다.
IllegalArgumentException은 잡지 않는다. 도시 이름이 비어 있는 요청은 다시 해도 똑같이 실패하니까 바로 올려보낸다.
중요한 점은 재시도 담당은 기상청에 직접 전화하지 못한다는 것이다. 아는 건 다음 사람(delegate)뿐이다. 그래서 "한 번 더 시도한다"는 건 "다음 사람에게 한 번 더 넘긴다"는 뜻이다. 다음 사람이 로그면 매번 로그를 거친다.
CachingClient: 안 넘길 수도 있다
@Override
public Weather fetch(String city) {
Instant now = clock.instant();
CachedWeather cached = cache.get(city);
if (cached != null && now.isBefore(cached.expiresAt())) {
return cached.weather();
}
Weather weather = delegate.fetch(city);
cache.put(city, new CachedWeather(weather, now.plus(ttl)));
return weather;
}
메모지에 답이 있고 유효기간(예제에서는 10분)이 안 지났으면 바로 돌려준다. 이때는 뒤로 아예 넘기지 않으니까 그 뒤에 있는 재시도, 로그, 기상청은 아무것도 하지 않는다.
실패는 저장하지 않는다. delegate.fetch()에서 예외가 나면 cache.put()까지 가지 않고 바로 위로 올라간다. 실패를 메모해 두면 서버가 금방 살아나도 10분 동안 계속 실패를 돌려주게 된다.
정리하면 세 데코레이터는 같은 틀인데 delegate를 대하는 방식이 다르다.
| 데코레이터 | 다음 사람에게 넘기는 횟수 |
|---|---|
| LoggingClient | 항상 1번 |
| RetryingClient | 실패하면 최대 N번 |
| CachingClient | 캐시에 있으면 0번 |
조립은 안쪽부터, 호출은 바깥부터
WeatherClient client =
new CachingClient(
new RetryingClient(
new LoggingClient(new FakeWeatherApi(), "api"),
3),
Duration.ofMinutes(10));
client.fetch("서울");
new를 바깥 괄호부터 읽으면 그게 심부름 줄 순서다.
나 → [캐시] → [재시도] → [로그] → [기상청]
순서를 정리하면 이렇다.
| 무엇 | 순서 |
|---|---|
질문 (fetch 호출) |
바깥 → 안 |
| 답 (결과 반환) | 안 → 바깥 |
객체 생성 (new) |
안 → 바깥 |
객체가 안쪽부터 만들어지는 건 괄호 안을 먼저 계산하기 때문이다. 재시도 담당을 만들려면 다음 사람인 로그 담당이 먼저 있어야 한다.
"로그가 재시도 안쪽에 있다"는 말은 로그가 기상청에 더 가깝다는 뜻이다.
순서를 바꾸면 결과가 달라진다
이 예제에서 제일 보여주고 싶었던 부분이다. Main에서 세 가지 장면을 실행한다. 장면마다 FakeWeatherApi를 새로 만드는데, 하나를 같이 쓰면 앞 장면의 호출 횟수가 다음 장면에 섞이기 때문이다.
장면 1. 로그가 재시도 안쪽
FakeWeatherApi api1 = new FakeWeatherApi();
api1.failNext(2);
WeatherClient client1 = new RetryingClient(new LoggingClient(api1, "api"), 3);
client1.fetch("서울");
나 → [재시도] → [로그] → [기상청]
기상청은 두 번 실패하고 세 번째에 성공한다. 흐름을 따라가 보면,
[1번째 시도]
재시도 → 로그 → 기상청: 실패
로그: "실패" 기록 후 예외를 올려보냄
재시도: 예외 받음 → 다시
[2번째 시도]
재시도 → 로그 → 기상청: 실패
로그: "실패" 기록 후 예외를 올려보냄
재시도: 예외 받음 → 다시
[3번째 시도]
재시도 → 로그 → 기상청: 성공
로그: "성공" 기록 후 결과를 올려보냄
재시도: 결과를 나에게 반환
재시도가 로그에게 세 번 넘기니까 로그도 세 번 적는다.
[api] 서울 → 실패: 날씨 서버가 응답하지 않습니다. (0ms)
[api] 서울 → 실패: 날씨 서버가 응답하지 않습니다. (0ms)
[api] 서울 → 서울 맑음 18°C (2ms)
장면 2. 로그가 재시도 바깥
FakeWeatherApi api2 = new FakeWeatherApi();
api2.failNext(2);
WeatherClient client2 = new LoggingClient(new RetryingClient(api2, 3), "request");
client2.fetch("서울");
나 → [로그] → [재시도] → [기상청]
같은 데코레이터 두 개를 순서만 바꿨다.
1. 로그: 시간 재기 시작, 재시도에게 넘기고 기다림
2. 재시도 → 기상청: 실패 → 다시
3. 재시도 → 기상청: 실패 → 다시
4. 재시도 → 기상청: 성공
5. 재시도: 로그에게 결과 반환
6. 로그: 결과를 받고 이제 1줄 기록
로그는 재시도에게 한 번 넘기고 기다린다. 뒤에서 두 번 실패한 건 재시도가 catch로 잡아서 처리해 버렸으니 로그까지 올라오지 않는다. 로그 입장에서는 한 번 물어봤더니 한 번에 답이 온 것이다.
[request] 서울 → 서울 맑음 18°C (0ms)
참고로 여기 찍힌 시간에는 재시도 세 번에 걸린 시간이 전부 들어 있다.
결국 로그는 자기에게 일이 온 횟수만큼 적는다. 그리고 로그에게 일을 몇 번 보내느냐는 줄 순서가 정한다.
어느 쪽이 맞는지는 무엇을 보고 싶은지에 달려 있다. 서버가 몇 번 실패했는지 보려면(장애 분석) 장면 1, 사용자가 몇 번 요청했는지 보려면(사용량 집계) 장면 2가 맞다. 코드는 하나도 안 고치고 new 순서만 바꾸면 된다.
장면 3. 전부 조합
FakeWeatherApi api3 = new FakeWeatherApi();
api3.failNext(1);
WeatherClient client3 =
new CachingClient(
new RetryingClient(
new LoggingClient(api3, "api"),
3),
Duration.ofMinutes(10));
System.out.println("첫 번째 요청: " + client3.fetch("부산"));
System.out.println("두 번째 요청: " + client3.fetch("부산"));
System.out.println("실제 API 호출 수: " + api3.callCount());
나 → [캐시] → [재시도] → [로그] → [기상청]
장면 1 앞에 캐시를 하나 세운 구조다. 이번에는 기상청이 한 번만 실패한다.
첫 번째 요청
1. 캐시: 메모지에 부산 없음 → 재시도에게 넘김
[1번째 시도]
2. 재시도 → 로그 → 기상청: 실패
3. 로그: "실패" 기록 후 올려보냄
4. 재시도: 예외 받음 → 다시
[2번째 시도]
5. 재시도 → 로그 → 기상청: 성공
6. 로그: "성공" 기록 후 올려보냄
7. 재시도: 결과를 캐시에 반환
8. 캐시: 메모지에 "부산 맑음 0°C" 저장 후 반환
로그 2줄, 기상청 호출 2번.
두 번째 요청
1. 캐시: 메모지에 부산 있음, 10분 안 지남 → 바로 반환
재시도, 로그, 기상청은 아무것도 하지 않는다. 그래서 로그가 찍히지 않는다.
실행 결과는 이렇다.
[api] 부산 → 실패: 날씨 서버가 응답하지 않습니다. (0ms)
[api] 부산 → 부산 맑음 0°C (0ms)
첫 번째 요청: 부산 맑음 0°C
두 번째 요청: 부산 맑음 0°C
실제 API 호출 수: 2
처음에 헷갈렸던 것들
왜 "첫 번째 요청:"보다 로그가 먼저 찍힐까
System.out.println("첫 번째 요청: " + client3.fetch("부산"));
println은 출력할 문자열이 완성돼야 출력할 수 있다. 문자열에 날씨 결과가 들어가니까 괄호 안의 fetch()가 먼저 끝까지 실행된다. 로그는 그 안에서 일하는 도중에 바로바로 찍히고, println은 fetch()가 다 끝난 뒤에 실행된다. 그래서 로그가 먼저 보인다.
"실제 API 호출 수"는 뭘 센 걸까
FakeWeatherApi.fetch()는 첫 줄에서 callCount++를 한다. 앞의 데코레이터들을 다 지나서 진짜 기상청까지 도착한 횟수다. 실패한 호출도 포함된다.
| 시점 | 기상청까지 왔나 | 카운트 |
|---|---|---|
| 첫 요청, 1번째 시도 | O (실패) | 1 |
| 첫 요청, 2번째 시도 | O (성공) | 2 |
| 두 번째 요청 | X (캐시가 답함) | 2 |
fetch("부산")은 두 번 불렀는데 카운트는 2다. 캐시가 없었다면 두 번째 요청도 기상청까지 가서 3이 됐을 것이다. 이 숫자가 캐시가 실제로 일했다는 증거다.
실제 외부 API는 호출마다 비용이 들거나 호출 횟수 제한이 있는 경우가 많아서, 실무에서도 이 숫자를 줄이는 게 캐시를 쓰는 이유다.
재시도가 3번 해보고 나서 로그로 넘어가는 걸까
아니다. 장면 1에서 재시도는 한 번 시도할 때마다 로그에게 넘긴다. 재시도 → 로그 → 기상청 → 로그 → 재시도, 이 왕복을 세 번 한다. 재시도 담당은 다음 사람밖에 모르기 때문이다.
테스트로 확인하기
Main에서 눈으로 본 결과를 테스트로 고정해 뒀다. 로그 출력을 logs::add로 받아서 줄 수를 센다.
@Test
void 로그가_재시도_안쪽이면_시도마다_기록된다() {
api.failNext(2);
WeatherClient client = new RetryingClient(new LoggingClient(api, "api", logs::add), 3);
client.fetch("서울");
assertThat(logs).hasSize(3);
}
@Test
void 로그가_재시도_바깥이면_요청당_한_번만_기록된다() {
api.failNext(2);
WeatherClient client = new LoggingClient(new RetryingClient(api, 3), "request", logs::add);
client.fetch("서울");
assertThat(logs).singleElement().asString().doesNotContain("실패");
assertThat(api.callCount()).isEqualTo(3);
}
이 밖에 로그가_캐시_바깥이면_캐시_적중도_기록된다, 로그가_캐시_안쪽이면_실제_API_호출만_기록된다 테스트도 있다. 데코레이터마다 따로 테스트 클래스도 두었는데, 한 클래스에 다 넣은 버전에서는 이렇게 하나씩 떼어서 테스트하기가 어렵다.
캐시 만료 테스트는 실제로 10분을 기다릴 수 없으니 시간을 직접 돌릴 수 있는 MutableClock을 만들어서 Clock으로 주입했다.
왜 상속이 아니라 감싸기인가
상속으로 하면 LoggingRetryingCachingWeatherClient처럼 조합마다 클래스를 만들어야 한다. 기능이 n개면 조합만 2ⁿ개이고, 순서까지 따지면 더 늘어난다. 감싸기는 기능마다 클래스 하나씩만 두고, 어떤 기능을 어떤 순서로 쓸지는 객체를 만들 때 정한다.
실무에서 보이는 데코레이터
- Java I/O:
new BufferedReader(new InputStreamReader(new FileInputStream(file)))가 같은 구조다. - Spring AOP:
@Transactional,@Cacheable같은 기능은 Spring이 런타임에 프록시 객체로 감싸서 적용한다. 같은 클래스 안에서this.method()로 호출하면 적용되지 않는 이유도 프록시를 거치지 않기 때문이다.
프록시 패턴과 구조가 거의 같아서 헷갈리는데, 의도가 다르다. 프록시는 접근을 통제하는 대리인이고 보통 한 겹이다. 데코레이터는 기능을 덧붙이는 게 목적이고, 쓰는 쪽이 여러 겹을 직접 조립한다.
예제의 한계
CachingClient는 크기 제한이 없다. 실제로는 Caffeine 같은 캐시 라이브러리로 크기와 만료 정책을 관리하는 게 낫다.RetryingClient는 시도 사이에 대기 시간이 없다. 실제 외부 API라면 지수 백오프를 넣어야 한다.- 층이 많아지면 호출 흐름을 따라가기 어려워진다. 조립 코드는 팩토리나 설정 클래스 한곳에 모아두는 게 좋다.
마치며
데코레이터 패턴을 정리하면 이렇다.
- 데코레이터도 원래 객체와 같은 인터페이스를 구현한다.
- 안쪽 객체를 들고 있다가, 자기 일을 하고 나머지는 넘긴다.
- 쓰는 쪽은 몇 겹으로 감싸져 있는지 모른다.
- 감싸는 순서가 곧 동작이다.
처음에는 클래스 구조만 보고 이해하려다 막혔는데, 호출 하나를 잡고 "지금 누가 누구한테 넘기고 있나"를 따라가 보니 훨씬 쉬웠다. 혹시 같은 데서 막힌다면 Main을 직접 실행해 보고 로그가 몇 줄 찍히는지부터 세어 보는 걸 추천한다.
'Design Pattern' 카테고리의 다른 글
| Builder + Prototype (0) | 2026.10.11 |
|---|---|
| 팩토리 매소드, 추상 팩토리 (0) | 2026.10.06 |
| 옵저버 패턴으로 회원 탈퇴 후 남는 데이터 정리하기 (0) | 2026.09.30 |
| [디자인 패턴] 데코레이터 패턴, 기존 객체에 기능 덧붙이기 (0) | 2026.09.29 |
| [디자인 패턴] 옵저버 패턴, 값이 바뀌면 여러 객체에 알리기 (0) | 2026.09.28 |