[강의] 김영한님의 스프링 핵심 원리 - 고급편 (실전 + 주의사항)

크리링·2023년 5월 21일
0
post-thumbnail

강의 : 스프링 핵심 원리 - 고급편
코드 : 스프링 핵심 원리 - 고급편 (코드)






예제 만들기

@Trace 애노테이션으로 로그 출력하기
@Retry 애노테이션으로 예외 발생시 재시도 하기



ExamRepository

@Repository
public class ExamRepository {
 	private static int seq = 0;
 	
    /**
 	* 5번에 1번 실패하는 요청
 	*/
 	public String save(String itemId) {
 		seq++;
 		if (seq % 5 == 0) {
 			throw new IllegalStateException("예외 발생");
 		}
 		return "ok";
 	}
}

5번에 1번 실패하는 저장소



ExamService

@Service
@RequiredArgsConstructor
public class ExamService {
 	private final ExamRepository examRepository;
 	public void request(String itemId) {
 		examRepository.save(itemId);
 	}
}



로그 출력 AOP

@Trace가 메서드에 붙어 있으면 호출 정보가 출력되는 편리한 기능



@Trace

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trace {
}



TraceAspect

@Slf4j
@Aspect
public class TraceAspect {
 	@Before("@annotation(hello.aop.exam.annotation.Trace)")
 	public void doTrace(JoinPoint joinPoint) {
 		Object[] args = joinPoint.getArgs();
 		log.info("[trace] {} args={}", joinPoint.getSignature(), args);
 	}
}

@annotation(hello.aop.exam.annotation.Trace) 포인트컷을 사용해서 @Trace 가 붙은 메서드에 어드바이스를 적용한다.



ExamService - @Trace 추가

@Service
@RequiredArgsConstructor
public class ExamService {
 	private final ExamRepository examRepository;
    
 	@Trace
 	public void request(String itemId) {
 		examRepository.save(itemId);
 	}
}

request()@Trace를 붙였다. 이제 메서드 호출 정보를 AOP를 사용해서 로그로 남길 수 있다.



ExamRepository - @Trace 추가

@Repository
public class ExamRepository {

 	@Trace		// 추가
 	public String save(String itemId) {
 	//...
 	}
}



ExamTest - 추가

@Import(TraceAspect.class)
@SpringBootTest
public class ExamTest {
}

@Import(TraceAspect.class) 를 사용해서 TraceAspect 를 스프링 빈으로 추가하자. 이제 애스펙트가 적용된다.



실행 결과

[trace] void hello.aop.exam.ExamService.request(String) args=[data0]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data0]
[trace] void hello.aop.exam.ExamService.request(String) args=[data1]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data1]
...

실행해보면 @Trace가 붙은 request(), save() 호출시 로그가 잘 남는 것을 확인






재시도 AOP

@Retry 애노테이션이 있으면 예외가 발생했을 때 다시 시도해서 문제를 복구



@Retry

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
 	int value() default 3;
}

재시도 횟수로 사용할 기본값 3



RetryAspect

@Slf4j
@Aspect
public class RetryAspect {

 	@Around("@annotation(retry)")
 	public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
 		log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);
 		int maxRetry = retry.value();
 		Exception exceptionHolder = null;
        
 		for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
 			try {
 				log.info("[retry] try count={}/{}", retryCount, maxRetry);
 				return joinPoint.proceed();
 			} catch (Exception e) {
 				exceptionHolder = e;
 			}
 		}
 		throw exceptionHolder;
 	}
}
  • 재시도하는 애스펙트
  • @annotation(retry) , Retry retry 를 사용해서 어드바이스에 애노테이션을 파라미터로 전달한다.
  • retry.value() 를 통해서 애노테이션에 지정한 값을 가져올 수 있다.
  • 예외가 발생해서 결과가 정상 반환되지 않으면 retry.value() 만큼 재시도한다.



ExamRepository - @Retry 추가

@Repository
public class ExamRepository {

 	@Trace
 	@Retry(value = 4)
 	public String save(String itemId) {
 		//...
 	}
}

ExamRepository.save() 메서드에 @Retry(value = 4)를 적용 - 문제 발생시 4번 재시도



