스프링 AOP - 실무 주의사항
- 김영한님의 스프링 핵심 원리 - 고급편 강의를 바탕으로 스프링 AOP 적용 시 마주할 수 있는 프록시 내부 호출 한계점과 해결책, 그리고 프록시 적용 기술(JDK 동적 프록시, CGLIB)의 발전 과정 및 차이를 정리함
프록시와 내부 호출 - 문제
주요 원칙
- 스프링 AOP는 프록시 방식으로 동작함
-
AOP가 적용되려면 반드시 프록시를 통해 대상 객체(Target)를 호출해야 함
1
클라이언트 → 프록시(어드바이스 실행) → Target 호출
- 스프링은 AOP 적용 시 대상 객체 대신 프록시를 스프링 빈으로 등록하므로 의존관계 주입 시 항상 프록시가 주입됨
문제 발생 상황
-
대상 객체 내부에서 자신의 메서드를 호출할 때(
this.internal()) 프록시를 거치지 않고 실제 객체를 직접 호출하게 되어 AOP가 적용되지 않음
예제 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal();
}
public void internal() {
log.info("call internal");
}
}
1
2
3
4
5
6
7
8
9
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
실행 결과 차이
- 외부에서
external()호출external()은 적용되지만 내부의internal()은 미적용됨
- 외부에서
internal()직접 호출internal()이 적용됨
1 2 3
CallLogAspect : aop=void ...CallServiceV0.external() CallServiceV0 : call external CallServiceV0 : call internal
- AOP가 적용되지 않는 현상이 발생하면 가장 먼저 내부 호출 여부를 의심하는 것이 좋음
해결책 대안 1 - 자기 자신 주입
개념
-
자기 자신을 스프링 빈으로 수정자(Setter) 주입 받으면 주입된 객체는 프록시이므로 프록시를 통한 호출이 가능해짐
-
주의사항
- 생성자 주입은 불가능함 (순환 참조가 발생하기 때문임)

예제 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Slf4j
@Component
public class CallServiceV1 {
private CallServiceV1 callServiceV1;
@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
this.callServiceV1 = callServiceV1;
}
public void external() {
log.info("call external");
callServiceV1.internal();
}
public void internal() {
log.info("call internal");
}
}
스프링 부트 설정 주의사항
- 스프링 부트 2.6 이상부터 순환 참조가 기본 금지됨
-
application.properties에 아래 설정 추가가 필요함1
spring.main.allow-circular-references=true
해결책 대안 2 - 지연 조회
개념
ObjectProvider를 사용해 스프링 빈 조회 시점을 실제 사용 시점으로 지연시킴-
빈 생성 시점에 자기 자신을 주입받는 것이 아니므로 순환 참조가 발생하지 않음

예제 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {
private final ObjectProvider<CallServiceV2> callServiceProvider;
public void external() {
log.info("call external");
CallServiceV2 callServiceV2 = callServiceProvider.getObject();
callServiceV2.internal();
}
public void internal() {
log.info("call internal");
}
}
해결책 대안 3 - 구조 변경 (권장)
개념
- 내부 호출이 발생하지 않도록 클래스 자체를 분리하는 것이 가장 근본적인 해결책임
-
internal()로직을 별도 클래스(InternalService)로 분리하면 자연스럽게 프록시를 통한 호출이 됨
예제 코드
1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal();
}
}
1
2
3
4
5
6
7
8
@Slf4j
@Component
public class InternalService {
public void internal() {
log.info("call internal");
}
}
실행 결과
1
2
3
4
CallLogAspect : aop=void ...CallServiceV3.external()
CallServiceV3 : call external
CallLogAspect : aop=void ...InternalService.internal()
InternalService : call internal
- 권장 이유
- 설계 자체가 개선되며 AOP도 자연스럽게 적용되므로 가장 권장하는 방법임
- 참고 사항
- AOP는
public메서드 수준에 적용하는 것이 적합하며private메서드처럼 작은 단위에는 AOP를 적용하지 않음
- AOP는
프록시 기술과 한계 - 타입 캐스팅
JDK 동적 프록시와 CGLIB 비교
- JDK 동적 프록시 방식
- 인터페이스 기반으로 작동함
- 인터페이스가 반드시 필수적임
- 구체 클래스로의 캐스팅이 불가함
- 인터페이스로의 캐스팅은 가능함
- CGLIB 방식
- 구체 클래스 기반으로 작동함
- 인터페이스가 불필요함
- 구체 클래스로의 캐스팅이 가능함
- 인터페이스로의 캐스팅도 가능함
JDK 동적 프록시 구조와 캐스팅

CGLIB 프록시 구조와 캐스팅

예제 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void jdkProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false);
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
});
}
@Test
void cglibProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true);
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}
프록시 기술과 한계 - 의존관계 주입
- 타입 캐스팅 한계가 실제로 문제가 되는 상황은 의존관계 주입(DI) 시점임
JDK 동적 프록시 - DI 실패 케이스

1
2
@Autowired MemberService memberService;
@Autowired MemberServiceImpl memberServiceImpl;
-
오류 메시지
1 2
BeanNotOfRequiredTypeException: Bean named 'memberServiceImpl' is expected to be of type 'hello.aop.member.MemberServiceImpl' but was actually of type 'com.sun.proxy.$Proxy54'
CGLIB 프록시 - DI 성공 케이스

