[Troubleshooting] - Spring-data-jpa 의 순환 의존 에러

청주는사과아님·2024년 11월 9일
1

Troubleshooting

목록 보기
2/7

저희 프로젝트는 처음부터 MyBatis 에서 Spring-data-JPA 로 마이그레이션을 계획했습니다.

저희는 주로 MyBatismapper 를 선언하고 DAODI 하는 형태로 사용했습니다.

그래서 마이그레이션 당시 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 proxySpring context 에 등록하는 과정 중 '연관된 구현체' 가 있는지 탐색하며 [a],

이 때 연관된 구현체(interface 이름) + (Impl) 이름으로 된 구현체default 로 탐색한다고 합니다.

이러한 경우 아래처럼 순환 의존 에러가 발생합니다.

  1. 스프링이 JPA interfaceAAAcontext 에 등록을 시도하며 연관된 구체가 있는지 탐색합니다.
    이 때 연관된 구현체는 오직 이름으로만 판별합니다. (AAA + Impl)
  2. 그런데 공교롭게도 저희는 Custom Repository 의 대상이 아니지만 AAAImpl 인 클래스가 존재합니다. 때문에 SpringAAAImplcontext 에 등록을 시도합니다.
  3. 하지만 AAAImpl 내부에는 AAA 가 존재합니다. 즉, AAA context 등록 중 자기자신을 만났습니다.
  4. 때문에 Spring 은 순환 의존 에러를 일으켜 Bean 등록을 중지합니다.

✅ 문제 해결 방법

결국 우리가 발견한 순환 의존 에러는 클래스 이름 때문 에 발생한 것이었습니다. 때문에 이를 해결하는 방법은 이를 조작하거나 변경하면 간단히 해결됩니다.

I. @EnableJpaRepositories(repositoryImplementationPostfix=" ... ")

@EnableJpaRepositoriesJPA 저장소를 활성시키는 어노테이션으로, Spring autoconfiguration 으로 인해 JPA repository 가 발견되면 자동으로 활성화 됩니다.

그런데 이 어노테이션의 repositoryImplementationPostfix 으로 Custom Repository 를 탐색할 접미사 (Postfix) 를 명시할 수 있습니다.

때문에 아래처럼 코드를 변경하면 에러를 회피할 수 있습니다.

@EnableJpaRepositories(repositoryImplementationPostfix = "CustomImpl")
@Repository
public class AAAImpl {

    @Autowired
    private AAA jpaRepo;
}

II. 구현체 이름 변경하기

아니면 차라리 AAAImpl 이름을 변경하는 방법도 존재합니다.

애초에 spring-data-jpaCustom Repository 를 탐색하는 방식이 (interface 이름) + (Impl) 이기 때문에 AAAImplAAAJpaImpl 처럼 변경하면 에러가 발생하지 않습니다.


❓ Possible Bug?

결국 에러가 발생한 원인은 클래스의 이름 때문이었습니다.

처음엔 이것이 정말 믿기지 않아서 더 다양한 상황을 만들어 테스트해 보았고, 그러다 "Spring 과 연관 없는 객체가 Spring context 에 등록되는 에러" 를 발견하였습니다.

interface AAA
        extends JpaRepository<TestEntity, Long> {
    
}

/* ---------------------------------------- */

//@Repository
class AAAImpl {

//    @Autowired
//    private AAA jpaRepo;
}

위 에러는 아래 Github 에서 직접 확인해 볼 수 있습니다.

위 예시를 보면 AAAJPA 인터페이스이고, AAAImpl 는 일개 DTO 처럼 Spring 과 아무 연관이 없는 객체 입니다.

즉, 앱 실행시 AAASpring 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 라이브러리를 직접 디버깅해 원인을 파악해 보려 하였고, 이를 간단히 설명해 보려 합니다.


1. Spring autoconfiguration 으로 인한 JpaRepository 자동 활성화 & Custom Repository 등록 시도