ExamTest - 추가

@SpringBootTest
//@Import(TraceAspect.class)
@Import({TraceAspect.class, RetryAspect.class})
public class ExamTest {
}
  • @Import(TraceAspect.class) 는 주석처리하고
  • @Import({TraceAspect.class, RetryAspect.class}) 를 스프링 빈으로 추가하자



실행 결과

...
[retry] try count=1/5
[retry] try count=2/5

실행 결과시 5번째 문제가 발생했을 때 재시도 덕에 문제 복구 정상 응답 확인



참고

@Transactional은 가장 대표적인 AOP






주의 사항

프록시와 내부 호출 - 문제

스프링은 프록시 방식의 AOP 사용
AOP 적용 -> 프록시를 통한 객체(Target) 호출
프록시에서 어드바이스 호출하고, 이후에 대상 객체 호출
프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 스프링은 의존관계 주입시에 항상 프록시 객체 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.



예제

CallServiceV0

@Slf4j
@Component
public class CallServiceV0 {

 	public void external() {
 		log.info("call external");
 		internal(); //내부 메서드 호출(this.internal())
 	}
    
 	public void internal() {
 		log.info("call internal");
 	}
}

CallServiceV0.external() 을 호출하면 내부에서 internal() 이라는 자기 자신의 메서드를 호출한다. 자바 언어에서 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this 가 붙게 된다. 그러니까 여기서는 this.internal() 이라고 이해하면 된다.

CallLogAspect

@Slf4j
@Aspect
public class CallLogAspect {

 	@Before("execution(* hello.aop.internalcall..*.*(..))")
 	public void doLog(JoinPoint joinPoint) {
 		log.info("aop={}", joinPoint.getSignature());
 	}
}

CallServiceV0Test

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {

 	@Autowired
 	CallServiceV0 callServiceV0;
    
 	@Test
 	void external() {
 		callServiceV0.external();
 	}
    
 	@Test
 	void internal() {
 		callServiceV0.internal();
 	}
}
  • @Import(CallLogAspect.class) : 앞서 만든 간단한 Aspect 를 스프링 빈으로 등록한다. 이렇게 해서 CallServiceV0 에 AOP 프록시를 적용한다.
  • @SpringBootTest : 내부에 컴포넌트 스캔을 포함하고 있다. CallServiceV0@Component 가 붙어있으므로 스프링 빈 등록 대상이 된다.

실행 결과 - external()

1. //프록시 호출
2. CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.external()
3. CallServiceV0 : call external
4. CallServiceV0 : call internal

문제는 callServiceV0.external() 안에서 internal() 을 호출할 때 발생한다. 이때는 CallLogAspect 어드바이스가 호출되지 않는다.

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다.

실행 결과 - internal()

CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.internal()
CallServiceV0 : call internal



대안1 - 자기 자신 주입

CallServiceV1

@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");
 	}
}

callServiceV1 를 수정자를 통해서 주입 받는 것을 확인할 수 있다. 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다.
external() 을 호출하면 callServiceV1.internal() 를 호출하게 된다. 주입받은 callServiceV1 은 프록시이다. 따라서 프록시를 통해서 AOP를 적용할 수 있다.

CallServiceV1Test

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV1Test {
 	@Autowired
 	CallServiceV1 callServiceV1;
    
 	@Test
 	void external() {
 		callServiceV1.external();
 	}
}

실행 결과

CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.external()
CallServiceV2 : call external
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.internal()
CallServiceV2 : call internal



대안2 - 지연 조회

앞서 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다.
이 경우 수정자 주입을 사용하거나 지연 조회를 사용하면 된다.

CallServiceV2

/**
 * ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
 */
@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 callServiceV2 = callServiceProvider.getObject();
 		callServiceV2.internal(); //외부 메서드 호출
 	}
    
 	public void internal() {
 		log.info("call internal");
 	}
}

ObjectProvider 는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
callServiceProvider.getObject() 를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다. 여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.



대안3 - 구조 변경

가장 권장

@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {

 	private final InternalService internalService;
    
 	public void external() {
 		log.info("call external");
 		internalService.internal(); //외부 메서드 호출
 	}
}
@Slf4j
@Component
public class InternalService {
 	public void internal() {
 		log.info("call internal");
 	}
}

