- 스프링 핵심원리 고급편 예제만들기
- 스프링 핵심원리 고급편(2) 쓰레드 로컬(threadlocal)
- 스프링 핵심원리 고급편(3) 템플릿 메서드 패턴과 콜백 패턴
- 스프링 핵심원리 고급편 프록시 패턴과 데코레이터 패턴
- 스프링 핵심원리 고급편(2) 동적 프록시 기술
- 스프링 핵심원리 고급편 스프링이 지원하는 프록시
- 스프링 핵심원리 고급편(2) 빈 후처리기
- 스프링 핵심원리 고급편(3) @aspect aop
- 스프링 핵심원리 고급편(3) 스프링 aop 구현
- 스프링 핵심원리 고급편 스프링 aop 포인트컷
- 스프링 핵심원리 고급편 스프링 aop 실전 예제
- 스프링 핵심원리 고급편(2) 스프링 aop 실무 주의사항
스프링 핵심원리 고급편(2) 스프링 aop 실무 주의사항
0. 내부호출 (예)
AOP 가 적용된 메서드를 호출할 때, 내부 객체를 함께 호출한다면 AOP 가 적용되지 않는다.
내부 객체는 프록시가 아닌 this.methodname
으로 실행되기 때문이다.
CallServiceV0
package hello.aop.internalcall;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CallServiceV0 {
public void external(){
log.info("external");
internal(); //내부 메서드 호출
}
public void internal(){
log.info("internal");
}
}
CallLogAspect
package hello.aop.internalcall.aop;
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(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint){
log.info("aop: {}", joinPoint.getSignature());
}
}
AOP 적용을 확인하기 위한 간단한 Aspect 이다.
CallServiceV0Test
package hello.aop.internalcall;
import hello.aop.internalcall.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.junit.jupiter.api.Assertions.*;
@Import(CallLogAspect.class)
@SpringBootTest
@Slf4j
class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
@Test
void external() {
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
실행 결과 - external()
1. //프록시 호출
2. CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.external()
3. CallServiceV0 : call external
4. CallServiceV0 : call internal
실행결과를 보면 callServiceV0.external()
안에서 internal()
을 호출할 때 AOP 적용이 안되는 것을 알 수 있다.
자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다.
1. 프록시와 내부 호출 - 대안1 자기 자신 주입
주의
스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다. 따라서 이번 예제를 스프링 부트 2.6 이상의 버전에서 실행하면 다음과 같은 오류 메시지가 나오면서 정상 실행되지 않는다.
Error creating bean with name 'callServiceV1': Requested bean is currently in creation: Is there an unresolvable circular reference?
이 문제를 해결하려면 application.properties 에 다음을 추가해야 한다.
spring.main.allow-circular-references=true
내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.
CallServiceV1
package hello.aop.internalcall;
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;
//생성자 주입은 순환참조 문제가 발생할 수 있음
//따라서 setter 주입을 사용하면, SpringBean 이 등록된 이후에 주입되기 때문에 문제 해결
@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
//프록시가 자동으로 들어오게 된다.
this.callServiceV1 = callServiceV1;
}
public void external(){
log.info("external");
callServiceV1.internal(); //내부 메서드 호출
}
public void internal(){
log.info("internal");
}
}
CallServiceV1Test
package hello.aop.internalcall;
import hello.aop.internalcall.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@Import(CallLogAspect.class)
@SpringBootTest
@Slf4j
class CallServiceV1Test {
@Autowired
CallServiceV1 callServiceV1;
@Test
void external() {
callServiceV1.external();
}
@Test
void internal() {
callServiceV1.internal();
}
}
실행 결과
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.external()
CallServiceV2 : call external
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.internal()
CallServiceV2 : call internal
실행 결과를 보면 이제는 internal() 을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하는 것을 확인할 수 있다. 당연히 AOP도 잘 적용된다.
2. 프록시와 내부 호출 - 대안2 지연 조회
스프링 빈을 지연해서 조회하여 빈 생성 이후 주입한다. ObjectProvider(Provider) , ApplicationContext 를 사용하면 된다.
CallServiceV2
package hello.aop.internalcall;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CallServiceV2 {
private final ObjectProvider<CallServiceV2> callServiceProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
public void external(){
log.info("external");
CallServiceV2 callServiceV2 = callServiceProvider.getObject();
callServiceV2.internal(); //내부 메서드 호출
}
public void internal(){
log.info("internal");
}
}
callServiceProvider.getObject()
를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다. 여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.
CallServiceV2Test
package hello.aop.internalcall;
import hello.aop.internalcall.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@Import(CallLogAspect.class)
@SpringBootTest
@Slf4j
class CallServiceV2Test {
@Autowired
CallServiceV2 callServiceV2;
@Test
void external() {
callServiceV2.external();
}
@Test
void internal() {
callServiceV2.internal();
}
}
실행 결과
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV2.external()
CallServiceV2 : call external
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV2.internal()
CallServiceV2 : call internal
3. 프록시와 내부 호출 - 대안3 구조 변경
구조를 변경하여 internal()
메서드를 다른 클래스로 만들고 주입받는다.
클래스가 나눠지고 internal()
클래스의 프록시가 생성되므로 당연히 자연스럽게 해결된다.
InternalService
package hello.aop.internalcall;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class InternalService {
public void internal(){
log.info("internal");
}
}
internal()
메서드를 밖으로 뺐다.
CallServiceV3
package hello.aop.internalcall;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
/**
* 구조를 변경(분리)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external(){
log.info("external");
internalService.internal();
}
}
CallServiceV3Test
package hello.aop.internalcall;
import hello.aop.internalcall.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@Import(CallLogAspect.class)
@SpringBootTest
@Slf4j
class CallServiceV3Test {
@Autowired
CallServiceV3 callServiceV3;
@Test
void external() {
callServiceV3.external();
}
}
실행결과
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV3.external()
CallServiceV3 : call external
CallLogAspect : aop=void hello.aop.internalcall.InternalService.internal()
InternalService : call internal
참고
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 더 풀어서 이야기하면 AOP는 public 메서드에만 적용한다. private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다.
AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다. 그러나 위 예제와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다. 실무에서 꼭 한번은 만나는 문제이기에 이번 강의에서 다루었다.
AOP가 잘 적용되지 않으면 내부 호출을 의심해보자.
4. 프록시 기술과 한계 - 타입 캐스팅
JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다. CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
JDK 동적 프록시 한계
인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.
package hello.aop.proxyvs;
import hello.aop.member.MemberService;
import hello.aop.member.MemberServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class ProxyCastingTest {
@Test
void jdkProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false); //JDK 동적 프록시 생성
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
//JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
assertThatThrownBy(()-> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}).isInstanceOf(ClassCastException.class);
}
}
JDK 동적 프록시는 MemberService 인터페이스를 기반으로 프록시를 생성한다. 따라서 JDK Proxy는 MemberService 로 캐스팅은 가능하지만 MemberServiceImpl 이 어떤 것인지 전혀 알지 못한다. 따라서 MemberServiceImpl 타입으로는 캐스팅이 불가능하다.
CGLIB 타입 캐스팅
@Test
void cglibProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true); //CGLIB 프록시 생성
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
//CGLIB 프록시를 구현 클래스로 캐스팅 시도 성공
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}
}
CGLIB Proxy 는 구체 클래스를 기반으로 프록시를 생성하므로 MemberServiceImpl 타입으로 캐스팅하면 성공한다.
5. 프록시 기술과 한계 - 의존관계 주입
JDK 동적 프록시를 사용하면서 의존관계 주입을 할 때 어떤 문제가 발생하는지 코드로 알아보자.
ProxyDIAspect (src/test)
package hello.aop.proxyvs.code;
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 ProxyDIAspect {
@Before("execution(* hello.aop..*.*(..))")
public void doTrace(JoinPoint joinPoint) {
log.info("[proxyDIAdvice] {}", joinPoint.getSignature());
}
}
AOP 프록시 생성을 위한 간단한 Aspect 다.
ProxyDITest
package hello.aop.proxyvs;
import hello.aop.member.MemberService;
import hello.aop.member.MemberServiceImpl;
import hello.aop.proxyvs.code.ProxyDIAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberServiceImpl memberServiceImpl;
@Autowired
MemberService memberService;
@Test
void go(){
log.info("memberService class={}", memberService.getClass());
log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
memberService.hello("hello");
}
}
properties = {"spring.aop.proxy-target-class=false"}
로 프록시를 만들 때 JDK 동적 프록시를 사용한다.
실행 결과
BeanNotOfRequiredTypeException: Bean named 'memberServiceImpl' is expected to
be of type 'hello.aop.member.MemberServiceImpl' but was actually of type
'com.sun.proxy.$Proxy54'
타입 예외가 발생했다. 다음과 같이 이유로 발생한다.
@Autowired MemberServiceImpl memberServiceImpl
: JDK Proxy는 MemberService 인터페이스를 기반으로 만들어진다. 따라서 MemberServiceImpl 타입이 뭔지 전혀 모른다. 그래서 해당 타입에 주입할 수 없다. MemberServiceImpl = JDK Proxy
가 성립하지 않는다.
해당 문제는 properties = {"spring.aop.proxy-target-class=true"}
로 하여 CGLIB 프록시를 사용한다면 해결간으하다.
6. 프록시 기술과 한계 - CGLIB
CGLIB 구체 클래스 기반 프록시 문제점
- 대상 클래스에 기본 생성자 필수
- 생성자 2번 호출 문제
- final 키워드 클래스, 메서드 사용 불가
- 대상 클래스에 기본 생성자 필수
CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되어 있다면 자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는 super() 가 자동으로 들어간다.) 따라서 대상 클래스에 기본 생성자를 만들어야 한다
- 생성자 2번 호출 문제
- 실제 target의 객체를 생성할 때
- 프록시 객체를 생성할 때 부모 클래스의 생성자 호출
- final 키워드 클래스, 메서드 사용 불가
7. 프록시 기술과 한계 - 스프링의 해결책
스프링은 objenesis
라이브러리를 사용하여 기본생성자 문제, 생성자 2번 호출 문제를 해결했다.
또한 스프링부트 2.0 부터 CGLIB 를 기본으로 사용한다. 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다. 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다.
댓글남기기