[Spring] 순환 참조의 올바른 방향

HenryHong·2026년 1월 8일

Spring

목록 보기
3/4
post-thumbnail

우리는 스프링을 배울 때 “DI는 생성자 주입이 기본”이라고 배우고, 실제로도 그렇게 개발한다. 그런데 프로젝트가 커지다 보면 어느 순간 빈 순환 참조가 터지고, IDE가 생성자에 빨간 줄을 그어 준다.

1년 차 때 나도 Spring Boot + JPA로 개발하면서 이 문제를 처음 겪었고, 그때는 깊게 고민하지 않고 생성자 주입을 필드 주입으로 바꿔서 에러를 없앴다. “왜 이렇게 하면 되지?”를 생각하기보다는, 그냥 StackOverflow 형님들이 알려준 대로 따라 했을 뿐이었다.

지금은 순환 참조가 생기는지, 그리고 정석으로는 어떻게 해결해야 하는지를 이해하고 있어서 이 글에서 정리해 보려고 한다. 핵심만 말하면 이렇다.

정답인 방향: 설계 단계에서 순환 의존 자체를 제거하고, 의존성 주입은 생성자 주입을 기본으로 가져간다.
임시 우회책: 이미 생겨버린 순환 참조를 당장 깨야 하는 상황이라면, 일부 빈만 세터/필드 주입(+ @Lazy) 로 돌려서 “일단 서비스는 뜨게 만드는” 응급처치로 쓸 수 있다.

즉, 생성자 주입 + 순환 의존 제거가 아키텍처 관점의 정답이고,
세터/필드 주입으로 순환을 끊는 건 “근본 해결”이 아니라 단기적인 회피 전략이라는 점을 분명히 하고 가는 게 중요하다.


1. 왜 스프링은 생성자 주입을 기본으로 추천할까

생성자 주입이 권장되는 이유는 이미 잘 알고 있을 거다.

  • 의존성이 불변(final)이라 설계가 명확해짐.
  • 필수 의존성이 생성 시점에 다 채워지므로 “null 상태” 위험이 줄어듦.
  • 순환 의존이 생기면 애초에 애플리케이션이 뜨지 않아서, 구조 문제가 빨리 드러남.

즉, 생성자 주입은 “순환 의존을 만들기 어렵게” 만드는 좋은 제약이기도 하다.
Spring 공식 문서/커뮤니티에서도 기본 선택은 항상 생성자 주입으로 보라고 안내한다.

자세한 예시

1) 의존성이 불변(final)이라 설계가 명확해짐

나쁜 예: 필드 주입 (숨은 의존성 + 가변 상태)

@Service
public class OrderService {

    @Autowired
    private PaymentClient paymentClient; // 어디서 어떻게 들어오는지 밖에서 안 보임

    public void pay(Order order) {
        paymentClient.requestPayment(order);
    }
}
  • 이 클래스를 단독으로 봤을 때는:
    • OrderService가 어떤 의존성을 꼭 가져야 하는지 명확하지 않다.
    • 테스트 코드에서 new로 만들면 paymentClientnull 이라 또 다른 문제가 된다.

좋은 예: 생성자 + final (의존성이 드러남)

@Service
public class OrderService {

    private final PaymentClient paymentClient;

    public OrderService(PaymentClient paymentClient) {
        this.paymentClient = paymentClient; // 생성 시점에 반드시 주입
    }

    public void pay(Order order) {
        paymentClient.requestPayment(order);
    }
}
  • OrderServicePaymentClient 없이는 의미가 없는 서비스라는 것이 설계 레벨에서 드러난다.

  • 스프링 없이 순수 자바 코드로 테스트할 때도:

    PaymentClient fake = new FakePaymentClient();
    OrderService service = new OrderService(fake);

    처럼 의존성이 강제되기 때문에 “숨은 의존성” 문제가 없다.


2) 필수 의존성이 생성 시점에 다 채워져서 null 위험 감소

필드/세터 주입에서 흔한 NPE 패턴

@Service
public class EmailService {

    @Autowired
    private EmailSender emailSender;

    public void sendWelcome(User user) {
        emailSender.send("WELCOME", user.getEmail()); // 여기가 NPE 포인트
    }
}
  • 실수로 new EmailService() 로 직접 생성하거나,

  • 테스트에서 스프링 컨텍스트 없이 인스턴스를 만들면:

    EmailService service = new EmailService();
    service.sendWelcome(user); // emailSender == null → NPE
  • 필드는 @Autowired라고 해서 언제나 초기화된다는 보장이 없다
    (스프링이 관리하지 않는 객체에서는 아예 주입이 안 됨).

생성자 주입으로 null 자체를 막는 예

@Service
public class EmailService {

    private final EmailSender emailSender;

