본문 바로가기

프록시·데코레이터

접근 프록시와 시간 측정 데코레이터를 조합하고 호출 순서·예외·중복 감싸기를 확인합니다.

프록시(proxy)와 데코레이터(decorator)는 모두 클라이언트와 대상 사이에 같은 인터페이스를 구현한 객체를 둡니다. 구조만 보면 구별하기 어렵지만 의도가 다릅니다. 프록시는 접근 시점, 지연 로딩, 원격 호출처럼 대상에 닿는 길을 대신 제어하고, 데코레이터는 기존 기능에 시간 측정이나 포매팅 같은 부가 기능을 더합니다.

Spring AOP는 결국 이런 대리 객체를 자동으로 만들어 줍니다. 그래서 이 글에서는 먼저 래퍼를 손으로 만들어 보고, 래퍼의 순서와 대상 호출 횟수가 동작을 어떻게 바꾸는지, 손으로 만든 래퍼가 어떤 반복을 낳는지를 확인합니다.

프록시·데코레이터

인가·cache·retry·각 시도 측정·대상 호출과 열린 timing의 finally가 실제 분기·반복을 만든다.

PROXY + DECORATOR · ORDER + INVOCATION COUNT

래퍼 순서와 대상 호출 횟수가 계약을 바꾼다

클라이언트와 래퍼는 같은 PostQuery 포트를 사용합니다. 인가·캐시·시간 측정·재시도는 각자의 의도와 호출 수를 명시해야 합니다.

인가·캐시 분기와 전체/시도 측정의 다른 범위한 가지 chain의 순서를 보여준다. 인가 거절은 전체측정도 대상도0회, 허용은전체측정으로들어간다. cache적중이면cache값을반환해각시도와대상을생략한다. miss는retry영역으로들어가 각시도측정후대상을호출한다. 최대2는초기시도를포함한총횟수이며일시실패만반복한다. 각branch종료시이미열린바깥timing의finally는종료한다. region은반복범위이고각노드가서로별도저장칸이라는뜻이아니다.각 시도 · 최대 2회인가 통과?접근 거절대상 0회전체 시간 측정캐시 적중?캐시값 반환대상 0회시도 시간 측정업무 대상거절허용적중미스일시 실패만 반복 · 반환 때 바깥 측정 종료
인가·캐시 분기와 전체/시도 측정의 다른 범위한 가지 chain의 순서를 보여준다. 인가 거절은 전체측정도 대상도0회, 허용은전체측정으로들어간다. cache적중이면cache값을반환해각시도와대상을생략한다. miss는retry영역으로들어가 각시도측정후대상을호출한다. 최대2는초기시도를포함한총횟수이며일시실패만반복한다. 각branch종료시이미열린바깥timing의finally는종료한다. region은반복범위이고각노드가서로별도저장칸이라는뜻이아니다.최대 2회인가통과?접근 거절대상 0회전체 시간 측정캐시적중?캐시값 반환대상 0회시도 시간 측정업무 대상거절허용적중미스일시 실패만 반복반환 때 바깥 측정 종료
가능한 인가, 캐시, 재시도와 대상의 외부-내부 순서한 가지 가능한 체인은 authorization, total timing, cache, retry, attempt timing, target 순서다. 인가는 캐시보다 먼저 판단하고 캐시는 적중에서 대상을 건너뛴다. 재시도는 멱등 대상의 일시 실패만 반복한다.인가거절하면 0회전체 측정 · 캐시hit이면 0회재시도시도 측정 포함대상조회·저장가능한 체인: 인가 → 전체 측정 → 캐시 → 재시도(시도 측정 → 대상)
  1. 인가 → 전체 측정 → 캐시

    가능한 구성 예입니다. 미인가이면 캐시와 대상에 도달하지 않고 전체 측정의 위치는 측정하려는 범위에 맞게 정합니다.

  2. 재시도(시도 측정 → 대상)

    캐시 miss일 때 멱등성·일시 실패 조건을 만족하는 작업만 반복합니다. cache key에는 구성원/테넌트와 게시글 ID를 포함합니다.

인가 프록시 · 0 또는 1회

허용 여부를 제어합니다. 미인가의 반환/예외를 포트 계약에 드러내고 캐시 정보 접근도 먼저 차단합니다.

캐시 프록시 · 0 또는 1회

hit이면 대상 생략, miss이면 호출합니다. 다른 구성원의 결과가 재사용되지 않게 key의 소유권을 정합니다.

시간 측정 · 정확히 1회

