이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 네 번째 글입니다. 3편에서는 빈을 만들어 주는 BeanFactory가 어떤 인터페이스와 클래스로 이루어져 있는지, 그리고 빈의 설계도인 BeanDefinition이 무엇인지 봤습니다. 이번에는 그 설계도로 실제 객체가 만들어지는 과정을 따라갑니다.
답하려는 질문은 세 가지입니다.
getBean("이름")을 한 번 부르면 안에서 어떤 순서로 일이 일어나는가- 만들어진 객체는 어디에 보관되고, 왜 두 번 만들어지지 않는가
- A는 B가 필요하고 B는 A가 필요할 때(순환 참조) Spring은 이것을 어떻게 풀고, 그 보관소는 왜 맵 하나가 아니라 세 개인가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-beans |
| 주로 읽는 클래스 | AbstractBeanFactory, AbstractAutowireCapableBeanFactory, DefaultSingletonBeanRegistry |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
원래 공부 노트는 줄 단위로 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
빈(Bean)은 Spring이 대신 만들어서 관리해 주는 객체입니다. 우리가 new로 직접 만드는 대신 "이런 객체가 필요하다"고 알려 두면 Spring이 만들어 둡니다.
컨테이너는 그 빈들을 만들고 보관하는 주체입니다. 소스에서는 BeanFactory라는 인터페이스와 그 구현 클래스들이 이 일을 하고, 빈을 달라고 할 때 부르는 메서드가 getBean()입니다. 자세한 설명은 1편과 3편에 있습니다.
의존성 주입(DI)은 어떤 객체가 일하는 데 필요한 다른 객체(의존성)를 스스로 만들지 않고 컨테이너가 넣어 주는 것입니다. 필드에 넣어 주면 필드 주입, 생성자의 인자로 넣어 주면 생성자 주입이라고 부릅니다. 이 구분이 글 뒤쪽에서 중요해집니다.
스코프(scope)는 빈을 몇 개 만들어 얼마 동안 쓰느냐에 대한 규칙입니다. 따로 정하지 않으면 싱글톤(singleton), 곧 "하나만 만들어 계속 같이 쓴다"입니다. 이 글은 싱글톤 빈만 다룹니다.
프록시(proxy)는 원래 객체를 감싸서 대신 나서는 객체입니다. 겉으로는 원래 객체와 같은 타입이지만, 메서드가 불리면 앞뒤로 다른 일(예를 들어 트랜잭션의 시작과 끝)을 하고 나서 원래 객체에게 넘깁니다. 이렇게 여러 클래스에 공통으로 필요한 동작을 각 클래스를 고치지 않고 끼워 넣는 기법을 AOP(관점 지향 프로그래밍)라고 하고, Spring의 AOP는 프록시를 만들어 끼우는 방식으로 동작합니다. 프록시 자체가 어떻게 만들어지는지는 10편에서, 트랜잭션을 프록시가 열고 닫는 과정은 17편에서 봅니다.
람다는 "나중에 실행할 코드 조각"입니다. 지금 값을 계산하지 않고 계산하는 방법만 건네 둘 때 씁니다. 이 글의 마지막 수수께끼가 이것으로 풀립니다.
1. Spring의 싱글톤은 "싱글톤 패턴"이 아닙니다
Java를 배우다 보면 싱글톤 패턴을 만납니다. 클래스가 자기 객체를 하나만 만들도록 스스로 막는 방법입니다.
// 이 글을 위해 쓴 예시입니다 (Spring의 코드가 아닙니다)
public class Config {
private static final Config INSTANCE = new Config();
private Config() { } // 밖에서 new 하지 못하게 막는다
public static Config getInstance() {
return INSTANCE; // 언제 불러도 같은 객체
}
}
Spring의 "싱글톤 빈"은 이름만 같고 방식이 다릅니다. 빈으로 쓰는 클래스는 생성자를 숨기지도 않고 static 필드도 없는 평범한 클래스입니다. 하나만 있도록 지키는 쪽은 클래스가 아니라 컨테이너입니다. 컨테이너가 객체를 한 번 만들어 빈 이름을 열쇠로 하는 맵에 넣어 두고, 같은 이름으로 달라고 할 때마다 그 맵에서 꺼내 줍니다.
왼쪽은 클래스가 스스로 지키는 방식, 오른쪽은 컨테이너가 맵에 보관하는 방식입니다. 노란 상자가 이 글의 주인공인 맵입니다.
| 싱글톤 패턴 | Spring 싱글톤 | |
|---|---|---|
| 하나임을 지키는 쪽 | 클래스 자신 | 컨테이너 (DefaultSingletonBeanRegistry) |
| 무엇을 기준으로 하나인가 | 클래스 | 빈 이름 |
| 어느 범위에서 하나인가 | 프로그램 전체 | 컨테이너 하나 |
| 생성자 | private |
숨기지 않는다 |
| 같은 클래스의 객체를 둘 두기 | 불가능 | 이름을 다르게 등록하면 가능 |
헷갈리기 쉬운 곳: "싱글톤인데 왜 객체가 둘이지?"
기준이 클래스가 아니라 컨테이너 안의 이름이라는 점에서 세 가지가 따라 나옵니다.
- 같은 클래스를 서로 다른 이름의 빈 두 개로 등록하면 객체도 두 개입니다. 맵의 열쇠가 이름이기 때문입니다.
- 컨테이너를 두 개 띄우면 같은 이름의 빈도 컨테이너마다 하나씩 생깁니다. 그 맵은
static이 아니라 컨테이너 객체가 가진 평범한 필드입니다(아래 5절에 코드가 있습니다). - 생성자가 열려 있으니 코드에서
new UserService()를 직접 부를 수 있습니다. 하지만 그렇게 만든 객체는 맵에 없는, 컨테이너가 모르는 객체입니다. 의존성도 주입되지 않습니다.
2. getBean() 한 번의 여정
getBean()은 우리가 직접 부를 수도 있지만 실제로는 대부분 Spring이 부릅니다.
- 컨테이너가 시작할 때. 시작 과정의 끝에서
preInstantiateSingletons()가, 나중에 만들라고(lazy) 따로 표시하지 않은 싱글톤 빈마다getBean()을 한 번씩 불러 미리 만들어 둡니다(AbstractApplicationContext995행,DefaultListableBeanFactory1102행, 1217–1227행). 시작 과정 전체는 2편에 있습니다. - 다른 빈을 만드는 도중에. 빈 A에 빈 B를 주입해야 하면 컨테이너는 B를
getBean()으로 가져옵니다(DependencyDescriptor224–230행). 그래서getBean()안에서 다시getBean()이 불리는 일이 흔합니다. 이것이 뒤에 나올 순환 참조 문제의 출발점입니다.
어느 쪽이든 실제 일은 AbstractBeanFactory.doGetBean()이 합니다(249–413행). 싱글톤 빈의 경우 큰 줄기는 이렇습니다.
노란색은 캐시(맵), 파란색은 만드는 절차입니다.
- 이름을 정리합니다. 별칭으로 불렸으면 정식 이름으로 바꿉니다(253행).
- 캐시부터 봅니다.
getSingleton(beanName)으로 이미 만들어 둔 객체가 있는지 확인하고(257행), 있으면 그것을 돌려줍니다. 두 번째 호출부터는 여기서 끝납니다. 싱글톤 빈이 두 번 만들어지지 않는 이유입니다. - 없으면 설계도를 꺼냅니다. 이 이름의
BeanDefinition, 정확히는 3편에서 본 병합이 끝난 설계도를 가져옵니다(309행). - 싱글톤이면 "만들어서 캐시에 넣기"를 맡깁니다.
getSingleton(beanName, 람다)를 부르는데, 그 람다의 내용이createBean(...), 곧 실제로 만드는 코드입니다(343–355행).
4번의 getSingleton(이름, 람다)는 DefaultSingletonBeanRegistry에 있고(255–432행), 여러 스레드가 동시에 들어오는 경우를 다루느라 길지만 뼈대는 네 줄입니다.
- 이 이름을 "만드는 중" 집합(
singletonsCurrentlyInCreation)에 넣습니다(310행, 구현은 539–543행). - 건네받은 람다를 실행합니다. 여기서 빈이 만들어집니다(371행).
- "만드는 중" 집합에서 이름을 뺍니다(399행).
- 완성된 객체를 캐시에 넣습니다(404행, 구현은 159–173행).
"만드는 중" 집합은 지금은 사소해 보이지만 순환 참조를 다룰 때 핵심 역할을 합니다. 기억해 두세요.
헷갈리기 쉬운 곳: 이름이 같은 getSingleton이 둘입니다
getSingleton(이름, 람다)— 없으면 만들어서 캐시에 넣는 쪽. 위 4번이 부릅니다.getSingleton(이름)— 캐시를 들여다보기만 하는 쪽. 위 2번이 부릅니다. 안에서는getSingleton(이름, true)를 부릅니다(196–198행).
이 글의 후반부는 뒤쪽, 들여다보기만 하는 getSingleton의 이야기입니다.
이 절에서 뺀 것: 싱글톤이 아닌 스코프의 빈, 부모 컨테이너에 물어보는 경우, 먼저 만들어야 할 빈을 지정하는 depends-on, 다른 객체를 만들어 내는 특수한 빈인 FactoryBean은 같은 메서드 안에 있지만 다루지 않습니다.
3. 빈 하나를 만드는 세 단계
위의 람다가 부르는 createBean()은 준비를 조금 하고 doCreateBean()에 실제 일을 넘깁니다(AbstractAutowireCapableBeanFactory 555–648행). Spring 소스에는 이렇게 xxx()가 준비와 예외 처리를 하고 doXxx()가 본론을 하는 짝이 자주 나옵니다. getBean()과 doGetBean()도 같은 관계입니다.
doCreateBean()은 세 단계입니다.
- 인스턴스화 —
createBeanInstance()(564행). 생성자를 불러 객체를 만듭니다. Java의new에 해당합니다. 이 순간의 객체는 메모리에 존재하지만 필드는 아직 비어 있습니다. - 의존성 주입 —
populateBean()(601행). 비어 있는 필드에 다른 빈을 넣어 줍니다. 넣을 빈은getBean()으로 가져오므로 그 빈이 아직 없으면 여기서 그 빈을 만드는 여정이 통째로 한 번 더 시작됩니다. - 초기화 —
initializeBean()(602행). 주입이 끝난 뒤에 해야 하는 준비 작업을 실행하고, 필요하면 객체를 프록시로 감쌉니다.
①과 ② 사이의 노란 상자 "조기 노출"은 5절에서 설명합니다. 파란 상자는 후처리기가 끼어드는 자리입니다.
BeanPostProcessor가 끼어드는 자리
3단계 initializeBean()의 본문은 짧습니다. (// ... 줄은 이 글에서 줄인 곳이고, 나머지는 소스 그대로입니다.)
protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
// ...
invokeAwareMethods(beanName, bean);
Object wrappedBean = bean;
if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
}
try {
invokeInitMethods(beanName, wrappedBean, mbd);
}
// ...
if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
}
return wrappedBean;
}
가운데 invokeInitMethods가 빈이 스스로 정해 둔 초기화 메서드를 부르는 곳입니다. 그 앞과 뒤에 applyBeanPostProcessors…가 한 번씩 있습니다. 여기서 불리는 것이 BeanPostProcessor(빈 후처리기)입니다.
BeanPostProcessor는 메서드가 둘뿐인 인터페이스입니다(67–111행). postProcessBeforeInitialization은 초기화 메서드 전에, postProcessAfterInitialization은 후에 불립니다. 컨테이너는 등록된 후처리기를 전부 차례로 불러 "지금 이 빈이 만들어지고 있는데 손댈 것이 있습니까" 하고 묻습니다. 빈 하나하나가 만들어질 때마다 그렇게 합니다.
중요한 것은 두 메서드가 객체를 반환한다는 점입니다. 위 코드에서도 반환값을 wrappedBean에 다시 담습니다. 후처리기가 받은 객체를 그대로 돌려주면 아무 일도 없고, 다른 객체를 돌려주면 그 객체가 빈이 됩니다. AOP 프록시가 들어오는 길이 이것입니다. Spring의 자동 프록시 후처리기는 "초기화 후" 훅에서, 감싸야 하는 빈이면 원본 대신 프록시를 돌려줍니다(AbstractAutoProxyCreator 285–293행). 이 후처리기가 "감싸야 하는 빈"을 어떻게 알아보는지는 11편에서 봅니다.
헷갈리기 쉬운 곳
- 캐시에 들어가는 것은 원본이 아닐 수 있습니다.
initializeBean()이 돌려준 객체가 최종 빈이 되어 캐시에 들어갑니다. 프록시로 감싸졌다면getBean()으로 받는 것은 프록시입니다. - 프록시는 맨 마지막에 만들어집니다. 주입과 초기화가 끝난 원본을 감싸는 것이 원칙입니다.
BeanPostProcessor의 설명 주석에도 프록시로 감싸는 후처리기는 보통postProcessAfterInitialization을 구현한다고 적혀 있습니다(27–30행). 이 "원칙은 맨 마지막"이 5절에서 맵이 세 개가 되는 이유입니다. - 후처리기는 초기화 앞뒤에만 있는 것이 아닙니다.
@Autowired가 붙은 필드에 값을 넣는 일도 2단계populateBean()안에서 후처리기가 합니다(AutowiredAnnotationBeanPostProcessor490행).@PostConstruct가 붙은 메서드를 부르는 일은 "초기화 전" 훅에서 일어납니다(InitDestroyAnnotationBeanPostProcessor214–226행). Spring의 많은 기능이 "빈을 만드는 흐름에 후처리기를 끼워 넣는" 한 가지 방식으로 붙어 있습니다. 훅의 전체 목록은 6편에서 봅니다.
4. 순환 참조: A는 B가 필요하고 B는 A가 필요할 때
이제 문제를 하나 만들어 보겠습니다. 빈 두 개가 서로를 필드로 가집니다.
// 이 글을 위해 쓴 예시입니다 (Spring의 코드가 아닙니다)
@Component // 이 클래스를 빈으로 등록해 달라는 표시
class A {
@Autowired B b; // 이 필드에 B 빈을 넣어 달라는 표시
}
@Component
class B {
@Autowired A a; // 이 필드에 A 빈을 넣어 달라는 표시
}
지금까지 본 흐름대로 따라가 봅니다. 빈 이름은 a, b라고 하겠습니다.
getBean("a")— 캐시에 없으니 만듭니다. A를 인스턴스화하고, 주입 단계에서 B가 필요해집니다.getBean("b")— 캐시에 없으니 만듭니다. B를 인스턴스화하고, 주입 단계에서 A가 필요해집니다.getBean("a")— A는 아직 완성되지 않아 캐시에 없습니다. 그러면 또 만들기 시작하고, 또 B가 필요해지고…
완성된 빈만 캐시에 넣는다면 이 고리는 끝나지 않습니다. 이것이 순환 참조(circular reference) 문제입니다.
빠져나갈 길은 3번에서 2번을 다시 보면 보입니다. A는 완성되지 않았을 뿐 이미 인스턴스화되어 메모리에 존재합니다. 그리고 B가 지금 당장 필요한 것은 A를 사용하는 것이 아니라 자기 필드에 A를 가리키는 참조를 적어 두는 것뿐입니다. 그렇다면 아직 덜 만들어진 A의 참조를 B에게 먼저 건네주면 됩니다. B가 완성되고 나서 A도 마저 완성되면, B가 들고 있던 참조는 어느새 완성된 A를 가리키고 있습니다.
빨간 칸에서 오른쪽 실선을 따르면 고리가 끝나지 않습니다. 가운데 점선이 5절에서 볼 해법의 방향입니다.
이렇게 완성 전에 미리 건네주는 참조를 조기 참조(early reference), 건네줄 수 있게 내놓는 일을 조기 노출(early exposure)이라고 합니다. 3절 그림의 노란 상자가 그것입니다.
5. 맵 세 개로 푸는 방법 — 3단계 캐시
DefaultSingletonBeanRegistry에는 싱글톤을 보관하는 맵이 하나가 아니라 셋 있습니다.
/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/** Creation-time registry of singleton factories: bean name to ObjectFactory. */
private final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(16);
// ...
/** Cache of early singleton objects: bean name to bean instance. */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
| 흔히 부르는 이름 | 필드 | 담는 것 |
|---|---|---|
| 1단 | singletonObjects |
완성된 빈. 지금까지 "캐시"라고 부른 것 |
| 2단 | earlySingletonObjects |
조기 참조. 아직 완성되지 않은 빈을 가리키는 객체 |
| 3단 | singletonFactories |
조기 참조를 만들어 주는 람다 (ObjectFactory) |
"1단·2단·3단 캐시"는 사람들이 붙인 별명이고 소스에 그런 낱말은 없습니다. 조회하는 순서대로 번호를 붙인 것입니다. ObjectFactory는 getObject() 메서드 하나만 가진 인터페이스로, 람다를 담는 그릇입니다.
빈 하나가 세 맵을 거치는 길. 순환 참조가 없는 빈은 오른쪽 점선을 따라 3단에서 곧바로 1단으로 갑니다.
넣는 쪽: 인스턴스화 직후에 람다를 등록합니다
doCreateBean()에서 인스턴스화와 주입 사이에 이 코드가 있습니다.
// Eagerly cache singletons to be able to resolve circular references
// even when triggered by lifecycle interfaces like BeanFactoryAware.
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
// ...
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
// Initialize the bean instance.
Object exposedObject = bean;
try {
populateBean(beanName, mbd, instanceWrapper);
exposedObject = initializeBean(beanName, exposedObject, mbd);
}
// ...
조건은 세 가지입니다. 싱글톤이고, 순환 참조를 허용하는 설정이고(allowCircularReferences), 이 빈이 "만드는 중" 집합에 있을 것. 2절에서 본 그 집합입니다. 조건이 맞으면 addSingletonFactory가 3단 맵에 람다를 넣습니다(DefaultSingletonBeanRegistry 183–188행).
눈여겨볼 것은 두 가지입니다. 넣는 것이 방금 만든 객체 bean이 아니라 람다라는 점, 그리고 이 코드가 populateBean(주입)보다 앞에 있다는 점입니다. 주입하다가 순환이 생겨도 그때는 이미 람다가 맵에 들어가 있습니다.
꺼내는 쪽: getSingleton(이름, true)
2절에서 doGetBean()이 맨 먼저 부른다고 했던, 들여다보기만 하는 getSingleton입니다. 락을 잡고 앞의 두 맵을 한 번 더 확인하는 부분 등은 // ...로 줄였습니다.
protected @Nullable Object getSingleton(String beanName, boolean allowEarlyReference) {
// Quick check for existing instance without full singleton lock.
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
// ...
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
// Singleton could have been added or removed in the meantime.
if (this.singletonFactories.remove(beanName) != null) {
this.earlySingletonObjects.put(beanName, singletonObject);
}
// ...
}
// ...
}
}
return singletonObject;
}
위에서부터 읽으면 이렇습니다.
- 1단(
singletonObjects)을 봅니다. 완성된 빈이 있으면 끝입니다. - 없는데 그 빈이 "만드는 중"이면 2단(
earlySingletonObjects)을 봅니다. 누가 이미 조기 참조를 받아 간 적이 있으면 같은 것을 줍니다. - 2단에도 없으면 3단(
singletonFactories)에서 람다를 꺼내 실행합니다. 그 결과가 조기 참조입니다. - 람다를 3단에서 지우고 결과를 2단에 넣습니다. 람다는 빈 하나에 딱 한 번만 실행됩니다.
A와 B로 따라가 보기
4절의 예제를 세 맵과 함께 다시 따라갑니다. 표의 오른쪽 세 칸은 그 단계가 끝났을 때 각 맵에 들어 있는 빈 이름입니다.
가운데 줄이 맵 세 개입니다. ⑥이 핵심입니다. A의 람다는 B가 A를 찾는 순간에야 실행됩니다.
| 단계 | 일어나는 일 | 3단 | 2단 | 1단 |
|---|---|---|---|---|
| ① | getBean("a"). 캐시에 없어 만들기 시작. a를 "만드는 중"에 넣고 A를 인스턴스화 (b 필드는 null) |
|||
| ② | A의 람다를 3단에 등록 | a | ||
| ③④ | A에 주입할 B가 필요 → getBean("b"). B도 같은 길을 걸어 인스턴스화하고 람다를 등록 |
a, b | ||
| ⑤⑥ | B에 주입할 A가 필요 → getBean("a"). 1단에 없고, a는 "만드는 중"이고, 2단에도 없으므로 3단의 람다를 실행해 결과를 2단으로 옮김 |
b | a | |
| ⑦⑧ | B는 A의 조기 참조를 필드에 받고 초기화까지 마침. 완성된 B가 1단에 들어가고 B의 람다는 실행되지 않은 채 지워짐 | a | b | |
| ⑨ | A로 돌아와 b 필드에 완성된 B를 주입하고 A를 초기화 |
a | b | |
| ⑩ | 완성된 A가 1단에 들어가고 2단에서 지워짐 | a, b |
4절에서 끝나지 않던 고리가 ⑤에서 끊깁니다. 세 번째 getBean("a")가 A를 또 만들기 시작하지 않고 조기 참조를 받아 돌아가기 때문입니다.
왜 맵 두 개로는 부족한가 — 프록시
여기까지만 보면 3단은 없어도 될 것 같습니다. 인스턴스화 직후에 객체 자체를 2단에 넣어 두고, 찾는 빈에게 그것을 주면 되니까요. 프록시가 없다면 실제로 그것으로 충분했을 것입니다.
문제는 A가 프록시로 감싸져야 하는 빈일 때 생깁니다. 3절에서 봤듯이 그 경우 최종 빈은 원본 A가 아니라 A를 감싼 프록시입니다. 그런데 프록시는 초기화가 끝난 맨 마지막에 만들어지고, B는 그보다 훨씬 전에 A의 참조가 필요합니다. 이때 B에게 원본 A를 줘 버리면 B만 원본을 들고 있고 나머지 모두는 프록시를 받습니다. 하나여야 할 빈이 둘이 되고, B가 부르는 A의 메서드에는 프록시가 덧붙이는 동작이 빠집니다.
그래서 조기 참조를 줄 때는 "이 빈이 나중에 프록시가 될 것이라면 지금 그 프록시를 만들어서 그것을 줘야" 합니다. 3단에 넣어 둔 람다가 하는 일이 그것입니다. 람다가 부르는 getEarlyBeanReference()는 후처리기들에게 조기 참조로 무엇을 내놓을지 묻고(965–973행), 자동 프록시 후처리기는 그 자리에서 프록시를 만들어 돌려줍니다(AbstractAutoProxyCreator 237–241행).
그러면 인스턴스화 직후에 무조건 이 질문을 해서 결과를 2단에 넣어 두면 맵 두 개로 되지 않을까요? 그렇게 하면 순환 참조가 전혀 없는 대부분의 빈까지, 주입도 초기화도 되기 전에 프록시 여부를 결정하게 됩니다. "프록시는 맨 마지막에"라는 원칙을 모든 빈에서 깨는 셈입니다. Spring은 대신 묻는 방법만 람다로 넣어 둡니다. 누군가 만드는 중인 빈을 실제로 찾았을 때만 람다가 실행되고, 순환이 없는 빈의 람다는 한 번도 실행되지 않은 채 버려집니다. 그 빈의 프록시는 원칙대로 맨 마지막에 만들어집니다.
2단이 따로 있어야 하는 이유도 프록시에서 나옵니다.
- 같은 것을 주기 위해서. 만드는 중인 A를 B뿐 아니라 C도 찾을 수 있습니다. 자동 프록시 후처리기는 감쌀 대상이면 부를 때마다 프록시를 새로 만들기 때문에(321–345행), 람다를 두 번 실행하면 B와 C가 서로 다른 객체를 받게 됩니다. 한 번 실행한 결과를 2단에 적어 두고 람다를 지우면 모두 같은 객체를 받습니다.
- 마지막에 맞춰 보기 위해서. A의 초기화가 끝나면
doCreateBean()은 2단을 들여다봅니다.
if (earlySingletonExposure) {
Object earlySingletonReference = getSingleton(beanName, false);
if (earlySingletonReference != null) {
if (exposedObject == bean) {
exposedObject = earlySingletonReference;
}
// ...
}
}
getSingleton(beanName, false)의 false는 "3단의 람다는 실행하지 말고 2단까지만 보라"는 뜻입니다. 2단에 무언가 있다면 누군가 조기 참조를 받아 갔다는 뜻이므로, 초기화가 객체를 바꾸지 않았다면(exposedObject == bean) 그 조기 참조를 최종 빈으로 삼습니다. B가 미리 받아 간 것과 1단에 들어가는 것이 같은 객체가 됩니다. 자동 프록시 후처리기도 조기 참조로 이미 프록시를 만들어 준 빈은 "초기화 후" 훅에서 다시 감싸지 않습니다(285–293행).
정리하면 3단은 방법, 2단은 그 방법을 한 번 실행한 결과, 1단은 완성품입니다.
헷갈리기 쉬운 곳
- 조기 참조가 가리키는 객체는 아직 덜 만들어진 상태입니다. 필드가 비어 있을 수 있고 초기화 메서드도 실행되기 전입니다. 받아서 필드에 적어 두기만 하면 괜찮지만, 받은 쪽이 자기 초기화 도중에 그 객체의 메서드를 부르면 문제가 생길 수 있습니다. 소스의 설명 주석도 순환 참조를 풀면 한쪽 빈이 완전히 초기화되지 않은 빈의 참조를 받게 되고 이것이 초기화에 부작용을 낳을 수 있다고 적어 두었습니다(223–238행).
- 2단과 3단은 "만드는 중"인 빈에 대해서만 봅니다. 조회 코드의 첫
if에isSingletonCurrentlyInCreation(beanName)이 있습니다. 평소의getBean()은 1단 조회 한 번으로 끝납니다. - 순환이 없으면 2단은 쓰이지 않습니다. 대부분의 빈은 3단에 람다가 잠깐 들어갔다가 완성과 함께 지워지고(165–166행) 곧바로 1단으로 갑니다.
6. 생성자 주입끼리의 순환은 왜 못 푸나
같은 예제를 생성자 주입으로 바꿔 봅니다.
// 이 글을 위해 쓴 예시입니다 (Spring의 코드가 아닙니다)
@Component
class A {
private final B b;
@Autowired A(B b) { this.b = b; } // 생성자의 인자로 B를 받는다
}
@Component
class B {
private final A a;
@Autowired B(A a) { this.a = a; } // 생성자의 인자로 A를 받는다
}
이 경우는 3단계 캐시로 풀리지 않습니다. 이유는 5절의 순서에 이미 들어 있습니다. 람다는 인스턴스화가 끝난 뒤에 등록됩니다. 그런데 생성자 주입은 인스턴스화, 곧 생성자를 부르는 그 순간에 B가 필요합니다(ConstructorResolver 900–912행).
조기 노출(노란 상자)보다 앞에서 다른 빈을 요구하면 내놓을 것이 없습니다.
getBean("a")—a를 "만드는 중"에 넣고 A의 생성자를 부르려는데, 인자로 B가 필요합니다.getBean("b")—b를 "만드는 중"에 넣고 B의 생성자를 부르려는데, 인자로 A가 필요합니다.getBean("a")— 1단에 없고, 2단에 없고, 3단에도 없습니다. A는 아직 생성자조차 끝나지 않아 람다를 등록한 적이 없습니다. 내놓을 객체 자체가 아직 없는 것입니다.- 캐시에서 못 찾았으니 A를 만들려고
a를 "만드는 중"에 넣으려 하지만, 이미 들어 있습니다. 여기서BeanCurrentlyInCreationException이 던져집니다(539–543행).
"만드는 중" 집합의 두 번째 역할이 이것입니다. 풀 수 없는 순환을 무한히 돌지 않고 알아채는 장치입니다. 이 예외의 기본 메시지는 "Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?"입니다(BeanCurrentlyInCreationException 34–37행).
위의 순서를 그대로 적용해 보면 한쪽만 생성자 주입인 경우는 어느 빈을 먼저 만들기 시작하느냐에 따라 결과가 갈립니다. 필드 주입 쪽을 먼저 만들면 그 빈의 람다가 먼저 등록되어 풀리고, 생성자 주입 쪽을 먼저 만들면 위와 같은 이유로 예외가 납니다.
순환 참조는 풀리더라도 피하는 것이 좋습니다
Spring이 필드 주입 순환을 풀어 준다고 해서 권장한다는 뜻은 아닙니다. 순환 참조를 허용할지 정하는 setAllowCircularReferences의 설명 주석에 이렇게 적혀 있습니다(223–238행).
It is generally recommended to not rely on circular references between your beans. Refactor your application logic to have the two beans involved delegate to a third bean that encapsulates their common logic.
서로를 필요로 하는 두 빈이 있다면 둘이 함께 쓰는 부분을 세 번째 빈으로 떼어 내라는 뜻입니다.
이 저장소의 소스에서 allowCircularReferences의 기본값은 true입니다(131행). false로 바꾸면 5절에서 본 조기 노출 조건이 거짓이 되어 람다가 아예 등록되지 않으므로, 필드 주입 순환도 생성자 주입 순환과 같은 예외로 끝납니다. 참고로 Spring Boot는 이 값을 기본으로 꺼 두는 것으로 알려져 있는데, Spring Boot는 이 저장소와 별개의 프로젝트라서 이 글에서 소스로 확인한 내용은 아닙니다.
더 깊이 들어가면
이 글은 큰 줄기를 위해 많은 것을 뺐습니다. 공부 노트에는 있지만 여기서는 이름만 적어 둡니다. 여러 스레드가 동시에 싱글톤을 만들 때 쓰는 락(singletonLock)과, 락을 잡지 못했을 때의 우회 경로(소스 주석에 6.2부터라고 적혀 있습니다, 268–271행). 빈을 백그라운드 스레드에서 만들게 하는 @Bean(bootstrap = BACKGROUND)(역시 6.2부터, Bean 295–307행). 컨테이너가 닫힐 때 빈을 정리하는 소멸 경로(AbstractBeanFactory 1935행부터). 매번 새로 만드는 프로토타입 스코프와 그 밖의 스코프(359–396행). 조기 참조를 내준 뒤 초기화에서 객체가 다른 것으로 바뀌었을 때 던지는 예외(617–634행). 그리고 주입할 자리에 "나중에 찾아오는 프록시"를 대신 넣어 순환을 끊는 @Lazy(ContextAnnotationAutowireCandidateResolver 53–54행)가 있습니다. @Lazy는 5편 6절에서 다시 봅니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| Spring 싱글톤 | 클래스가 아니라 컨테이너가 지킨다. 기준은 빈 이름, 범위는 컨테이너 하나 |
getBean() |
먼저 캐시를 보고, 없을 때만 만든다 |
| "만드는 중" 집합 | 만들기 시작할 때 이름을 넣고 끝나면 뺀다. 조기 노출의 조건이자 풀 수 없는 순환을 알아채는 장치 |
| 만드는 세 단계 | 인스턴스화 → 의존성 주입 → 초기화 |
BeanPostProcessor |
초기화 앞뒤에 끼어드는 후처리기. 다른 객체를 돌려주면 그것이 빈이 된다. 프록시가 들어오는 길 |
| 순환 참조 | A는 B가, B는 A가 필요한 관계. 완성된 빈만 캐시에 두면 끝나지 않는다 |
| 조기 참조 | 인스턴스화만 끝난 빈을 가리키는 참조를 미리 건네주는 것 |
1단 singletonObjects |
완성품 |
2단 earlySingletonObjects |
람다를 한 번 실행한 결과. 모두에게 같은 조기 참조를 주고, 마지막에 최종 빈과 맞춘다 |
3단 singletonFactories |
조기 참조를 만드는 람다. 누가 찾을 때만 실행되므로 순환이 없는 빈은 원칙대로 맨 마지막에 프록시가 된다 |
| 생성자 주입 순환 | 람다를 등록하기 전인 인스턴스화 도중에 상대를 요구하므로 풀 수 없다 |
다음에 볼 것
컨테이너가 설계도를 어디에 두는지(3편)에 이어, 그 설계도로 객체를 만들고 보관하는 과정을 봤습니다.
이번 글에서 여러 번 "여기서 다른 빈을 getBean()으로 가져온다"고만 하고 넘어간 곳이 있습니다. @Autowired가 붙은 자리에 어떤 빈을 넣을지 고르는 과정입니다. 같은 타입의 빈이 여럿일 때는 어떻게 하는지, 생성자가 여럿일 때는 무엇을 부르는지가 거기에 들어 있습니다. 5편: @Autowired는 넣을 빈을 어떻게 고르나 — 의존성 주입 해석에서 이어집니다.