-
[Spring] 스프링 핵심 원리 - 스프링 AOP 주의사항Study/Spring 2025. 5. 7. 06:47
프록시와 내부 호출 - 문제
- 개요
- 스프링은 프록시 방식의 AOP를 사용함
- AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)를 호출해야 함
- 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않으므로 Advice(부가 기능)도 호출되지 않음
- 일반적인 상황
- AOP를 적용하면 스프링은 대상 객체 대신에 프록시 객체를 스프링 빈으로 등록함
- 따라서 스프링은 의존관계 주입 시 항상 프록시 객체를 주입함
- 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않음
- 문제 상황
- 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생함
- 프록시 방식의 AOP 한계 : 메서드 내부 호출에 프록시를 적용할 수 없음
프록시와 내부 호출 - 대안
1. 자기 자신 주입
- 내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것
- 생성자 주입 방식은 순환 사이클을 만들기 때문에 사용 불가하므로 setter 주입 사용해야 함
예시)
더보기import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; @Slf4j @Aspect public class CallLogAspect { @Before("execution(* com.example.demo.proxy.internalcall..*(..))") public void doLog(JoinPoint joinPoint) { log.info("app={}", joinPoint.getSignature()); } }
- 아래 CallServiceV1에 AOP를 적용하기 위해 Aspect 정의함
import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @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(); // 외부 메서드 호출로 변경 } private void internal() { log.info("call internal"); } }
- 자기자신을 의존관계 주입으로 받는 경우 생성자 주입은 순환 사이클을 만들기 때문에 사용할 수 없음 (위에서는 setter 주입 대신 사용)
- 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체임
- 위에서 수정자를 통해 주입받은 callServiceV1은 프록시이므로 AOP를 적용할 수 있음
2. 지연 조회
- 스프링 빈을 지연해서 조회하는 방식으로, ObjectProvider 혹은 ApplicationContext를 사용하면 됨
- ObjectProvider는 스프링 컨테이너에서 객체를 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있음
예시)
더보기아래 CallServiceV2에 AOP를 적용하기 위해 Aspect 정의한 상태라고 가정
import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor public class CallServiceV2 { // private final ApplicationContext applicationContext; private final ObjectProvider<CallServiceV2> callServiceProvider; public void external() { log.info("call external"); // CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class); CallServiceV2 serviceV2 = callServiceProvider.getObject(); serviceV2.internal(); // 외부 메서드 호출 } private void internal() { log.info("call internal"); } }
- `callServiceProvider.getObject()`를 호출하는 시점에 스프링 컨테이너에서 빈을 조회함 (프록시 객체가 반환됨)
3. 구조 변경
- 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것 (실제 이 방법을 가장 권장함)
- 구조를 변경하는 것은 클래스 분리말고도 다양한 방법들이 있을 수 있음
예시)
더보기import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor public class CallServiceV3 { private final InternalService internalService; public void external() { log.info("call external"); internalService.internal(); // 외부 메서드 호출 } }
- 내부 호출을 InternalService라는 별도의 클래스로 분리함
프록시 기술과 한계
JDK 동적 프록시
- 구체 클래스로 타입 캐스팅 불가
- 인터페이스를 기반으로 프록시를 생성하기 때문 (인터페이스를 구현하여 프록시 생성)
예시)
더보기- 인터페이스를 구현하여 프록시를 생성하므로 인터페이스의 구체 클래스로 타입 캐스팅 불가
- 구체 클래스를 상속하여 프록시를 생성하므로 인터페이스와 구체 클래스로 타입 캐스팅 가능
CGLIB
- 구체 클래스를 상속받기 때문에 다음과 같은 문제가 있음
- 대상 클래스에 기본 생성자 필수
- 대상 클래스의 생성자 2번 호출
- final 키워드 클래스, 메서드 사용 불가
예시)
더보기대상 클래스에 기본 생성자 필수
- 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 함
- 이 부분이 코드상 생략되어 있다면 컴파일 시 컴파일러가 자식 클래스의 생성자 첫 줄에 부모 클래스의 기본 생성자를 호출하는 super()가 자동으로 추가함 (이것은 자바 문법 규약)
- CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출함. 따라서 대상 클래스에 기본 생성자를 만들어야 함
- 기본 생성자는 파라미터가 하나도 없는 생성자
- 생성자가 하나도 없으면 자동으로 만들어짐
대상 클래스의 생성자 2번 호출 문제
- 아래 상황으로 생성자가 2번 호출됨
- 실제 target의 객체를 생성할 때
- 프록시 객체를 생성할 때 부모 클래스의 생성자 호출
final 키워드 클래스, 메서드 사용 불가
- final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩 불가능함
- CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않음
프록시 기술과 한계 - 스프링의 해결책
스프링의 기술 선택 변화
- 스프링 3.2부터 CGLIB를 스프링 내부에 함께 패키징
- 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별도의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 됨
- CGLIB 기본 생성자 필수 문제 해결
- 스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결됨
- objenesis라는 특별한 라이브러리를 사용해 기본 생성자 없이 객체 생성이 가능해짐 (해당 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해줌)
- 생성자 2번 호출 문제
- 스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결됨 (target 객체 생성할 때만 1번 호출됨)
- 이것도 objenesis 라이브러리 덕분에 가능해짐
- 스프링 부트 2.0부터 CGLIB 기본 사용
- 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용함 (CGLIB 사용)
- 인터페이스가 있어도 JDK 동적 프록시가 아닌 항상 CGLIB를 사용해 구체 클래스를 기반으로 프록시를 생성함
- 다음과 같이 설정하면 JDK 동적 프록시도 사용할 수 있음
- application.properties > spring.aop.proxy-target-class=false
정리하자면,
- 스프링 부트 2.0에서부터 CGLIB를 기본으로 사용하도록 함
- CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능함
- 추가로 objenesis 라이브러리를 사용해 CGLIB의 단점들이 많이 해결됨
참고
'Study > Spring' 카테고리의 다른 글
[Spring DB] 데이터 접근 핵심 원리 - JDBC 이해 (1) 2025.06.28 [Spring] 스프링 핵심 원리 - 스프링 AOP (0) 2025.04.23 [Spring] 스프링 핵심 원리 - @Aspect AOP (0) 2025.04.22 [Spring] 스프링 핵심 원리 - 빈 후처리기 (0) 2025.04.21 [Spring] 스프링 핵심 원리 - 스프링이 지원하는 프록시 (0) 2025.04.20 - 개요