다음 대상을 한 번 호출하며 관찰만 더합니다. 전체 지연과 각 시도 지연은 서로 다른 측정 범위입니다.

재시도 · 1회 이상

기한·최대 횟수·일시 실패·멱등성을 검증합니다. 두 번 래핑해 재시도가 곱으로 늘어나지 않게 합니다.

관찰 래퍼는 반환과 업무 예외를 유지합니다. Object 메서드 의미, advisor/래퍼 수와 실제 side effect 횟수도 검사합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 한 가지 chain의 순서를 보여 준다. 인가 거절은 전체 측정도 대상도 0회이고, 허용은 전체 측정으로 들어간다. cache 적중이면 cache 값을 반환해 각 시도와 대상을 생략한다. miss는 retry 영역으로 들어가 각 시도 측정 후 대상을 호출한다. 최대 2는 초기 시도를 포함한 총횟수이며 일시 실패만 반복한다. 각 branch 종료 시 이미 열린 바깥 timing의 finally는 종료한다. region은 반복 범위이고 각 노드가 서로 별도 저장 칸이라는 뜻이 아니다.
  • 한 가지 가능한 체인은 authorization, total timing, cache, retry, attempt timing, target 순서다. 인가는 캐시보다 먼저 판단하고 캐시는 적중에서 대상을 건너뛴다. 재시도는 멱등 대상의 일시 실패만 반복한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1프록시·데코레이터
6한 가지 chain의 순서를 보여 준다. 인가 거절은 전체 측정도 대상도 0회이고, 허용은 전체 측정으로 들어간다. cache 적중이면 cache 값을 반환해 각 시도와 대상을 생략한다. miss는 retry 영역으로 들어가 각 시도 측정 후 대상을 호출한다. 최대 2는 초기 시도를 포함한 총횟수이며 일시 실패만 반복한다. 각 branch 종료 시 이미 열린 바깥 timing의 finally는 종료한다. region은 반복 범위이고 각 노드가 서로 별도 저장 칸이라는 뜻이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
인가와 cache

인가 거부는 timing·target 0이고 허용 뒤 cache를 확인합니다.

거부: timing·target 0 · hit: target 생략접근과 cache가 내부 실행을 결정합니다.
miss의 제한 retry

miss에서 각 시도 측정과 target을 최대 두 번 실행합니다.

총 시도 상한: 2 · 반복: 일시 실패만cache miss만 제한 반복에 들어갑니다.
반환과 열린 측정 정리

각 분기가 끝나면 이미 열린 바깥 timing finally를 닫습니다.

정리: 열린 timing finally · 결과: cache·target·실패밖에서 시작한 측정의 소유 범위가 끝납니다.

프록시·데코레이터

인가·cache·retry·각 시도 측정·대상 호출과 열린 timing의 finally가 실제 분기·반복을 만든다.

PROXY + DECORATOR · ORDER + INVOCATION COUNT

래퍼 순서와 대상 호출 횟수가 계약을 바꾼다

클라이언트와 래퍼는 같은 PostQuery 포트를 사용합니다. 인가·캐시·시간 측정·재시도는 각자의 의도와 호출 수를 명시해야 합니다.

인가·캐시 분기와 전체/시도 측정의 다른 범위한 가지 chain의 순서를 보여준다. 인가 거절은 전체측정도 대상도0회, 허용은전체측정으로들어간다. cache적중이면cache값을반환해각시도와대상을생략한다. miss는retry영역으로들어가 각시도측정후대상을호출한다. 최대2는초기시도를포함한총횟수이며일시실패만반복한다. 각branch종료시이미열린바깥timing의finally는종료한다. region은반복범위이고각노드가서로별도저장칸이라는뜻이아니다.각 시도 · 최대 2회인가 통과?접근 거절대상 0회전체 시간 측정캐시 적중?캐시값 반환대상 0회시도 시간 측정업무 대상거절허용적중미스일시 실패만 반복 · 반환 때 바깥 측정 종료
인가·캐시 분기와 전체/시도 측정의 다른 범위한 가지 chain의 순서를 보여준다. 인가 거절은 전체측정도 대상도0회, 허용은전체측정으로들어간다. cache적중이면cache값을반환해각시도와대상을생략한다. miss는retry영역으로들어가 각시도측정후대상을호출한다. 최대2는초기시도를포함한총횟수이며일시실패만반복한다. 각branch종료시이미열린바깥timing의finally는종료한다. region은반복범위이고각노드가서로별도저장칸이라는뜻이아니다.최대 2회인가통과?접근 거절대상 0회전체 시간 측정캐시적중?캐시값 반환대상 0회시도 시간 측정업무 대상거절허용적중미스일시 실패만 반복반환 때 바깥 측정 종료
가능한 인가, 캐시, 재시도와 대상의 외부-내부 순서한 가지 가능한 체인은 authorization, total timing, cache, retry, attempt timing, target 순서다. 인가는 캐시보다 먼저 판단하고 캐시는 적중에서 대상을 건너뛴다. 재시도는 멱등 대상의 일시 실패만 반복한다.인가거절하면 0회전체 측정 · 캐시hit이면 0회재시도시도 측정 포함대상조회·저장가능한 체인: 인가 → 전체 측정 → 캐시 → 재시도(시도 측정 → 대상)
  1. 인가 → 전체 측정 → 캐시

    가능한 구성 예입니다. 미인가이면 캐시와 대상에 도달하지 않고 전체 측정의 위치는 측정하려는 범위에 맞게 정합니다.

  2. 재시도(시도 측정 → 대상)

    캐시 miss일 때 멱등성·일시 실패 조건을 만족하는 작업만 반복합니다. cache key에는 구성원/테넌트와 게시글 ID를 포함합니다.

