1. 리플렉션
1.1. 리플렉션의 필요성
지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다. 그러나 문제는 대상 클래스 수 만큼 로그 추적을 위한 프록시 클래스를 만들어야 한다는 점이다. 로그 추적을 위한 프록시 클래스들의 소스코드는 거의 같은 모양을 하고 있다.
자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다. 쉽게 이야기해서 프록시 클래스를 지금처럼 계속 만들지 않아도 된다는 것이다. 프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 된다.
JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다. 리플렉션 기술을 사용하면 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.
1.2. 리플렉션 기본 예제
1.2.1. 반복적인 코드의 문제점
리플렉션을 이해하기 전에 먼저 왜 필요한지부터 살펴보자.
package hello.proxy.jdkdynamic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class ReflectionTest {
@Test
void reflection0() {
Hello target = new Hello();
//공통 로직1 시작
log.info("start");
String result1 = target.callA(); //호출하는 메서드가 다름
log.info("result={}", result1);
//공통 로직1 종료
//공통 로직2 시작
log.info("start");
String result2 = target.callB(); //호출하는 메서드가 다름
log.info("result={}", result2);
//공통 로직2 종료
}
@Slf4j
static class Hello {
public String callA() {
log.info("callA");
return "A";
}
public String callB() {
log.info("callB");
return "B";
}
}
}
여기서 공통 로직1과 공통 로직2는 호출하는 메서드만 다르고 전체 코드 흐름이 완전히 같다:
- 먼저 start 로그를 출력한다.
- 어떤 메서드를 호출한다.
- 메서드의 호출 결과를 로그로 출력한다.
이 두 공통 로직을 하나의 메서드로 합치고 싶지만, 중간에 호출하는 메서드가 target.callA()와 target.callB()로 다르기 때문에 쉽지 않다. 만약 메서드 호출 부분만 동적으로 처리할 수 있다면 문제를 해결할 수 있을 것이다.
log.info("start");
String result = xxx(); //호출 대상이 다름, 동적 처리 필요
log.info("result={}", result);
이럴 때 사용하는 기술이 바로 리플렉션(Reflection)이다. 리플렉션은 클래스나 메서드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다.
1.2.2. 리플렉션의 핵심: 메서드를 '데이터'처럼 다루기
리플렉션의 가장 중요한 개념은 "메서드를 데이터처럼 다룬다"는 것이다. 일반적인 코드에서 메서드는 실행하는 '동작'이지만, 리플렉션에서는 메서드를 Method라는 객체로 만들어서 변수에 저장하고, 매개변수로 전달하고, 필요할 때 실행할 수 있다.
@Test
void reflection1() throws Exception {
// 1. 클래스 정보(설명서) 획득
// JVM의 메타스페이스 영역에서 해당 클래스의 구조 정보를 담은 Class 객체를 참조한다.
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
// 2. 실제 데이터 인스턴스 생성
Hello target = new Hello();
// 3. 메서드 메타정보(추상화된 실행권한) 획득
// getMethod는 클래스 설계도에서 "callA"라는 메서드의 메모리 위치와 규격 정보(Method)를 추출한다.
// 이제 methodCallA는 'callA를 실행할 수 있는 리모컨'이 된다.
Method methodCallA = classHello.getMethod("callA");
// 4. 추상화된 메서드 실행 (invoke)
// [원리] methodCallA 내부에는 이미 "어디를 호출해야 하는지"에 대한 정보가 담겨 있다.
// .invoke(target)은 "가져온 설계도(Method)를 실제 객체(target)에 대입하여 실행하라"는 명령이다.
// 내부적으로는 JVM이 MethodAccessor를 통해 실제 메서드의 메모리 지점으로 제어권을 넘긴다.
Object result1 = methodCallA.invoke(target);
log.info("result1={}", result1);
// callB 역시 같은 원리로, Method 객체만 다를 뿐 invoke라는 동일한 통로로 실행된다.
Method methodCallB = classHello.getMethod("callB");
Object result2 = methodCallB.invoke(target);
log.info("result2={}", result2);
}
여기서 중요한 점:
- methodCallA는 단순한 객체이다. 이 객체는 callA() 메서드에 대한 정보를 담고 있다.
- methodCallA.invoke(target)을 호출하면 비로소 실제 callA() 메서드가 실행된다.
- 이렇게 하면 메서드를 변수에 저장했다가 나중에 실행할 수 있다.
1.2.3. 리플렉션의 진짜 위력: 동적 메서드 호출 (★)
리플렉션의 진짜 힘은 메서드를 변수로 다룰 수 있다는 점에서 나온다. 이를 통해 공통 로직을 추출할 수 있다.
@Test
void reflection2() throws Exception {
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA, target); // callA 메서드를 실행
Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, target); // callB 메서드를 실행
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
Object result = method.invoke(target); // 📌 어떤 메서드가 올지 모르지만 실행 가능!
log.info("result={}", result);
}
dynamicCall 메서드의 매개변수 Method method는 핵심이다:
- 이전 방식: target.callA(), target.callB()처럼 메서드를 하드코딩
- 리플렉션 방식: method라는 변수에 어떤 메서드가 들어올지 모르지만, method.invoke(target)으로 실행 가능
이것이 바로 "메서드를 동적으로 호출한다"는 의미이다. 실행할 메서드를 런타임에 결정할 수 있다.
1.3. invoke() vs 직접 메서드 호출의 차이
이 부분이 가장 혼동하기 쉬운 부분이므로 명확히 구분하자:
// 1. 직접 호출 (정적 호출)
target.callA(); // 컴파일 시점에 callA()로 고정됨
// 2. 리플렉션 호출 (동적 호출)
method.invoke(target); // method 변수에 들어있는 메서드를 실행
// method가 callA()일 수도, callB()일 수도 있음
비유로 이해하기:
- 직접 호출: "김밥 주세요" (항상 김밥을 주문)
- 리플렉션 호출: "이 메뉴판에 있는 거 주세요" (메뉴판 내용에 따라 달라짐)
1.4. 리플렉션 정리
정적인 target.callA(), target.callB() 코드를 리플렉션을 사용해서 Method라는 메타정보로 추상화했다. 덕분에 공통 로직을 만들 수 있게 되었다.
1.5. 리플렉션 주의사항
리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다. 하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.
예를 들어서 지금까지 살펴본 코드에서 getMethod("callA") 안에 들어가는 문자를 실수로 getMethod("callZ")로 작성해도 컴파일 오류가 발생하지 않는다. 그러나 해당 코드를 직접 실행하는 시점에 발생하는 오류인 런타임 오류가 발생한다. 가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고, 가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류다.
따라서 리플렉션은 일반적으로 사용하면 안된다. 지금까지 프로그래밍 언어가 발달하면서 타입 정보를 기반으로 컴파일 시점에 오류를 잡아준 덕분에 개발자가 편하게 살았는데, 리플렉션은 그것에 역행하는 방식이다. 리플렉션은 프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.
2. JDK 동적 프록시
2.1. JDK 동적 프록시 소개
JDK 동적 프록시는 Java에서 기본적으로 제공하는 동적 프록시 생성 기술이다. 이 기술은 런타임 시점에 인터페이스를 기반으로 프록시 객체를 동적으로 생성한다. JDK 동적 프록시는 1)java.lang.reflect.Proxy 클래스와 2)InvocationHandler 인터페이스를 사용하여 구현된다.
2.1.1. JDK 동적 프록시 동작의 비유적 이해
프록시를 '비서', InvocationHandler를 '업무 매뉴얼', 실제 객체를 '사장님'이라고 가정한다.
- 클라이언트가 비서에게 업무 요청: 사장님인 줄 알고 비서에게 "A 작업을 처리해달라"고 말한다.
- 비서가 요청을 가로챔: 비서는 사장님께 바로 전달하지 않고 일단 요청을 메모한다.
- 업무 분석: 비서는 이 요청이 'A 작업'에 대한 것임을 확인한다. (이 정보가 Method 객체임)
- 매뉴얼 확인 및 실행: 비서는 미리 받은 '업무 매뉴얼(InvocationHandler)'을 펼친다. 매뉴얼에는 "모든 업무 전후에 시간을 기록하라"는 지침이 있다.
- 사장님 호출: 매뉴얼에 따라 시간 기록을 한 뒤, 비서가 아까 분석한 'A 작업' 정보(Method)를 가지고 실제 사장님(Target)에게 가서 일을 시킨다. (method.invoke)
- 결과 보고: 사장님이 준 결과물을 비서가 다시 클라이언트에게 전달한다.
2.1.2. 각 단계별 상세 분석
사용자가 제시한 기술적 흐름을 구체적으로 해설한다.
1~2단계: 요청의 가로채기 (Intercept)
- JDK 동적 프록시는 인터페이스를 기반으로 실행 시점에 가짜 객체를 만들어낸다. 클라이언트는 이 객체가 진짜인 줄 알고 메서드를 호출하지만, 사실 이 객체는 로직이 비어 있는 껍데기에 불과하다. 이 껍데기가 호출을 가로채는 역할을 수행한다.
3~4단계: 정보 전달과 핸들러 실행
- 프록시는 호출된 메서드의 이름, 파라미터 등의 정보를 Method 객체라는 형태로 포장한다. 그 후 이 포장된 정보와 인자들을 InvocationHandler의 invoke() 메서드로 던져준다. 이때부터 모든 제어권은 개발자가 직접 작성한 InvocationHandler로 넘어간다.
5단계: 리플렉션을 통한 실제 실행
- InvocationHandler 내부에는 부가 기능(로깅, 트랜잭션 등)이 작성되어 있다. 공통 로직을 수행한 뒤, 아까 전달받은 Method 객체의 invoke(target, args)를 호출한다. 여기서 리플렉션 기술이 사용되는데, "전달받은 설명서(Method)를 바탕으로 실제 객체(target)의 기능을 실행하라"는 명령이 떨어진다.
6단계: 결과 반환
- 실제 객체가 계산한 결과값은 다시 InvocationHandler를 거쳐 프록시로 전달되고, 최종적으로 클라이언트에게 도달한다. 클라이언트 입장에서는 로그가 찍혔는지 모른 채 결과만 받게 되지만, 내부적으로는 부가 기능이 완벽하게 수행된 상태가 된다.
2.2. JDK 동적 프록시 예제 코드

