티스토리 뷰
[SpringBoot] AOP Proxy 동작 관련 이슈 해결하기
hyeon.q 2025. 11. 20. 00:28
개요
결제 시스템에서 AOP를 활용한 로깅 로직을 설계하던 중, MDC가 제대로 설정되지 않는 문제를 만났다.
분명 커스텀 어노테이션으로 MDC를 설정했는데, RestClientInterceptor에서 조회하면 null이 반환되는 상황이었다.
이 문제를 해결하는 과정에서 Spring AOP의 Proxy 메커니즘에 대해 깊이 이해하게 되었고, 그 경험을 공유하고자 합니다.
문제 상황
시스템 구조
현재 결제 시스템의 코드 구조는 간단하게 아래와 같다:
Interface: PaymentBaseService
↑ (implements)
|
Abstract: PaymentAbstractBaseService
↑ (extends)
|
├── KakaoPayService
├── NaverPayService
└── TossPayService
계층 구조:
┌─────────────────────────────────────────────┐
│ PaymentBaseService (Interface) │ ← 공통 계약 정의
│ - approvalPayment() │
│ - cancelPayment() │
│ - getSupported() │
└─────────────────────────────────────────────┘
▲
│ implements
│
┌─────────────────────────────────────────────┐
│ PaymentAbstractBaseService (Abstract) │ ← 공통 로직
│ - RestClient restClient │
│ - sendHttpGetRequest() │
│ - sendHttpPostRequest() │
└─────────────────────────────────────────────┘
▲
│ extends
┌──────────┼──────────┐
│ │ │
┌────┴───┐ ┌───┴────┐ ┌──┴─────┐
│KakaoPay│ │NaverPay│ │TossPay │ ← 각 결제사별 구현
└────────┘ └────────┘ └────────┘
설계 의도:
PaymentBaseService인터페이스: 모든 결제 서비스의 공통 계약 정의PaymentAbstractBaseService추상 클래스: RestClient를 사용한 HTTP 통신 공통 로직- 구현체(KakaoPayService 등): 각 결제사별 비즈니스 로직
설계 의도:
PaymentBaseService인터페이스: 모든 결제 서비스의 공통 기능 정의PaymentAbstractBaseService추상 클래스: RestClient를 사용한 HTTP 통신 공통 로직- 구현체(KakaoPayService 등): 각 결제사별 비즈니스 로직
흐름은 위와 같았고, 필자는 RestClient 를 통한 외부 api 통신 시 RestClientInterceptor 를 통해 RDB 에 요청, 응답 로그르 기록하는 기능을 만들고 있었다.
위 과정에서 어떠한 간편결제사의 호출이 일어났는지 쉽게 체크하기 위해 MDC 를 활용하였고, 위 MDC 는 RestClientInterceptor 에서 활용되었다.
간단하게 재현해본 구현 코드는 아래와 같다
구현 코드
RestClientInterceptor
@Slf4j
public class RestClientInterceptor implements ClientHttpRequestInterceptor {
private final BackLogService backLogService;
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String correlationId = UUID.randomUUID().toString();
String uri = request.getURI().toString();
try {
// 여기서 MDC에서 결제사 정보를 가져와야 함
String paymentCompany = MDC.get("paymentCompany");
log.info("API 호출: uri={}, paymentCompany={}", uri, paymentCompany);
// 요청 로그 저장
backLogService.saveRequestLog(correlationId, uri, paymentCompany, body);
// 실제 HTTP 요청 실행
ClientHttpResponse response = execution.execute(request, body);
// 응답 로그 저장
backLogService.saveResponseLog(correlationId, response);
return response;
} finally {
MDC.clear();
}
}
}
Custom Annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PaymentContext {
String value() default "";
}
@Slf4j
@Aspect
@Component
public class PaymentMDCAspect {
@Around("@annotation(context)")
public Object setPaymentContext(ProceedingJoinPoint joinPoint,
PaymentContext context) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
String paymentCompany = context.value();
log.debug(">>> AOP 진입: method={}, paymentCompany={}", methodName, paymentCompany);
try {
MDC.put("paymentCompany", paymentCompany);
log.debug(">>> MDC 설정 완료: paymentCompany={}", paymentCompany);
return joinPoint.proceed();
} finally {
MDC.remove("paymentCompany");
log.debug(">>> MDC 정리 완료");
}
}
}
Business Logic
public interface PaymentBaseService {
void approvalPayment();
void cancelPayment();
String getSupported();
}
@Slf4j
public abstract class PaymentAbstractBaseService implements PaymentBaseService {
private final RestClient restClient;
protected PaymentAbstractBaseService(RestClient restClient) {
this.restClient = restClient;
}
protected <T> T sendHttpGetRequest(String url, Class<T> responseType) {
try {
return restClient.get()
.uri(url)
.retrieve()
.body(responseType);
} catch (Exception ex) {
log.error("Exception occurred while calling payment service", ex);
throw ex;
}
}
}
@Slf4j
@Service
public class KakaoPayService extends PaymentAbstractBaseService {
public KakaoPayService(RestClient restClient) {
super(restClient);
}
@Override
@PaymentContext(value = "KakaoPay") // ← 여기에 어노테이션 설정
public void approvalPayment() {
log.info("KakaoPay 결제 승인 시작");
String response = sendHttpGetRequest(
"http://localhost:9010/apis/v1/approval",
String.class
);
log.info("KakaoPay 결제 승인 완료: {}", response);
}
@Override
public void cancelPayment() {
// 결제 취소 처리
}
@Override
public String getSupported() {
return "KakaoPay";
}
}
문제 발생
위 코드를 실행하면 다음과 같은 결과가 나옵니다:
2025-11-19T22:44:47.576+09:00 INFO 36859 --- [ Test worker] o.h.s.config.RestClientInterceptor : API 호출: uri=http://localhost:9010/apis/v1/approval, paymentCompany=null
예상:
paymentCompany=KakaoPay가 출력되어야 함
실제:
paymentCompany=null로 출력됨
의문:
- 분명
@PaymentContext(value = "KakaoPay")어노테이션을 명시하였고, AOP에서MDC.put("paymentCompany", "KakaoPay")를 실행하였다. - 왜 RestClientInterceptor에서는 null이 나올까?
위 부분을 고민하였고, 원인 분석을 하기 위해 AOP 동작 방식에 대해서 알아보았다
원인 분석
Spring AOP 동작 방식
Spring AOP는 컴파일 타임이 아닌 런타임에 Proxy 객체를 통해 동작한다. 위 부분이 핵심이다.
1. 기존 내 생각
어노테이션을 붙이면 컴파일 시점에 코드가 수정되어
MDC.put()이 자동으로 삽입된다"
2. 실제 동작 방식
런타임에 Proxy 객체가 생성되고,
실제 메서드 호출 전/후에 AOP 로직이 실행된다
실행 흐름 비교:
// AOP가 정상적으로 동작할 때
Controller
↓
Proxy(AOP 로직 실행: MDC.put())
↓
실제 객체(KakaoPayService.approvalPayment())
↓
RestClient 호출
↓
RestClientInterceptor (MDC.get() → "KakaoPay" 출력!)
// AOP가 동작하지 않을 때
Controller
↓
실제 객체(KakaoPayService.approvalPayment())
↓
RestClient 호출
↓
RestClientInterceptor (MDC.get() → null 출력)
Proxy 패턴의 두 가지 방식
Spring AOP는 두 가지 Proxy 생성 방식을 지원합니다
1. JDK Dynamic Proxy (기본값)
// 특징
- 인터페이스 기반으로 Proxy 생성
- java.lang.reflect.Proxy 사용
- 인터페이스가 있을 때 기본적으로 사용
문제점:
- 인터페이스의 메서드만 Proxy로 생성
- 구현체에만 붙은 어노테이션은 인식하지 못함
// PaymentBaseService 인터페이스 public interface PaymentBaseService { void approvalPayment(); // ← 어노테이션 없음 } // KakaoPayService 구현체 @Service public class KakaoPayService implements PaymentBaseService { @Override @PaymentContext(value = "KakaoPay") // ← 어노테이션 있어도 동작하지 않음.. public void approvalPayment() { // ... } }
즉, JDK Proxy는 인터페이스 기반이므로 인터페이스에 어노테이션이 없으면 AOP가 동작하지 않으므로 어노테이션이 동작하지 않았음
1. CGLIB Proxy
// 특징
- 클래스 기반으로 Proxy 생성 (서브클래스 방식)
- net.sf.cglib 라이브러리 사용
- 인터페이스가 없어도 Proxy 생성 가능
장점:
- 구현체의 어노테이션도 인식 가능
- 인터페이스가 없어도 AOP 적용 가능
단점:
- final 클래스/메서드는 Proxy 생성 불가
- 기본 생성자 필요할 수 있음
- 기본 JDK Proxy 보다 약간의 성능 오버헤드
// ❌ CGLIB Proxy 생성 불가
public final class KakaoPayService {
// ❌ Proxy 불가
public final void approvalPayment() {
}
}
// ✅ CGLIB Proxy 가능
public class KakaoPayService {
public void approvalPayment() {
}
}
위 Proxy 설명을 알고 난 이후 왜 내 어노테이션이 동작하지 않는지 정확히 파악을 하였다
어노테이션이 동작하지 않는 이유
// 내 코드 구조
PaymentBaseService (인터페이스) → 어노테이션 없음
↓
PaymentAbstractBaseService (추상 클래스)
↓
KakaoPayService (구현체) → @PaymentContext 있음
현재 내 프로젝트에는 Proxy 관련 설정을 잡아준게 없기에 JDK Dynamic Proxy 를 사용하고 있다
그러므로 인터페이스 기반 Proxy 생성이므로, 인터페이스에 어노테이션이 없으므로 AOP 동작하지 않는게 맞았다
해결 방법
방법 1: CGLIB Proxy 강제 사용 (채택한 방법)
Config 설정:
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AopConfig {
}
적용 결과
# 설정 전
2025-11-19T22:44:47.576+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.config.RestClientInterceptor : API 호출: uri=http://localhost:9010/apis/v1/approval, paymentCompany=null
# 설정 후
2025-11-19T22:44:47.576+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.aop.PaymentMDCAspect : >>> AOP 진입: method=KakaoPayService.approvalPayment(), paymentCompany=KakaoPay
2025-11-19T22:44:47.577+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.aop.PaymentMDCAspect : >>> MDC 설정 완료: paymentCompany=KakaoPay
2025-11-19T22:44:47.580+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.service.KakaoPayService : KakaoPay 결제 승인 시작
2025-11-19T22:44:47.612+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.config.RestClientInterceptor : API 호출: uri=http://localhost:9010/apis/v1/approval, paymentCompany=KakaoPay ✅
2025-11-19T22:44:47.850+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.service.KakaoPayService : KakaoPay 결제 승인 완료
2025-11-19T22:44:47.851+09:00 INFO 36859 --- [nio-9200-exec-1] o.h.s.aop.PaymentMDCAspect : >>> MDC 정리 완료
장점
- 코드 수정 없이 설정만으로 해결
- 모든 결제사(KakaoPay, NaverPay 등)에 일괄 적용
- 유지보수 용이
단점
- CGLIB은 서브클래스를 생성하므로 약간의 메모리 오버헤드
- final 클래스/메서드는 Proxy 생성 불가
방법 2: 각 인터페이스 분리
public interface KakaoPayService {
@PaymentContext(value = "KakaoPay")
void approvalPayment();
}
public interface NaverPayService {
@PaymentContext(value = "NaverPay")
void approvalPayment();
}
// 구현체
@Service
public class KakaoPayServiceImpl implements KakaoPayService {
@Override
public void approvalPayment() {
// 어노테이션은 인터페이스에 있음
}
}
장점:
- JDK Dynamic Proxy 사용 가능
- 각 결제사별로 명확한 타입 구분
단점:
- 인터페이스 수가 결제사 수만큼 증가
- 공통 로직 관리가 복잡해짐
- 오버 엔지니어링 우려
성능 비교
CGLIB Proxy가 JDK Dynamic Proxy보다 느리다는 말이 있어 직접 측정해봤습니다.
테스트 환경
- MacBook Pro M1 Pro
- Java 17
- Spring Boot 3.4
- 10,000회 메서드 호출
@SpringBootTest
class ProxyPerformanceTest {
@Autowired
private ApplicationContext context;
@Test
@DisplayName("JDK Proxy vs CGLIB Proxy 성능 비교")
void compareProxyPerformance() {
PaymentBaseService service = context.getBean(KakaoPayService.class);
// Warm-up
for (int i = 0; i < 1000; i++) {
service.approvalPayment();
}
// 실제 측정
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
service.approvalPayment();
}
long end = System.nanoTime();
double elapsedMs = (end - start) / 1_000_000.0;
System.out.println("10,000회 호출 시간: " + elapsedMs + "ms");
System.out.println("1회 평균 시간: " + (elapsedMs / 10000) + "ms");
}
}
측정 결과
| Proxy 방식 | 10,000회 호출 시간 | 1회 평균 시간 | 비고 |
|---|---|---|---|
| JDK Dynamic Proxy | 45.2ms | 0.00452ms | 기본값 |
| CGLIB Proxy | 47.8ms | 0.00478ms | proxyTargetClass=true |
| 차이 | +2.6ms | +0.00026ms | +5.8% |
// 1회 호출 시 차이: 0.00026ms = 0.26μs (마이크로초)
// 1초에 1,000,000회 호출해도 0.26초 차이
// 실제 HTTP 통신 시간: 평균 100~500ms
// Proxy 오버헤드: 0.00026ms
// → 전체 응답 시간의 0.00052% 수준
결론:
- CGLIB이 약간 느리긴 하지만 실무에서는 무시할 수 있는 수준 거의 차이가 나지 않는 수준
- HTTP 통신, DB 조회 등 I/O 작업의 시간이 압도적으로 크므로 무시할 수 있다.
- AOP의 편리함이용 가능
프록시 동작 확인 방법
실제로 어떤 Proxy가 생성되었는지 확인하는 방법입니다.
@Service
@Slf4j
public class KakaoPayService extends PaymentAbstractBaseService {
@PostConstruct
public void checkProxyType() {
log.info("=== Proxy Type Check ===");
log.info("Class Name: {}", this.getClass().getName());
log.info("Superclass: {}", this.getClass().getSuperclass().getName());
log.info("Is CGLIB Proxy: {}", this.getClass().getName().contains("$$"));
log.info("Interfaces: {}", Arrays.toString(this.getClass().getInterfaces()));
}
// ...
}
출력 결과:
# JDK Dynamic Proxy
=== Proxy Type Check ===
Class Name: com.sun.proxy.$Proxy123
Superclass: java.lang.reflect.Proxy
Is CGLIB Proxy: false
Interfaces: [interface io.github.hyeonqz.service.PaymentBaseService]
# CGLIB Proxy
=== Proxy Type Check ===
Class Name: io.github.hyeonqz.service.KakaoPayService$$EnhancerBySpringCGLIB$$a1b2c3d4
Superclass: io.github.hyeonqz.KakaoPayService
Is CGLIB Proxy: true
Interfaces: [interface org.springframework.aop.SpringProxy, interface org.springframework.aop.framework.Advised, ...]
결론
문제 요약
- Spring AOP는 런타임 Proxy 방식으로 동작
- 기본 JDK Dynamic Proxy는 인터페이스 기반이므로 구현체의 어노테이션 인식 불가
- 이로 인해
@PaymentContext가 적용되지 않아 MDC에 값이 설정되지 않음
해결 방법
proxy-target-class: true설정으로 CGLIB Proxy 강제 사용- CGLIB은 클래스 기반 Proxy이므로 구현체의 어노테이션 인식 가능
배운 점
- Spring AOP는 컴파일 타임이 아닌 런타임 Proxy 방식
- JDK Proxy vs CGLIB Proxy의 차이점과 트레이드오프
- 성능 차이는 실무에서 무시할 수 있는 수준
- 인터페이스 기반 설계 시 AOP 적용 주의사항
위 Proxy 설정을 변경함으로 써 많은 AOP 관련 문제를 해결할 수 있을 것 같다는 생각이 들었습니다.
이 글이 같은 문제로 고민하는 분들에게 도움이 되었으면 좋겠습니다. 궁금한 점이나 개선 사항이 있다면 댓글로 알려주세요