티스토리 뷰
[Spring] @Async에서 The bean could not be injected because it is a JDK dynamic proxy 에러 발생 원인 분석하기
망나니개발자 2025. 9. 2. 10:00
1. @Async에서 The bean could not be injected because it is a JDK dynamic proxy 에러 발생 원인 분석하기
[ 문제 발생 상황 ]
다음과 같이 TestInterface와 이를 구현한 TestService가 있고, TestService를 주입받는 TestController가 있다고 하자.
public interface TestInterface {
default void hello() {
System.out.println("hello");
}
}
@Service
@Slf4j
public class TestService implements TestInterface {
@Async
public void gogo() {
log.info("async");
}
}
@RestController
@RequiredArgsConstructor
public class TestController {
private final TestService testService;
@GetMapping("/test")
public void test() {
testService.gogo();
}
}
TestService에서는 @Async를 활용해 비동기 호출을 하고 있기 때문에, 다음과 같은 비동기 설정을 추가해주어야 한다. 물론 현업에서는 보다 고도화된 Async 설정이 필요한데, 관련 내용은 여기 포스팅을 참고하도록 하자.
@EnableAsync
@Configuration
public class AsyncConfig {
}
문제는 위의 코드를 실행하면 다음과 같은 에러가 발생한다는 것이다.
2025-08-03T17:44:42.365+09:00 WARN 90225 --- [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'testController' defined in file [/Users/mangkyu/IdeaProjects/untitled/build/classes/java/main/com/mangkyu/TestController.class]: Unsatisfied dependency expressed through constructor parameter 0: Bean named 'testService' is expected to be of type 'com.mangkyu.TestService' but was actually of type 'jdk.proxy2.$Proxy131'
2025-08-03T17:44:42.366+09:00 INFO 90225 --- [ main] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'main'
2025-08-03T17:44:42.367+09:00 INFO 90225 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2025-08-03T17:44:42.369+09:00 INFO 90225 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
2025-08-03T17:44:42.371+09:00 INFO 90225 --- [ main] o.apache.catalina.core.StandardService : Stopping service [Tomcat]
2025-08-03T17:44:42.382+09:00 INFO 90225 --- [ main] .s.b.a.l.ConditionEvaluationReportLogger :
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2025-08-03T17:44:42.393+09:00 ERROR 90225 --- [ main] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'testService' could not be injected because it is a JDK dynamic proxy
The bean is of type 'jdk.proxy2.$Proxy131' and implements:
com.mangkyu.TestInterface
org.springframework.aop.SpringProxy
org.springframework.aop.framework.Advised
org.springframework.core.DecoratingProxy
Expected a bean of type 'com.mangkyu.TestService' which implements:
com.mangkyu.TestInterface
Action:
Consider injecting the bean as one of its interfaces or forcing the use of CGLib-based proxies by setting proxyTargetClass=true on @EnableAsync and/or @EnableCaching.
에러 내용을 보면, TestController에서 주입받고자 하는 TestService는 JDK 동적 프록시를 기반으로 프록시 객체가 생성되었기 때문에, TestService 클래스를 직접 주입받을 수 없다는 것이다. 그리고 @EnableAsync 또는 @EnableCaching 애노테이션에서 proxyTargetClass 값을 true로 설정하여, CGLib 기반의 프록시를 생성하라고까지 알려주고 있다.
실제로 다음과 같이 코드를 수정하면 문제가 해결이 된다. 하지만 우리는 Async 관련 부분이 어떻게 설정되었는지 구체적인 원인 분석을 할 필요가 있을 것이다.
@EnableAsync(proxyTargetClass = true)
@Configuration
public class AsyncConfig {
}
[ JDK 동적 프록시와 CGLib 프록시 ]
사실 스프링의 AOP 프록시 관련 학습을 했었다면, 위의 에러 로그 만으로도 문제가 발생한 이유를 충분히 알 수 있을 것이다. 만약 해당 내용에 대한 이해가 부족한다면, 다음 포스팅을 참고해주도록 하고, 간략히 살펴보도록 하자.
스프링 프레임워크에서 @Transactional, @Async, @Cacheable 등과 같은 애노테이션은 스프링 AOP 기반으로 동작한다. 그리고 해당 AOP를 구현할 때는 프록시 패턴이 활용되는데, 그 프록시 기반의 구현 방법으로 JDK 동적 프록시와 CGLib이 존재한다.
JDK 동적 프록시는 인터페이스를 구현하는 프록시 객체를 하나 만들고, 해당 클래스가 기존의 클래스에 의존하는 방식으로 구체 클래스를 만든다. 즉, TestInterface를 상속받는 새로운 클래스(TestInterfaceProxy)가 생기고, 해당 클래스가 TestService를 내부에서 참조하는 방식으로 동작하는 것이다. 반면에 CGLib은 기존 클래스를 상속받는 방식으로 이를 구현한다. 이러한 구현 계층의 관계를 살펴보면 다음과 같다.
- JDK 동적 프록시: 인터페이스를 상속받는 프록시 대상을 구현함
- 프록시 클래스: $ProxyNN
├─ implements TestInterface
├─ implements SpringProxy, Advised, DecoratingProxy
└─ (has) InvocationHandler → AdvisedSupport → Target(TestService)
- 프록시 클래스: $ProxyNN
- CGLib: 기존 클래스를 상속받는 방식으로 프록시 대상을 구현함
- TestService$$SpringCGLIB$$N
- extends TestService
├─ implements SpringProxy, Advised, DecoratingProxy
└─ (has) MethodInterceptor[] + AdvisedSupport → Target(TestService)
- extends TestService
- TestService$$SpringCGLIB$$N
JDK 동적 프록시를 사용하면, 스프링 빈으로 등록되는 대상은 인터페이스 기반의 새로운 객체이지, 기존 클래스(TestServce)가 아니다. 따라서 스프링의 입장에서 빈으로 등록되는 대상 역시 새로운 프록시 클래스(TestInterfaceProxy)이지, 프록시 클래스가 내부 참조로 갖는 기존의 클래스(TestServce)가 아니다. 따라서 JDK 동적 프록시로 생성된 객체는, 다른 빈에서 기존의 구체 클래스(TestService) 타입으로 주입받으려고 하면 문제가 생긴다.

하지만 스프링 부트는 spring-boot-autoconfigure 모듈의 spring-configuration-metadata.json에서 해당 값의 기본값을 true로 해두어, 항상 CGLib을 강제하는 것으로 알고 있었는데 왜 JDK 동적 프록시를 기준으로 생성되었을까? 그리고 어떠한 조건에서 JDK 동적 프록시로 만들어지는 것일까? 해당 부분을 보다 구체적인 동작 흐름을 분석해보도록 하자.
[ 문제 발생 상황 ]
먼저 @Async 애노테이션부터 살펴보도록 하자. @Async 애노테이션이 있을 때, 해당 원본 클래스에 부가 기능(Advice)를 적용하여 프록시 기반으로 AOP를 적용하는 클래스는 빈 후처리기(BeanPostProcessor) 중 하나인 AsyncAnnotationBeanPostProcessor이다.
빈 후처리기(BeanPostProcessor)는 모든 빈이 생성되고, 특정 빈의 내용이나 빈 자체를 변경하기 위해 활용되는데, @Async의 경우 프록시를 통해 기존 빈에 비동기 부가 기능을 감싼 프록시 생성을 위해 활용된다. 빈 후처리기 관련 내용은 여기에서 참고해주도록 하자.
AsyncAnnotationBeanPostProcessor는 다음과 같은 계층 구조를 갖고 있는데, AsyncAnnotationBeanPostProcessor의 부모 클래스 중 하나인 AbstractAdvisingBeanPostProcessor 클래스가 부가 기능을 판단하여 적용 여부를 결정한다.

빈 후처리기 인터페이스에는 2가지 메서드 메서드가 존재한다.
- postProcessBeforeInitialization
- 빈 초기화 직전 (의존성 주입 완료 후, afterPropertiesSet/init-method 호출 전)
- 빈 상태 검증, 기본 값 설정, marker interface 기반 처리
- postProcessAfterInitialization
- 빈 초기화 직후 (모든 init 과정 완료 후)
- 프록시 적용, 빈 래핑, 부가기능 주입

해당 클래스의 postProcessAfterInitialization 메서드에서는 먼저 isEligible 메서드를 통해 AOP 적용 대상인지를 식별하고, 적용 대상이라면 이를 위한 ProxyFactory 클래스를 생성한다.

ProxyFactory는 프록시 적용을 도와주는 클래스인데, 해당 클래스가 생성되는 부분은 prepareProxyFactory 이다. 해당 로직을 보면 this(AsyncAnnotationBeanPostProcessor)을 copy하여 새롭게 생성됨을 확인할 수 있다.

위의 계층 구조에서 보이듯이 AsyncAnnotationBeanPostProcessor의 부모 클래스로 ProxyConfig가 존재하기에, 이러한 로직의 처리가 가능한 것이다.

그리고 proxyFactory.copyForm 내부를 보면, 원본 클래스에서 proxyTargetClass 속성값을 그대로 복제함을 확인할 수 있다.

그렇다면 AsyncAnnotationBeanPostProcessor의 proxyTargetClass 값은 어떻게 설정되는 것일까? AsyncAnnotationBeanPostProcessor의 proxyTargetClass 값이 설정되는 위치는 ProxyAsyncConfiguration 클래스이다. @EnableAsync 애노테이션에 의해 설정된 proxyTargetClass 값이 AsyncAnnotationBeanPostProcessor으로 전달되고, 새롭게 생성한 ProxyFactory에도 전달된다.

AOP 적용을 도와주는 @EnableAspectJAutoProxy, @EnableTransactionManagement, @EnableAsync 또는 @EnableCaching 등의 클래스에는 proxyTargetClass라는 옵션이 있는데, 해당 클래스는 AOP 적용을 위해 프록시 객체의 생성 방법을 결정한다. proxyTargetClass= true라면 기본적으로 CGLIB 기반의 클래스 상속 기법으로 프록시 객체를 생성하고, 해당 값이 false라면 JDK 동적 프록시를 기반으로 객체를 생성한다. (보다 자세한 내용은 해당 포스팅을 참고하도록 하자.)

따라서 @EnableAsync 애노테이션에 의해 AsyncAnnotationBeanPostProcessor에 설정된 proxyTargetClass 값이 새롭게 생성한 ProxyFactory에도 적용이 되는 것이며, 우리는 별도의 설정을 적용하지 않았기 때문에 proxyTargetClass이 false로 설정되어 JDK 동적 프록시가 기본 프록시 구현 방식으로 채택된 것이다.
따라서 이어서 JDK 동적 프록시를 적용할 수 있는 인터페이스가 존재하는지를 판단하는 evaluateProxyInterfaces에 진입하게 된다.

해당 메서드 내부에서는 먼저 해당 클래스에 존재하는 인터페이스들을 모두 추출하고, 그 중에서 프록시를 적용할만한 인터페이스가 있는지를 검사한다.

이때 프록시를 적용할만한 인터페이스 여부를 판단하는 기준은 다음과 같다.
- InitializingBean, DisposableBean, Closeable, AutoCloseable, Aware.class와 같은 인터페이스가 아니고
- groovy.lang.GroovyObject 인터페이스가 아니고, cglib.proxy.Factory으로 끝나는 인터페이스가 아니고, cglib.proxy.Factory으로 끝나는 인터페이스가 아니고
- 해당 인터페이스에 메서드가 존재하는 경우
위의 3가지 조건을 충족시킨다면, 프록시 기반의 인터페이스를 생성하게 되는데, 쉽게 정리하면 개발자가 직접 추가한 인터페이스가 존재하고, 추상 메서드가 있으면 사실상 JDK 프록시의 적용 대상 인터페이스로 식별된다고 볼 수 있다. 우리가 작성했던 TestInterface의 경우, 위의 3가지 조건을 모두 만족하기에 인터페이스 기반의 프록시가 만들어진 것이다.
이때 @EnableAsync의 proxyTargetClass 값을 true로 설정하여 위의 문제를 해결할 수도 있겠지만, 그 이전에 먼저 해당 인터페이스의 필요성 여부를 검토해보는 것이 좋을 것 같다. 불필요한 인터페이스라면 제거하는 방향으로 검토해볼 필요가 있다.
[ spring.aop.proxy-target-class=true 옵션은? ]
해당 값을 설정하면, @EnableAspectJAutoProxy가 true로 설정되는 것과 동일하다.

@EnableAspectJAutoProxy가 true로 설정되면, 스프링이 내부적으로 관리하는 AopConfigUtils에 CGLib 방식을 강제하게 된다. 하지만 @Async는 위의 유틸 클래스를 활용하지 않기에, spring.aop.proxy-target-class=true을 통해 CGLib이 적용되지 않았던 것이다.

이와 관련하여 여러 가지 스프링 그리고 스프링 부트의 이슈들이 존재했다.
- Spring Issue: Spring은 기본적으로 일관성 있게 처리하려고 하지만, 프레임워크 수준에서는 프록시 전략들이 꽤나 분리되어 있는데, 이는 이들이 독립적인 구성 요소들이기 때문이라고 함

- Spring Boot Issue: Spring Boot에서도 해당 값이 true로 설정되어 있지만, proxyTargetClass 인자를 가진 다른 애노테이션들에는 영향을 주지 않는다고 함

따라서 스프링이 제공하는 여러 가지 Proxy 구현을 통한 부가 기능 관련 애노테이션(@EnableTransactionManagement, @EnableAsync, @EnableCaching 등)은, 개별로 설정이 독립적으로 적용된다고 볼 수 있다.