인가 프록시 · 0 또는 1회

허용 여부를 제어합니다. 미인가의 반환/예외를 포트 계약에 드러내고 캐시 정보 접근도 먼저 차단합니다.

캐시 프록시 · 0 또는 1회

hit이면 대상 생략, miss이면 호출합니다. 다른 구성원의 결과가 재사용되지 않게 key의 소유권을 정합니다.

시간 측정 · 정확히 1회

다음 대상을 한 번 호출하며 관찰만 더합니다. 전체 지연과 각 시도 지연은 서로 다른 측정 범위입니다.

재시도 · 1회 이상

기한·최대 횟수·일시 실패·멱등성을 검증합니다. 두 번 래핑해 재시도가 곱으로 늘어나지 않게 합니다.

관찰 래퍼는 반환과 업무 예외를 유지합니다. Object 메서드 의미, advisor/래퍼 수와 실제 side effect 횟수도 검사합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 한 가지 chain의 순서를 보여 준다. 인가 거절은 전체 측정도 대상도 0회이고, 허용은 전체 측정으로 들어간다. cache 적중이면 cache 값을 반환해 각 시도와 대상을 생략한다. miss는 retry 영역으로 들어가 각 시도 측정 후 대상을 호출한다. 최대 2는 초기 시도를 포함한 총횟수이며 일시 실패만 반복한다. 각 branch 종료 시 이미 열린 바깥 timing의 finally는 종료한다. region은 반복 범위이고 각 노드가 서로 별도 저장 칸이라는 뜻이 아니다.
  • 한 가지 가능한 체인은 authorization, total timing, cache, retry, attempt timing, target 순서다. 인가는 캐시보다 먼저 판단하고 캐시는 적중에서 대상을 건너뛴다. 재시도는 멱등 대상의 일시 실패만 반복한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1프록시·데코레이터
6한 가지 chain의 순서를 보여 준다. 인가 거절은 전체 측정도 대상도 0회이고, 허용은 전체 측정으로 들어간다. cache 적중이면 cache 값을 반환해 각 시도와 대상을 생략한다. miss는 retry 영역으로 들어가 각 시도 측정 후 대상을 호출한다. 최대 2는 초기 시도를 포함한 총횟수이며 일시 실패만 반복한다. 각 branch 종료 시 이미 열린 바깥 timing의 finally는 종료한다. region은 반복 범위이고 각 노드가 서로 별도 저장 칸이라는 뜻이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
인가와 cache

인가 거부는 timing·target 0이고 허용 뒤 cache를 확인합니다.

거부: timing·target 0 · hit: target 생략접근과 cache가 내부 실행을 결정합니다.
miss의 제한 retry

miss에서 각 시도 측정과 target을 최대 두 번 실행합니다.

총 시도 상한: 2 · 반복: 일시 실패만cache miss만 제한 반복에 들어갑니다.
반환과 열린 측정 정리

각 분기가 끝나면 이미 열린 바깥 timing finally를 닫습니다.

정리: 열린 timing finally · 결과: cache·target·실패밖에서 시작한 측정의 소유 범위가 끝납니다.