참고

AOP는 public 메서드에만 적용한다. private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다. AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다. 그러나 위 예제와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다.



프록시 기술과 한계 - 타입 캐스팅

  • proxyTargetClass=false JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성
  • proxyTargetClass=true CGLIB를 사용해서 구체 클래스 기반 프록시 생성
  • 참고로 옵션과 무관하게 인터페이스가 없으면 JDK 동적 프록시를 적용할 수 없으므로 CGLIB를 사용한다.

JDK 동적 프록시 한계

인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.



예제

@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();
 		log.info("proxy class={}", memberServiceProxy.getClass());
	 	//JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
 		assertThrows(ClassCastException.class, () -> {
	 		MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
 		});
 	}
}

jdkProxy() 테스트

MemberServiceImpl 타입을 기반으로 JDK 동적 프록시를 생성했다. MemberServiceImpl 타입은 MemberService 인터페이스를 구현한다. 따라서 JDK 동적 프록시는 MemberService 인터페이스를 기반으로 프록시를 생성한다. 이 프록시를 JDK Proxy 라고 하자. 여기서 memberServiceProxy 가 바로 JDK Proxy 이다.

JDK 동적 프록시 캐스팅

그런데 여기에서 JDK Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅 하려고 하니 예외가 발생한다.
왜냐하면 JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문이다. JDK Proxy는 MemberService 인터페이스를 기반으로 생성된 프록시이다. 따라서 JDK Proxy는 MemberService 로 캐스팅은 가능하지만 MemberServiceImpl 이 어떤 것인지 전혀 알지 못한다. 따라서 MemberServiceImpl 타입으로는 캐스팅이 불가능하다. 캐스팅을 시도하면 ClassCastException.class 예외가 발생한다.

CGLIB는 구체 클래스를 기반으로 프록시를 생성하기 때문이다. CGLIB Proxy는
MemberServiceImpl 구체 클래스를 기반으로 생성된 프록시이다. 따라서 CGLIB Proxy는
MemberServiceImpl 은 물론이고, MemberServiceImpl 이 구현한 인터페이스인 MemberService 로도 캐스팅 할 수 있다.


정리

JDK 동적 프록시는 대상 객체인 MemberServiceImpl 로 캐스팅 할 수 없다.
CGLIB 프록시는 대상 객체인 MemberServiceImpl 로 캐스팅 할 수 있다.
그런데 프록시를 캐스팅 할 일이 많지 않을 것 같은데 왜 이 이야기를 하는 것일까? 진짜 문제는 의존관계 주입시에 발생한다.



프록시 기술과 한계 - 의존관계 주입

예제

ProxyDIAspect

@Slf4j
@Aspect
public class ProxyDIAspect {
 	@Before("execution(* hello.aop..*.*(..))")
 	public void doTrace(JoinPoint joinPoint) {
 		log.info("[proxyDIAdvice] {}", joinPoint.getSignature());
 	}
}

ProxyDITest

@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시, DI 예외 발생
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) //CGLIB
프록시, 성공
@Import(ProxyDIAspect.class)
public class ProxyDITest {

 	@Autowired MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK
 	@Autowired MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK
 
 	@Test
 	void go() {
 		log.info("memberService class={}", memberService.getClass());
 		log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
 		memberServiceImpl.hello("hello");
 	}
}

JDK 동적 프록시를 구체 클래스 타입에 주입할 때 어떤 문제가 발생 -> 실행시 오류



프록시 기술과 한계 - CGLIB

CGLIB 구체 클래스 기반 프록시 문제점

  • 대상 클래스에 기본 생성자 필수
    • => 스프링 4.0부터 objenesis 라는 라이브러리를 통해 해결
  • 생성자 2번 호출 문제
      1. 실제 target의 객체를 생성할 때
      1. 프록시 객체를 생성할 때 부모 클래스의 생성자 호출
    • => 스프링 4.0부터 objenesis 라는 라이브러리를 통해 해결
  • final 키워드 클래스, 메소드 사용 불가
    • final 클래슨나 final 메서드를 잘 사용하지 않으므로 크게 문제 없음




0개의 댓글