[Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점
![[Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점](https://blog.kakaocdn.net/dna/bR7ufq/dJMcabYNmOz/AAAAAAAAAAAAAAAAAAAAAOhz7K55OGG_PGE2YZob1dhB4k6JQfgNM6v16Z6mxnh1/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=kFoIOr5UbRRWHcl7ekZ7qhTbNVY%3D)
[Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점
"The dependencies of some of the beans in the application context form a cycle" — 기동이 안 되는 그 에러예요. 스프링 부트 2.6부터 순환 참조가 기본 금지라, 옛 코드를 올리다 처음 만나는 경우가 많아요. 급한 마음에 설정으로 허용해버리기 쉬운데, 이 에러는 버그가 아니라 설계 경고예요.
순환 참조가 왜 문제인지, 끊는 법 세 가지(구조 추출·이벤트·@Lazy), 그리고 같은 "기동 시점" 주제인 @PostConstruct의 함정과 ApplicationReadyEvent를 차례로 짚어요.
01. 순환 참조, 닭과 달걀

A의 생성자가 B를 요구하고 B의 생성자가 A를 요구하면, 스프링은 어느 쪽도 먼저 완성할 수 없어요. 그래서 생성자 주입에서는 기동이 즉시 실패해요.
"필드 주입(@Autowired) 시절엔 됐는데?"라는 의문이 들 수 있어요. 맞아요 — 필드 주입은 일단 빈 객체를 만들고 나중에 필드를 채우니, 미완성 객체를 서로 참조시키는 식으로 몰래 돌아갔어요. 부트 2.6이 기본 금지로 바꾼 건, 그 "몰래"가 위험해서예요. 미완성 빈이 노출되는 타이밍 버그도 있지만 두 컴포넌트가 서로를 안다 = 책임 경계가 무너졌다는 신호거든요.
spring.main.allow-circular-references=true로 덮고 싶은 유혹이 있는데, 그건 경고등에 테이프를 붙이는 거예요. 순환은 그대로 남아서 — 리팩토링을 막고 테스트를 어렵게 해요(둘을 항상 같이 띄워야 함). 어느 날은 프록시·AOP와 얽혀 더 이상한 버그로 돌아오기도 해요. 게다가 이 플래그는 setter·필드 주입이 낀 사이클만 살려줘요. 생성자끼리의 순환은 켜도 그대로 기동 실패라, 어차피 구조를 풀어야 해요. 마이그레이션 중의 한시적 우회까지만 허용하고 끊는 게 답이에요.
02. 끊는 법, 구조가 정석이에요

① 공통 책임 추출
A가 B에서 원하는 것과 B가 A에서 원하는 걸 들여다보면, 보통 둘 다 필요로 하는 제3의 책임이 숨어 있어요. 그걸 C로 추출하면 A→C←B로 화살표가 한 방향이 돼요. 예를 들어 주문과 회원이 서로를 부르고 있다면, 실은 "포인트 계산"이라는 별도 도메인이 끼어 있는 경우 — 그걸 PointService로 빼는 식이에요. 순환 해결이 곧 설계 개선이 되는, 가장 권장하는 길이에요.
② 이벤트로 역전
B가 A를 직접 부르는 대신, B는 이벤트를 발행하고 A가 구독해요. 의존 자체가 사라져요. 이벤트 편에서 본 그 패턴이 순환 해소 도구이기도 한 거예요. "주문 완료 시 회원 등급 갱신" 같은 부가 흐름이면 이게 자연스러워요.
③ @Lazy, 임시방편
public OrderService(@Lazy UserService userService) {
this.userService = userService; // 진짜 대신 지연 프록시 주입
}
한쪽에 지연 프록시를 주입해 "만들 때"의 순환만 피하는 거예요. 기동은 되지만 순환 구조 자체는 그대로라, 문제를 첫 호출 시점으로 미룬 것뿐이에요. 리팩토링 일정 잡기 전 응급처치까지만 써요.
03. @PostConstruct, 아직 완전한 세상이 아니에요
기동 시점의 또 다른 함정이 초기화 메서드예요. @PostConstruct는 "빈 하나의 의존성 주입이 끝난 직후"에 불리는데, 그 시점은 생각보다 이른 시점이에요.

- 자기 자신의 @Transactional·@Async가 안 먹을 수 있어요. 초기화 콜백은 프록시 적용이 끝나기 전의 원본 객체에서 실행될 수 있어요. 게다가 자기 메서드 호출은 자기호출 문제까지 겹쳐요. "초기화에서 DB 세팅을 트랜잭션으로 했는데 트랜잭션이 없네?"가 이 조합이에요.
- 무거운 작업은 기동을 인질로 잡아요. 외부 API 대기, 대량 캐시 워밍을 @PostConstruct에서 하면 기동 시간이 그만큼 늘어요. 컨테이너 환경에서는 헬스체크 타임아웃 → 재시작 → 또 초기화... 루프의 시작이 되기도 해요.
"기동이 다 끝난 뒤 한 번 실행"이 필요하면 정답은 ApplicationReadyEvent예요.
@Component
@RequiredArgsConstructor
public class CacheWarmer {
@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
// 모든 빈·프록시 준비 완료 + 트래픽 받기 직전 시점
// 트랜잭션·@Async 전부 정상 동작
}
}
@PostConstruct는 필드 검증·내부 자료구조 셋업 같은 가벼운 일까지만, "스프링 기능을 쓰는 초기화"는 ApplicationReadyEvent로 — 이 분담이 깔끔해요.
04. 자주 만나는 문제
부트 업그레이드 후 순환 참조 에러로 기동이 안 돼요
2.6+ 기본 금지에 걸린 거예요. 에러 메시지에 순환 고리가 그려져 나오니, 그 고리에서 공통 책임 추출·이벤트 역전으로 한 곳을 끊어요. allow-circular-references는 마이그레이션 한시 우회까지만요.
@PostConstruct에서 한 DB 작업이 트랜잭션 없이 돌았어요
프록시 미적용+자기호출 조합이에요. ApplicationReadyEvent로 옮기고 트랜잭션 단위는 별도 빈으로 분리해요.
기동이 느려졌어요
초기화 콜백들의 무거운 작업을 의심해요. 기동 필수가 아닌 워밍은 ApplicationReadyEvent + @Async로 미루면 기동이 가벼워져요.
정리
순환 참조 에러는 버그가 아니라 설계가 보내는 경고예요. 그래서 덮지 말고 끊는 게 답이고, 끊는 방법은 공통 책임 추출이나 이벤트 역전이 먼저예요 — @Lazy와 허용 설정은 일정을 잡기 전까지의 임시방편일 뿐이고요. 기동 시점 작업도 같은 결의 분담이 필요해요. 가벼운 내부 초기화는 @PostConstruct에 두고, 스프링 기능(트랜잭션·@Async)을 쓰는 본격 작업은 모든 준비가 끝난 ApplicationReadyEvent로 미뤄요. 둘 다 "스프링이 다 준비되기 전 시점"을 의식하는 습관에서 나와요.
시리즈 마지막인 다음 글에서는 지금까지의 함정 전부를 증상별로 묶는 트러블슈팅 허브로 마무리해요.
출처: Spring Framework — Dependency Injection · Spring Boot — Application Events
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Java] 돈 계산에 double 쓰면 안 되는 이유: BigDecimal 사용법과 함정 (0) | 2026.08.13 |
|---|---|
| [Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드 (0) | 2026.08.05 |
| [Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파 (0) | 2026.08.03 |
| [Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍 (0) | 2026.07.31 |
| [Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결 (0) | 2026.07.30 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."