1
2
@Autowired MemberService memberService;
@Autowired MemberServiceImpl memberServiceImpl;
-
설계 원칙
- 올바른 설계에서는 구체 클래스가 아닌 인터페이스로 의존관계를 주입받아야 함
- 그러나 테스트 등 특수한 경우 구체 클래스 주입이 필요할 때는 CGLIB를 사용하면 됨
프록시 기술과 한계 - CGLIB 단점
-
CGLIB는 구체 클래스를 상속받아 프록시를 생성하므로 여러 제약이 발생함
- 기본 생성자 필수 제약
- 자식 객체 생성 시 부모 생성자가 호출되는 자바 상속 규약에 따라 자식 생성자에서
super()가 자동 호출되기 때문임
- 자식 객체 생성 시 부모 생성자가 호출되는 자바 상속 규약에 따라 자식 생성자에서
- 생성자 2번 호출 제약
- 실제 대상(
target) 객체 생성 시 1회, 프록시 생성 시 부모 생성자가 추가로 호출되어 총 2회 호출됨
- 실제 대상(
final클래스와 메서드 사용 불가 제약- 상속 및 오버라이딩이 불가능하여 프록시 생성 자체가 안됨
스프링의 해결책
-
스프링은 버전을 지속적으로 올리며 CGLIB의 단점들을 점진적으로 해결해왔음

스프링 부트 2.0 이후 기본 동작
1
spring.aop.proxy-target-class=true
objenesis 라이브러리가 해결한 문제들
objenesis라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 지원하여 문제점들을 해결했음- 기본 생성자 필수 문제
- 기본 생성자 없이도 프록시 객체 생성이 가능해짐
- 생성자 2번 호출 문제
- 프록시 생성 시 부모 생성자 호출 없이 구조를 생성함
- 기본 생성자 필수 문제
확인 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@SpringBootTest
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired MemberService memberService;
@Autowired MemberServiceImpl memberServiceImpl;
@Test
void go() {
log.info("memberService class={}", memberService.getClass());
log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
memberServiceImpl.hello("hello");
}
}
- 실행 결과
1 2
memberService class=class hello.aop.member.MemberServiceImpl$$EnhancerBySpringCGLIB$$83e257b3 memberServiceImpl class=class hello.aop.member.MemberServiceImpl$$EnhancerBySpringCGLIB$$83e257b3
- 위 결과를 통해 두 빈 모두 정상적으로 CGLIB 프록시로 적용되었음을 확인할 수 있음
연습 문제
-
Spring AOP는 애플리케이션의 런타임 비즈니스 로직 외에 모듈 전체에 걸쳐 등장하는 여러 부가 기능을 처리하도록 돕습니다. 로깅이나 재시도 같은 기능은 주로 어떤 부류의 로직으로 정의될까요?
a. 횡단 관심사
- AOP는 핵심 기능과 섞이기 쉬운 로깅, 보안, 재시도 등 여러 곳에 반복되는 기능을 깔끔하게 분리하고 모듈화함
- 이를 횡단 관심사라고 부름
-
대상 메서드가 시작되기 전에 특정 동작(예: 파라미터 로깅 등)을 수행하려 할 때, 가장 가볍게 사용할 수 있는 어드바이스 유형은 무엇일까요?
a. @Before
- 메서드 실행 전 시점에만 동작해야 한다면 가장 안전한 옵션인 @Before 어드바이스를 사용하는 것이 권장됨
-
예외가 발생했을 때 로직을 버리지 않고 다시 시도하는 ‘재시도 로직’ 개발 시 어떤 종류의 어드바이스가 필수적으로 사용되어야 할까요?
a. @Around
- 재시도 로직은 실행 흐름 전체를 가로채 진행을 막고 반복문 등을 통해 여러 번 시도할 통제 권한이 필요함
- 따라서 메서드 실행 자체를 제어할 수 있는 @Around만 사용 가능함
-
AOP를 활용하여 재시도 로직을 설계할 때 무한 루프 등 서비스 장애를 유발할 수 있어 코드 작성 시 반드시 고려되어야 하는 필수 구현 사항은 무엇일까요?
a. 최대 재시도 횟수 지정
- 재시도를 통제할 최대 횟수 상한선을 설정하지 않으면 네트워크나 시스템 통신 장애가 터졌을 때 트래픽이 폭주함
- 이로 인해 시스템 전체가 다운될 위험이 존재함
-
실무에서
@Transactional이나@Async등 스프링이 제공하는 편리한 선언적 기능들은 모두 어떤 핵심 기술 메커니즘을 기반으로 구현되어 있을까요?a. 스프링 동적 프록시와 AOP
- 스프링 프로그래밍 모델의 거의 모든 공통 처리는 전부 프록시를 통한 AOP 기반으로 작동함
- 이는 개발자가 비즈니스 로직에만 집중하게 만들어주는 핵심적인 원동력이 됨
요약 정리
- 프록시의 내부 호출 문제를 방지하려면
this등을 통해 빈의 인터널 메서드를 직접 호출하는 대신 구조를 개편하여 별도 컴포넌트로 분리 주입받아 사용하는 방식이 절대적으로 유리함 - 객체 지향 원칙에 다가가기 위해서는 AOP 프록시 기술 구현체(JDK 동적 프록시, CGLIB)의 차이에 의존하기보다는 철저하게 인터페이스 기반 설계를 먼저 수행하는 것이 추천됨
- 스프링 프레임워크와 스프링 부트의 지속적인 발전을 통해, 과거 CGLIB 기술의 고질적인 단점이었던 디폴트 생성자 필수 문제와 중복 초기화 문제 등이 해결되었음
- 특별한 경우가 아닌 한 CGLIB 기반의 단일 프록시 생성을 믿고 편하게 의존성 주입과 AOP 로직을 개발해도 무방함