프록시·데코레이터
같은 인터페이스를 구현해 대상을 감싸는 접근 프록시와 시간 측정 데코레이터를 조합하고, 호출 순서·동일성·예외·중복 감싸기·수동 등록 비용을 실행 결과로 판단합니다.
Proxy와 데코레이터는 모두 클라이언트와 대상 사이에 같은 인터페이스의 객체를 둡니다. 구조만 보면 구별하기 어렵지만 의도가 다릅니다. Proxy는 접근 시점, 지연 로딩, 원격 경계처럼 대상을 대신 제어하고 데코레이터는 기존 기능에 시간 측정이나 포매팅을 더합니다. Spring AOP를 이해하려면 먼저 수동 래퍼가 어떤 반복을 만드는지 경험해야 합니다.
프록시 인터페이스
게시글 조회 포트를 대상과 래퍼가 함께 구현합니다.
클라이언트는 PostQuery만 의존하고 구성이 어떤 체인을 전달할지 결정합니다.
package board.proxy;
import java.util.Optional;
public interface PostQuery {
Optional<PostView> find(long postId, long authorId);
record PostView(long id, String title, String excerpt) {
public PostView {
if (id < 1 || title.isBlank() || excerpt.isBlank()) {
throw new IllegalArgumentException("invalid post view");
}
}
}
}래퍼가 인터페이스 메서드의 사전 조건과 반환 규칙을 바꾸면 투명한 대체가 아닙니다. 권한 프록시가 미인가에서 빈 선택적을 반환할지 예외를 던질지는 포트 계약에 반영해야 합니다.
접근 제어 프록시
인가 프록시는 행위자와 구성원이 다르면 대상을 부르지 않습니다. 캐시 프록시는 적중하면 대상을 건너뛰고 미스에서만 호출합니다. 지연 프록시는 첫 접근까지 무거운 대상 생성을 미룹니다. 모두 “대신 접근을 제어”한다는 공통 의도가 있습니다.
데코레이터는 대상 호출을 전제로 결과에 부가 책임을 더하는 경우가 많습니다. 경과 시간, 메트릭, 응답 장식이 예입니다. 재시도 데코레이터는 대상을 여러 번 부를 수 있으므로 멱등성 계약을 함께 요구합니다.
래퍼 순서도 의미를 바꿉니다. 인가 밖에 캐시가 있으면 미인가 요청이 캐시 시간 측정을 관찰할 수 있고, 캐시 밖에 인가가 있으면 잘못 설계한 키로 다른 회원 결과가 재사용될 수 있습니다. 보안 경계를 가장 먼저 검토합니다.
수동 프록시 연쇄
다음 프로그램은 대상 앞에 시간 측정 데코레이터와 책임 주체 프록시를 조합합니다. 거절된 호출에서는 대상 개수가 증가하지 않습니다.
package board.proxy;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
public final class ManualProxyDemo {
static final class InMemoryQuery implements PostQuery {
private final AtomicInteger calls;
InMemoryQuery(AtomicInteger calls) {
this.calls = calls;
}
@Override
public Optional<PostView> find(long postId, long authorId) {
calls.incrementAndGet();
return Optional.of(new PostView(postId, "proxy", 45));
}
}
record TimingQuery(PostQuery target) implements PostQuery {
@Override
public Optional<PostView> find(long postId, long authorId) {
long started = System.nanoTime();
try {
return target.find(postId, authorId);
} finally {
System.out.println("timed=" + (System.nanoTime() >= started));
}
}
}
record OwnerProxy(long actorId, PostQuery target) implements PostQuery {
@Override
public Optional<PostView> find(long postId, long authorId) {
if (actorId != authorId) {
throw new SecurityException("member mismatch");
}
return target.find(postId, authorId);
}
}
public static void main(String[] args) {
var calls = new AtomicInteger();
PostQuery chain = new OwnerProxy(
41L,
new TimingQuery(new InMemoryQuery(calls)));
System.out.println("found=" + chain.find(7L, 41L).isPresent());
try {
chain.find(8L, 99L);
} catch (SecurityException failure) {
System.out.println("denied=" + failure.getMessage());
}
System.out.println("targetCalls=" + calls.get());
}
}timed=true
found=true
denied=member mismatch
targetCalls=1시간 측정을 책임 주체 프록시 바깥에 두면 거절 지연 시간도 측정됩니다. 어떤 범위가 메트릭 의미인지 구성에서 의도적으로 순서를 정합니다.
예외·동일성 규칙
래퍼가 대상 예외를 다른 타입으로 바꾸면 롤백과 API 매핑이 달라집니다. 관찰 데코레이터는 같은 예외를 다시 던지고, 번역 프록시는 자신이 소유한 경계에서만 원인을 보존해 바꿉니다.
equals, hashCode, toString을 대상에 위임할지 래퍼 동일성을 사용할지도 결정합니다.
프록시 객체를 엔티티 키나 캐시 키로 쓰면 예상치 못한 비교가 생길 수 있습니다.
Spring AOP 프록시에서는 객체 메서드 처리와 대상 클래스 조회 API를 사용하고 구체 클래스 형 변환을 피합니다.
래퍼를 두 번 적용하면 메트릭이 이중 증가하거나 재시도가 곱집합으로 실행됩니다. 구성 테스트에서 체인의 어드바이저 또는 래퍼 수를 확인하고 멱등 장식 여부를 표시할 수 있습니다.
수동 프록시의 중복
인터페이스마다 래퍼 클래스를 만들고 모든 빈 연결에서 대상 대신 프록시를 등록해야 합니다. 메서드 시그니처가 추가되면 모든 래퍼가 컴파일 영향을 받는 장점도 있지만, 수십 서비스에 같은 트레이싱 코드가 복제됩니다.
구체 클래스만 있고 인터페이스가 없으면 수동 위임이 공개 메서드를 모두 다시 노출해야 합니다. 동적 프록시와 클래스 기반 프록시는 이 반복을 런타임에 생성합니다. 하지만 자동화 뒤에도 포인트컷으로 어떤 메서드에 적용할지 결정해야 합니다.
프록시 선택 기록
| 래퍼 | 대상 호출 수 | 실패 의미 | 순서 핵심 |
|---|---|---|---|
| 인가 프록시 | 0 또는 1 | 접근 거부 | 캐시보다 바깥 검토 |
| 캐시 프록시 | 0 또는 1 | 캐시 대체 | 테넌트 키 포함 |
| 시간 측정 데코레이터 | 정확히 1 | 원본 유지 | 측정 범위 선택 |
| 재시도 데코레이터 | 1 이상 | 마지막 원인 | 멱등 대상 필요 |
이 표가 없으면 클래스 이름에 Proxy나 데코레이터를 붙여도 런타임 의미를 설명하지 못합니다.
연습 문제
책임 주체 인가, 캐시, 재시도, 시간 측정 네 래퍼의 순서를 설계하세요. 미인가 캐시 적중 방지, 일시적 실패만 최대 두 번, 전체 지연 시간과 시도 지연 시간을 각각 측정하고 대상 호출 수 테스트를 작성하세요.
해설 보기
한 가지 가능한 체인은 authorization → total timing → cache → retry(attempt timing → target)입니다.
캐시 키에 구성원 ID와 게시글 ID를 모두 포함합니다.
package board.proxy;
public record RetryPolicy(int maximumAttempts) {
public RetryPolicy {
if (maximumAttempts < 1 || maximumAttempts > 3) {
throw new IllegalArgumentException("unsafe retry count");
}
}
public <T> T run(Operation<T> operation) {
RuntimeException last = null;
for (int attempt = 1; attempt <= maximumAttempts; attempt++) {
try {
return operation.call();
} catch (StoreTemporarilyUnavailable failure) {
last = failure;
}
}
throw last;
}
@FunctionalInterface
public interface Operation<T> { T call(); }
public static final class StoreTemporarilyUnavailable extends RuntimeException { }
}쓰기 대상에는 멱등성 키 없이 이 재시도 래퍼를 적용하지 않습니다.