[디자인 패턴] 프록시 패턴(Proxy Pattern)

LYM·2026년 8월 23일

디자인패턴

목록 보기
2/2

Spring으로 백엔드를 개발하다 보면 @Transactional 하나만 붙여도 트랜잭션이 알아서 시작되고 커밋·롤백된다. 어떻게 메서드에 애너테이션 하나 붙였을 뿐인데 그 안에 코드가 자동으로 끼워지는 걸까? 이 질문의 답 밑바닥에는 항상 하나의 구조가 있다 — 프록시(Proxy). AOP도, @Transactional도, 결국은 프록시라는 하나의 디자인 패턴 위에 지어진 집이다. 그래서 AOP를 제대로 이해하려면 프록시부터 짚고 넘어가고자 한다.

1. 프록시가 필요한 순간

클라이언트가 어떤 객체의 기능을 쓰고 싶을 때, 가장 단순한 방법은 그 객체에 직접 접근하는 것이다. 그런데 다음과 같은 상황에서는 "직접 접근"이 곤란해진다.

  • 생성 비용이 큰 객체: 고해상도 이미지, 대용량 데이터 조회 결과처럼 만드는 데 시간이나 자원이 많이 드는 객체를, 정말 필요한 순간까지 만들지 않고 미루고 싶다.
    -> 이런 무거운 객체를 필요 여부와 상관없이 전부 미리 만들어서 붙들고 있으면, 실제로는 안 쓰이는 것까지 메모리를 차지한다. 특히 서버 시작 시점처럼 짧은 시간에 여러 개를 한꺼번에 만들면 그 순간 힙 사용량이 급증해 OOM(Out Of Memory)으로 이어질 수 있다.

  • 접근을 통제해야 하는 객체: 아무나 호출하면 안 되고, 권한이 있는 클라이언트만 실제 기능에 닿을 수 있어야 한다.

  • 호출 전후에 뭔가를 더 하고 싶은 경우: 로깅, 캐싱, 트랜잭션 시작/종료처럼 진짜 하려는 일(비즈니스 로직) 앞뒤에 부가적인 작업을 끼우고 싶다.

세 상황의 공통점은 이거다 — 클라이언트의 코드는 그대로 두고 싶다. 클라이언트가 "이 객체는 사실 아직 안 만들어졌어요"라거나 "지금 권한 검사 중이에요"라는 사정을 알 필요는 없다. 클라이언트는 여전히 똑같은 방식으로 호출하는데, 그 호출과 진짜 객체 사이에 누군가 끼어들어 이 사정들을 대신 처리해주면 된다 — 그 "누군가"가 프록시다.

2. 프록시 패턴의 구조

프록시 패턴의 핵심은 클라이언트가 진짜 객체와 대리인을 구분하지 못한다는 데 있다. 이게 가능한 이유는 프록시가 진짜 객체(RealSubject)와 똑같은 인터페이스(Subject)를 구현하기 때문이다.

interface MessageSender {
    void send(String message);
}

class SmsMessageSender implements MessageSender {
    public void send(String message) {
        System.out.println("[SMS 발송] " + message);
    }
}

클라이언트는 이렇게 쓴다.

MessageSender sender = new SmsMessageSender();
sender.send("주문이 완료되었습니다");

클라이언트는 MessageSender라는 타입만 보고 코드를 짠다. 이 자리에 SmsMessageSender가 오든, 앞으로 만들 프록시가 오든 클라이언트 코드는 한 글자도 바뀌지 않는다 — 프록시 패턴이 성립하려면 이 조건이 반드시 지켜져야 한다.

3. 정적 프록시를 직접 만들어보기, 그리고 그 한계

가장 단순한 방법은 프록시 클래스를 직접 만드는 것이다. MessageSender를 호출하기 전후로 로그를 남기는 프록시를 만들어보자.

class LoggingMessageSenderProxy implements MessageSender {
    private final MessageSender target; // 진짜 객체(RealSubject)에 대한 참조

    LoggingMessageSenderProxy(MessageSender target) {
        this.target = target;
    }

