에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/프레임워크 & 언어

[Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점

반응형
[Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점

[Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점

"The dependencies of some of the beans in the application context form a cycle" — 기동이 안 되는 그 에러예요. 스프링 부트 2.6부터 순환 참조가 기본 금지라, 옛 코드를 올리다 처음 만나는 경우가 많아요. 급한 마음에 설정으로 허용해버리기 쉬운데, 이 에러는 버그가 아니라 설계 경고예요.

순환 참조가 왜 문제인지, 끊는 법 세 가지(구조 추출·이벤트·@Lazy), 그리고 같은 "기동 시점" 주제인 @PostConstruct의 함정과 ApplicationReadyEvent를 차례로 짚어요.

01. 순환 참조, 닭과 달걀

스프링 순환 참조. OrderService가 UserService를 필요로 하고 UserService도 OrderService를 필요로 하면 생성자 주입에서 기동이 실패한다. 스프링 부트 2.6부터 기본 금지이며 이는 설계 경고다

A의 생성자가 B를 요구하고 B의 생성자가 A를 요구하면, 스프링은 어느 쪽도 먼저 완성할 수 없어요. 그래서 생성자 주입에서는 기동이 즉시 실패해요.

"필드 주입(@Autowired) 시절엔 됐는데?"라는 의문이 들 수 있어요. 맞아요 — 필드 주입은 일단 빈 객체를 만들고 나중에 필드를 채우니, 미완성 객체를 서로 참조시키는 식으로 몰래 돌아갔어요. 부트 2.6이 기본 금지로 바꾼 건, 그 "몰래"가 위험해서예요. 미완성 빈이 노출되는 타이밍 버그도 있지만 두 컴포넌트가 서로를 안다 = 책임 경계가 무너졌다는 신호거든요.

spring.main.allow-circular-references=true로 덮고 싶은 유혹이 있는데, 그건 경고등에 테이프를 붙이는 거예요. 순환은 그대로 남아서 — 리팩토링을 막고 테스트를 어렵게 해요(둘을 항상 같이 띄워야 함). 어느 날은 프록시·AOP와 얽혀 더 이상한 버그로 돌아오기도 해요. 게다가 이 플래그는 setter·필드 주입이 낀 사이클만 살려줘요. 생성자끼리의 순환은 켜도 그대로 기동 실패라, 어차피 구조를 풀어야 해요. 마이그레이션 중의 한시적 우회까지만 허용하고 끊는 게 답이에요.

02. 끊는 법, 구조가 정석이에요

순환 참조 해법. 서로 원하던 기능을 별도 컴포넌트로 추출해 의존을 한 방향으로 만드는 게 정석이고, 이벤트로 의존 방향을 제거할 수도 있으며, Lazy는 기동만 시키는 임시방편이다

① 공통 책임 추출

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는 "빈 하나의 의존성 주입이 끝난 직후"에 불리는데, 그 시점은 생각보다 이른 시점이에요.

PostConstruct 시점 함정. 프록시 적용 전이라 자기 자신의 Transactional이나 Async 호출이 동작하지 않을 수 있고, 무거운 작업은 기동 시간과 헬스체크에 문제를 만든다. 기동 완료 후 1회 실행은 ApplicationReadyEvent가 정답이다
  • 자기 자신의 @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

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."