2.2.1. InvocationHandler: 프록시의 '두뇌'
JDK 동적 프록시에 적용할 로직은 InvocationHandler 인터페이스를 구현해서 작성한다. 이 핸들러는 프록시가 무엇을 할지 정의하는 설계도 역할을 한다.
InvocationHandler라는 Interface는 개발자가 작성하는게 아니라 Spring이 기본적으로 제공한다. 따라서 우리는 이 InvocationHandler를 implements하는 구현체(아래 참조)만 만들어주면 된다.
package java.lang.reflect;
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}
매개변수 설명:
- Object proxy: 프록시 자신 (거의 사용하지 않음)
- Method method: 호출된 메서드 정보 (리플렉션의 Method 객체)
- Object[] args: 메서드 호출 시 전달된 인수들
2.2.2. TimeInvocationHandler 구현
위에서 언급했던 JDK가 기본적으로 제공해주는 InvocationHandler을 Implements 하여 구현체를 작성하면 된다.
package hello.proxy.jdkdynamic.code;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target; // 실제 대상 객체 (원본)
public TimeInvocationHandler(Object target) {
this.target = target;
}
// invoke: Proxy가 호출하는 메서드 (=proxy.call()을 하면 invoke가 뺏어옴)
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// ✅ 공통처리 로직(1)
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
// 📌 개별 로직 - 실제 메서드 실행 (리플렉션 사용!)
Object result = method.invoke(target, args);
// ✅ 공통처리 로직(2)
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
핵심 라인 이해하기:
Object result = method.invoke(target, args);
- method: 프록시를 통해 호출된 메서드 (예: call() 메서드)
- target: 실제 객체 (예: AImpl 인스턴스)
- args: 메서드에 전달된 인수들
- 이 한 줄이 의미하는 바: "실제 객체(target)의 method 메서드를 args 인수로 실행하라"
2.2.3. 인터페이스와 구현체 정의
package hello.proxy.jdkdynamic.code;
public interface AInterface {
String call();
}
public class AImpl implements AInterface {
@Override
public String call() {
return "A";
}
}
public interface BInterface {
String call();
}
public class BImpl implements BInterface {
@Override
public String call() {
return "B";
}
}
2.2.4. JdkDynamicProxyTest: 동적 프록시 생성과 사용
package hello.proxy.jdkdynamic;
import hello.proxy.jdkdynamic.code.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
@Slf4j
public class JdkDynamicProxyTest {
@Test
void dynamicA() {
// 1. 실제 객체 생성
AInterface target = new AImpl();
// 2. 핸들러 생성 (프록시의 로직 정의)
TimeInvocationHandler handler = new TimeInvocationHandler(target);
// 3. 동적 프록시 생성 (마법 발생!)
AInterface proxy = (AInterface) Proxy.newProxyInstance(
AInterface.class.getClassLoader(), // 어떤 클래스 로더 사용할지
new Class[]{AInterface.class}, // 어떤 인터페이스 구현할지
handler // 어떤 핸들러(로직) 사용할지
);
// 4. 프록시 사용
// 사용자는 실제 객체인지 프록시인지 모른다!
proxy.call(); // 이 호출이 핸들러의 invoke()를 트리거한다
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
@Test
void dynamicB() {
BInterface target = new BImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
BInterface proxy = (BInterface) Proxy.newProxyInstance(
BInterface.class.getClassLoader(),
new Class[]{BInterface.class},
handler
);
proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
}
2.2.5. 무슨 일이 일어나는지 단계별로 이해하기
1. 프록시 생성 시:
Proxy.newProxyInstance(...)
- 자바가 내부적으로 $Proxy0 클래스(가상 클래스)를 생성
- 이 클래스는 AInterface를 구현
- 모든 메서드 호출을 handler.invoke()로 전달하도록 구현됨
2. 프록시 사용 시:
proxy.call(); // 우리가 호출하는 코드
내부적으로 발생하는 일:
1. $Proxy0.call() 호출
2. $Proxy0: "핸들러님, call() 메서드가 호출되었어요!"
3. TimeInvocationHandler.invoke() 실행
3-1. "TimeProxy 실행" 로그 출력
3-2. 시간 측정 시작
3-3. method.invoke(target, args) 실행 → AImpl.call() 실행
3-4. 시간 측정 종료
3-5. "TimeProxy 종료" 로그 출력
4. 결과 반환
2.2.6. 동적 프록시의 장점
BEFORE: 각 클래스마다 프록시 직접 생성
class AProxy implements AInterface {
private AInterface target;
public String call() {
// 시간 측정 로직
long start = System.currentTimeMillis();
String result = target.call();
long end = System.currentTimeMillis();
System.out.println("실행 시간: " + (end - start));
return result;
}
}
class BProxy implements BInterface {
private BInterface target;
public String call() {
// 시간 측정 로직 (중복!)
long start = System.currentTimeMillis();
String result = target.call();
long end = System.currentTimeMillis();
System.out.println("실행 시간: " + (end - start));
return result;
}
}
// CProxy, DProxy, EProxy... 계속 만들어야 함
AFTER: 동적 프록시 사용
// 핸들러 하나만 만들고
TimeInvocationHandler handler = new TimeInvocationHandler(실제객체);
// 여러 인터페이스에 적용
AInterface proxyA = Proxy.newProxyInstance(..., handler);
BInterface proxyB = Proxy.newProxyInstance(..., handler);
CInterface proxyC = Proxy.newProxyInstance(..., handler);
// 모두 자동으로 시간 측정!
2.2.7. 출력 결과 분석
TimeInvocationHandler - TimeProxy 실행
AImpl - A 호출
TimeInvocationHandler - TimeProxy 종료 resultTime=0
JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.AImpl
JdkDynamicProxyTest - proxyClass=class com.sun.proxy.$Proxy1
- proxyClass=class com.sun.proxy.$Proxy1: 자바가 동적으로 생성한 프록시 클래스
- dynamicA()와 dynamicB()를 각각 실행하면 $Proxy1, $Proxy2처럼 다른 클래스가 생성됨
생성된 JDK 동적 프록시:
proxyClass=class com.sun.proxy.$Proxy1 이 부분이 동적으로 생성된 프록시 클래스 정보이다. 이것은 우리가 만든 클래스가 아니라 JDK 동적 프록시가 이름 그대로 동적으로 만들어준 프록시이다. 이 프록시는 TimeInvocationHandler 로직을 실행한다.
실행 순서:

- 클라이언트는 JDK 동적 프록시의 call()을 실행한다.
- JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다. TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler.invoke()가 호출된다.
- TimeInvocationHandler가 내부 로직을 수행하고, method.invoke(target, args)를 호출해서 target인 실제 객체(AImpl)를 호출한다.
- AImpl 인스턴스의 call()이 실행된다.
- AImpl 인스턴스의 call()의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.
동적 프록시 클래스 정보:
dynamicA()와 dynamicB() 둘을 동시에 함께 실행하면 JDK 동적 프록시가 각각 다른 동적 프록시 클래스를 만들어주는 것을 확인할 수 있다.
proxyClass=class com.sun.proxy.$Proxy1 // dynamicA
proxyClass=class com.sun.proxy.$Proxy2 // dynamicB
2.2.5. 정리
예제를 보면 AImpl, BImpl 각각 프록시를 만들지 않았다. 프록시는 JDK 동적 프록시를 사용해서 동적으로 만들고 TimeInvocationHandler는 공통으로 사용했다. JDK 동적 프록시 기술 덕분에 적용 대상 만큼 프록시 객체를 만들지 않아도 된다. 그리고 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다.
만약 적용 대상이 100개여도 동적 프록시를 통해서 생성하고, 각각 필요한 InvocationHandler만 만들어서 넣어주면 된다. 결과적으로 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.
JDK 동적 프록시 도입 전 - 직접 프록시 생성:


JDK 동적 프록시 도입 후 - 자동 생성:


- 점선은 개발자가 직접 만드는 클래스가 아니다.
- 하나의 InvocationHandler 구현체로 여러 인터페이스에 적용 가능하다.
- 프록시 객체는 런타임에 동적으로 생성된다.
2.3. JDK 동적 프록시 적용 1 - 기본 적용
JDK 동적 프록시는 인터페이스가 필수이기 때문에 V1 애플리케이션에만 적용할 수 있다. 먼저 LogTrace를 적용할 수 있는 InvocationHandler를 만들자.
2.3.1. LogTraceBasicHandler
package hello.proxy.config.v2_dynamicproxy.handler;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class LogTraceBasicHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
public LogTraceBasicHandler(Object target, LogTrace logTrace) {
this.target = target;
this.logTrace = logTrace;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
TraceStatus status = null;
try {
// 동적으로 메서드 정보 얻기
String message = method.getDeclaringClass().getSimpleName() + "."
+ method.getName() + "()";
status = logTrace.begin(message);
// 실제 메서드 실행
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
리플렉션의 활용:
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
- method.getDeclaringClass(): 메서드가 선언된 클래스 정보
- method.getName(): 메서드 이름
- 이를 통해 "OrderController.request()" 같은 로그 메시지를 동적으로 생성
2.3.2. DynamicProxyBasicConfig
동적 프록시를 사용하도록 수동 빈 등록을 설정하자.
package hello.proxy.config.v2_dynamicproxy;
import hello.proxy.app.v1.*;
import hello.proxy.config.v2_dynamicproxy.handler.LogTraceBasicHandler;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Proxy;
@Configuration
public class DynamicProxyBasicConfig {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(
OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceBasicHandler(orderController, logTrace)
);
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(
OrderServiceV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceBasicHandler(orderService, logTrace)
);
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(
OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceBasicHandler(orderRepository, logTrace)
);
return proxy;
}
}
이전에는 프록시 클래스를 직접 개발했지만, 이제는 JDK 동적 프록시 기술을 사용해서 각각의 Controller, Service, Repository에 맞는 동적 프록시를 생성해주면 된다.
- LogTraceBasicHandler: 동적 프록시를 만들더라도 LogTrace를 출력하는 로직은 모두 같기 때문에 프록시는 모두 LogTraceBasicHandler를 사용한다.
2.4. JDK 동적 프록시 적용 2 - 메서드 필터 기능 추가
2.4.1. 문제점 인식
요구사항에 의해 no-log를 호출했을 때는 로그가 남으면 안된다. 이런 문제를 해결하기 위해 메서드 이름을 기준으로 특정 조건을 만족할 때만 로그를 남기는 기능을 개발해보자.
2.4.2. LogTraceFilterHandler
package hello.proxy.config.v2_dynamicproxy.handler;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.util.PatternMatchUtils;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class LogTraceFilterHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
private final String[] patterns;
public LogTraceFilterHandler(Object target, LogTrace logTrace, String... patterns) {
this.target = target;
this.logTrace = logTrace;
this.patterns = patterns;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 메서드 이름 필터링
String methodName = method.getName();
// 패턴에 맞지 않으면 바로 실행 (로그 없이)
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
return method.invoke(target, args);
}
// 패턴에 맞으면 로그 출력 후 실행
TraceStatus status = null;
try {
String message = method.getDeclaringClass().getSimpleName() + "."
+ method.getName() + "()";
status = logTrace.begin(message);
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
패턴 매칭:
- PatternMatchUtils.simpleMatch(patterns, methodName): 메서드 이름이 패턴들 중 하나와 일치하는지 확인
- 패턴 예시:
- "request*": request로 시작하는 모든 메서드
- "*save": save로 끝나는 모든 메서드
- "*get*": get이 포함된 모든 메서드
2.4.3. DynamicProxyFilterConfig
package hello.proxy.config.v2_dynamicproxy;
import hello.proxy.app.v1.*;
import hello.proxy.config.v2_dynamicproxy.handler.LogTraceFilterHandler;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Proxy;
@Configuration
public class DynamicProxyFilterConfig {
// 로그를 남길 메서드 패턴 정의
private static final String[] PATTERNS = {"request*", "order*", "save*"};
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(
OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceFilterHandler(orderController, logTrace, PATTERNS)
);
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(
OrderServiceV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceFilterHandler(orderService, logTrace, PATTERNS)
);
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(
OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS)
);
return proxy;
}
}
- public static final String[] PATTERNS = {"request*", "order*", "save*"}: 적용할 패턴이다. request, order, save로 시작하는 메서드에 로그가 남는다.
- LogTraceFilterHandler: 앞서 만든 필터 기능이 있는 핸들러를 사용한다. 그리고 핸들러에 적용 패턴도 넣어준다.
2.5. JDK 동적 프록시의 한계
JDK 동적 프록시는 인터페이스가 필수라는 큰 제약이 있다. 그렇다면 V2 애플리케이션처럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 이것은 일반적인 방법으로는 어렵고 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다. CGLIB는 클래스를 상속받아서 프록시를 생성하기 때문에 인터페이스가 없어도 동적 프록시를 적용할 수 있다.
3. CGLIB
3.1. CGLIB 소개
CGLIB(Code Generator Library)는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다. CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다. CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다. 참고로 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하게 도와주기 때문에, 너무 깊이있게 파기 보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다.
3.2. 공통 예제 코드
앞으로 다양한 상황을 설명하기 위해서 먼저 공통으로 사용할 예제 코드를 만들어보자.
3.2.1. 인터페이스와 구현이 있는 서비스 클래스
ServiceInterface:
package hello.proxy.common.service;
public interface ServiceInterface {
void save();
void find();
}
ServiceImpl:
package hello.proxy.common.service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ServiceImpl implements ServiceInterface {
@Override
public void save() {
log.info("save 호출");
}
@Override
public void find() {
log.info("find 호출");
}
}
3.2.2. 구체 클래스만 있는 서비스 클래스
ConcreteService:
package hello.proxy.common.service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ConcreteService {
public void call() {
log.info("ConcreteService 호출");
}
}
3.3. CGLIB 예제 코드
3.3.1. MethodInterceptor 인터페이스
JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler를 제공했듯이, CGLIB는 MethodInterceptor를 제공한다.
MethodInterceptor - CGLIB 제공:
package org.springframework.cglib.proxy;
public interface MethodInterceptor extends Callback {
Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
- obj: CGLIB가 적용된 객체
- method: 호출된 메서드
- args: 메서드를 호출하면서 전달된 인수
- proxy: 메서드 호출에 사용
3.3.2. TimeMethodInterceptor 구현
package hello.proxy.cglib.code;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
public TimeMethodInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = proxy.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
TimeMethodInterceptor는 MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.
- Object target: 프록시가 호출할 실제 대상
- proxy.invoke(target, args): 실제 대상을 동적으로 호출한다. 참고로 method를 사용해도 되지만, CGLIB는 성능상 MethodProxy proxy를 사용하는 것을 권장한다.
3.3.3. CglibTest
package hello.proxy.cglib;
import hello.proxy.cglib.code.TimeMethodInterceptor;
import hello.proxy.common.service.ConcreteService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;
@Slf4j
public class CglibTest {
@Test
void cglib() {
ConcreteService target = new ConcreteService();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteService.class);
enhancer.setCallback(new TimeMethodInterceptor(target));
ConcreteService proxy = (ConcreteService) enhancer.create();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.call();
}
}
ConcreteService는 인터페이스가 없는 구체 클래스이다. 여기에 CGLIB를 사용해서 프록시를 생성해보자.
- Enhancer: CGLIB는 Enhancer를 사용해서 프록시를 생성한다.
- enhancer.setSuperclass(ConcreteService.class): CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있다. 어떤 구체 클래스를 상속 받을지 지정한다.
- enhancer.setCallback(new TimeMethodInterceptor(target)): 프록시에 적용할 실행 로직을 할당한다.
- enhancer.create(): 프록시를 생성한다. 앞서 설정한 enhancer.setSuperclass(ConcreteService.class)에서 지정한 클래스를 상속 받아서 프록시가 만들어진다.
JDK 동적 프록시는 인터페이스를 구현(implement)해서 프록시를 만든다. CGLIB는 구체 클래스를 상속(extends)해서 프록시를 만든다.
실행 결과:
CglibTest - targetClass=class hello.proxy.common.service.ConcreteService
CglibTest - proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$25d6b0e3
TimeMethodInterceptor - TimeProxy 실행
ConcreteService - ConcreteService 호출
TimeMethodInterceptor - TimeProxy 종료 resultTime=9
실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다.

3.3.4. CGLIB가 생성한 프록시 클래스 이름
CGLIB를 통해서 생성된 클래스의 이름을 확인해보자.
ConcreteService$$EnhancerByCGLIB$$25d6b0e3
CGLIB가 동적으로 생성하는 클래스 이름은 다음과 같은 규칙으로 생성된다:
대상클래스$$EnhancerByCGLIB$$임의코드
참고로 다음은 JDK Proxy가 생성한 클래스 이름이다:
proxyClass=class com.sun.proxy.$Proxy1
3.3.5. CGLIB 제약
클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다:
- 부모 클래스의 생성자를 체크해야 한다: CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.
- 클래스에 final 키워드가 붙으면 상속이 불가능하다: CGLIB에서는 예외가 발생한다.
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다: CGLIB에서는 프록시 로직이 동작하지 않는다.
참고: CGLIB를 사용하면 인터페이스가 없는 V2 애플리케이션에 동적 프록시를 적용할 수 있다. 그런데 지금 당장 적용하기에는 몇가지 제약이 있다. V2 애플리케이션에 기본 생성자를 추가하고, 의존관계를 setter를 사용해서 주입하면 CGLIB를 적용할 수 있다. 하지만 다음에 학습하는 ProxyFactory를 통해서 CGLIB를 적용하면 이런 단점을 해결하고 또 더 편리하기 때문에, 애플리케이션에 CGLIB로 프록시를 적용하는 것은 조금 뒤에 알아보겠다.
4. 정리 및 요약
4.1. 동적 프록시 메커니즘 핵심 요약
👤 등장인물 (Role)
- 클라이언트(Client): "난 진짜 객체(Target)인 줄 알고 호출해."
- 프록시(Proxy): "난 껍데기야. 요청이 들어오면 무조건 핸들러에게 토스해."
- 핸들러/인터셉터(Handler): "난 프록시의 두뇌야. 공통 로직(로그 등)을 실행하고, 리플렉션으로 진짜 객체를 호출해."
- 타겟(Target): "난 실제 비즈니스 로직을 수행하는 진짜 객체야."
4.2. 전체 실행 흐름 (Sequence)
클라이언트가 메서드를 호출하는 시점부터 결과가 반환되기까지의 과정이다.
- 설정 단계 (Config): 서버 기동 시 Proxy.newProxyInstance() 등을 사용하여 진짜 객체 대신 프록시 객체를 스프링 빈(Bean)으로 등록한다.
- 호출 단계 (Call): 클라이언트는 의존 관계 주입(DI)을 통해 받은 객체의 메서드(예: call())를 호출한다. (이때 객체는 진짜가 아닌 프록시이다.)
- 가로채기 (Intercept): 프록시 객체 내부에는 로직이 없다. 대신 연결된 InvocationHandler의 invoke()를 즉시 호출한다. 이때 "누구를 호출했는지(Method)"와 "전달된 인자(Args)"를 함께 넘긴다.
- 부가 기능 수행: 핸들러의 invoke() 내부에서 개발자가 작성한 로그 출력, 시간 측정 등 공통 로직이 실행된다.
- 실제 호출 (Reflection): 핸들러는 가지고 있는 target(진짜 객체)과 전달받은 Method 정보를 이용해 method.invoke(target, args)를 실행한다. 이제서야 진짜 비즈니스 로직이 돌아간다.
- 반환 (Return): 진짜 객체가 준 결과값을 핸들러가 받아 클라이언트에게 최종 전달한다.
4.3. 설정(Config)에서의 마법: 객체 갈아치우기
클라이언트가 왜 프록시인지 모르는 이유는 설정 정보에서 주소를 바꿔치기했기 때문이다.
- 기본 상태: return new AImpl(); → 클라이언트는 진짜 객체를 가짐.
- 프록시 상태: return Proxy.newProxyInstance(...) → 클라이언트는 인터페이스 타입의 프록시 객체를 주입받음.
- 결과: 클라이언트는 AInterface라는 타입만 보고 쓰기 때문에, 내부에 진짜가 들었는지 프록시가 들었는지 알 수 없으며 알 필요도 없다.
4.4. JDK 동적 프록시 vs CGLIB 한눈에 비교
| 구분 | JDK 동적 프록시 | CGLIB |
| 핵심 기술 | Java Reflection API | CGLIB 라이브러리 (Bytecode 조작) |
| 대상 | 인터페이스가 반드시 필요함 | 클래스만 있어도 가능 (상속 이용) |
| 가로채는 객체 | InvocationHandler | MethodInterceptor |
| 실행 방식 | method.invoke(target, args) | proxy.invokeSuper(obj, args) 등 |
"프록시는 껍데기일 뿐이며, 모든 핵심 제어권은 핸들러(InvocationHandler/MethodInterceptor)가 뺏어와서 리플렉션으로 요리한다."
5. 남은 문제
- 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야할까?
- 애플리케이션에 인터페이스 기반 프록시와 클래스 기반 프록시가 공존할 때, 일관된 방식으로 처리할 수 있는 방법이 필요하다.
- 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해서 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?
- 두 기술은 서로 다른 인터페이스를 사용하기 때문에, 동일한 부가 기능을 적용하려면 두 개의 구현체를 만들어야 하는 문제가 있다.
- 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면?
- 메서드 필터링 기능과 같은 공통적인 요구사항에 대해 일관된 방식으로 처리할 수 있는 방법이 필요하다.
'Spring > Core' 카테고리의 다른 글
| [Advanced-7] 빈 후처리기 (0) | 2026.01.02 |
|---|---|
| [Advanced-6] 스프링 지원 프록시 (0) | 2026.01.02 |
| [Advanced-4] 프록시 패턴과 데코레이터 패턴 (0) | 2025.12.31 |
| [Advanced-3] 템플릿 메서드 패턴과 콜백 패턴 (0) | 2025.12.31 |
| [Advanced-2] 스레드 로컬 - ThreadLocal (0) | 2025.12.31 |
