본문 바로가기

프록시·데코레이터

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

Proxy와 데코레이터는 모두 클라이언트와 대상 사이에 같은 인터페이스의 객체를 둡니다.

구조만 보면 구별하기 어렵지만 의도가 다릅니다.

Proxy는 접근 시점, 지연 로딩, 원격 경계처럼 대상을 대신 제어하고 데코레이터는 기존 기능에 시간 측정이나 포매팅을 더합니다.

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·실패밖에서 시작한 측정의 소유 범위가 끝납니다.

프록시 인터페이스

게시글 조회 포트를 대상과 래퍼가 함께 구현합니다.

클라이언트는 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을 반환할지 예외를 던질지는 포트 계약에 반영해야 합니다.


접근 제어 프록시

인가 프록시는 행위자와 구성원이 다르면 대상을 부르지 않습니다.

캐시 프록시는 적중하면 대상을 건너뛰고 미스에서만 호출합니다.

지연 프록시는 첫 접근까지 무거운 대상 생성을 미룹니다.

모두 “대신 접근을 제어”한다는 공통 의도가 있습니다.

데코레이터는 대상 호출을 전제로 결과에 부가 책임을 더하는 경우가 많습니다.

경과 시간, 메트릭, 응답 장식이 예입니다.

재시도 데코레이터는 대상을 여러 번 부를 수 있으므로 멱등성 계약을 함께 요구합니다.

래퍼 순서도 의미를 바꿉니다.

캐시가 인가보다 바깥에서 적중을 반환하면 인가 자체를 건너뛰어 미인가 요청에 결과가 나갈 수 있습니다. 인가를 먼저 해도 구성원 ID를 빠뜨린 캐시 키는 다른 구성원의 결과를 섞을 수 있으므로, 호출 순서와 키 구성을 각각 검토합니다.

보안 경계를 가장 먼저 검토합니다.


수동 프록시 연쇄

다음 프로그램은 대상 앞에 시간 측정 데코레이터와 책임 주체 프록시를 조합합니다.

거절된 호출에서는 대상 호출 횟수가 증가하지 않습니다.

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());
    }
}
ManualProxyDemo 실행
timed=true
found=true
denied=member mismatch
targetCalls=1

시간 측정을 책임 주체 프록시 바깥에 두면 거절 지연 시간도 측정됩니다.

어떤 범위가 메트릭 의미인지 구성에서 의도적으로 순서를 정합니다.

예시 원 main의 chain은 OwnerProxy 바깥, TimingQuery 안쪽입니다. 첫 요청의 41=41은 통과해 InMemoryQuery가 calls를 1로 만들고 결과를 반환합니다. 둘째 요청의 41≠99는 OwnerProxy에서 던져 TimingQuery와 대상에 도달하지 않습니다. 그래서 timed 출력은 한 번, targetCalls는 1입니다. TimingQuery를 바깥으로 옮기면 finally가 거절 경로에도 실행되어 timed 출력은 두 번이지만 targetCalls는 여전히 1입니다.

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


예외·동일성 규칙

래퍼가 대상 예외를 다른 타입으로 바꾸면 롤백과 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 { }
}

쓰기 대상에는 멱등성 키 없이 이 재시도 래퍼를 적용하지 않습니다.

이 문제의 “최대 두 번”은 첫 시도까지 포함한 총 두 번이므로 RetryPolicy(2)를 선택합니다. 원 생성자가 허용하는 최대 3과 문제에서 선택한 2를 구별하세요. catch는 StoreTemporarilyUnavailable만 받으므로 다른 RuntimeException을 무조건 재시도하지 않습니다. 네 래퍼의 실제 구현·캐시 저장은 이 RetryPolicy 발췌에 모두 들어 있는 것은 아닙니다.

다음은 해설의 체인 순서로 예상하는 개수입니다. 각 기록기는 실패하지 않고, hit/miss는 인가 뒤에 판정되며, miss의 첫 일시 실패 뒤 둘째 시도는 성공한다고 정합니다.

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

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

독립 확인: 같은 체인에서 인가 통과·캐시 미스이고 첫 대상 호출이 IllegalArgumentException을 던집니다. 전체·시도 기록과 대상 호출 수, 두 번째 시도 여부를 구하세요. 첫째와 둘째 모두 StoreTemporarilyUnavailable을 던진 별도 경우에는 마지막에 어느 실패가 남나요? maximumAttempts는 2이고 기록기는 실패하지 않습니다.

풀이 확인

첫 경우는 전체 1회·시도 1회·대상 1회이며 두 번째 시도는 없습니다. IllegalArgumentException은 해당 catch와 맞지 않아 바로 빠져나옵니다. 별도 경우는 전체 1회·시도 2회·대상 2회이고 last가 둘째 실패로 바뀐 뒤 그 객체를 던집니다. 두 실패가 발생했다는 사실이 대상의 외부 부작용까지 되돌려 준다는 뜻은 아니므로 재시도 안전성 전제가 필요합니다.