    public EmailService(EmailSender emailSender) {
        this.emailSender = Objects.requireNonNull(emailSender);
    }

    public void sendWelcome(User user) {
        emailSender.send("WELCOME", user.getEmail());
    }
}
  • EmailService를 만들려면 무조건 EmailSender를 넘겨야 한다.
  • 못 넘기면 컴파일/런타임에서 즉시 실패하고,
    최소한 “null인 상태로 돌아다니는 객체”는 만들 수 없다.

3) 순환 의존이 생기면 애플리케이션이 뜨지 않아 구조 문제가 빨리 드러남

순환 의존 예: A ↔ B

@Service
public class AService {
    private final BService b;

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

@Service
public class BService {
    private final AService a;

    public BService(AService a) {
        this.a = a;
    }
}
  • 스프링이 시작하면서:

    1. AService 생성 → BService 필요
    2. BService 생성 → 다시 AService 필요
    3. 서로를 요구하다가 BeanCurrentlyInCreationException 같은 예외를 던지고
      애플리케이션이 부팅 단계에서 바로 실패한다.

이게 오히려 장점이다.

  • “순환 구조가 애초에 잘못됐다”는 사실을 아주 이른 시점에 강하게 알려준다.
  • 운영 중 애매한 버그 대신, 시작 단계에서 바로 문제를 발견하고 설계를 다시 보게 만든다.

세터/필드 주입이면 어떻게 되나

@Service
public class AService {
    @Autowired
    private BService b;
}

@Service
public class BService {
    @Autowired
    private AService a;
}
  • 스프링이 둘 다 먼저 인스턴스를 만들고, 나중에 세터/필드로 서로 주입할 수 있어서
    애플리케이션은 일단 뜬다.
  • 대신:
    • 라이프사이클 복잡,
    • 어디서 어떤 타이밍에 null인지/아닌지 추적이 어려움,
    • 구조적으로는 여전히 꼬여 있는데 눈에 잘 안 띄는 상태로 남는다.

그래서 생성자 주입은:

순환 의존이 있으면 “애초에 안 뜨게” 만들어서,
구조 문제가 운영 중이 아니라 개발 단계에서 바로 드러나게 해준다.


2. Spring Boot 2.6 이후와의 관계

Spring Boot 2.6부터는 순환 참조가 기본 금지다.

  • 과거: 세터/필드 주입 기반의 순환 참조를 Spring이 적당히 처리해 줌.
  • 지금: 순환 그래프가 발견되면 바로 예외 내고 애플리케이션 기동을 막는다.

우회 방법으로 공식적으로 언급되는 건 대략 세 가지 정도다.

  1. spring.main.allow-circular-references=true
    → 전역 설정으로 다시 허용 (추천 X).
  2. 일부 빈을 세터/필드 주입 + @Lazy로 바꿔서 순환 고리 늦게 연결.
  3. 비즈니스 구조를 바꿔서 근본적으로 순환을 제거.

당신이 본 “생성자 대신 세터/필드 주입을 사용한다”는 답은, 이 중 2번 계열의 우회책에 해당한다.


3. 그러면 “권장사항에 위배”가 맞는가?

정리하면 이렇게 보는 게 정확하다.

  • 아키텍처 관점(디자인 원칙)
    • 생성자 주입을 기본으로 쓰고,
    • 순환 의존이 생기면 설계를 재검토해서 끊는 게 정석이다.
  • 운영/실무에서 당장 서비스는 띄워야 하는 비상 상황
    • 구조를 당장 크게 바꾸기 어려울 때,
    • 한쪽을 세터/필드 주입(+ @Lazy, @PostConstruct 등)으로 바꿔 순환을 늦게 연결하는 임시 방편을 쓸 수 있다.

실무에서는:

  • 새로 짜는 코드: 생성자 주입 + 순환 의존 자체를 만들지 않기
  • 레거시나 급한 장애: 일시적으로 세터/필드 주입 + @Lazy / 구조 분리로 버텨두고,
    나중에 리팩터링 백로그에 “순환 제거”를 확실하게 올려두는 쪽이 현실적인 선택지다.

[참조]
https://dev.to/xuan_56087d315ff4f52254e6/resolving-circular-dependencies-between-spring-beans-using-constructor-injection-42f2
https://www.baeldung.com/circular-dependencies-in-spring
https://madplay.github.io/post/why-constructor-injection-is-better-than-field-injection
https://www.reddit.com/r/SpringBoot/comments/11v0ers/when_to_use_constructor_injection_vs_setter/
https://stackoverflow.com/questions/21182230/spring-circular-dependencies-on-constructor-setter-injection
https://www.geeksforgeeks.org/java/circular-dependencies-in-spring/

profile
주니어 백엔드 개발자

0개의 댓글