그림은 인가, 전체 시간 측정, 캐시, 재시도, 시도별 시간 측정, 대상의 순서로 감싼 한 가지 체인을 따라갑니다. 인가가 거절하면 전체 측정도 대상도 0회이고, 허용하면 전체 측정 안으로 들어가 캐시를 확인합니다. 캐시가 적중하면 캐시 값을 돌려주므로 시도 측정과 대상 호출을 건너뛰고, 미스일 때만 재시도 영역에서 각 시도를 측정하며 대상을 부릅니다. 최대 2회는 첫 시도를 포함한 총횟수이고 일시적 실패만 반복하며, 어느 갈래로 끝나든 이미 열린 바깥 측정의 finally가 닫힙니다.


프록시 인터페이스

게시글 조회 포트를 대상과 래퍼가 함께 구현합니다. 클라이언트는 PostQuery에만 의존하고, 어떤 체인을 넘길지는 구성 코드가 정합니다.

src/main/java/board/proxy/PostQuery.java
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");
            }
        }
    }
}

래퍼가 인터페이스 메서드의 사전 조건이나 반환 규칙을 바꾸면 더는 투명한 대체가 아닙니다. 예를 들어 권한 프록시가 미인가 요청에 빈 Optional을 돌려줄지 예외를 던질지는 포트의 규칙으로 문서에 적어 두어야 합니다.


접근 제어 프록시

인가 프록시는 행위자와 구성원이 다르면 대상을 부르지 않습니다. 캐시 프록시는 적중하면 대상을 건너뛰고 미스일 때만 호출합니다. 지연 프록시는 첫 접근까지 무거운 대상의 생성을 미룹니다. 셋 모두 대상에 대한 접근을 대신 제어한다는 의도를 공유합니다.

데코레이터는 대상을 호출한다는 전제 위에서 결과에 기능을 더하는 경우가 많으며, 경과 시간, 메트릭, 응답 장식이 그런 예입니다. 재시도 데코레이터는 대상을 여러 번 부를 수 있으므로, 대상이 멱등(idempotent)이어야 한다는 조건을 함께 요구합니다.

래퍼의 순서도 의미를 바꿉니다. 캐시가 인가보다 바깥에서 적중을 반환하면 인가 자체를 건너뛰어 미인가 요청에 결과가 나갈 수 있습니다. 인가를 먼저 하더라도 캐시 키에 구성원 ID를 빠뜨리면 다른 구성원의 결과가 섞일 수 있으므로, 호출 순서와 키 구성을 따로 검토합니다. 순서를 정할 때는 보안 검사의 위치부터 봅니다.

더 깊이: Spring JDBC 안의 지연 프록시

지연 프록시의 실제 예가 Spring JDBC에 있습니다. LazyConnectionDataSourceProxy는 DataSource와 같은 인터페이스를 구현하면서, getConnection()이 불려도 실제 JDBC 연결을 바로 빌리지 않고 첫 Statement를 만들 때까지 미룹니다. 그동안 자동 커밋, 격리 수준, 읽기 전용 같은 설정은 기억해 두었다가 실제 연결을 얻는 순간 적용하므로, 구문을 하나도 만들지 않은 트랜잭션의 커밋과 롤백은 아무 일도 하지 않고 풀에서 연결을 꺼내지도 않습니다. API 문서는 이것이 실제로 데이터에 접근할지 모르는 메서드에도 비용 걱정 없이 트랜잭션을 선언할 수 있게 해 준다고 설명합니다.

이 프록시는 앞에서 말한 래퍼의 순서 문제도 함께 보여 줍니다. 같은 문서는 TransactionAwareDataSourceProxy와 함께 쓴다면 그것을 가장 바깥에 두라고 요구합니다. 또 6.1.2부터는 읽기 전용 트랜잭션에서 쓸 별도의 DataSource를 지정할 수 있는데, 실제 연결을 늦게 얻기 때문에 그 시점에는 이미 트랜잭션이 읽기 전용인지 알 수 있어서 가능한 기능입니다. 다만 문서에 적힌 대로 설정하지 않은 속성의 getter나 getClientInfo 같은 일부 메서드는 즉시 실제 연결을 얻으므로, 지연 프록시라고 해서 모든 호출이 연결 없이 끝난다고 가정하지는 않습니다.


수동 프록시 연쇄

다음 프로그램은 대상 앞에 시간 측정 데코레이터와 소유자 프록시를 차례로 세웁니다. 거절된 호출에서는 대상 호출 횟수가 늘지 않습니다.

src/main/java/board/proxy/ManualProxyDemo.java
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", "excerpt"));
        }
    }

    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());
    }
}

