Skip to content

Latest commit

 

History

History
115 lines (88 loc) · 8.8 KB

File metadata and controls

115 lines (88 loc) · 8.8 KB

03 — 싱글톤 레지스트리와 순환참조

무엇을 / 왜

"싱글톤 빈은 컨테이너당 하나"라는 약속을 실제로 지키는 곳이 DefaultSingletonBeanRegistry다. 이 약속은 보기보다 까다롭다. 빈 A를 만드는 도중에 A가 B를 필요로 하고, B가 다시 A를 필요로 하면(순환참조), "아직 다 안 만들어진 A"를 B에게 줘야 할 수도 있다. 또 여러 스레드가 동시에 같은 빈을 요청하면, 두 번 만들지 않으면서도 교착(deadlock)에 빠지지 않아야 한다.

이 장은 그 두 문제 — 순환참조 해소동시 생성 제어 — 를 푸는 3단계 캐시 구조를 본다.

핵심 타입 — 3단계 캐시

DefaultSingletonBeanRegistry의 핵심 필드는 세 개의 맵이다.

┌──────────────────────────────────────────────────────────────┐
│ singletonObjects        : Map<String, Object>                 │  ① 완성된 싱글톤
│   "다 만들어진" 빈. 정상 조회는 거의 여기서 끝난다.            │
├──────────────────────────────────────────────────────────────┤
│ earlySingletonObjects   : Map<String, Object>                 │  ② 조기 노출된 참조
│   생성 중인 빈을 누군가 미리 가져갔을 때, 그 (프록시 포함)     │
│   참조를 고정해 두는 자리.                                    │
├──────────────────────────────────────────────────────────────┤
│ singletonFactories      : Map<String, ObjectFactory<?>>       │  ③ 조기 참조 팩토리
│   "필요해지면 ②를 만들어 줄" 람다. doCreateBean의             │
│   addSingletonFactory가 여기에 넣는다.                        │
└──────────────────────────────────────────────────────────────┘

  보조: singletonsCurrentlyInCreation : Set<String>  (지금 만들고 있는 빈 이름)

세 맵은 승급(promotion) 관계다: 빈은 ③에서 시작해, 조기 참조가 요구되면 ②로 올라가고, 생성이 끝나면 ①로 확정된다. addSingleton이 ①에 넣으면서 ②③에서 지운다.

동작 흐름 — 순환참조 A ↔ B 해소

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);  // ③ → ②
    }
}

왜 3단계인가 (2단계가 아니라)

조기 참조를 팩토리(③)로 두는 이유는 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/afterSingletonCreationsingletonsCurrentlyInCreation 집합을 관리한다. 이 집합은 순환참조 감지(isSingletonCurrentlyInCreation)와 동시 생성 감지에 둘 다 쓰인다. 생성 실패 시 doGetBeandestroySingleton으로 ①②③과 의존 관계를 모두 정리해, 반쯤 만들어진 빈이 캐시에 남지 않게 한다.

핵심 메서드

  • getSingleton(beanName, boolean allowEarlyReference) — 조회 전용. ①→②→③ 순으로 찾되, 조기 참조 생성은 allowEarlyReferencetrue일 때만. 정상 조회(doGetBean 첫 줄)는 allowEarlyReference=true로 호출되지만 대개 ①에서 끝난다.
  • getSingleton(beanName, ObjectFactory) — 생성 전용. 위 동시성 제어의 본체. 락을 잡고 팩토리를 실행한 뒤 addSingleton으로 확정.
  • addSingletonFactorydoCreateBean이 인스턴스화 직후 호출해 ③에 조기 참조 팩토리를 심는다. 순환참조의 "보험".
  • registerDependentBean / getDependentBeans — 빈 간 의존 그래프를 기록해, 소멸 시 역순 정리와 순환 depends-on 감지에 쓴다.

설계 포인트 / 확장점

  • SingletonBeanRegistry 인터페이스registerSingleton(name, obj)로 컨테이너 밖에서 만든 객체를 싱글톤으로 등록할 수 있다. ConfigurableBeanFactory가 이를 상속하므로, 설정 시점에 기존 객체를 빈처럼 끼워 넣는 게 가능하다.
  • allowCircularReferences 플래그 — 기본 true. 끄면(false) 조기 노출 자체를 막아, 순환참조를 항상 즉시 에러로 드러낸다(엄격 모드).
  • Scope SPI — 싱글톤/프로토타입 외의 스코프(request, session, 커스텀)는 Scope.get(name, ObjectFactory) 구현으로 꽂는다. doGetBean은 등록된 Scope에 생성을 위임할 뿐이다(02장의 scoped 경로).

정리

DefaultSingletonBeanRegistry는 "완성본 / 조기 참조 / 조기 참조 팩토리"의 3단계 캐시로 두 목표를 동시에 달성한다 — 순환참조가 있어도(세터/필드 주입에 한해) 일관된 참조를 공급하고, 동시 요청에도 빈을 한 번만 만든다. 3단계로 나눈 이유는 "순환참조가 실제로 일어날 때만, 그것도 단 한 번만" 조기(프록시) 참조를 만들기 위함이다. 다음 장은 populateBean이 어떤 빈을 주입할지 고르는 과정을 본다.