우리가 Spring Boot 를 실행시키면 Spring autoconfiguration 에 의해 JPA repository 를 탐색합니다.

때문에 spring-data-jpaCustom repository 를 탐색합니다.

이 때 Spring 혹은 Spring Boot 가 제공하는 config 를 통해 Custom repository 의 후보자를 탐색, Bean 으로 등록을 시도합니다.


2. 앱 Configuration & MetadataCustom Repository 후보자 선정

Custom repository 후보자 탐색은 configuration 과 클래스 Metadata 를 이용해 탐색합니다.

아래의 그림은 주어진 configurationmetadataFactory 제공해, Custom repositorylook up 하는 모습입니다.

look up 시 주어진 Metadata 그대로 Repository configuration 으로 변경을 진행합니다.

그런데 Repository configuration 변경 시, 아래 그림처럼 AAAImpl 가 포함되고, 무엇보다 { (빈 이름 : AAA) + (구현체 postfix : 기본 Impl) }Custom Repository 이름을 뱉는 걸 볼 수 있습니다.


3. 만약 AAAImplBean 으로 등록되어 있었다면

결국 위 look up 과정으로 SpringAAAImpl 를 추가해야 할 Bean 으로 생각합니다.

그래서 먼저 registrybeanDefinitionMap 에서 해당 이름을 가진 인스턴스가 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;
}

참고로 SpringAAAImpl 처럼 사용자가 정의한 Bean 을 외부 라이브러리가 제공하는 Bean 보다 먼저 생성하는 경향이 있습니다.
그래서 위 상황에선 AAAImpl 가 반드시 beanDefinitionMap 에 존재해 에러를 일으킵니다.


4. 만약 AAAImplBean 이 아니라면

반면 AAAImplBean 이 아닐 경우, 주어진 lookup 을 이용해 존재하는 Metadata 에서 Custom Repository 를 더 자세히 탐지합니다.

아래 그림은 Custom Repository 를 탐색하는 로직을 설정하는 메서드입니다.

그림을 보시면 provider.addIncludeFilter( ... ), provider.addExcludeFilter( ... ) 를 통해 Custom Repository 로 인식, 배제하는 filter 를 설정합니다.

그런데 이상하게도 Custom Repo 대상으로 인식하는 filter(reader, factory) -> true 형태로 "제외되지 않은 후보자는 모두 Custom Repository 로 설정" 하는 걸 볼 수 있습니다.

때문에 결국 AAAImplBean 이 아니어도 Custom Repo 의 대상이 되어 Spring context 에 등록되게 됩니다.


처음엔 단순한 에러정도로 생각했습니다.

하지만 그 원인을 파악하고 라이브러리를 디버깅하며 이는 에러가 아닌 버그라 확신이 조금 들었습니다.

물론 spring-data-jpa 가 이를 의도한 것일 수도 있습니다. 저도 이를 고쳐보려고 다양한 방법을 시도했지만 MetadataReaderMetadataFactory 만으로는 고칠 방법이 떠오르지 않았습니다.

그래서 이를 확인하고자 spring-data-jpa github 에 직접 질문을 올리기로 하였습니다.

issue 로 부터 제가 몰랐던 사실을 알게되거나 개선사항이 생기면 더 알려드리도록 하겠습니다.


📝 Reference

💬 Comment

  • [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 {}

    하지만 저희 예시가 위처럼 구성되지 않았음에도 에러가 발생하고, 무엇보다 예시를 실행하며 "빈 등록 에러" 를 발견하였기 때문에 "연관된 구현체를 탐색한다" 라고 결론지었습니다.

    ++ 디버깅해보니 "연관된 구현체를 탐색" 하는 것이 맞는 것 같습니다.

profile
나 같은게... 취준?!

1개의 댓글

comment-user-thumbnail
2024년 11월 10일

덕분에 간지러웠던 부분이 해결되었네요 즐 코딩하세요~

답글 달기