체인의 바깥은 OwnerProxy, 안쪽은 TimingQuery입니다. 첫 요청은 41과 41이 같아 통과하고, InMemoryQuery가 calls를 1로 만든 뒤 결과를 돌려줍니다. 둘째 요청은 41과 99가 달라 OwnerProxy가 예외를 던지므로 TimingQuery와 대상에 도달하지 않습니다. 그래서 timed 출력은 한 번이고 targetCalls는 1입니다.

ManualProxyDemo 실행
timed=true
found=true
denied=member mismatch
targetCalls=1

timed=true는 두 nanoTime 값을 비교한 결과일 뿐, 지속 시간을 출력한 값은 아닙니다. 또 이 최소 프록시는 호출자가 넘긴 authorId와 actorId를 비교할 뿐, 저장된 게시글의 실제 소유자를 조회하지 않습니다. 그러니 이 비교만으로 임의의 postId에 대한 접근 권한 검사가 완성됐다고 읽지 않습니다. 실제 어댑터에서는 신뢰할 수 있는 소유자 정보와 조회 조건을 연결해야 합니다.

시간 측정을 소유자 프록시 바깥으로 옮기면 거절된 요청의 지연 시간도 측정됩니다. 어느 범위를 메트릭으로 볼지는 구성 코드에서 순서로 정합니다.


예외와 동일성 규칙

래퍼가 대상의 예외를 다른 타입으로 바꾸면 롤백 규칙과 API 응답 매핑이 달라집니다. 기록만 하는 데코레이터는 같은 예외를 다시 던지고, 예외를 번역하는 프록시는 번역을 맡은 지점에서만 원인을 보존한 채 바꿉니다.

equals, hashCode, toString을 대상에 위임할지, 래퍼 자신의 동일성을 쓸지도 정해야 합니다. 프록시 객체를 엔티티 키나 캐시 키로 쓰면 예상하지 못한 비교 결과가 생길 수 있습니다. Spring AOP 프록시를 다룰 때는 대상 클래스를 조회하는 API를 쓰고, 구체 클래스로 형 변환하지 않습니다.

래퍼를 두 번 적용하면 메트릭이 두 번 늘거나, 재시도가 곱으로 실행됩니다. 구성 테스트에서 체인에 걸린 어드바이저(advisor)나 래퍼의 수를 확인하고, 중복 적용해도 안전한 래퍼인지 표시해 둘 수 있습니다.


수동 프록시의 중복

손으로 만들면 인터페이스마다 래퍼 클래스를 만들고, 모든 빈 연결에서 대상 대신 프록시를 등록해야 합니다. 메서드가 추가되면 모든 래퍼가 컴파일 오류로 알려 준다는 장점은 있지만, 수십 개의 서비스에 같은 트레이싱 코드가 복제됩니다. 인터페이스 없이 구체 클래스만 있다면, 수동 위임 클래스가 공개 메서드를 모두 다시 노출해야 합니다.

동적 프록시와 클래스 기반 프록시는 이 반복을 런타임에 생성해 줍니다. 하지만 생성을 자동화한 뒤에도, 어떤 메서드에 적용할지는 포인트컷(pointcut)으로 따로 정해야 합니다.


래퍼별 동작 기록

래퍼대상 호출 수실패의 의미순서에서 볼 점
인가 프록시0 또는 1접근 거부캐시보다 바깥에 두는지 검토
캐시 프록시0 또는 1캐시 값으로 대체키에 구성원 ID 포함
시간 측정 데코레이터정확히 1원래 예외 유지측정 범위 선택
재시도 데코레이터1 이상마지막 원인멱등 대상 필요

이런 표가 없으면 클래스 이름에 Proxy나 Decorator를 붙여도 런타임에 무슨 일이 일어나는지 설명하지 못합니다.


정리

  • 프록시는 대상에 대한 접근을 대신 제어하고, 데코레이터는 대상을 호출하면서 기능을 더합니다. 둘은 같은 인터페이스를 구현한다는 구조를 공유합니다.
  • 래퍼의 순서가 대상 호출 수와 측정 범위를 바꾸며, 캐시는 인가보다 안쪽에 두고 키에 구성원 정보를 넣습니다.
  • 기록용 래퍼는 같은 예외를 다시 던지고, 두 번 적용되지 않았는지 구성 테스트로 확인합니다.
  • 수동 래퍼는 인터페이스마다 코드가 복제되므로, 다음 단계로 런타임에 프록시를 생성하는 방법이 필요합니다.

