Spring으로 실무를 하다 보면 @Transactional이 걸린 메서드가 이상하게 동작하지 않거나, @Async를 붙였는데 같은 스레드에서 실행되거나, @Cacheable이 캐시를 태우지 않고 무한히 원본 메서드를 호출하는 상황을 만나게 된다. 대부분의 원인은 코드가 아니라 프록시(proxy) 메커니즘에 있다. Spring의 트랜잭션, 비동기, 캐시, 시큐리티, 아키텍처 레벨의 모든 "AOP 스러운 마법"은 예외 없이 프록시 위에서 돌아간다.
시니어 백엔드 개발자에게 "Spring AOP가 내부적으로 어떻게 동작하나요?"라는 질문은 단순히 @Aspect 문법을 아는지 묻는 질문이 아니다. 이 질문은 다음을 확인하려는 질문이다.
이 문서는 위 질문에 "네, 알고 있습니다"라고 대답할 수 있게 만드는 것을 목표로 한다. 단순 사용법이 아니라 왜 그렇게 동작하는지를 밑바닥에서부터 쌓아 올린다.
OOP는 공통 기능을 수직 방향으로 재사용하기 좋다. 상속, 합성, 인터페이스를 통해 공통 로직을 묶을 수 있다. 그러나 실제 시스템에는 수평 방향으로 여러 계층을 가로지르는 관심사가 존재한다.
이런 관심사는 "모든 서비스 계층 public 메서드에 동일하게 적용"되는 성격을 가진다. OOP만 쓰면 이 로직이 모든 메서드에 다음처럼 흩어진다.
public Order placeOrder(OrderCommand cmd) {
long start = System.currentTimeMillis();
try {
transactionManager.begin();
securityChecker.check(cmd);
// 실제 비즈니스 로직
Order order = ...;
transactionManager.commit();
return order;
} catch (Exception e) {
transactionManager.rollback();
throw e;
} finally {
log.info("elapsed={}", System.currentTimeMillis() - start);
}
}
템플릿 메서드나 데코레이터로 줄일 수는 있지만, 새로운 횡단 관심사(cross-cutting concern) 가 추가될 때마다 전 계층을 수정해야 한다는 근본 문제가 남는다. AOP는 이 횡단 관심사를 핵심 로직과 분리해서 선언적으로 적용하기 위해 나온 패러다임이다.
AOP의 용어는 실무에서 혼용되기 쉽기 때문에 먼저 또렷하게 정의하자.
execution(* com.acme.service..*.*(..)) 같은 표현식으로 정의한다.@Before, @After, @AfterReturning, @AfterThrowing, @Around 다섯 종류가 있다.@Aspect 붙은 클래스 자체.이 중 Spring AOP가 택한 위빙 전략이 바로 런타임 프록시 기반 위빙이다. 이게 AspectJ와의 근본적인 차이다.
AspectJ는 바이트코드를 직접 수정한다. 컴파일러를 바꿔치기하거나(compile-time weaving), 클래스 로딩 시점에 바이트코드를 주입한다(load-time weaving). 이 덕분에 AspectJ는 필드 접근, 생성자 호출, private 메서드, static 메서드, self-invocation, final 메서드까지 전부 가로챌 수 있다. 대신 빌드 툴체인과 에이전트 설정이 까다롭다.
Spring은 "평범한 Java, 평범한 빌드, 평범한 실행"을 목표로 한다. 별도 에이전트나 특수 컴파일러 없이 순수 Java 런타임에서 동작해야 한다. 그래서 Spring은 아래 전략을 택했다.
이 선택에는 장점과 대가가 따른다.
장점
대가
이 대가들이 실무에서 끊임없이 "왜 내 @Transactional이 안 먹지?"라는 질문을 만들어낸다.
JDK Dynamic Proxy는 JDK 1.3부터 포함된 표준 기능이며, 핵심 구성요소는 두 가지다.
java.lang.reflect.Proxy — 프록시 클래스를 런타임에 생성하는 팩토리java.lang.reflect.InvocationHandler — 프록시 메서드 호출이 들어왔을 때 실행될 단일 진입점핵심 제약: 인터페이스가 있어야 한다. JDK Proxy는 지정된 인터페이스들을 implements하는 새로운 클래스를 런타임에 만들어낸다. 대상 객체의 구체 타입은 상관없다. 대신 주입받는 쪽도 반드시 인터페이스 타입으로 받아야 한다.
public interface OrderService {
Order placeOrder(OrderCommand cmd);
}
public class OrderServiceImpl implements OrderService {
@Override
public Order placeOrder(OrderCommand cmd) {
return new Order(cmd.userId(), cmd.amount());
}
}
public class LoggingInvocationHandler implements InvocationHandler {
private final Object target;
public LoggingInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.nanoTime();
try {
return method.invoke(target, args);
} finally {
long elapsed = System.nanoTime() - start;
System.out.printf("[LOG] %s took %d ns%n", method.getName(), elapsed);
}
}
}
public class Demo {
public static void main(String[] args) {
OrderService target = new OrderServiceImpl();
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class<?>[] { OrderService.class },
new LoggingInvocationHandler(target)
);
proxy.placeOrder(new OrderCommand("u1", 1000));
}
}
이 코드의 흐름은 다음과 같다.
Proxy.newProxyInstance가 런타임에 com.sun.proxy.$Proxy0 같은 동적 클래스를 만든다. 이 클래스는 OrderService를 implements한다.placeOrder 구현은 실제로 InvocationHandler.invoke(...)를 호출하도록 되어 있다.LoggingInvocationHandler.invoke가 호출되면서 로그를 남기고, method.invoke(target, args)로 진짜 구현체를 호출한다.즉 JDK Proxy는 "인터페이스의 모든 메서드를 단일 invoke로 몰아주는 구조" 다. 이 때문에 JDK Proxy의 제약이 자연스럽게 나온다.
Proxy.newProxyInstance는 인터페이스 배열을 요구한다.method.invoke 호출이라 순수 호출보다 오버헤드가 있다. 현대 JVM에서는 JIT으로 상당 부분 사라지지만 0은 아니다.대상 객체가 인터페이스를 구현하지 않은 순수 클래스라면 JDK Proxy를 쓸 수 없다. 이때 등장하는 것이 CGLIB다. CGLIB는 ASM 위에서 동작하는 바이트코드 조작 라이브러리로, 대상 클래스를 상속받는 서브클래스를 런타임에 만든다.
OrderServiceImpl (target class)
▲
│ extends
OrderServiceImpl$$EnhancerByCGLIB$$abc123 (proxy subclass)
이 서브클래스는 원본 클래스의 모든 public/protected 메서드를 오버라이드하고, 오버라이드된 메서드 안에서 MethodInterceptor를 호출한다.
public class LoggingInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
throws Throwable {
long start = System.nanoTime();
try {
// super 호출과 동일. JDK Proxy의 method.invoke(target,...)과 대비
return proxy.invokeSuper(obj, args);
} finally {
System.out.printf("[CGLIB] %s took %d ns%n",
method.getName(), System.nanoTime() - start);
}
}
}
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderServiceImpl.class);
enhancer.setCallback(new LoggingInterceptor());
OrderServiceImpl proxy = (OrderServiceImpl) enhancer.create();
proxy.placeOrder(new OrderCommand("u1", 1000));
CGLIB의 특성이 여기서 드러난다.
final 클래스는 상속 불가 → 프록시 불가, final 메서드는 오버라이드 불가 → Advice가 걸리지 않는다.private / package-private 메서드는 오버라이드 의미론적으로 가로챌 수 없다. Spring AOP도 기본적으로 public 메서드에만 적용된다고 말하는 이유가 이것이다.objenesis를 이용해 생성자를 우회하는 전략을 쓰기도 한다.CGLIB는 오래된 라이브러리이고, 오랫동안 거의 유지보수 상태가 아니었다. JDK 9 이후의 모듈 시스템, 불투명한 sun.misc.Unsafe, JDK 17+의 강화된 접근 제어 등 JVM 내부 변화에 취약했다. CGLIB가 패치를 따라가지 못하는 사이에 등장한 현대적 대안이 ByteBuddy다.
java.lang.instrument) 작성의 사실상 표준.정리하면, 우리가 면접에서 "CGLIB으로 프록시를 만듭니다"라고 답해도 개념적으로는 맞지만, 현대 Spring 내부에서는 "서브클래싱 프록시를 ByteBuddy 기반으로 생성한다" 가 더 정확하다.
Spring의 기본 선택 규칙은 대략 이렇다.
spring.aop.proxy-target-class=true 이거나 @EnableAspectJAutoProxy(proxyTargetClass=true)이면 → 항상 CGLIB(서브클래싱 프록시).Spring Boot 2.x 이후 기본값은 proxyTargetClass=true 다. 즉 요즘 Spring Boot 프로젝트는 인터페이스 유무와 무관하게 사실상 CGLIB 기반 프록시가 쓰인다. 이것 덕분에 "인터페이스를 분리하지 않은 @Service 클래스"에 @Transactional을 붙여도 동작한다.
실무에서 기억할 포인트는 다음과 같다.
final 메서드에 @Transactional 붙이면 조용히 Advice가 사라진다. 컴파일 에러도 안 난다.final이라 open을 붙이거나 kotlin-spring 플러그인을 써야 한다. (현재 우리는 Java 트랙이지만 알아두면 좋다.)@Transactional이 동작하지 않는가이것이 Spring AOP 관련 질문에서 가장 자주 나오는 함정이다.
@Service
public class OrderService {
public void placeOrder(OrderCommand cmd) {
validate(cmd);
save(cmd);
}
@Transactional
public void save(OrderCommand cmd) {
orderRepository.save(toEntity(cmd));
}
}
개발자는 "save에 트랜잭션이 걸리길" 기대한다. 하지만 외부에서 placeOrder를 호출하면 트랜잭션은 걸리지 않는다. 왜 그런가?
호출 스택을 보자.
caller -> proxy.placeOrder(cmd)
-> target.placeOrder(cmd) // 여기까지는 프록시 통과
-> this.save(cmd) // 'this'는 target 자신. 프록시가 아님!
-> orderRepository.save(...) // 트랜잭션 없음
placeOrder 내부의 this.save(cmd) 호출은 Java의 일반 메서드 호출이다. this는 프록시가 아니라 원본 OrderServiceImpl 인스턴스다. 프록시를 우회했으니 Advice(트랜잭션 시작)가 끼어들 틈이 없다.
같은 원리로 다음이 전부 조용히 실패한다.
@Transactional이 self-invocation으로 호출되어 트랜잭션이 안 열림@Async가 self-invocation으로 호출되어 같은 스레드에서 동기 실행됨@Cacheable이 self-invocation으로 호출되어 캐시가 전혀 타지 않음분리 (권장) 횡단 관심사가 걸린 메서드를 다른 빈으로 뽑아낸다. 그러면 호출이 "다른 빈의 프록시"를 거치게 된다.
@Service
@RequiredArgsConstructor
public class OrderFacade {
private final OrderTxService txService;
public void placeOrder(OrderCommand cmd) {
validate(cmd);
txService.save(cmd); // 다른 빈의 프록시를 탄다
}
}
AopContext.currentProxy() + exposeProxy=true
프록시 자신을 노출시키고 자기 자신을 프록시로 호출한다. 동작은 하지만 코드 가독성이 떨어져 최후의 수단으로만 쓴다.
@EnableAspectJAutoProxy(exposeProxy = true)
...
((OrderService) AopContext.currentProxy()).save(cmd);
AspectJ 위빙으로 전환
바이트코드 위빙이라 self-invocation도 잡힌다. 대신 spring-aspects, AspectJ 컴파일/에이전트 설정이 필요해 운영 복잡도가 크게 올라간다.
실무 99%는 "그냥 빈을 분리한다" 가 정답이다.
@Order로 통제. 기본 순서는 보장되지 않으니 명시해야 한다.Spring Boot에서 다음 코드를 디버깅해보면 실제 스택이 어떻게 보이는지 감을 잡을 수 있다.
@Service
public class PaymentService {
@Transactional
public void pay(PayCommand cmd) {
// breakpoint here
}
}
디버거로 pay 메서드 진입 지점에서 멈추면 스택은 보통 이렇게 생긴다.
PaymentService.pay(PayCommand) // ← 내 코드
PaymentService$$SpringCGLIB$$0.pay(PayCommand) // ← 프록시 서브클래스
CglibAopProxy$DynamicAdvisedInterceptor.intercept(...)
ReflectiveMethodInvocation.proceed()
TransactionInterceptor.invoke(MethodInvocation)
TransactionAspectSupport.invokeWithinTransaction(...)
ReflectiveMethodInvocation.proceed()
CglibAopProxy$DynamicAdvisedInterceptor.intercept(...)
...
PaymentController.pay(...)
읽는 법은 다음과 같다.
paymentService.pay(...)를 호출하면 실제로는 CGLIB 프록시의 pay 가 먼저 호출된다.DynamicAdvisedInterceptor.intercept(...)를 거쳐 Advisor 체인을 구성한다.ReflectiveMethodInvocation.proceed()를 통해 한 단계씩 앞으로 나아간다.TransactionInterceptor가 트랜잭션을 시작하고, 체인의 끝에서 진짜 PaymentService.pay 가 호출된다.이 스택을 한 번이라도 본 개발자와 안 본 개발자는 @Transactional 버그를 만났을 때 대응 속도가 완전히 다르다.
성능
equals, hashCode, toString에 영향을 줄 수 있다. JDK Proxy는 인터페이스에 없는 이 메서드들의 의미론이 살짝 달라진다.디버깅
EnhancerBySpringCGLIB, $$SpringCGLIB$$, $Proxy 같은 이름이 보이면 프록시를 타고 있다는 증거다.service.getClass() 를 찍어보면 프록시 타입인지 원본인지 즉시 확인할 수 있다.테스트
@Transactional 같은 AOP 동작은 단위 테스트에서 보장되지 않는다.mockito-inline, ByteBuddy agent)이 없으면 모킹되지 않는다. 이것도 결국 프록시(서브클래싱) 한계의 연장선이다.Bad: self-invocation으로 트랜잭션 누락
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final MailClient mailClient;
public void signUp(SignUpCommand cmd) {
saveUser(cmd); // ← @Transactional이 걸리지 않는다
mailClient.sendWelcome(cmd.email());
}
@Transactional
public void saveUser(SignUpCommand cmd) {
userRepository.save(new User(cmd));
}
}
문제:
signUp 내부에서 this.saveUser를 호출 → 프록시 우회 → 트랜잭션 안 열림.userRepository.save가 JPA라면 EntityManager가 기대했던 트랜잭션 범위 밖에서 동작해 TransactionRequiredException 혹은 자동 커밋 모드 이슈로 이어진다.Improved: 책임 분리로 프록시 경계를 확보
@Service
@RequiredArgsConstructor
public class UserSignUpFacade {
private final UserRegistrationService registrationService;
private final MailClient mailClient;
public void signUp(SignUpCommand cmd) {
registrationService.register(cmd);
mailClient.sendWelcome(cmd.email());
}
}
@Service
@RequiredArgsConstructor
public class UserRegistrationService {
private final UserRepository userRepository;
@Transactional
public void register(SignUpCommand cmd) {
userRepository.save(new User(cmd));
}
}
개선 포인트:
signUp → registrationService.register는 다른 빈의 프록시 호출 → 트랜잭션 정상 진입.@TransactionalEventListener 활용.)최소 환경:
build.gradle 핵심:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-aop'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
application.yml:
spring:
datasource:
url: jdbc:h2:mem:demo;MODE=MySQL
jpa:
hibernate.ddl-auto: create-drop
show-sql: true
logging:
level:
org.springframework.transaction: TRACE
org.springframework.aop: DEBUG
TRACE 로그를 켜두면 트랜잭션 Advisor가 언제 시작·커밋·롤백되는지 콘솔에 그대로 찍힌다. self-invocation으로 트랜잭션이 누락되면 아예 로그가 안 찍힌다. 이게 가장 빠른 진단 방법이다.
@Aspect
@Component
public class TimingAspect {
private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);
@Around("execution(public * com.example.demo.service..*(..))")
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long elapsedUs = (System.nanoTime() - start) / 1_000;
log.info("[TIMING] {}.{} took {} us",
pjp.getSignature().getDeclaringType().getSimpleName(),
pjp.getSignature().getName(),
elapsedUs);
}
}
}
확인 포인트:
service..*(..) 표현식으로 service 패키지의 모든 public 메서드를 대상으로 함.ProceedingJoinPoint.proceed()를 호출해야 원본 메서드가 실행된다. 빼먹으면 비즈니스 로직이 증발한다 — 실제 운영 장애로 보고된 적이 여러 번 있는 흔한 실수다.1분 답변 (엘리베이터 버전)
Spring AOP는 런타임 프록시 기반입니다. 빈이 생성될 때 대상 객체 대신 프록시 객체가 컨테이너에 등록되고, 이 프록시가 Advice 체인을 먼저 실행한 뒤 원본 메서드를 호출합니다. 인터페이스가 있으면 JDK Dynamic Proxy, 없거나
proxyTargetClass=true면 CGLIB 서브클래싱 프록시가 만들어집니다. Spring Boot 기본값은 CGLIB 쪽입니다. 프록시를 거치지 않는 호출, 즉 같은 객체의 내부 메서드 호출(self-invocation)에는 Advice가 걸리지 않아서@Transactional,@Async,@Cacheable이 조용히 안 먹는 현상이 자주 발생합니다. 해결은 보통 해당 메서드를 다른 빈으로 분리합니다.
3분 답변 (심화 버전)
Spring AOP를 이해하려면 세 가지 레이어를 구분해야 합니다. 첫째, 개념 레이어: Pointcut이 JoinPoint를 고르고, 거기에 Advice를 끼워 넣는 것이 AOP의 본질입니다. 둘째, 구현 레이어: AspectJ는 바이트코드 위빙으로 해결하는 데 반해 Spring은 순수 Java로 동작하는 런타임 프록시를 택했습니다. 셋째, 프록시 생성 레이어: 인터페이스가 있으면 JDK Dynamic Proxy를 써서
InvocationHandler기반으로 단일 진입점을 통해 호출을 위임하고, 인터페이스가 없거나proxyTargetClass=true면 서브클래싱 기반 프록시를 씁니다. 과거에는 CGLIB이었고 최근 Spring에서는 ByteBuddy 기반으로 현대화되었습니다.이 선택 때문에 실무에서 중요한 함정들이 따라옵니다. CGLIB은 서브클래싱이라
final클래스/메서드에 Advice를 걸 수 없고, JDK Proxy는 인터페이스에 선언되지 않은 메서드를 가로챌 수 없습니다. 가장 큰 이슈는 self-invocation입니다. 같은 클래스 안에서this.someMethod()를 호출하면 프록시를 우회하기 때문에 트랜잭션, 비동기, 캐시가 전부 동작하지 않습니다. 해결책은 우선순위 순으로 (1) 해당 메서드를 별도 빈으로 분리, (2)exposeProxy=true와AopContext.currentProxy(), (3) AspectJ 위빙 도입입니다. 대부분 (1)로 해결합니다.디버깅 측면에서는 스택 트레이스에
SpringCGLIB,DynamicAdvisedInterceptor,ReflectiveMethodInvocation.proceed같은 프레임이 보이면 프록시를 제대로 타고 있다는 신호입니다. 반대로@Transactional을 걸었는데 로그 레벨을 TRACE로 올려도 트랜잭션 시작 로그가 안 찍히면 self-invocation이나 final 메서드를 의심합니다. 저는 이 원리를 기반으로 팀에 "AOP가 걸리는 메서드는 반드시 프록시 경계를 넘어서 호출되어야 한다"는 규약을 정해두고 리뷰에서 체크하는 편입니다.
학습 완료 기준으로 자신에게 묻는다. 모두 Yes여야 한다.
final에 약한지, 생성자가 한 번 더 호출될 수 있는지 설명할 수 있다.@Transactional, @Async, @Cacheable이 동작하지 않는 증상을 보고 프록시 함정을 가장 먼저 의심할 수 있다.@Around Aspect를 직접 작성해서 걸고, 걸리지 않는 케이스를 재현할 수 있다.이 체크리스트를 전부 통과하면 "Spring AOP는 내부적으로 프록시로 동작합니다" 수준의 대답에서 벗어나, "프록시 생성 전략과 호출 스택 관점에서 AOP의 범위와 한계를 지배하는 개발자" 로 면접관에게 읽힌다. 이 지점이 시니어 백엔드 면접에서 기대되는 깊이다.