[JAVA] Circular dependency 문제 왜 생기고 어떻게 풀었는지

Spring Boot에서 순환 참조(Circular dependency)는 처음에는 단순한 설정 문제처럼 보이지만, 구조를 잘못 잡았을 때 자연스럽게 발생하는 신호이기도 합니다. 왜 발생하는지, 그리고 실무에서는 어떻게 풀어가는지 정리해보겠습니다.

Java Circular dependency 문제는 왜 생길까

Java, 특히 Spring Boot에서 Circular dependency는 두 개 이상의 Bean이 서로를 참조할 때 발생합니다. 쉽게 말하면 A가 B를 필요로 하고, B가 다시 A를 필요로 하는 구조입니다.

Spring의 DI 컨테이너는 Bean을 생성하면서 의존성을 주입하는데, 이 과정에서 생성 순서를 결정할 수 없으면 순환 참조로 판단합니다. 생성이 끝나지 않은 객체를 다시 요구하게 되기 때문입니다.

 

가장 흔한 형태


@Service
public class AService {
    private final BService bService;

    public AService(BService bService) {
        this.bService = bService;
    }
}

@Service
public class BService {
    private final AService aService;

    public BService(AService aService) {
        this.aService = aService;
    }
}

이 구조는 직관적으로도 순환이 보입니다. 실제 프로젝트에서는 이보다 더 복잡하게 얽혀 있어서 바로 눈에 들어오지 않는 경우가 많습니다.

 

Circular dependency가 생기는 진짜 이유

단순히 코드 실수라기보다는 설계 단계에서 책임이 섞였을 때 자주 발생합니다. 특히 서비스 레이어에서 역할 구분이 흐려지면 자연스럽게 생깁니다.

예를 들어 주문 서비스와 결제 서비스가 서로를 직접 호출하는 구조는 처음에는 편해 보이지만, 점점 로직이 커지면서 서로 의존하게 됩니다.

실무에서 자주 보이는 패턴

서비스 간 역할이 명확하지 않을 때 발생합니다.

  • 비즈니스 로직이 한쪽에 몰려 있다가 분리되는 과정
  • 편의를 위해 다른 서비스 메서드를 그대로 호출하는 구조
  • 도메인 경계 없이 서비스가 확장된 경우

처음에는 문제 없이 동작하다가, 리팩토링이나 기능 추가 시점에 순환 구조가 드러나는 경우가 많습니다.

 

Spring이 예전에는 허용했는데 지금은 막는 이유

과거 Spring은 setter 기반 주입에서는 순환 참조를 어느 정도 허용했습니다. 내부적으로 proxy를 만들어서 해결했기 때문입니다.

하지만 생성자 주입이 권장되면서 상황이 달라졌습니다. 생성자 주입은 객체 생성 시점에 모든 의존성이 확정되어야 하기 때문에 순환 구조를 해결할 수 없습니다.

이 때문에 최신 Spring Boot에서는 기본적으로 Circular dependency를 막는 방향으로 설정되어 있습니다.

 

해결 방법 1: 구조를 나누는 것이 가장 깔끔하다

가장 추천하는 방식은 중간 역할을 하는 컴포넌트를 만들어서 의존성을 끊는 것입니다.


@Service
public class OrderService {
    private final PaymentFacade paymentFacade;
}

@Service
public class PaymentService {
    private final PaymentFacade paymentFacade;
}

@Service
public class PaymentFacade {
    private final OrderService orderService;
    private final PaymentService paymentService;
}

이 방식은 의존 방향을 한쪽으로 모으는 구조입니다. 서비스끼리 직접 참조하지 않도록 만드는 것이 핵심입니다.

협업 관점에서도 이 방식이 읽기 쉽고, 책임 분리가 명확해집니다.

 

해결 방법 2: @Lazy로 임시 회피

급하게 문제를 해결해야 할 때는 @Lazy를 사용할 수 있습니다.


public BService(@Lazy AService aService) {
    this.aService = aService;
}

이 방식은 실제 Bean 생성 시점을 늦춰서 순환 참조를 피합니다.

다만 구조 자체는 그대로 유지되기 때문에 장기적으로는 추천하지 않습니다. 유지보수 단계에서 의존 관계를 파악하기 어려워집니다.

 

해결 방법 3: 인터페이스로 의존 방향 분리

구현체가 아니라 인터페이스를 기준으로 의존하도록 바꾸는 방법도 있습니다.

이 경우에도 결국은 책임을 나누는 방향으로 가야 합니다. 단순히 인터페이스만 분리하고 구조는 그대로 두면 근본적인 해결은 되지 않습니다.

 

실무 기준으로 보는 해결 우선순위

Circular dependency를 발견했을 때는 단순히 에러를 없애는 것보다 구조를 먼저 의심하는 편이 좋습니다.

  • 1순위: 서비스 책임 분리 (구조 개선)
  • 2순위: 중간 계층 도입 (Facade, Manager 등)
  • 3순위: @Lazy 사용 (임시 대응)

특히 서비스 간 양방향 참조는 대부분 설계 문제로 이어집니다. 의존 방향을 한쪽으로 흐르게 만드는 것이 장기적으로 안정적입니다.

 

마무리 정리

Java Circular dependency는 단순한 설정 오류가 아니라 설계의 냄새로 보는 편이 더 맞습니다.

에러를 해결하는 것보다 중요한 것은 왜 이 구조가 생겼는지 파악하는 것입니다. 그 과정을 통해 서비스 경계가 더 명확해지고, 코드 읽기도 훨씬 편해집니다.

실무에서는 빠르게 @Lazy로 넘길 수도 있지만, 결국 다시 돌아와서 구조를 정리하게 됩니다. 처음부터 의존 방향을 단방향으로 설계하는 것이 가장 깔끔한 접근입니다.