"싱글톤 빈은 컨테이너당 하나"라는 약속을 실제로 지키는 곳이 DefaultSingletonBeanRegistry다. 이 약속은 보기보다 까다롭다. 빈 A를 만드는 도중에 A가 B를 필요로 하고, B가 다시 A를 필요로 하면(순환참조), "아직 다 안 만들어진 A"를 B에게 줘야 할 수도 있다. 또 여러 스레드가 동시에 같은 빈을 요청하면, 두 번 만들지 않으면서도 교착(deadlock)에 빠지지 않아야 한다.
이 장은 그 두 문제 — 순환참조 해소와 동시 생성 제어 — 를 푸는 3단계 캐시 구조를 본다.
DefaultSingletonBeanRegistry의 핵심 필드는 세 개의 맵이다.
┌──────────────────────────────────────────────────────────────┐
│ singletonObjects : Map<String, Object> │ ① 완성된 싱글톤
│ "다 만들어진" 빈. 정상 조회는 거의 여기서 끝난다. │
├──────────────────────────────────────────────────────────────┤
│ earlySingletonObjects : Map<String, Object> │ ② 조기 노출된 참조
│ 생성 중인 빈을 누군가 미리 가져갔을 때, 그 (프록시 포함) │
│ 참조를 고정해 두는 자리. │
├──────────────────────────────────────────────────────────────┤
│ singletonFactories : Map<String, ObjectFactory<?>> │ ③ 조기 참조 팩토리
│ "필요해지면 ②를 만들어 줄" 람다. doCreateBean의 │
│ addSingletonFactory가 여기에 넣는다. │
└──────────────────────────────────────────────────────────────┘
보조: singletonsCurrentlyInCreation : Set<String> (지금 만들고 있는 빈 이름)
세 맵은 승급(promotion) 관계다: 빈은 ③에서 시작해, 조기 참조가 요구되면 ②로 올라가고, 생성이 끝나면 ①로 확정된다. addSingleton이 ①에 넣으면서 ②③에서 지운다.
A가 B를, B가 A를 생성자 아닌 세터/필드로 참조하는 경우:
getBean("A")
└ getSingleton("A", factory) → 락 잡고 A 생성 시작
singletonsCurrentlyInCreation += A
doCreateBean(A)
(1) createBeanInstance(A) ← A 껍데기 생성됨
(3) addSingletonFactory("A", ...) ← ③에 "A의 조기참조 팩토리" 등록
(4) populateBean(A)
└ A가 B를 주입받아야 함 → getBean("B")
└ getSingleton("B", factory) → B 생성 시작
doCreateBean(B)
(4) populateBean(B)
└ B가 A를 주입받아야 함 → getBean("A")
└ getSingleton("A", false, allowEarly=true)
① miss → A는 생성 중
② miss → ③의 팩토리 실행!
getEarlyBeanReference(A) 호출
→ 결과를 ②에 올림
★ B는 "조기 A 참조"를 받아 완성
B 완성 → ①로 승급
└ B 주입 완료
(5) initializeBean(A)
A 완성 → ①로 승급 (②의 조기참조와 동일성 검증)
핵심은 getSingleton(beanName, allowEarlyReference=true) 다. 완성본(①)도 조기본(②)도 없는데 빈이 "생성 중"이면, ③의 팩토리를 실행해 조기 참조를 만들어 ②에 올린다(실제 코드, singleton 락 안에서 일관성 보장):
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject(); // = getEarlyBeanReference
if (this.singletonFactories.remove(beanName) != null) {
this.earlySingletonObjects.put(beanName, singletonObject); // ③ → ②
}
}조기 참조를 팩토리(③)로 두는 이유는 AOP 때문이다. 순환참조가 없으면 조기 참조를 만들 필요조차 없으므로, 미리 프록시를 만드는 비용을 피한다. 순환참조가 실제로 발생할 때만 ③의 팩토리(getEarlyBeanReference)가 SmartInstantiationAwareBeanPostProcessor를 거쳐 프록시를 만든다. ②는 그렇게 만들어진 결과를 한 번만 만들도록 고정하는 캐시다. 즉 "지연 생성(③) + 일관성 고정(②)"을 분리한 것.
위 트릭은 "껍데기를 먼저 만들고 나중에 주입"하기에 가능하다. 생성자 주입은 껍데기를 만들기 전에 상대가 필요하므로 조기 노출이 불가능하다 → BeanCurrentlyInCreationException. (@Lazy로 한쪽을 끊거나 설계를 고쳐야 한다.)
getSingleton(beanName, ObjectFactory)은 같은 빈을 두 번 만들지 않도록 보호한다. 단순 synchronized 하나가 아니라, 데드락을 피하기 위한 세밀한 락 정책을 쓴다.
getSingleton("X", factory)
├ ① singletonObjects.get("X") 히트 → 즉시 반환 (락 없이)
├ 현재 스레드가 싱글톤 락을 잡아도 되는지 판단
│ (isCurrentThreadAllowedToHoldSingletonLock)
│ 백그라운드 부트스트랩 스레드 등은 락 회피해 데드락 방지
├ beforeSingletonCreation("X") → currentlyInCreation에 추가
│ 이미 만들고 있으면 BeanCurrentlyInCreationException
├ singletonObject = factory.getObject() ← 실제 createBean
├ afterSingletonCreation("X") → currentlyInCreation에서 제거
└ addSingleton("X", obj) → ①에 넣고 ②③ 정리
beforeSingletonCreation/afterSingletonCreation이 singletonsCurrentlyInCreation 집합을 관리한다. 이 집합은 순환참조 감지(isSingletonCurrentlyInCreation)와 동시 생성 감지에 둘 다 쓰인다. 생성 실패 시 doGetBean은 destroySingleton으로 ①②③과 의존 관계를 모두 정리해, 반쯤 만들어진 빈이 캐시에 남지 않게 한다.
getSingleton(beanName, boolean allowEarlyReference)— 조회 전용. ①→②→③ 순으로 찾되, 조기 참조 생성은allowEarlyReference가true일 때만. 정상 조회(doGetBean첫 줄)는allowEarlyReference=true로 호출되지만 대개 ①에서 끝난다.getSingleton(beanName, ObjectFactory)— 생성 전용. 위 동시성 제어의 본체. 락을 잡고 팩토리를 실행한 뒤addSingleton으로 확정.addSingletonFactory—doCreateBean이 인스턴스화 직후 호출해 ③에 조기 참조 팩토리를 심는다. 순환참조의 "보험".registerDependentBean/getDependentBeans— 빈 간 의존 그래프를 기록해, 소멸 시 역순 정리와 순환depends-on감지에 쓴다.
SingletonBeanRegistry인터페이스 —registerSingleton(name, obj)로 컨테이너 밖에서 만든 객체를 싱글톤으로 등록할 수 있다.ConfigurableBeanFactory가 이를 상속하므로, 설정 시점에 기존 객체를 빈처럼 끼워 넣는 게 가능하다.allowCircularReferences플래그 — 기본true. 끄면(false) 조기 노출 자체를 막아, 순환참조를 항상 즉시 에러로 드러낸다(엄격 모드).ScopeSPI — 싱글톤/프로토타입 외의 스코프(request,session, 커스텀)는Scope.get(name, ObjectFactory)구현으로 꽂는다.doGetBean은 등록된Scope에 생성을 위임할 뿐이다(02장의 scoped 경로).
DefaultSingletonBeanRegistry는 "완성본 / 조기 참조 / 조기 참조 팩토리"의 3단계 캐시로 두 목표를 동시에 달성한다 — 순환참조가 있어도(세터/필드 주입에 한해) 일관된 참조를 공급하고, 동시 요청에도 빈을 한 번만 만든다. 3단계로 나눈 이유는 "순환참조가 실제로 일어날 때만, 그것도 단 한 번만" 조기(프록시) 참조를 만들기 위함이다. 다음 장은 populateBean이 어떤 빈을 주입할지 고르는 과정을 본다.