Proxy 패턴으로 접근 제어와 지연 초기화 분리하기

Proxy 패턴이 접근 제어·지연 초기화·원격 호출·캐싱을 대리 객체에 어떻게 위임하는지, Java 구현 예시로 구조를 정리한다.

2026-08-13 · 최초 발행 2025-10-14

정의와 역할

Proxy 패턴은 실제 객체(Real Subject) 앞에 대리 객체를 세워 요청을 중계하면서, 접근 제어·지연 초기화·원격 호출·캐싱 같은 횡단 관심사를 그 대리 객체에서 처리하는 구조 패턴이다. 클라이언트는 실제 객체를 직접 다루는 대신 같은 인터페이스를 구현한 프록시를 통해서만 접근하므로, 비용이 큰 생성이나 호출을 필요한 시점까지 미루고 권한·정책·관찰 가능성을 하나의 경로에서 강제할 수 있다.

이 패턴은 무엇을 대리하느냐에 따라 몇 가지 형태로 나뉜다. 지연 초기화가 목적이면 Virtual Proxy, 접근 제어가 목적이면 Protection Proxy, 원격 호출을 감싸면 Remote Proxy, 캐싱·로깅·회로 차단처럼 부가 기능을 얹으면 Smart Proxy라고 부른다.

Protection Proxy: 접근을 먼저 걸러낸다

  • 프록시 단계에서 사용자·시스템 권한, 속성 기반 정책(ABAC), 레이트 리미팅을 검증한다.
  • 인증·인가 실패는 여기서 즉시 차단하고, 로깅과 감사 추적 경로를 하나로 통일한다.

Virtual Proxy: 비용이 큰 생성을 미룬다

  • 메모리·네트워크 비용이 큰 객체는 최초 접근 시점에만 실체를 생성하고 이후 재사용한다.
  • 동시성 환경에서는 이 지연 생성이 안전해야 한다. Double-Checked Locking이나 Holder idiom을 적용하는 이유다.

Remote Proxy: 원격 호출을 감싼다

  • 네트워크 마샬링, 재시도, 타임아웃, 서킷 브레이커를 프록시 계층에 캡슐화한다.
  • 호출자 코드가 단순해지고, 이런 정책을 전역에 일관되게 적용할 수 있다.

Smart Proxy: 캐싱과 관찰 가능성을 더한다

  • 결과 캐싱, 메트릭 수집, 트레이싱, 구조적 로깅처럼 비즈니스 로직 밖의 기능을 여기에 통합한다.
  • 회로 차단, 벌크헤딩, 폴백 전략으로 임계 리소스 앞단에서 실패를 격리해 장애 도미노 효과를 완화하기도 한다.

요청이 흘러가는 순서

입력에서 출력까지는 권한 검증 → 지연 초기화 → 캐시 확인 → 원격/로컬 호출 → 오류 처리 → 메트릭 기록 순서로 진행된다. 트랜잭션 경계는 기본적으로 실체 객체에 위임하는 편이 낫다 — 프록시 단계에서는 부수효과를 최소화하고 재시도·타임아웃·아이들포인트 관리에 집중한다. 캐싱을 쓴다면 TTL·Write-through·Invalidate-on-write 중 어떤 일관성 전략을 쓸지 미리 정해야 한다.

실패성공미생성생성됨히트미스오류정상요청 수신권한 검증접근 거부/감사 로그응답 반환실체 초기화 여부실체 생성(지연 초기화)캐시 조회캐시 결과 반환실체 호출(로컬/원격)오류 여부서킷 카운트/폴백결과 캐시/메트릭/트레이스

직접 호출과 비교하면

관점 직접 호출 Proxy 중계
성능 오버헤드 최소 정책/캐싱 적용 시 평균 지연 감소, 프록시 자체 오버헤드 발생
확장성 호출부마다 확장 로직 분산 공통 정책 집약으로 확장성 향상, 캐시/회로차단으로 부하 완화
일관성 팀/모듈별 상이 권한·타임아웃·로깅 일관 적용
안정성 장애 전파 가능 실패 격리·재시도·폴백으로 탄력성 증가
운영 편의 관찰성 편차 중앙 집중 메트릭/트레이스·감사 용이

미디어 로딩·리포트 서비스·API 게이트웨이에서

대용량 미디어 로딩

이미지·동영상 썸네일 생성기는 지연 초기화와 캐시를 함께 적용한다. 콜드 스타트 비용이 줄고 응답 변동성도 낮아진다.

데이터 보고/리포트 서비스

사용자의 역할·속성 기반 접근 제어를 프록시에서 먼저 검증하고, 생성 비용이 큰 리포트 엔진 인스턴스는 지연 초기화한다.

외부 API 게이트웨이

