우리는 스프링을 배울 때 “DI는 생성자 주입이 기본”이라고 배우고, 실제로도 그렇게 개발한다. 그런데 프로젝트가 커지다 보면 어느 순간 빈 순환 참조가 터지고, IDE가 생성자에 빨간 줄을 그어 준다.
1년 차 때 나도 Spring Boot + JPA로 개발하면서 이 문제를 처음 겪었고, 그때는 깊게 고민하지 않고 생성자 주입을 필드 주입으로 바꿔서 에러를 없앴다. “왜 이렇게 하면 되지?”를 생각하기보다는, 그냥 StackOverflow 형님들이 알려준 대로 따라 했을 뿐이었다.
지금은 순환 참조가 왜 생기는지, 그리고 정석으로는 어떻게 해결해야 하는지를 이해하고 있어서 이 글에서 정리해 보려고 한다. 핵심만 말하면 이렇다.
정답인 방향: 설계 단계에서 순환 의존 자체를 제거하고, 의존성 주입은 생성자 주입을 기본으로 가져간다.
임시 우회책: 이미 생겨버린 순환 참조를 당장 깨야 하는 상황이라면, 일부 빈만 세터/필드 주입(+ @Lazy) 로 돌려서 “일단 서비스는 뜨게 만드는” 응급처치로 쓸 수 있다.
즉, 생성자 주입 + 순환 의존 제거가 아키텍처 관점의 정답이고,
세터/필드 주입으로 순환을 끊는 건 “근본 해결”이 아니라 단기적인 회피 전략이라는 점을 분명히 하고 가는 게 중요하다.
생성자 주입이 권장되는 이유는 이미 잘 알고 있을 거다.
즉, 생성자 주입은 “순환 의존을 만들기 어렵게” 만드는 좋은 제약이기도 하다.
Spring 공식 문서/커뮤니티에서도 기본 선택은 항상 생성자 주입으로 보라고 안내한다.
@Service
public class OrderService {
@Autowired
private PaymentClient paymentClient; // 어디서 어떻게 들어오는지 밖에서 안 보임
public void pay(Order order) {
paymentClient.requestPayment(order);
}
}
OrderService가 어떤 의존성을 꼭 가져야 하는지 명확하지 않다.paymentClient가 null 이라 또 다른 문제가 된다.final (의존성이 드러남)@Service
public class OrderService {
private final PaymentClient paymentClient;
public OrderService(PaymentClient paymentClient) {
this.paymentClient = paymentClient; // 생성 시점에 반드시 주입
}
public void pay(Order order) {
paymentClient.requestPayment(order);
}
}
OrderService가 PaymentClient 없이는 의미가 없는 서비스라는 것이 설계 레벨에서 드러난다.
스프링 없이 순수 자바 코드로 테스트할 때도:
PaymentClient fake = new FakePaymentClient();
OrderService service = new OrderService(fake);
처럼 의존성이 강제되기 때문에 “숨은 의존성” 문제가 없다.
@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라고 해서 언제나 초기화된다는 보장이 없다
(스프링이 관리하지 않는 객체에서는 아예 주입이 안 됨).
@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를 넘겨야 한다.@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;
}
}
스프링이 시작하면서:
AService 생성 → BService 필요BService 생성 → 다시 AService 필요이게 오히려 장점이다.
@Service
public class AService {
@Autowired
private BService b;
}
@Service
public class BService {
@Autowired
private AService a;
}
그래서 생성자 주입은:
순환 의존이 있으면 “애초에 안 뜨게” 만들어서,
구조 문제가 운영 중이 아니라 개발 단계에서 바로 드러나게 해준다.
Spring Boot 2.6부터는 순환 참조가 기본 금지다.
우회 방법으로 공식적으로 언급되는 건 대략 세 가지 정도다.
spring.main.allow-circular-references=true@Lazy로 바꿔서 순환 고리 늦게 연결.당신이 본 “생성자 대신 세터/필드 주입을 사용한다”는 답은, 이 중 2번 계열의 우회책에 해당한다.
정리하면 이렇게 보는 게 정확하다.
실무에서는:
[참조]
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/