    @Override
    public void send(String message) {
        long start = System.currentTimeMillis();
        System.out.println("[로그] send 호출 시작");

        target.send(message); // 실제 작업은 진짜 객체에게 위임

        long elapsed = System.currentTimeMillis() - start;
        System.out.println("[로그] send 호출 종료 - " + elapsed + "ms");
    }
}
MessageSender sender = new LoggingMessageSenderProxy(new SmsMessageSender());
sender.send("주문이 완료되었습니다");
// 클라이언트는 여전히 MessageSender 타입만 사용한다 — 프록시가 끼어든 걸 전혀 모른다

이렇게 직접 만든 프록시를 정적 프록시(Static Proxy)라고 부른다. send 메서드가 하나뿐이라 코드가 짧아 보이지만, 실제로는 두 가지 문제가 금방 드러난다.

첫째, 인터페이스에 메서드가 10개면 로깅 코드를 10번 복사해서 각 메서드마다 "시작 로그 → 위임 → 종료 로그"를 반복해서 써야 한다. 둘째, 이 로깅을 OrderServicePaymentService에도 붙이고 싶다면, 그때마다 LoggingOrderServiceProxy, LoggingPaymentServiceProxy를 새로 만들어야 한다. "로깅"이라는 관심사 하나가 대상 클래스 수만큼 복제되는 것이다.

4. 동적 프록시 (1) — JDK Dynamic Proxy

이 반복을 없애려면 질문을 바꿔야 한다 — "프록시 클래스 자체를 자바가 런타임에 대신 만들어주면 안 될까?" JDK Dynamic Proxy는 정확히 이걸 해준다.

핵심은 InvocationHandler다. 프록시의 어떤 메서드가 호출되든, 그 호출은 전부 InvocationHandler.invoke() 한 곳으로 모인다.

class LoggingInvocationHandler implements InvocationHandler {
    private final Object target;

    LoggingInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long start = System.currentTimeMillis();
        System.out.println("[로그] " + method.getName() + " 호출 시작");

        Object result = method.invoke(target, args); // 리플렉션으로 실제 메서드 호출

        long elapsed = System.currentTimeMillis() - start;
        System.out.println("[로그] " + method.getName() + " 호출 종료 - " + elapsed + "ms");
        return result;
    }
}
MessageSender target = new SmsMessageSender();
MessageSender proxy = (MessageSender) Proxy.newProxyInstance(
        MessageSender.class.getClassLoader(),
        new Class[]{MessageSender.class},
        new LoggingInvocationHandler(target)
);
proxy.send("주문이 완료되었습니다");

이제 LoggingMessageSenderProxy라는 클래스를 직접 쓰지 않았다. Proxy.newProxyInstance()MessageSender 인터페이스를 구현하는 클래스를 런타임에 즉석에서 만들어내고, 그 인스턴스의 모든 메서드 호출은 LoggingInvocationHandler 하나로 다 처리된다 — 메서드가 몇 개든, 대상 클래스가 몇 개든 새로 만들어야 하는 건 target을 갈아 끼우는 것뿐이다.

다만 Proxy.newProxyInstance가 인터페이스 배열을 받는다는 것 자체가 이 방식의 제약을 말해준다. JDK Dynamic Proxy는 인터페이스 기반이라, 대상 클래스가 구현하는 인터페이스가 있어야만 동작한다. OrderService가 인터페이스 없이 클래스 하나로만 존재한다면, 이 방식으로는 프록시를 만들 수 없다.

5. 동적 프록시 (2) — CGLIB

인터페이스가 없는 클래스에도 프록시가 필요할 때 쓰는 게 CGLIB다. JDK Dynamic Proxy가 인터페이스를 "구현"하는 방식이라면, CGLIB는 대상 클래스를 "상속"하는 서브클래스를 런타임에 만들어 메서드를 오버라이드하는 방식이다.

