저희 프로젝트는 처음부터 MyBatis
에서 Spring-data-JPA
로 마이그레이션을 계획했습니다.
저희는 주로 MyBatis
의 mapper
를 선언하고 DAO
에 DI
하는 형태로 사용했습니다.
그래서 마이그레이션 당시 JPA
또한 이처럼 사용하려 했습니다.
하지만 그러던 중 Class
이름으로 인한 순환의존 에러 를 발견하였고, 이에 대한 원인과 해결 방법을 설명하고자 합니다.
상황을 더 명확히 하기 위해 테스트 용 Repository
를 만들었습니다.
Repository
의 소스코드는 다음처럼 간단히 구성되어 있습니다.
그림의 예시는 JPA interface
인 "AAA"
가 존재하고, 이를 DI
받는 AAAImpl
클래스가 존재합니다.
이를 실행할 시 다음의 순환 의존 에러가 발생합니다.
2024-11-07T18:41:10.810+09:00 ERROR 50213 --- [testing] [ main] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌──->──┐
| AAAImpl (field private core.testing.AAA core.testing.AAAImpl.jpaRepo)
└──<-──┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
Process finished with exit code 1
콘솔의 에러에 의하면 "AAAImpl 는 자기 자신을 DI 받는다"
고 되어있습니다. 하지만 AAAImpl
코드를 보면 알 수 있듯, AAA
를 DI 받을 뿐, 자기자신을 DI 하지 않음을 알 수 있습니다.
@Repository
public class AAAImpl {
@Autowired
private AAA jpaRepo;
}
즉, 코드상 순환 의존이 없는데 순환 의존 에러가 발생하는 것입니다.
원인은 Spring-data-jpa
의 기본 동작 에 있었습니다.
다양한 검색을 하던 중, 다음 공식 문서를 찾게 되었습니다. [2]
위 문서는 "Spring-data-jpa 에서 사용자 (Custom) repository 를 정의하는 방법"
을 설명하는 문서입니다.
이에 의하면 JPA interface proxy
를 Spring context
에 등록하는 과정 중 '연관된 구현체'
가 있는지 탐색하며 [a]
,
이 때 연관된 구현체
는 (interface 이름) + (Impl)
이름으로 된 구현체
를 default
로 탐색한다고 합니다.
이러한 경우 아래처럼 순환 의존 에러가 발생합니다.
JPA interface
인 AAA
를 context
에 등록을 시도하며 연관된 구체가 있는지 탐색합니다.(AAA + Impl)
Custom Repository
의 대상이 아니지만 AAAImpl
인 클래스가 존재합니다. 때문에 Spring
은 AAAImpl
를 context
에 등록을 시도합니다.AAAImpl
내부에는 AAA
가 존재합니다. 즉, AAA
context
등록 중 자기자신을 만났습니다.Spring
은 순환 의존 에러를 일으켜 Bean
등록을 중지합니다.결국 우리가 발견한 순환 의존 에러는 클래스 이름 때문 에 발생한 것이었습니다. 때문에 이를 해결하는 방법은 이를 조작하거나 변경하면 간단히 해결됩니다.
@EnableJpaRepositories(repositoryImplementationPostfix=" ... ")
@EnableJpaRepositories
는 JPA
저장소를 활성시키는 어노테이션으로, Spring autoconfiguration
으로 인해 JPA repository
가 발견되면 자동으로 활성화 됩니다.
그런데 이 어노테이션의 repositoryImplementationPostfix
으로 Custom Repository
를 탐색할 접미사 (Postfix)
를 명시할 수 있습니다.
때문에 아래처럼 코드를 변경하면 에러를 회피할 수 있습니다.
@EnableJpaRepositories(repositoryImplementationPostfix = "CustomImpl")
@Repository
public class AAAImpl {
@Autowired
private AAA jpaRepo;
}
아니면 차라리 AAAImpl
이름을 변경하는 방법도 존재합니다.
애초에 spring-data-jpa
가 Custom Repository
를 탐색하는 방식이 (interface 이름) + (Impl)
이기 때문에 AAAImpl
를 AAAJpaImpl
처럼 변경하면 에러가 발생하지 않습니다.
결국 에러가 발생한 원인은 클래스의 이름 때문이었습니다.
처음엔 이것이 정말 믿기지 않아서 더 다양한 상황을 만들어 테스트해 보았고, 그러다 "Spring 과 연관 없는 객체가 Spring context 에 등록되는 에러"
를 발견하였습니다.
interface AAA
extends JpaRepository<TestEntity, Long> {
}
/* ---------------------------------------- */
//@Repository
class AAAImpl {
// @Autowired
// private AAA jpaRepo;
}
위 에러는 아래 Github
에서 직접 확인해 볼 수 있습니다.
위 예시를 보면 AAA
는 JPA
인터페이스이고, AAAImpl
는 일개 DTO
처럼 Spring 과 아무 연관이 없는 객체
입니다.
즉, 앱 실행시 AAA
는 Spring context
에 등록되지만 AAAImpl
는 절대로 context
에 등록되면 안됩니다.
@SpringBootApplication
public class TestingApplication {
public static void main(String[] args) {
ConfigurableApplicationContext context
= SpringApplication.run(TestingApplication.class, args);
System.out.println("JPA interface : ");
AAA jpaRepo = context.getBean(AAA.class);
showProperties(jpaRepo);
System.out.println("\nImpl : ");
AAAImpl impl = context.getBean(AAAImpl.class);
showProperties(impl);
}
private static void showProperties(Object o) {
System.out.println("As string\t\t\t\t\t: " + o.toString());
System.out.println("Class\t\t\t\t\t\t\t: " + o.getClass());
System.out.println("Name\t\t\t\t\t\t\t: " + o.getClass().getSimpleName());
System.out.printf("IdentityHashcode\t: 0x%08x\n", System.identityHashCode(o));
}
}
JPA interface :
As string : core.testing.AAAImpl@43cbafa6
Class : class jdk.proxy2.$Proxy96
Name : $Proxy96
IdentityHashcode : 0x182fd26b
Impl :
As string : core.testing.AAAImpl@43cbafa6
Class : class core.testing.AAAImpl
Name : AAAImpl
IdentityHashcode : 0x43cbafa6
하지만 실행해 확인해보면 충격적이게도 AAAImpl 가 context 에 존재
하는 걸 볼 수 있습니다.
저로서는 도저히 납득할 수 없는 상황이었습니다.
Spring Boot
부터 우리 눈에 보이지 않는 다양한 작업이 있는 건 알고 있었지만, 해당 상황은 Spring
의 기본 이념에서부터 어긋나는 이상한 상황이라 생각했습니다.
그래서 나름의 오기를 갖고 spring
라이브러리를 직접 디버깅해 원인을 파악해 보려 하였고, 이를 간단히 설명해 보려 합니다.
Spring autoconfiguration
으로 인한 JpaRepository
자동 활성화 & Custom Repository
등록 시도우리가 Spring Boot
를 실행시키면 Spring autoconfiguration
에 의해 JPA repository
를 탐색합니다.
때문에 spring-data-jpa
는 Custom repository
를 탐색합니다.
이 때 Spring
혹은 Spring Boot
가 제공하는 config
를 통해 Custom repository
의 후보자를 탐색, Bean
으로 등록을 시도합니다.
Configuration
& Metadata
로 Custom Repository
후보자 선정Custom repository
후보자 탐색은 configuration
과 클래스 Metadata
를 이용해 탐색합니다.
아래의 그림은 주어진 configuration
에 metadataFactory
제공해, Custom repository
를 look up
하는 모습입니다.
look up
시 주어진 Metadata
그대로 Repository configuration
으로 변경을 진행합니다.
그런데 Repository configuration
변경 시, 아래 그림처럼 AAAImpl
가 포함되고, 무엇보다 { (빈 이름 : AAA) + (구현체 postfix : 기본 Impl) }
로 Custom Repository
이름을 뱉는 걸 볼 수 있습니다.
AAAImpl
가 Bean
으로 등록되어 있었다면결국 위 look up
과정으로 Spring
은 AAAImpl
를 추가해야 할 Bean
으로 생각합니다.
그래서 먼저 registry
의 beanDefinitionMap
에서 해당 이름을 가진 인스턴스가 Spring context
에 존재하는지 확인합니다.
참고로 registry.containsBeanDefinition
메서드는 Map<String, BeanDefinition>
으로 정의된 인스턴스 (registry.beanDefinitionMap instance)
에 주어진 이름이 key
로 존재하는지 확인합니다.
때문에 만약 AAAImpl
이 @Repository
등의 어노테이션으로 이미 context
에 등록되어 있으면 결국 Circular Dependency
를 일으키게 됩니다.
@Repository // or @Component, @Service, @Controller etc...
public class AAAImpl {
@Autowired
private AAA jpaRepo;
}
참고로 Spring
은 AAAImpl
처럼 사용자가 정의한 Bean
을 외부 라이브러리가 제공하는 Bean
보다 먼저 생성하는 경향이 있습니다.
그래서 위 상황에선 AAAImpl
가 반드시 beanDefinitionMap
에 존재해 에러를 일으킵니다.
AAAImpl
가 Bean
이 아니라면반면 AAAImpl
가 Bean
이 아닐 경우, 주어진 lookup
을 이용해 존재하는 Metadata
에서 Custom Repository
를 더 자세히 탐지합니다.
아래 그림은 Custom Repository
를 탐색하는 로직을 설정하는 메서드입니다.
그림을 보시면 provider.addIncludeFilter( ... )
, provider.addExcludeFilter( ... )
를 통해 Custom Repository
로 인식, 배제하는 filter
를 설정합니다.
그런데 이상하게도 Custom Repo
대상으로 인식하는 filter
는 (reader, factory) -> true
형태로 "제외되지 않은 후보자는 모두 Custom Repository 로 설정"
하는 걸 볼 수 있습니다.
때문에 결국 AAAImpl
가 Bean
이 아니어도 Custom Repo
의 대상이 되어 Spring context
에 등록되게 됩니다.
처음엔 단순한 에러정도로 생각했습니다.
하지만 그 원인을 파악하고 라이브러리를 디버깅하며 이는 에러가 아닌 버그라 확신이 조금 들었습니다.
물론 spring-data-jpa
가 이를 의도한 것일 수도 있습니다. 저도 이를 고쳐보려고 다양한 방법을 시도했지만 MetadataReader
와 MetadataFactory
만으로는 고칠 방법이 떠오르지 않았습니다.
그래서 이를 확인하고자 spring-data-jpa
github
에 직접 질문을 올리기로 하였습니다.
위 issue
로 부터 제가 몰랐던 사실을 알게되거나 개선사항이 생기면 더 알려드리도록 하겠습니다.
[1] : Custom Repository Implementations - Spring Data JPA Document
[2] : JPA Repository 기본 postfix로 인한 순환참조 해결 - 당케's Tistory
[a]
: 사실 공식문서 [1]
자체에는 "연관된 구현체를 탐색한다"
는 언급은 없습니다. 단지 아래처럼 구성해 Custom Repository
를 이용할 수 있다 나와있습니다.
interface Custom {}
class CustomImpl implements Custom {}
interface Repository // Custom 인터페이스로 확장
extends JpaRepository< ... >, Custom {}
때문에 "문서에 의하면"
저희 예시가 아래처럼 구성되어야지만 Custom Repository
로 인식되어야 합니다.
// repo 규약
interface AAA {}
class AAAImpl {}
interface jpaRepo
extends JpaRepository< ... >, AAA {}
하지만 저희 예시가 위처럼 구성되지 않았음에도 에러가 발생하고, 무엇보다 예시를 실행하며 "빈 등록 에러"
를 발견하였기 때문에 "연관된 구현체를 탐색한다"
라고 결론지었습니다.
++ 디버깅해보니 "연관된 구현체를 탐색"
하는 것이 맞는 것 같습니다.
덕분에 간지러웠던 부분이 해결되었네요 즐 코딩하세요~