서로를 참조하는 두 빈 A, B가 있다. 필드 주입(@Autowired 필드)으로 쓰면 애플리케이션이 뜨는데, 똑같은 관계를 생성자 주입으로 바꾸면 BeanCurrentlyInCreationException이 터진다. "Spring이 3-level 캐시로 순환참조를 푼다"는 말은 들어봤지만, 그렇다면 왜 생성자 주입은 그 캐시의 혜택을 못 받는지, 그리고 그 캐시가 왜 하필 2단계가 아니라 3단계인지는 설명하기 어려웠다.
이 글은 Spring Framework 소스(DefaultSingletonBeanRegistry, AbstractAutowireCapableBeanFactory)를 따라가며 세 개의 맵이 각각 무슨 역할을 하는지, 그리고 3단계가 담는 것이 왜 객체가 아니라 팩토리인지를 정리한 결과다.
DefaultSingletonBeanRegistry는 싱글톤을 세 단계로 나눠 들고 있다.
| 레벨 | 필드 | 담는 것 | 의미 |
|---|---|---|---|
| 1 | singletonObjects | 완성된 빈 | 초기화까지 끝난 최종 싱글톤 |
| 2 | earlySingletonObjects | 조기 참조(객체) | 노출됐지만 아직 프로퍼티 주입 미완 |
| 3 | singletonFactories | ObjectFactory | 조기 참조를 생성하는 팩토리 (아직 호출 안 함) |
여기에 더해 singletonsCurrentlyInCreation(지금 생성 중인 빈 이름 Set)이 "이 빈이 순환에 걸려 있는가"를 판정하는 데 쓰인다.
핵심은 조회 순서다. getSingleton은 L1 → L2 → L3를 차례로 본다.
Object singletonObject = this.singletonObjects.get(beanName); // L1: 완성본
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName); // L2: 조기 참조
if (singletonObject == null && allowEarlyReference) {
ObjectFactory<?> factory = this.singletonFactories.get(beanName); // L3
if (factory != null) {
singletonObject = factory.getObject(); // 여기서 조기 참조 '생성'
this.earlySingletonObjects.put(beanName, singletonObject); // L3 → L2 승격
this.singletonFactories.remove(beanName);
}
}
}
L3에서 팩토리를 호출한 결과는 즉시 L2로 옮기고 L3에서는 지운다. 그래서 팩토리는 빈당 최대 한 번만 실행되고, 이후 같은 빈의 조기 참조 요청은 전부 L2에 캐시된 동일 객체를 돌려받는다. 이 "한 번만 호출 + 캐시" 규칙이 뒤에서 프록시 단일성을 지키는 근거가 된다.
A와 B가 필드/세터로 서로를 참조할 때 흐름은 이렇다.
getBean(A)
└ createBeanInstance(A) // 생성자로 raw A 인스턴스화
└ addSingletonFactory(A, ()->getEarlyBeanReference(A)) // ★ L3 등록 (초기화 前)
└ populateBean(A) → A는 B 필요 → getBean(B)
└ createBeanInstance(B) // raw B
└ addSingletonFactory(B, ...) // L3 등록
└ populateBean(B) → B는 A 필요 → getBean(A)
└ getSingleton(A): L1✗ → 생성중O → L2✗ → L3 팩토리 호출!
→ getEarlyBeanReference(A) = (필요시)프록시(A) → L2 승격 → 반환
└ B가 조기 A를 주입받고 initializeBean(B) 완료 → L1 등록
└ A가 완성된 B를 주입받고 initializeBean(A) 완료 → L1 등록
열쇠는 addSingletonFactory가 인스턴스화 직후, 프로퍼티 주입 전에 실행된다는 점이다. raw 객체는 이미 힙에 있으니 참조 자체는 넘길 수 있고, 필드는 나중에 채워지므로 "일단 껍데기라도 넘겨주고 속은 뒤에 채운다"가 성립한다. 순환의 고리가 여기서 끊긴다.
L3가 객체가 아니라 ObjectFactory를 담는 진짜 이유는 AOP 프록시 때문이다.
보통 프록시는 AbstractAutoProxyCreator가 초기화 후처리(postProcessAfterInitialization) 단계에서 만든다. 그런데 순환 지점에서는 초기화가 끝나기 전에 A의 참조를 B에게 넘겨야 한다. 이때 B가 받는 A는 나중에 완성될 최종 프록시와 같은 객체여야 한다. 그렇지 않으면 B는 프록시가 안 걸린 raw A를, 컨테이너는 프록시 A를 들고 있게 되어 둘이 갈라진다.
팩토리(L3)의 getEarlyBeanReference는 SmartInstantiationAwareBeanPostProcessor(대표적으로 자동 프록시 생성기)에게 "지금 이 조기 참조를 프록시로 감쌀까"를 물어, 필요하면 프록시를 앞당겨 생성한다. 그 결과를 L2에 캐시하기 때문에 두 가지가 동시에 지켜진다.
순환이 실제로 일어난 빈만 프록시를 조기 생성하고(지연 평가), 여러 빈이 A의 조기 참조를 요청해도 전부 같은 프록시 인스턴스를 받는다(팩토리 1회 호출 + L2 캐시).
정리하면 L2는 "이미 만들어 둔 조기 객체(재사용용)", L3는 "아직 만들지 않은 생성 로직(프록시 여부 미정)"으로 역할이 갈린다. 두 개를 하나로 합치면 프록시 지연 생성과 단일성 중 하나가 깨진다. 이것이 2단계로는 안 되고 3단계가 필요한 이유다.
마지막으로 doCreateBean은 안전장치를 하나 더 둔다. 조기 참조가 이미 누군가에게 노출됐는데(earlySingletonReference != null) 초기화 후처리가 빈을 다른 객체로 바꿔치기했다면, 이미 주입된 조기 참조와 최종 빈이 달라질 위험을 감지해 예외를 던진다(allowRawInjectionDespiteWrapping 기본값 false). 조기 노출 경로가 프록시를 일관되게 만들어야 하는 이유가 여기서도 드러난다.
이제 처음 질문으로 돌아온다. 팩토리 등록(addSingletonFactory)은 createBeanInstance, 즉 생성자 실행이 끝난 뒤에 일어난다. 그런데 생성자 주입은 인스턴스화 도중에 의존성을 요구한다.
// 생성자 주입: A(B), B(A)
getBean(A) → createBeanInstance(A) // 생성자가 B를 요구
→ getBean(B) → createBeanInstance(B) // 생성자가 A를 요구
→ getSingleton(A): L1✗ → 생성중O → L2✗ → L3에 A 팩토리가 아직 없음(등록 前)
→ 조기 참조를 못 얻음 → BeanCurrentlyInCreationException
A의 생성자가 끝나야 A의 팩토리가 L3에 올라가는데, A의 생성자는 B를 기다리고 B의 생성자는 다시 A를 기다린다. 팩토리가 등록될 틈 자체가 없으니 조기 참조를 만들 수 없다. 3-level 캐시가 풀 수 있는 것은 인스턴스화가 끝난 뒤 채워지는 setter/field 주입뿐이다.
몇 가지 자주 오해하는 지점을 함께 정리한다.
spring.main.allow-circular-references 기본값이 false다. 캐시가 있어도 순환이 감지되면 부팅이 실패한다. 3-level 캐시는 "풀 수 있는 메커니즘"일 뿐 권장 설계는 아니다.생성자 주입에서 순환 예외가 뜨는 건 사실 설계가 잘못됐다는 신호에 가깝다. 억지로 @Lazy로 우회하기 전에, 두 빈의 책임을 다시 나눌 수 있는지 먼저 보는 편이 낫다.
Spring은 빈을 인스턴스화한 직후(초기화 전) "조기 참조를 만들 팩토리"를 L3에 등록해 두고, 순환 지점에서 그 팩토리를 딱 한 번 호출해 (필요하면 프록시로 감싼) 조기 참조를 L2에 캐시함으로써 setter/field 순환을 푼다. 생성자 주입은 팩토리 등록보다 앞서 의존성을 요구하므로 이 경로에 닿지 못한다.
더 파고들 만한 주제:
getEarlyBeanReference와 AbstractAutoProxyCreator의 조기 프록시 생성 경로, 그리고 중복 방지용 earlyProxyReferences 캐시.@Lazy 주입이 프록시로 순환을 우회하는 방식과 3-level 캐시 경로의 차이.DefaultSingletonBeanRegistry(getSingleton / addSingletonFactory), AbstractAutowireCapableBeanFactory#doCreateBean / getEarlyBeanReferencespring.main.allow-circular-references