타임아웃·재시도·서킷 브레이커·레이트 리미트 정책을 프록시로 통합하면 장애가 국지화되고 호출자 코드가 단순해진다.

마이크로서비스 클라이언트

mTLS, 요청 서명, 관찰 가능성 헤더 주입을 클라이언트 프록시에서 일괄 처리하면 보안 준수가 일관되게 유지된다.

Proxy 패턴 전반을 이런 지점에 적용했을 때 흔히 언급되는 변화 폭은, 캐시 히트율 5080%를 가정하면 p95 지연이 2040% 줄고, 고비용 객체 생성을 지연·재사용해 CPU/메모리 사용량이 1530% 절감되며, 캐싱·회로 차단을 함께 적용하면 DB/API 백엔드 호출량이 2560% 줄고, 서킷 브레이커로 연쇄 실패율이 3050% 축소되고, 로깅·트레이싱을 표준화하면 MTTR이 2035% 단축된다는 것이다. 수치는 워크로드·구현·인프라 특성에 따라 달라지므로 PoC 기반 정량 검증을 권장한다.

설계할 때 챙길 것들

지연 초기화는 volatile + DCL 또는 Initialization-on-demand Holder로 스레드 안전하게 만든다. 타임아웃·재시도는 표준화한다 — 백오프, 재시도 한계, 멱등성 확보가 기본이다. 캐시 전략은 TTL·사이즈 제한·무효화 트리거를 명시적으로 정의하고, 일관성 요구 수준에 맞춰 고른다. 보안 기본값은 최소 권한·거부 기본 정책·상세 감사 로그로 올려 잡는다. 메트릭(성공/실패/지연/캐시히트), 분산 추적, 구조화 로그는 필수다.

반대로 프록시를 추가하면 정책 평가 비용만큼 지연이 늘고, 스레드 안전성·캐시 일관성을 관리해야 하는 복잡성이 붙는다. 프록시 자체가 실패하면 우회·폴백 경로를 따로 설계해야 한다는 점도 감안해야 한다.

Java로 짜보면

전제조건: JDK 17+, 외부 라이브러리 불필요. 단일 파일로 컴파일/실행 가능하다.

// File: ProxyDemo.java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

interface ReportService {
    String fetchReport(String userId);
}

final class RealReportService implements ReportService {
    @Override
    public String fetchReport(String userId) {
        // 비용이 큰 연산 시뮬레이션
        try { Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return "REPORT:" + userId + ":" + System.currentTimeMillis();
    }
}

final class AccessControl {
    private final Map<String, String> roleByUser = Map.of("alice", "ADMIN", "bob", "USER");
    boolean canRead(String userId) {
        return "ADMIN".equals(roleByUser.getOrDefault(userId, "GUEST")) || "USER".equals(roleByUser.getOrDefault(userId, "GUEST"));
    }
}

final class ProxyReportService implements ReportService {
    private volatile RealReportService target; // 지연 초기화 대상
    private final AccessControl ac = new AccessControl();
    private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
    private final long ttlMillis = 1000; // 간단 TTL 데모
    private final ConcurrentHashMap<String, Long> ts = new ConcurrentHashMap<>();

    @Override
    public String fetchReport(String userId) {
        if (!ac.canRead(userId)) throw new SecurityException("접근 거부: " + userId);

        // 캐시 히트 체크
        String c = cache.get(userId);
        Long t = ts.get(userId);
        if (c != null && t != null && (System.currentTimeMillis() - t) < ttlMillis) {
            return c; // 캐시 반환
        }

        // 실체 지연 초기화(DCL)
        RealReportService local = target;
        if (local == null) {
            synchronized (this) {
                local = target;
                if (local == null) {
                    local = new RealReportService();
                    target = local;
                }
            }
        }

        // 실체 호출
        String result = local.fetchReport(userId);

        // 캐시 저장
        cache.put(userId, result);
        ts.put(userId, System.currentTimeMillis());
        return result;
    }
}

public class ProxyDemo {
    public static void main(String[] args) {
        ReportService service = new ProxyReportService();
        System.out.println(service.fetchReport("alice")); // 최초 호출: 지연 초기화 + 캐시 저장
        System.out.println(service.fetchReport("alice")); // 캐시 히트
        try {
            System.out.println(service.fetchReport("mallory")); // 권한 없음 예외
        } catch (SecurityException e) {
            System.out.println(e.getMessage());
        }
    }
}

구현 포인트:

  • 권한 검증 → 지연 초기화 → 캐시 처리 순서를 따른다.
  • 동시성 안전한 지연 초기화를 보장한다(DCL + volatile).
  • 캐시 TTL은 데모 수준이며, 실제 환경에서는 사이즈·TTL·무효화 전략을 더 정교하게 다듬어야 한다.
디자인 패턴Proxy 패턴구조 패턴지연 초기화접근 제어