class OrderService { // 인터페이스가 없는 클래스
    void placeOrder() {
        System.out.println("[주문 처리]");
    }
}
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderService.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
    System.out.println("[로그] " + method.getName() + " 호출 시작");
    Object result = proxy.invokeSuper(obj, args); // 부모(진짜 객체)의 메서드 호출
    System.out.println("[로그] " + method.getName() + " 호출 종료");
    return result;
});

OrderService proxy = (OrderService) enhancer.create();
proxy.placeOrder();

이 방식은 상속을 전제로 하기 때문에 명확한 제약이 따라온다. OrderServicefinal class라면 애초에 상속이 불가능해 프록시를 만들 수 없다. 같은 이유로 개별 메서드가 final이면 그 메서드만 오버라이드가 안 되는데, 이때는 에러가 나는 게 아니라 프록시를 통한 가로채기가 조용히 무시된다. final 메서드에 @Transactional을 걸면 트랜잭션이 하나도 걸리지 않는데, 컴파일도 되고 실행도 되니 알아채기가 특히 어렵다.

6. 프록시의 쓰임새들

지금까지 만든 건 전부 "호출 전후에 로그를 남기는" 프록시였다. 그런데 프록시가 그 자리에서 하는 일이 로깅이 아니라 다른 것이어도 구조는 완전히 같다. 목적에 따라 이름이 붙을 뿐이다.

  • 가상 프록시(Virtual Proxy): 생성 비용이 큰 객체를 실제로 쓰이는 순간까지 미룬다. 사실 이건 낯선 개념이 아니다 — Hibernate가 LAZY 연관관계를 조회할 때 실제 엔티티 대신 프록시 객체를 돌려주고, 그 프록시의 필드에 처음 접근하는 순간에야 진짜 SELECT 쿼리를 날리는 것과 정확히 같은 구조다.
  • 보호 프록시(Protection Proxy): 클라이언트의 권한을 먼저 확인하고, 통과해야만 진짜 객체로 위임한다.
  • 원격 프록시(Remote Proxy): 네트워크 너머에 있는 객체를 로컬 객체처럼 다루게 해준다. 클라이언트 입장에서는 "이게 원격 호출"이라는 사실 자체가 프록시 뒤에 숨는다.
  • 캐싱 프록시: 이전 호출 결과를 저장해뒀다가, 같은 요청이 오면 진짜 객체를 부르지 않고 캐시된 값을 바로 돌려준다.
  • 로깅/모니터링 프록시: 지금까지 예제로 써온 바로 그것 — 호출 시간, 호출 여부, 예외 발생 여부를 기록한다.

이름은 다섯 가지로 다르지만 구조는 하나다 — 클라이언트는 인터페이스만 보고, 진짜 객체 앞단에서 뭔가(지연·검사·전송·캐싱·기록)를 대신 처리하는 대리인을 세운다.

7. 마치며

프록시는 결국 "클라이언트의 코드를 바꾸지 않고, 진짜 객체 앞단에서 뭔가를 더 하거나 걸러내는" 구조다. 그런데 3~5번에서 본 것처럼 이걸 손으로 만들면(정적 프록시) 대상 클래스마다 새 프록시 클래스가 필요하고, 메서드 수만큼 위임 코드가 반복된다. JDK Dynamic Proxy와 CGLIB는 이 반복을 "런타임에 프록시를 자동으로 생성하는" 방식으로 없앴다.

그런데 아직 남은 질문이 있다. InvocationHandlerMethodInterceptor 안에 "로깅을 할지, 트랜잭션을 걸지, 권한을 검사할지"를 여전히 코드로 직접 써야 한다. 이걸 코드가 아니라 선언적으로 — "이 패키지의 이 메서드들에는 이 부가 기능을 붙여라"는 식으로 지정할 수 있게 만든 것이 AOP(Aspect-Oriented Programming)다. Spring은 이 선언을 보고 내부적으로 JDK Dynamic Proxy와 CGLIB 중 무엇을 쓸지도 알아서 고른다. AOP에 대해서는 다음 글에서 자세하게 다룰 예정이다.

참고 문헌

https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%ED%94%84%EB%A1%9D%EC%8B%9CProxy-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90

profile
열정 열정 열정

0개의 댓글