티스토리 뷰
[Spring] Spring의 AOP 프록시 구현 방법(JDK 동적 프록시, CGLib 프록시)과 @EnableAspectJAutoProxy의 proxyTargetClass - (3/3)
망나니개발자 2021. 7. 20. 21:45아래의 내용은 토비의 스프링 2권 5장을 읽고 정리한 내용입니다.
1. Spring의 AOP 프록시 구현 방법(JDK Dynamic Proxy, CGLib Proxy)
[ AOP에 대한 이해 ]
AOP는 부가 기능을 핵심 기능으로부터 분리하기 위해 등장한 기술이다. 부가 기능을 분리함으로써 우리는 해당 로직을 재사용할 수 있고, 핵심 기능은 핵심 역할에만 집중할 수 있도록 도와준다.
Spring의 AOP는 기본적으로 프록시 방식으로 동작하도록 되어 있는데, Spring에서 AOP를 활용하기 위해서는 @EnableAspectJAutoProxy 어노테이션을 붙여주어야 하며, 이에 대한 옵션으로 proxyTargetClass가 있다.
그리고 이 옵션을 주지 않으면 스프링 빈을 찾지 못하는 등의 에러가 발생할 수 있는데, 이를 이해하기 위해서 우리가 수동으로 구현하는 프록시 방식과 Spring이 자동으로 구현하는 프록시 방식에 대해 알아보고 왜 이 옵션이 생기게 되었는지 이해보도록 하자.
[ 수동으로 직접 Proxy 구현 ]
앞서 설명하였듯이 AOP를 구현하는 기본적인 방법은 프록시 패턴을 이용하는 것인데, 우리가 수동으로 직접 AOP를 구현하고자 한다면 다음과 같이 구현할 수 있다.
예를 들어 다음과 같은 DiscountController와 DiscountService 및 구현체(RateDiscountService)가 있다고 하자.
@RestController
@RequiredArgsConstructor
public class DiscountController {
private final DiscountService discountService;
}
public interface DiscountService {
int discount();
}
@Service
public class RateDiscountService implements DiscountService {
@Override
public int discount() {
...
}
}
우리는 RateDiscountService의 discount 메소드가 호출되기 이전에 부가 기능을 적용하기 위해 다음과 같이 인터페이스를 구현(implements)하여 Proxy 객체를 직접 생성할 수 있다.
@Service
public class RateDiscountServiceProxy implements DiscountService {
// 여기서는 RateDiscountService에 해당한다.
private DiscountService discountService;
public DiscountServiceProxy(DiscountService discountService) {
this.discountService = discountService;
}
@Override
public int discount() {
// 1. 메소드 호출 전의 부가 기능 처리
// 2. 실제메소드 호출
this.discountService.discount()
// 3. 메소드 호출 후의 부가 기능 처리
}
}
위와 같이 실제 객체의 메소드가 호출 전/후에 처리해야 하는 부가기능을 추가함으로써 AOP를 구현할 수 있다.
하지만 이러한 방식은 다음과 같은 문제점을 가지고 있다.
- 불필요하게 DiscountService 타입의 빈이 2개 등록됨
- DI(Dependency Injection, 의존성 주입) 시에 문제가 발생할 수 있음
Spring이 1개의 타입에 대해 불필요하게 여러 개의 빈을 관리해야 할 뿐만 아니라 해당 타입의 빈이 여러 개이므로 의존성 주입 시에도 문제가 발생할 여지가 있는 것이다. 물론 변수 이름이나 지시자 등으로 피할 수 있지만 이는 번거롭다.
[ Spring의 JDK dynamic proxy 구현 ]
위와 같은 문제를 해결하기 위해 Spring은 Java 언어 차원에서 제공하는 자동 프록시 생성기를 통해 직접 프록시 객체를 생성한 후에 특별한 처리를 해주는데, 이를 JDK 동적 프록시 또는 JDK 다이나믹 프록시라고 부른다. Spring은 프록시를 구현할 때 프록시를 구현한 객체(RateDiscountServiceProxy)를 마치 실제 빈(RateDiscountService)인 것처럼 포장하고, 2개의 빈을 모두 등록하는 것이 아니라 실제 빈을 프록시가 적용된 빈으로 바꿔치기한다.
이러한 방식이 가능한 이유는 프록시 구현 대상(RateDiscountService)이 인터페이스(DiscountService)를 구현하고 있으며, Spring이 프록시 구현체(RateDiscountServiceProxy)를 만들때 프록시 대상과 동일한 인터페이스(DiscountService)를 구현하도록 했기 때문이다. 즉, 프록시 대상(RateDiscountService)과 프록시 구현체(RateDiscountServiceProxy) 모두 동일한 인터페이스(DiscountService) 타입 및 구현체이기 때문에 기존의 RateDiscountService 빈을 RateDiscountServiceProxy로 바꿔치기 하고, 빈 후처리기를 통해 이미 정의된 의존 관계 역시 바꿀 수 있는 것이다.
이를 그림으로 표현하면 다음과 같다.
기존에는 왼쪽과 같이 실제 빈과 Proxy 빈 2개가 스프링 컨테이너에 등록되었다면, Spring이 JDK 동적 프록시를 구현할 때에는 Proxy 빈을 실제 빈처럼 구현하고 기존의 빈을 대체하도록 하는 것이다.
이러한 방식은 괜찮아 보이지만 만약 실제 빈을 직접 참조하고 있는 경우라면 문제가 발생한다.
위의 예제에서 문제가 발생하지 않은 이유는 실제 빈(RateDiscountService)이 DiscountService라는 인터페이스에 의존하고 있고, DiscountController에서도 DiscountService에 의존하고 있기 때문이다.
하지만 만약 다음과 같이 결제를 담당하는 PaymentService에서 구체 클래스(RateDiscountService)를 주입받고 있다면 어떻게 될까?
@Service
@RequiredArgsConstructor
public class PaymentService {
private final RateDiscountService rateDiscountService;
}
Spring이 새롭게 추가한 RateDiscountServiceProxy는 DiscountService 인터페이스를 구현한 클래스이지 RateDiscountService를 상속받은 클래스가 아니다. 그래서 RateDiscountService 타입의 빈을 찾을 수 없어 에러가 발생하게 된다.
또한 JDK dynamic proxy는 인터페이스를 기반으로 프록시 객체를 생성하는데, 인터페이스를 구현한 클래스가 아니면 프록시 객체를 생성할 수 없다. 즉, JDK 동적 프록시는 다음과 같은 두 가지 한계점을 가지게 된다.
- 프록시를 적용하기 위해서 반드시 인터페이스를 생성해야 함
- 구체 클래스로는 빈을 주입받을 수 없고, 반드시 인터페이스로만 주입받아야 함
하지만 실제 개발을 하다보면 인터페이스 없이 구체 클래스에 의존하는 경우도 많은데, AOP를 적용하기 위해 모든 빈들에게 인터페이스를 만들어주는 것은 상당히 번거롭다. 또한 구체 클래스에 의존해야 하는 경우에는 빈을 찾을 수 없어서 에러가 발생하므로 대처가 어렵다. 이러한 이유로 Spring은 JDK 동적 프록시 방식이 아닌 또 다른 프록시 구현 방식을 구현하였다.
[ Spring의 CGLib Proxy 구현 ]
위와 같은 문제를 해결하기 위해서는 Spring이 구현해주는 Proxy 객체가 인터페이스(DiscountService)를 기반으로 하지 않고 클래스(RateDiscountService)를 구현한 객체여야 한다. 즉, 클래스 상속을 기반으로 프록시를 구현하도록 강제해야 하는 것이다.
public class RateDiscountServiceProxy extends RateDiscountService {
...
}
그러면 Spring은 RateDiscountServiceProxy를 구현할 때 위와 같이 RateDiscountService를 상속받아 구현하는데, 이러한 클래스 기반의 프록시를 구현하기 위해서는 바이트 코드를 조작해야 한다. 그래서 Spring은 CGLib이라는 바이트 조작 라이브러리를 통해 클래스 상속으로 프록시를 구현함으로써 JDK 동적 프록시에 의한 문제를 완전히 해결하고 있다. CGLib을 이용한 프록시 방식은 클래스 기반의 프록시이므로 인터페이스가 없어도 적용가능하며, 인터페이스가 구현된 빈의 경우에도 인터페이스 주입과 구체 클래스 주입이 모두 가능하다. 대신 CGLib 프록시는 상속을 이용하므로 기본 생성자를 필요로 하며, 생성자가 2번 호출되고 final 클래스나 final 메소드면 안된다는 제약이 있다.
2. @EnableAspectJAutoProxy의 proxyTargetClass
[ @EnableAspectJAutoProxy의 proxyTargetClass ]
스프링 프레임워크는 프록시를 구현할 때 기존의 방식처럼 인터페이스를 구현하도록 할 것인지(JDK 동적 프록시) 또는 해당 클래스를 바이트 조작하여 직접 구현하도록 할 것인지(CGLib)에 대한 옵션을 제공하고 있는데, 이것이 바로 proxyTargetClass이다.
@SpringBootApplication
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AtddMembershipApplication {
public static void main(String[] args) {
SpringApplication.run(AtddMembershipApplication.class, args);
}
}
일반적으로 인터페이스 주입에 의한 문제를 예방하고자 proxyTargetClass를 true로 주는 경우가 많은데, SpringBoot를 사용하고 있다면 더이상 이러한 옵션을 부여하지 않아도 된다. 왜냐하면 SpringBoot에서는 CGLib 라이브러리가 안정화되었다고 판단하여 proxyTargetClass 옵션의 기본값을 true로 사용하고 있다.
CGLib은 원래 외부 라이브러리이지만 스프링이 3.2부터 이를 내장시켰다. 그리고 objenesis라는 특별한 라이브러리를 사용해 기본 생성자 없이 객체 생성이 가능하며 생성자가 2번 호출되는 문제 역시 해결하여 스프링 4.0에서 발전시켰다. 그리고 이제 더 이상 final을 제외한 CGLib의 한계점이 존재하지 않으니 스프링 부트 2.0에서는 기본값으로 설정해둔 것이다.
여기서 하나 주의할 점은 @EnableAspectJAutoProxy 어노테이션의 기본값이 true로 바뀐 것이 아니라는 점이다. @EnableAspectJAutoProxy는 SpringBoot가 아닌 Spring에서 만들어진 어노테이션이므로 해당 어노테이션의 기본값은 false가 맞다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(AspectJAutoProxyRegistrar.class)
public @interface EnableAspectJAutoProxy {
boolean proxyTargetClass() default false;
boolean exposeProxy() default false;
}
위에서 proxyTargetClass 옵션의 기본값을 true로 바꾼 것은 SpringBoot에만 해당된다. SpringBoot는 애플리케이션을 실행할 때 AutoConfigure를 위한 정보들을 spring-boot-autoconfigure의 spring-configuration-metadata.json에서 관리하고 있다.
그리고 AutoConfigure를 진행할 때 해당 값을 참조해서 설정을 진행한다. 그러므로 proxyTargetClass의 기본값이 true라는 것은 Spring MVC가 아닌 SpringBoot에서만 해당하는 내용이다. 만약 SpringBoot에서 proxyTargetClass의 값을 false로 설정하려면 프로퍼티에서 spring.aop.proxy-target-class를 false로 주면 된다.
spring.aop.proxy-target-class=false
만약 spring-boot-starter-aop 의존성이 추가되어 있다면 AopAutoConfiguration을 통한 자동 설정에 의해 @EnableAspectJAutoProxy를 추가하지 않아도 된다.
[ 정리 및 요약 ]
원래 Spring은 프록시 타깃 객체에 인터페이스가 있다면 그 인터페이스를 구현한 JDK 다이내믹 프록시 방식으로 객체를 생성하고, 인터페이스가 없다면 CGLib을 이용한 클래스 프록시를 만든다.
- 인터페이스를 구현하고 있는지 확인함
- 인터페이스를 구현하고 있으면 JDK 다이내믹 프록시 방식으로 객체를 생성
- 인터페이스를 구현하지 않으면 GCLib 방식으로 객체를 생성
하지만 JDK 동적 프록시 방식은 다음과 같은 2가지 한계점을 가지고 있다.
- 프록시를 적용하기 위해서 반드시 인터페이스를 생성해야 함
- 구체 클래스로는 빈을 주입받을 수 없고, 반드시 인터페이스로만 주입받아야 함
그래서 스프링은 CGLib 방식의 프록시를 강제하는 옵션을 제공하고 있는데, 이것이 바로 proxyTargetClass이며, 이 값을 true로 지정해주면 Spring은 인터페이스가 있더라도 무시하고 클래스 프록시를 만들게 된다.
SpringBoot에서는 CGLib 라이브러리가 갖는 단점들을 모두 해결하였고, proxyTargetClass 옵션의 기본값을 true로 사용하고 있다.
관련 포스팅
- AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)의 이해 - (1/3)
- AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)의 개념 및 사용 방법 예제 코드 - (2/3)
- Spring의 AOP 프록시 구현 방법(JDK 동적 프록시, CGLib 프록시)과 @EnableAspectJAutoProxy의 proxyTargetClass - (3/3)
'Spring' 카테고리의 다른 글
[Spring] BeanFactoryPostProcessor 타입의 빈(PropertySourcesPlaceholderConfigurer) 생성 메소드를 static으로 선언해야 하는 이유 (0) | 2021.08.01 |
---|---|
[Spring] 설정 자동화와 설정의 변경, @EnableWebMvc와 WebMvcConfigurer (2) | 2021.07.25 |
[Spring] @Valid와 @Validated를 이용한 유효성 검증의 동작 원리 및 사용법 예시 - (1/2) (28) | 2021.07.19 |
[Spring] 필터(Filter) vs 인터셉터(Interceptor) 차이 및 용도 - (1) (73) | 2021.07.14 |
[Spring] Spring에서 트랜잭션의 사용법 - (3/3) (6) | 2021.07.09 |