연습 문제

1. 네 래퍼의 순서

소유자 인가, 캐시, 재시도, 시간 측정 네 래퍼의 순서를 설계하세요. 미인가 요청이 캐시 적중으로 결과를 받지 않아야 하고, 일시적 실패만 최대 두 번 시도하며, 전체 지연 시간과 시도별 지연 시간을 각각 측정해야 합니다. 대상 호출 수를 확인하는 테스트도 작성하세요.

해설 보기

가능한 체인 하나는 인가, 전체 시간 측정, 캐시, 재시도 순서로 감싸고, 재시도 안에서 시도 시간 측정과 대상을 부르는 구성입니다. 캐시 키에는 구성원 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 { }
}

이 문제의 최대 두 번은 첫 시도를 포함한 총 두 번이므로 RetryPolicy(2)를 고릅니다. 생성자가 허용하는 최대 3과, 문제에서 고른 2를 구별합니다. catch는 StoreTemporarilyUnavailable만 받으므로 다른 RuntimeException은 재시도하지 않습니다. 쓰기 대상에는 멱등성 키 없이 이 재시도 래퍼를 적용하지 않으며, 네 래퍼의 나머지 구현과 캐시 저장은 이 발췌에 들어 있지 않습니다.

이 체인에서 기록기는 실패하지 않고, 캐시 적중 여부는 인가 뒤에 판정되며, 미스의 첫 시도가 일시적으로 실패한 뒤 둘째 시도는 성공한다고 정하면 개수는 다음과 같습니다.

입력 조건전체 시간 기록시도 시간 기록대상 호출
인가 거절000
인가 통과, 캐시 적중100
인가 통과, 미스, 첫 일시 실패 뒤 성공122

전체 시간은 하나의 요청을, 시도 시간은 안쪽의 각 호출을 감쌉니다. 둘째 행에서 대상은 0회여도 요청은 이미 전체 측정 범위를 지났습니다.

2. 재시도되지 않는 실패

연습 1의 체인에서 인가는 통과하고 캐시는 미스이며, 첫 대상 호출이 IllegalArgumentException을 던집니다. 전체 시간 기록, 시도 시간 기록, 대상 호출 수와 두 번째 시도가 있는지를 구하세요. 별도의 경우로 첫째와 둘째 시도가 모두 StoreTemporarilyUnavailable을 던지면, 마지막에 어느 실패가 호출자에게 가나요? maximumAttempts는 2이고 기록기는 실패하지 않습니다.

해설 보기

첫 경우는 전체 1회, 시도 1회, 대상 1회이며 두 번째 시도는 없습니다. IllegalArgumentException은 catch의 타입과 맞지 않아 그대로 빠져나갑니다.

별도의 경우는 전체 1회, 시도 2회, 대상 2회이고, last가 둘째 실패로 바뀐 뒤 그 객체를 던집니다. 두 번 실패했다는 사실이 대상이 남긴 외부 부작용까지 되돌려 준다는 뜻은 아니므로, 재시도해도 안전한 대상이라는 전제가 필요합니다.

3. 시간 측정을 바깥으로 옮기면

ManualProxyDemo의 체인을 new TimingQuery(new OwnerProxy(41L, new InMemoryQuery(calls)))로 바꿉니다. 같은 두 요청을 보내면 출력의 timed 줄은 몇 번 나오고, targetCalls는 얼마인가요? 두 번째 요청의 timed 줄과 denied 줄 가운데 무엇이 먼저 나오는지도 구하세요.

해설 보기

TimingQuery가 가장 바깥이므로 두 요청 모두 측정 범위에 들어가고, finally가 거절 경로에서도 실행되어 timed 줄은 두 번 나옵니다. 대상에 닿는 것은 여전히 첫 요청뿐이므로 targetCalls는 1입니다.

두 번째 요청에서는 OwnerProxy가 던진 SecurityException이 TimingQuery를 지나 main의 catch에 닿기 전에 finally가 먼저 실행됩니다. 그래서 timed=true가 먼저, denied=member mismatch가 그다음에 나옵니다. 거절 지연까지 메트릭에 넣고 싶다면 이 순서를, 대상의 처리 시간만 보고 싶다면 원래 순서를 고릅니다.

다음 글 JDK·클래스 기반 프록시에서는 이렇게 반복되는 래퍼 클래스를 런타임에 만들어 주는 JDK 동적 프록시와 클래스 기반 프록시를 비교합니다.