1. 글을 작성하게 된 계기
사람들과 스프링 서버를 만들어보는 프로젝트를 진행하며 Proxy 패턴과 Decorator 패턴의 차이에 대한 질문을 받았습니다. 이전에 디자인 패턴을 학습하며 한 번 공부했던 주제지만, 이를 한 번 더 정리하고 싶어 글을 작성하게 되었습니다.
2. Proxy 패턴
Proxy 패턴은 객체에 대한 접근을 제어하기 위해 사용됩니다. 이는 원본 객체를 대신해 클라이언트의 요청을 처리하는 대리 객체를 사용합니다.
Proxy로 클라이언트 요청을 실제 객체에 전달하기 전, 접근 제어, 성능 최적화, 로깅 등의 특정 작업을 처리할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
public class UserServiceProxy implements UserSaveUseCase {
private final UserSaveUseCase target;
public UserServiceProxy(UserSaveUseCase target) {
this.target = target;
}
// 실제 작업이 처리되기 전/후로 로그 메시지 출력.
public User save(User user) {
log.info("Before call target.");
User savedUser = target.save(user);
log.info("After call target.");
return savedUser;
}
}
프록시를 상속하는 유형은 JDK/CGLIB가 있는데, 이에 대해서는 별도로 포스팅할 예정입니다.
정리하면 target을 프록시로 감싼 후 target에 접근하는 것을 제어할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// target을 감싸고 있는 프록시
public class UserServiceProxy implements UserSaveUseCase {
private final UserSaveUseCase target;
......
public User save(User user) {
// 프록시로 부터 타겟 호출
User savedUser = target.save(user);
log.info("After call target.");
return savedUser;
}
}
3. Decorator 패턴
Decorator 패턴은 객체에 동적으로 새로운 책임을 추가하기 위해 사용되며, 기존 객체를 변경하지 않고 런타임에 객체의 행동을 확장할 수 있습니다.
특정 API를 호출할 때, 각 API 호출은 1초가 지나면 TimeoutException이 발생하며, 이를 최대 5번까지 재시도 수 있다고 가정해 보겠습니다.
1
2
3
4
# 각 API는 호출 후 1초 초과 시 실패하며, 실패했을 시 최대 5번까지 재시도가 가능 합니다.
-----> -----> -----> -----> ----->
API API API API API - 최종 실패
(1초후 실패) (1초후 실패) (1초후 실패) (1초후 실패) (1초후 실패)
물론 중간에 API 호출이 성공하면 재시도하지 않습니다.
1
2
3
-----> -----> -----> 더 이상 재시도 X
API API API
(1초후 실패) (1초후 실패) (성 공)
이를 위해서는 Timeout과 최대 시도 횟수 두 가지 책임/역할을 고려해야 하는데, 이를 분리한 후, 객체로 만들어 연결하면 Decorator 패턴이 됩니다. 코드로 이를 살펴보겠습니다. 먼저 재시도 인터페이스와 구현체입니다.
1
2
3
public interface Retryable<T> {
T execute();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j
public class RetryDecorator<T> implements Retryable<T> {
private final Supplier<T> operation;
private final int maxRetryCount;
private final long delay;
public RetryDecorator(
Supplier<T> operation,
int maxRetryCount,
long delay
) {
this.operation = operation;
this.maxRetryCount = maxRetryCount;
this.delay = delay;
}
@Override
public T execute() {
for (int attempt = 1; attempt <= maxRetryCount; attempt++) {
try {
T result = operation.get();
if (attempt <= 3) {
throw new RuntimeException("");
}
return result;
} catch (Exception exception) {
log.info("RetryCount: {}", attempt);
if (attempt < maxRetryCount) {
try {
sleep(delay);
} catch (InterruptedException interruptedException) {
currentThread().interrupt();
throw new RuntimeException("Retry interrupted: ", interruptedException);
}
} else {
throw new RuntimeException("API-Call failed.");
}
}
}
throw new IllegalStateException("Unreachable code");
}
}
다음은 타임아웃에 관한 인터페이스와 구현체입니다.
1
2
3
public interface Timeoutable<T> {
T execute();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Slf4j
public class TimeoutDecorator<T> implements Timeoutable {
private final Supplier<T> operation;
private final long delay;
private final TimeUnit timeUnit;
public TimeoutDecorator(
Supplier<T> operation,
long delay
) {
this.operation = operation;
this.delay = delay;
this.timeUnit = TimeUnit.SECONDS;
}
@Override
public T execute() {
CompletableFuture<T> future = supplyAsync(operation);
try {
log.info("TimeoutDecorator");
return future.get(delay, timeUnit);
} catch (InterruptedException exception) {
currentThread().interrupt();
log.error("InterruptedException: {}", exception.getMessage());
throw new RuntimeException("Interrupted: " + currentThread());
} catch (ExecutionException exception) {
log.error("ExecutionException: {}", exception.getMessage());
throw new RuntimeException("Execution failed: ", exception.getCause());
} catch (TimeoutException exception) {
log.error("TimeoutException");
future.cancel(true);
throw new RuntimeException("Operation failed.");
} catch (Exception exception) {
log.error("Exception");
throw new RuntimeException("Unresolved exception.");
}
}
}
실행 코드는 다음과 같은데, 기존 비즈니스 로직에 재시도에 타임아웃이라는 새로운 책임을 동적으로 추가한 것입니다. 이를 통해 유연성과 확장성을 얻을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
public class Main {
public static void main(String[] args) {
Supplier<String> operation = () -> {
log.info("Business Logic");
return "result";
};
TimeoutDecorator<String> timeDecorator = new TimeoutDecorator<>(operation, 1);
Retryable<String> retryDecorator = new RetryDecorator<>(timeDecorator::execute, 4, 1000);
try {
String result = retryDecorator.execute();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
이는 Resilience4j의 Retry를 간략하게 구현한 것으로, 이 또한 Decorator 패턴으로 구현되어 있습니다.
4. 어떤 차이가 있을까?
두 패턴 모두 래퍼 클래스를 사용한다는 공통점이 있지만, Proxy 패턴은 객체에 대한 접근을 제어하며, Decorator 패턴은 객체에 동적으로 새로운 책임을 추가합니다.
디자인 패턴은 대체로 비슷하게 생겼기 때문에 어떤
목적/의도를 가지고 사용했는지가 중요합니다.
따라서 만약 원본 객체에 대한 접근 제어가 필요하다면 Proxy 패턴을, 원본 객체에 행동을 동적으로 추가하고 싶다면 Decorator 패턴을 적용해야 합니다.
5. 정리
Proxy 패턴과 Decorator 패턴과 그 차이점에 대해 간략하게 살펴보았습니다. 디자인 패턴은 비슷하게 생긴 것들이 많은데, 이는 모두 추상화에 의존하기 때문입니다. 따라서 디자인 패턴의 사용 목적을 파악하는 것이 중요하기 때문에, 디자인 패턴의 외관을 보기보다는 언제, 왜 이를 사용하는지를 파악하도록 합니다.