이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 다섯 번째 글입니다. 4편에서는 getBean() 한 번에 빈이 인스턴스화 → 의존성 주입 → 초기화의 세 단계를 거쳐 만들어지는 것을 봤습니다. 그때 두 번째 단계인 의존성 주입은 "@Autowired가 붙은 자리에 후처리기가 다른 빈을 getBean()으로 가져와 넣는다"고만 하고 넘어갔습니다. 이번 글은 그 한 문장 안에 있는 일, 곧 어떤 빈을 넣을지 찾고 고르는 과정을 따라갑니다. 1편의 지도에서는 U03 단위입니다.
답하려는 질문은 네 가지입니다.
@Autowired가 붙은 필드는 누가 발견하고, 넣을 빈은 어디서 찾는가- 같은 타입의 빈이 둘 이상이면 무엇을 넣는가 —
@Primary,@Qualifier, 필드 이름은 어떤 순서로 보는가 List<T>나Map<String, T>로 받으면 무엇이 들어오고, 순서는 누가 정하는가- 생성자가 여럿이면 어느 생성자를 부르는가, 그리고 넣을 빈이 없으면 어떻게 되는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-beans (일부 spring-context) |
| 주로 읽는 클래스 | AutowiredAnnotationBeanPostProcessor, DependencyDescriptor, DefaultListableBeanFactory, QualifierAnnotationAutowireCandidateResolver, ConstructorResolver |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
이 글의 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 출력은 실제로 나온 것을 그대로 옮겼습니다.
의존성 주입의 소스는 특수한 경우를 위한 분기가 많습니다. 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐고, 뺀 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
빈과 컨테이너는 1편과 3편에서 설명했습니다. 빈은 Spring이 만들어 관리하는 객체, 컨테이너는 그 빈을 만들고 보관하는 주체입니다.
의존성 주입(DI)은 필요한 객체를 컨테이너가 넣어 주는 것입니다. 필드에 넣으면 필드 주입, 생성자 인자로 넣으면 생성자 주입이라고 부른다는 것까지 4편에서 봤습니다.
주입 지점(injection point)은 컨테이너가 무언가를 넣어 주어야 하는 자리입니다. @Autowired가 붙은 필드 하나, 메서드의 파라미터 하나, 생성자의 파라미터 하나가 각각 주입 지점 하나입니다. 소스에서는 InjectionPoint라는 클래스이고, 이 글의 주인공인 DependencyDescriptor가 그것을 상속합니다.
후보(candidate)는 어떤 주입 지점에 넣을 수 있는 빈입니다. 보통 타입이 맞으면 후보입니다. 이 글은 결국 "후보를 모으고, 하나로 줄이는" 이야기입니다.
한정자(qualifier)는 후보를 더 좁히기 위해 주입 지점에 붙이는 표시입니다. @Qualifier("english")처럼 씁니다.
후처리기(BeanPostProcessor)는 빈이 만들어지는 흐름에 끼어드는 객체입니다. 4편에서 초기화 앞뒤의 훅을 봤는데, @Autowired를 처리하는 것도 후처리기입니다. 이 글의 첫 절이 그 후처리기입니다.
1. 누가 @Autowired를 찾나 — AutowiredAnnotationBeanPostProcessor
@Autowired는 어노테이션일 뿐이라 혼자서는 아무것도 하지 않습니다. 그것을 찾아서 값을 넣는 쪽은 AutowiredAnnotationBeanPostProcessor라는 후처리기입니다. 이름이 길지만 "Autowired 어노테이션을 처리하는 빈 후처리기"라고 읽으면 됩니다.
어떤 어노테이션을 찾는가
이 클래스의 생성자가 찾을 어노테이션의 목록을 만듭니다.
public AutowiredAnnotationBeanPostProcessor() {
this.autowiredAnnotationTypes.add(Autowired.class);
this.autowiredAnnotationTypes.add(Value.class);
ClassLoader classLoader = AutowiredAnnotationBeanPostProcessor.class.getClassLoader();
try {
this.autowiredAnnotationTypes.add((Class<? extends Annotation>)
ClassUtils.forName("jakarta.inject.Inject", classLoader));
logger.trace("'jakarta.inject.Inject' annotation found and supported for autowiring");
}
catch (ClassNotFoundException ex) {
// jakarta.inject API not available - simply skip.
}
}
세 가지입니다. Spring의 @Autowired, 값을 넣는 @Value, 그리고 클래스패스에 있을 때만 표준 어노테이션인 jakarta.inject.Inject. 뒤에 나오는 "@Autowired가 붙은"은 모두 "이 세 가지 가운데 하나가 붙은"이라는 뜻입니다.
이 후처리기는 AnnotationConfigApplicationContext처럼 어노테이션 설정을 쓰는 컨테이너가 시작할 때 자동으로 등록됩니다(AnnotationConfigUtils 164–168행). 같은 자리에서 이 글의 3절과 4절에 나올 두 가지, 한정자를 판단하는 ContextAnnotationAutowireCandidateResolver와 순서를 정하는 AnnotationAwareOrderComparator도 컨테이너에 꽂힙니다(148–153행).
언제, 어떻게 끼어드는가
4편의 두 번째 단계 populateBean()은 등록된 후처리기마다 postProcessProperties()를 부릅니다(AbstractAutowireCapableBeanFactory 1442–1448행). 우리 후처리기의 그 메서드는 두 줄이 전부입니다. 이 클래스의 주입 지점 목록(InjectionMetadata)을 찾아서, 주입하라고 시킵니다(490–502행).
필드 하나가 값을 받기까지. 노란 상자는 클래스마다 한 번만 만들어 두는 목록, 파란 상자는 4편에서 본 후처리기 훅입니다.
주입 지점 목록은 클래스를 뒤져서 만듭니다(buildAutowiringMetadata, 547–607행). 필드와 메서드를 훑어 위의 세 어노테이션이 붙은 것을 모으고, 부모 클래스로 올라가며 같은 일을 반복합니다(do ... while (targetClass != null && targetClass != Object.class), 602–604행). 모은 결과는 빈 이름을 열쇠로 캐시해 두므로 리플렉션으로 클래스를 뒤지는 일은 클래스마다 한 번입니다(527–545행).
필드 하나를 실제로 채우는 AutowiredFieldElement.resolveFieldValue()가 이 글 전체의 출발점입니다(756–791행). 두 줄만 보면 됩니다. 필드 하나가 DependencyDescriptor 하나가 되고(new DependencyDescriptor(field, this.required), 757행), 그것을 컨테이너의 resolveDependency() 에 넘깁니다(764행). 메서드 주입도 파라미터마다 같은 일을 합니다(864행). 돌아온 값이 null이 아닐 때만 필드에 넣습니다(750–753행).
질문서 — DependencyDescriptor
DependencyDescriptor는 "이 자리에 무엇이 필요한가"를 적은 질문서입니다. 필드나 파라미터를 감싸서 다음을 알려 줍니다.
| 묻는 것 | 메서드 | 답 |
|---|---|---|
| 타입 | getDependencyType() |
필드의 타입, 또는 파라미터의 타입 (346–359행) |
| 이름 | getDependencyName() |
필드 이름, 또는 파라미터 이름 (338–340행) |
| 꼭 필요한가 | isRequired() |
@Autowired(required = ...)의 값에 더해, 타입이 Optional이거나 @Nullable이 붙었으면 false (157–168행) |
| 붙은 어노테이션 | getAnnotations() |
@Qualifier, @Lazy 같은 것을 뒤에서 읽는다 |
| 고른 빈을 가져오기 | resolveCandidate() |
beanFactory.getBean(beanName, requiredType) (224–235행) |
마지막 줄이 4편과 이어지는 고리입니다. 후보를 찾고 하나를 고르는 일이 끝나면 결국 그 이름으로 getBean()을 부르고, 그 빈이 아직 없으면 4편의 생성 여정이 거기서 시작됩니다.
헷갈리기 쉬운 곳
static필드에는 주입되지 않습니다. 목록을 만들 때static필드와 메서드는 로그만 남기고 건너뜁니다(560–565행, 578–583행).- 부모 클래스의 필드가 먼저 채워집니다. 부모로 올라가며 모은 요소를 목록의 앞에 끼워 넣기 때문입니다(
elements.addAll(0, ...), 600–601행). private필드에도 들어갑니다. 필드에 값을 넣기 전에ReflectionUtils.makeAccessible(field)를 부릅니다(751행). 접근 제한자는 주입과 관계가 없습니다.ReflectionUtils같은 공용 도구는 16편에서 따로 봅니다.
2. 후보 찾기 — resolveDependency에서 findAutowireCandidates까지
질문서를 받은 resolveDependency()는 DefaultListableBeanFactory에 있습니다. 3편에서 본, 컨테이너의 모든 것을 구현하는 그 클래스입니다. 이 메서드는 Optional, ObjectProvider, @Lazy라는 세 가지 특별한 경우를 먼저 갈라내고(6절. 클래스패스에 있으면 jakarta.inject.Provider도 같은 자리에서 갈라냅니다, 1648–1650행), 나머지는 doResolveDependency()에 넘깁니다(1637–1659행). 4편에서 본 xxx()와 doXxx()의 짝입니다.
doResolveDependency()는 소스에 Step 1부터 Step 6까지 주석이 달려 있어 읽기 좋습니다. 뼈대만 남기면 이렇습니다.
public @Nullable Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
@Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
// ...
// Step 1: pre-resolved shortcut for single bean match, for example, from @Autowired
Object shortcut = descriptor.resolveShortcut(this);
// ...
// Step 2: pre-defined value or expression, for example, from @Value
Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
// ...
// Step 3: shortcut for declared dependency name or qualifier-suggested name matching target bean name
if (descriptor.usesStandardBeanLookup()) {
// ...
}
// Step 4a: multiple beans as stream / array / standard collection / plain map
Object multipleBeans = resolveMultipleBeans(descriptor, beanName, autowiredBeanNames, typeConverter);
// ...
// Step 4b: direct bean matches, possibly direct beans of type Collection / Map
Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor);
if (matchingBeans.isEmpty()) {
// ...
// Raise exception if nothing found for required injection point
if (isRequired(descriptor)) {
raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
}
return null;
}
// ...
// Step 5: determine single candidate
if (matchingBeans.size() > 1) {
autowiredBeanName = determineAutowireCandidate(matchingBeans, descriptor);
if (autowiredBeanName == null) {
if (isRequired(descriptor) || !indicatesArrayCollectionOrMap(type)) {
// Raise exception if no clear match found for required injection point
return descriptor.resolveNotUnique(descriptor.getResolvableType(), matchingBeans);
}
// ...
}
instanceCandidate = matchingBeans.get(autowiredBeanName);
}
// ...
// Step 6: validate single result
// ...
if (instanceCandidate instanceof Class) {
instanceCandidate = descriptor.resolveCandidate(autowiredBeanName, type, this);
}
return resolveInstance(instanceCandidate, descriptor, type, autowiredBeanName);
// ...
}
Step 1부터 5까지. 초록은 값을 돌려주는 끝, 빨강은 예외로 끝나는 길입니다.
순서대로 읽으면 이렇습니다.
- Step 1·2 — 빠른 길. 같은 자리를 전에 한 번 풀어서 빈 이름을 기억해 두었으면(1절 후처리기가
ShortcutDependencyDescriptor로 캐시합니다, 910–923행) 그 이름으로 바로 가져옵니다.@Value가 붙었으면 빈이 아니라 값을 변환해서 돌려줍니다. 이 글은@Value를 다루지 않습니다. - Step 3 — 이름이 같으면 바로. 필드 이름(또는
@Qualifier의 값)과 똑같은 이름의 빈이 있고 타입도 맞으면, 후보를 모으지도 않고 그 빈을 돌려줍니다(1696–1715행). 3절의 예제 두 개가 사실 여기서 끝납니다. - Step 4a — 여럿을 받는 자리. 타입이 배열,
List,Set,Map이면 맞는 빈을 전부 모아 돌려줍니다. 4절입니다. - Step 4b — 후보 모으기.
findAutowireCandidates()가 "타입이 맞고 후보 자격이 있는" 빈을 이름 → 객체 맵으로 돌려줍니다. 비어 있으면, 꼭 필요한 자리일 때NoSuchBeanDefinitionException을 던집니다. - Step 5 — 하나 고르기. 후보가 둘 이상이면
determineAutowireCandidate()가 고릅니다(3절). 못 고르면resolveNotUnique(), 곧NoUniqueBeanDefinitionException입니다(DependencyDescriptor191–193행). - Step 6 — 가져오기. 고른 이름으로
resolveCandidate(), 곧getBean()을 부릅니다.
후보 자격 — findAutowireCandidates
후보를 모으는 findAutowireCandidates()는 세 단계입니다(1959–2005행).
- 타입으로 이름을 모읍니다.
BeanFactoryUtils.beanNamesForTypeIncludingAncestors()(1962–1963행)는 3편의 5-3절에서 본getBeanNamesForType()을 부르고, 부모 컨테이너가 있으면 거기까지 묻습니다(BeanFactoryUtils261–274행). 설계도를 처음부터 끝까지 돌며 타입을 맞춰 보는 그 조회입니다. - 자기 자신은 뺍니다.
isSelfReference()는 지금 만들고 있는 빈이 자기 자신을 후보로 찾는 경우를 걸러 냅니다(1977행, 2269–2273행). - 후보 자격을 묻습니다.
isAutowireCandidate()는 1절에서 컨테이너에 꽂힌AutowireCandidateResolver에게 묻습니다. 가장 기본적인 자격은 설계도의autowireCandidate플래그이고(AutowireCandidateResolver45–46행), 그 위에 제네릭 타입 검사와@Qualifier검사가 얹힙니다. 3절에서 봅니다.
예제 ①: 같은 타입이 둘이면
Greeter 인터페이스를 구현한 빈이 둘이고, 받는 자리는 하나입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
public class NoUniqueExample {
interface Greeter {
String greet();
}
@Component
static class KoreanGreeter implements Greeter {
public String greet() { return "안녕하세요"; }
}
@Component
static class EnglishGreeter implements Greeter {
public String greet() { return "Hello"; }
}
@Component
static class Welcome {
@Autowired
Greeter greeter; // Greeter 타입 빈이 둘 — 무엇을 넣어야 하나?
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext()) {
context.register(KoreanGreeter.class, EnglishGreeter.class, Welcome.class);
context.refresh();
// ...
}
catch (BeansException ex) {
Throwable root = NestedExceptionUtils.getMostSpecificCause(ex);
System.out.println(root.getClass().getSimpleName() + ": " + root.getMessage());
}
}
}
실행하면 컨테이너가 남기는 WARN 로그 한 줄 뒤에 이렇게 찍힙니다(이 글의 출력은 모두 그 로그 한 줄을 뺀 것입니다).
NoUniqueBeanDefinitionException: No qualifying bean of type 'ex.u03.NoUniqueExample$Greeter' available: expected single matching bean but found 2: noUniqueExample.KoreanGreeter,noUniqueExample.EnglishGreeter
위 흐름 그대로입니다. Step 4b에서 후보 둘이 모이고, Step 5에서 고를 근거가 없어 resolveNotUnique()가 예외를 던집니다. 메시지의 "expected single matching bean but found 2"는 예외 클래스의 생성자가 만듭니다(NoUniqueBeanDefinitionException 95–100행). 앞의 "No qualifying bean of type ... available"는 부모 클래스 NoSuchBeanDefinitionException이 붙입니다(99–103행). "없다"와 "너무 많다"가 상속 관계인 셈입니다(38행).
헷갈리기 쉬운 곳
- 후보 맵의 값이 빈이 아닐 수 있습니다.
addCandidateEntry()는 이미 만들어진 싱글톤이면 객체를 넣지만, 아직 안 만들어진 빈은Class만 넣습니다(2011–2028행). 소스 주석대로 "하나를 고르기 전에 후보 전부를 만들어 버리지 않기" 위해서입니다. 그래서 Step 6에instanceCandidate instanceof Class면 그때getBean()을 부르는 코드가 있습니다. 고르지 않은 후보는 이 주입 때문에 만들어지지 않습니다. - 자기 자신은 맨 마지막 후보입니다. 2번에서 뺀 자기 참조는, 다른 후보가 하나도 없을 때 마지막으로 다시 넣어 봅니다(1992–2002행).
3. 여럿 중 하나 고르기 — @Primary, 이름, @Qualifier
후보가 둘 이상 남았을 때 부르는 determineAutowireCandidate()입니다. 소스 주석에 순서가 적혀 있습니다.
protected @Nullable String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
Class<?> requiredType = descriptor.getDependencyType();
// Step 1: check primary candidate
String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
if (primaryCandidate != null) {
return primaryCandidate;
}
// Step 2a: match bean name against declared dependency name
String dependencyName = descriptor.getDependencyName();
// ...
// Step 2b: match bean name against qualifier-suggested name
String suggestedName = getAutowireCandidateResolver().getSuggestedName(descriptor);
// ...
// Step 3: check highest priority candidate
String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType);
if (priorityCandidate != null) {
return priorityCandidate;
}
// Step 4: pick unique default-candidate
String defaultCandidate = determineDefaultCandidate(candidates);
if (defaultCandidate != null) {
return defaultCandidate;
}
// Step 5: pick directly registered dependency
// ...
return null;
}
고르는 순서. @Primary가 이름보다 앞이고, 이름이 @Priority보다 앞입니다.
| 순서 | 기준 | 소스 |
|---|---|---|
| ① | @Primary가 붙은 후보가 하나면 그것. 둘 이상이면 예외 |
determinePrimaryCandidate 2092–2128행 |
| ② | 필드·파라미터 이름, 또는 @Qualifier의 값과 같은 이름(별칭 포함)의 후보 |
matchesBeanName 2258–2261행 |
| ③ | jakarta.annotation.Priority 값이 가장 작은 후보. 가장 작은 값이 둘이면 예외 |
determineHighestPriorityCandidate 2144–2179행 |
| ④ | default-candidate로 남은 후보가 하나면 그것 |
2240–2252행 |
| ⑤ | 컨테이너에 직접 등록한 의존성 객체 | 2074–2080행 |
@Primary는 어노테이션이지만, 고르는 쪽이 보는 것은 어노테이션이 아니라 설계도의 isPrimary() 플래그입니다(2188–2195행). 어노테이션을 읽어 설계도에 옮겨 적는 일은 설계도를 만드는 단계에서 끝나 있습니다(8편의 주제입니다). @Primary의 반대인 @Fallback(6.2부터)도 같은 메서드에서 봅니다. 나머지 후보가 전부 @Fallback이면 아닌 하나를 고릅니다(2116–2126행, Fallback 25–47행).
예제 ②③④: @Primary, @Qualifier, 필드 이름
예제 ①의 빈 선언과 Welcome만 바꿔 가며 세 번 돌렸습니다. 나머지 코드는 ①과 같아서 바뀐 부분만 싣습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다) — PrimaryExample
@Component
@Primary // 후보가 여럿이면 나를 고르라는 표시
static class KoreanGreeter implements Greeter {
public String greet() { return "안녕하세요"; }
}
// ...
@Component
static class Welcome {
@Autowired
Greeter greeter;
}
KoreanGreeter -> 안녕하세요
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다) — QualifierExample
@Component("korean") // 빈 이름을 직접 정한다
static class KoreanGreeter implements Greeter {
public String greet() { return "안녕하세요"; }
}
@Component("english")
static class EnglishGreeter implements Greeter {
public String greet() { return "Hello"; }
}
@Component
static class Welcome {
@Autowired
@Qualifier("english") // 이 이름(또는 이 한정자)을 가진 후보만
Greeter greeter;
}
EnglishGreeter -> Hello
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다) — FieldNameExample
@Component
static class Welcome {
@Autowired
Greeter korean; // 필드 이름 = 빈 이름 "korean"
@Autowired
Greeter english; // 필드 이름 = 빈 이름 "english"
}
korean -> 안녕하세요
english -> Hello
셋 다 기대한 대로 골라졌지만, 소스를 보면 길이 다릅니다. @Primary는 Step 4b에서 후보 둘을 모은 뒤 위 표의 ①에서 골라집니다. 반면 @Qualifier("english")와 필드 이름 korean은 그 값이 빈 이름과 같기 때문에, 2절의 Step 3 바로가기에서 후보를 모으기도 전에 끝납니다(1697–1704행). 필드 이름은 getDependencyName()에서, @Qualifier의 값은 getSuggestedName()에서 옵니다(QualifierAnnotationAutowireCandidateResolver 394–404행). 표의 ②는 그 바로가기를 타지 못했을 때를 위한 두 번째 기회입니다.
@Qualifier는 "고르기"보다 "거르기"입니다
@Qualifier의 값이 빈 이름이 아닐 수도 있습니다. 빈 클래스 쪽에도 @Qualifier("fast")를 붙여 두고 주입 지점에 @Qualifier("fast")를 쓰는 방식입니다. 이때는 Step 3 바로가기가 없고, 대신 2절의 세 번째 단계 "후보 자격"에서 걸러집니다. QualifierAnnotationAutowireCandidateResolver.isAutowireCandidate()는 주입 지점의 어노테이션 가운데 한정자(@Qualifier, 그리고 클래스패스에 있으면 jakarta.inject.Qualifier, 76–85행)를 찾아, 후보 빈이 같은 한정자를 가졌는지 확인합니다(149–168행, 248–328행). 한정자가 맞지 않는 빈은 아예 후보가 되지 못합니다. 그래서 @Qualifier를 쓰면 보통 Step 5까지 가지 않고 후보가 하나로 줄어 있습니다. value가 빈 이름(또는 별칭)과 같으면 그것도 맞는 것으로 칩니다(311–315행).
헷갈리기 쉬운 곳
- 필드 이름이 맞아도
@Primary가 이깁니다. Step 3 바로가기에는!hasPrimaryConflict(dependencyName, type)라는 조건이 있습니다(1705–1707행, 2279–2291행). 같은 타입의 다른 빈에@Primary가 있으면 바로가기를 타지 않고, Step 5의 ①이@Primary를 고릅니다. 예제 ④에서EnglishGreeter에@Primary만 더 붙여 돌려 봤습니다(PrimaryBeatsNameExample).
korean -> EnglishGreeter / Hello
@Primary가 둘이면 예외입니다. 메시지는 "more than one 'primary' bean found among candidates"입니다(2103–2105행). 정확히는 둘이 같은 컨테이너에 있을 때이고, 하나는 이 컨테이너에 다른 하나는 부모 컨테이너에 있으면 이 컨테이너의 것을 고릅니다(2100–2109행).- 생성자·메서드 파라미터의 이름은 컴파일 옵션에 달려 있습니다. 파라미터 이름은
DefaultParameterNameDiscoverer가 리플렉션으로 읽는데, 소스 주석에 "Recommended approach on Java: compilation with -parameters"라고 적혀 있습니다(45–52행). 이 옵션 없이 컴파일하면 ②의 이름 일치는 생성자 주입에서 동작하지 않을 수 있습니다. 필드 이름은 컴파일 옵션과 무관합니다.
4. 전부 받기 — List, Map, 배열과 @Order
같은 타입의 빈을 하나만 고르는 대신 전부 받고 싶을 때가 있습니다. 주입 지점의 타입을 List<Greeter>, Set<Greeter>, Greeter[], Map<String, Greeter>로 선언하면 됩니다. 2절의 Step 4a, resolveMultipleBeans()가 이것을 처리합니다(1793–1846행). 타입을 보고 네 갈래로 나눕니다.
| 타입 | 처리 | 소스 |
|---|---|---|
| 배열 | 원소 타입으로 후보를 모아 배열로 변환하고 정렬 | 1811–1838행 |
Collection, Set, List |
제네릭 원소 타입으로 후보를 모아 변환하고, List면 정렬 |
resolveMultipleBeanCollection 1863–1887행 |
Map |
열쇠 타입이 String일 때만. 빈 이름 → 빈. 정렬하지 않음 |
resolveMultipleBeanMap 1889–1911행 |
Stream |
ObjectProvider.stream()으로 받을 때 |
1798–1810행 |
후보를 모으는 것은 2절과 같은 findAutowireCandidates()입니다. 다만 질문서를 MultiElementDescriptor로 감싸서 넘기는데(1870–1871행), 이 경우 addCandidateEntry()는 Class를 넣지 않고 후보마다 바로 getBean()을 부릅니다(2014–2019행). 어차피 전부 넣을 것이기 때문입니다.
정렬은 컨테이너에 꽂힌 dependencyComparator가 합니다(1922–1931행). 1절에서 본 대로 어노테이션 설정 컨테이너에는 AnnotationAwareOrderComparator가 꽂혀 있고, 이것은 @Order 값을, 없으면 jakarta.annotation.Priority 값을 읽습니다(OrderUtils 118–128행). 둘 다 없으면 가장 낮은 우선순위(Ordered.LOWEST_PRECEDENCE)로 칩니다(OrderComparator 128–135행). 값이 작을수록 앞입니다.
예제 ⑥: List와 Map, 그리고 @Order
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
public class ListOrderExample {
interface Greeter {
String greet();
}
@Component("korean")
@Order(2)
static class KoreanGreeter implements Greeter {
public String greet() { return "안녕하세요"; }
}
@Component("english")
@Order(1)
static class EnglishGreeter implements Greeter {
public String greet() { return "Hello"; }
}
@Component("japanese") // @Order 없음
static class JapaneseGreeter implements Greeter {
public String greet() { return "こんにちは"; }
}
@Component
static class Welcome {
@Autowired
List<Greeter> greeters; // Greeter 타입 빈 전부, 순서대로
@Autowired
Map<String, Greeter> greetersByName; // 빈 이름 -> 빈
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext()) {
context.register(KoreanGreeter.class, JapaneseGreeter.class, EnglishGreeter.class, Welcome.class);
// ... (greeters 를 차례로 찍고, greetersByName 의 keySet() 을 찍는다)
}
}
}
EnglishGreeter -> Hello
KoreanGreeter -> 안녕하세요
JapaneseGreeter -> こんにちは
[korean, japanese, english]
List는 @Order(1), @Order(2), 없음의 순서입니다. 등록한 순서(korean, japanese, english)와 다릅니다. 반면 Map의 열쇠 순서는 등록한 순서 그대로입니다. 위 표대로 Map은 정렬하지 않기 때문입니다.
같은 후보 셋이 List에서는 @Order 순서로, Map에서는 등록 순서로 들어갑니다. 순서는 예제 ⑥의 실제 출력 그대로입니다.
헷갈리기 쉬운 곳
@Primary는 컬렉션 주입에 아무 영향이 없습니다.@Primary의 설명 주석에 명시되어 있습니다. 배열, 컬렉션, 맵에는 타입이 맞는 빈이 전부 들어갑니다(Primary30–32행).Map의 열쇠는String이어야 합니다. 다른 타입이면resolveMultipleBeanMap()이null을 돌려주어 "전부 받기"로 처리되지 않습니다(1893–1896행).- 자기 자신은 자기 컬렉션에 들어가지 않습니다. 2절에서 본 자기 참조 제외가 여기도 적용되고, 마지막에 다시 넣어 보는 예외도 컬렉션에서는 "바로 그 빈"을 빼고 봅니다(1992–2002행).
5. 생성자 주입 — 생성자가 여럿이면
지금까지는 객체가 이미 만들어진 뒤의 필드 주입이었습니다. 생성자 주입은 4편의 첫 번째 단계, 인스턴스화 안에서 일어납니다. 두 가지를 정해야 합니다. 어느 생성자를 부를지, 그리고 그 인자에 무엇을 넣을지입니다.
어느 생성자인가 — determineCandidateConstructors
createBeanInstance()는 생성자를 고르기 전에 후처리기들에게 "쓸 만한 생성자가 있느냐"고 묻습니다(AbstractAutowireCapableBeanFactory 1215–1219행, 1313–1325행). 대답하는 쪽이 또 1절의 AutowiredAnnotationBeanPostProcessor입니다. determineCandidateConstructors()의 뒷부분이 규칙입니다.
if (!candidates.isEmpty()) {
// Add default constructor to list of optional constructors, as fallback.
if (requiredConstructor == null) {
if (defaultConstructor != null) {
candidates.add(defaultConstructor);
}
// ...
}
candidateConstructors = candidates.toArray(EMPTY_CONSTRUCTOR_ARRAY);
}
else if (rawCandidates.length == 1 && rawCandidates[0].getParameterCount() > 0) {
candidateConstructors = new Constructor<?>[] {rawCandidates[0]};
}
else if (nonSyntheticConstructors == 2 && primaryConstructor != null &&
defaultConstructor != null && !primaryConstructor.equals(defaultConstructor)) {
candidateConstructors = new Constructor<?>[] {primaryConstructor, defaultConstructor};
}
else if (nonSyntheticConstructors == 1 && primaryConstructor != null) {
candidateConstructors = new Constructor<?>[] {primaryConstructor};
}
else {
candidateConstructors = EMPTY_CONSTRUCTOR_ARRAY;
}
생성자 고르기. 회색 상자가 "후처리기가 아무것도 고르지 않은" 길입니다.
위에서부터 읽으면 네 가지 규칙입니다. candidates는 그 앞의 반복문에서 모은 "@Autowired가 붙은 생성자"의 목록입니다(375–418행).
@Autowired가 붙은 생성자가 있으면 그것들.required = true(기본값)인 것은 하나만 허용되고, 둘이면 "Invalid autowire-marked constructor" 예외입니다(397–412행).required = false인 것은 여럿일 수 있고, 그때 기본 생성자가 있으면 예비로 목록 끝에 붙입니다.- 없으면, 생성자가 딱 하나이고 인자가 있으면 그것. 그래서 생성자가 하나뿐인 클래스는
@Autowired없이도 생성자 주입이 됩니다. - 없으면, record나 Kotlin 클래스의 주 생성자. 위 코드의
else if둘이 이 규칙입니다. 주 생성자 말고 기본 생성자가 따로 있으면 그 둘을 함께 돌려주고(437–440행), 주 생성자뿐이면 그것만 돌려줍니다(441–443행).BeanUtils.findPrimaryConstructor()가 record의 표준 생성자나 Kotlin의 주 생성자를 찾아 줍니다(278–296행). - 그 밖에는 빈 배열, 곧 "고른 것 없음"(
null)입니다(451행). 그러면createBeanInstance()는 평범하게 기본 생성자로new합니다(1228행). 기본 생성자가 없으면 "No default constructor found"입니다(SimpleInstantiationStrategy98–102행).
인자에 무엇을 넣는가 — ConstructorResolver
후처리기가 생성자를 하나 이상 돌려주면 ConstructorResolver.autowireConstructor()가 받습니다(135–311행). 후보가 여럿이면 public이고 인자가 많은 것부터 정렬해(209행, AutowireUtils 64–73행) 차례로 인자를 채워 봅니다. 인자를 채우지 못하면 그 생성자는 건너뛰고 다음으로 넘어갑니다(243–253행). @Autowired 설명 주석의 "채울 수 있는 의존성이 가장 많은 생성자를 고른다"가 이것입니다(Autowired 36–39행).
인자 하나를 채우는 코드는 필드 주입과 같은 길로 갑니다. 파라미터마다 DependencyDescriptor를 만들어(789행) resolveDependency()를 부릅니다(900–912행). 2절부터 4절까지의 규칙이 생성자 인자에도 그대로 적용된다는 뜻입니다. 4편 6절에서 생성자 주입 순환이 풀리지 않는 이유를 설명하며 가리킨 곳이 바로 이 줄입니다.
예제 ⑤: 생성자가 하나면, 둘이면
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
public class ConstructorExample {
@Component
static class Engine {
public String toString() { return "Engine"; }
}
/** 생성자가 하나 — @Autowired 가 없어도 그 생성자가 쓰인다. */
@Component
static class Car {
final Engine engine;
Car(Engine engine) { this.engine = engine; }
}
/** 생성자가 둘, @Autowired 없음, 기본 생성자 있음 — 기본 생성자가 쓰인다. */
@Component
static class Bike {
Engine engine;
Bike() { }
Bike(Engine engine) { this.engine = engine; }
}
/** 생성자가 둘, @Autowired 없음, 기본 생성자 없음 — 고를 수 없다. */
@Component
static class Truck {
Engine engine;
Truck(Engine engine) { this.engine = engine; }
Truck(Engine engine, int wheels) { this.engine = engine; }
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext()) {
context.register(Engine.class, Car.class, Bike.class);
// ... (Car 와 Bike 의 engine 필드를 찍는다)
}
try (var context = new AnnotationConfigApplicationContext()) {
context.register(Engine.class, Truck.class);
// ... (예외가 나면 메시지와 root cause 를 찍는다)
}
}
}
출력은 이렇습니다.
Car.engine = Engine
Bike.engine = null
BeanCreationException: Error creating bean with name 'constructorExample.Truck': Failed to instantiate [ex.u03.ConstructorExample$Truck]: No default constructor found
root cause: NoSuchMethodException: ex.u03.ConstructorExample$Truck.<init>()
Car는 규칙 2입니다. 생성자가 하나라@Autowired없이Engine이 들어왔습니다.Bike는 규칙 4입니다. 생성자가 둘인데@Autowired가 없으니 후처리기는 아무것도 고르지 않고, 컨테이너는 기본 생성자로 만듭니다. 예외 없이engine이null로 남습니다. 두 번째 생성자는 쓰이지 않습니다.Truck도 규칙 4인데 기본 생성자가 없어서 "No default constructor found"로 끝납니다. 두 생성자 가운데 하나에@Autowired를 붙이면 규칙 1로 바뀌어 해결됩니다.
헷갈리기 쉬운 곳
Bike가 함정입니다. 생성자 둘 가운데 하나가 기본 생성자면 조용히 기본 생성자가 쓰이고, 주입을 기대했던 필드는null입니다. 예외가 나는Truck보다 알아채기 어렵습니다.- 기본 생성자는 예비일 뿐입니다. 규칙 1에서
required = false인@Autowired생성자와 기본 생성자가 함께 후보가 되면,ConstructorResolver는 (public여부가 같다면) 인자가 많은 쪽부터 시도하므로 기본 생성자는 다른 생성자를 전부 채우지 못했을 때만 쓰입니다. @Autowired가 붙은 생성자는public이 아니어도 됩니다. 설명 주석에도 적혀 있고(Autowired43행), 위 예제의 생성자들도 모두public이 아닙니다.
6. 없을 때와 나중에 — required, Optional, ObjectProvider, @Lazy
마지막으로 "넣을 빈이 없으면"입니다. 2절 Step 4b에서 후보가 없을 때 isRequired(descriptor)가 참이면 예외, 거짓이면 null이었습니다. isRequired의 답은 두 겹입니다. DependencyDescriptor.isRequired()가 타입이 Optional이거나 @Nullable이 붙었는지를 보고(157–168행), 그 위에서 QualifierAnnotationAutowireCandidateResolver.isRequired()가 @Autowired(required = false)를 봅니다(347–365행). 어느 쪽이든 "꼭 필요하지는 않다"면 null이 돌아오고, 1절에서 봤듯이 null은 필드에 쓰지 않으므로 필드는 선언할 때의 값 그대로 남습니다.
Optional, ObjectProvider, @Lazy는 "없을 수도 있다"를 넘어 "지금 찾지 않는다"는 뜻이 있어서, doResolveDependency()에 들어가기 전에 resolveDependency()가 타입을 보고 갈라냅니다. Optional이면 1641–1642행, ObjectFactory나 ObjectProvider면 1644–1646행, @Lazy가 붙었으면 1651–1657행이고(그 사이 1648–1650행은 jakarta.inject.Provider입니다), 셋 다 아니면 2절의 doResolveDependency()로 갑니다(1658행).
회색 상자 셋은 "진짜 빈 대신 다른 것을 넣는" 길입니다.
| 선언 | 넣어 주는 것 | 실제로 빈을 찾는 때 |
|---|---|---|
Optional<T> |
찾은 빈을 담은 Optional, 없으면 빈 Optional. required = false로 바꿔 2절의 흐름을 돌립니다 (2340–2360행) |
주입할 때 |
ObjectProvider<T> |
DependencyObjectProvider라는 작은 객체 (2509–2527행). getObject()는 꼭 찾고(2530–2536행), getIfAvailable()은 없으면 null, getIfUnique()는 여럿이어도 null (2559–2625행) |
그 메서드를 부를 때 |
@Lazy T |
T 타입의 프록시. 처음 메서드가 불릴 때 빈을 찾아 넘깁니다 (ContextAnnotationAutowireCandidateResolver 53–55행, 91–108행) |
프록시의 메서드를 처음 부를 때 |
ObjectProvider의 세 메서드는 모두 2절의 doResolveDependency()를 재활용합니다. 질문서(DependencyDescriptor)를 익명 클래스로 살짝 바꿔 isRequired()가 false를, resolveNotUnique()가 예외 대신 null을 돌려주게 한 뒤 같은 해석기를 부릅니다(2565–2575행, 2599–2612행).
@Lazy는 4편 끝에서 "순환을 끊는 방법"으로 이름만 적었던 것입니다. 생성자 주입 순환에서 한쪽 인자에 @Lazy를 붙이면, 인스턴스화 시점에 상대 빈을 찾는 대신 프록시를 넣으므로 4편 6절의 예외가 나지 않습니다(이것은 소스를 읽고 적은 것이고, 이 글에서 예제로 돌려 확인한 것은 아닙니다). 프록시는 ProxyFactory로 만들고(100–107행), 그 안쪽은 10편의 주제입니다. 이 클래스가 spring-beans가 아니라 spring-context에 있는 것도 프록시 기능(spring-aop)을 써야 하기 때문입니다.
헷갈리기 쉬운 곳
required = false는 "없어도 된다"이지 "여럿이어도 된다"가 아닙니다. 후보가 둘이면required와 관계없이 Step 5에서NoUniqueBeanDefinitionException입니다(1744–1747행). 여럿이어도 괜찮으려면ObjectProvider.getIfUnique()나 컬렉션을 씁니다.- 없을 때의 예외 메시지에는 어노테이션이 같이 찍힙니다. "expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: ..."의 형태입니다(2297–2305행). 한정자를 잘못 적어서 후보가 0이 된 경우를 알아채는 단서입니다.
더 깊이 들어가면
이 글이 뺀 것의 이름과 위치만 적어 둡니다. @Value의 값과 표현식(표현식 언어 SpEL은 15편에서 봅니다)을 풀어 변환하는 Step 2(1675–1694행, 15편의 SpEL과 이어집니다). 제네릭 타입까지 맞춰 보는 GenericTypeAwareAutowireCandidateResolver(46행부터, 13편의 ResolvableType이 필요합니다). @Qualifier를 메타 어노테이션으로 붙인 커스텀 한정자 어노테이션의 처리(checkQualifiers 176–221행). 첫 번째 후보 찾기가 실패했을 때의 두 번째 시도인 fallback 매치(1981–1991행, DependencyDescriptor 309–320행). 어노테이션 없이 설계도의 설정만으로 하는 옛 방식의 autowire="byName", "byType"(populateBean 1425–1437행). 빈이 아닌 객체를 주입 후보로 등록하는 registerResolvableDependency와 그것을 후보 맵에 넣는 코드(1965–1975행). 생성자 인자로 InjectionPoint 자체를 받는 경우(ConstructorResolver 903–909행). 한 번 푼 결과를 두 번째부터 재사용하는 캐시(ShortcutDependencyDescriptor, 910–923행). 그리고 주입 코드를 미리 생성해 두는 AOT 경로(AotContribution, 929행부터, 16편).
정리
| 개념 | 한 줄 요약 |
|---|---|
AutowiredAnnotationBeanPostProcessor |
@Autowired·@Value·@Inject를 찾아 넣는 후처리기. 필드 주입은 populateBean에서, 생성자 고르기는 createBeanInstance에서 끼어든다 |
InjectionMetadata |
클래스마다 한 번 만들어 캐시하는 주입 지점 목록. 부모 클래스의 것이 앞에 온다 |
DependencyDescriptor |
주입 지점 하나의 질문서. 타입·이름·required·어노테이션을 알려 주고, 고른 빈은 getBean()으로 가져온다 |
resolveDependency |
Optional·ObjectProvider·@Lazy를 먼저 갈라내고 doResolveDependency로 |
doResolveDependency |
바로가기 → 이름 일치 → 컬렉션 → 후보 모으기 → 하나 고르기 → getBean |
findAutowireCandidates |
타입으로 이름을 모으고, 자기 자신을 빼고, 후보 자격(autowireCandidate 플래그·제네릭·@Qualifier)을 묻는다 |
determineAutowireCandidate |
@Primary → 이름 일치 → @Priority → default-candidate 순서로 하나를 고른다 |
@Qualifier |
후보 자격 단계에서 거르는 것. 값이 빈 이름이면 바로가기도 탄다 |
| 컬렉션 주입 | 타입이 맞는 빈 전부. List·배열은 @Order로 정렬, Map은 등록 순서 |
| 생성자 선택 | @Autowired 붙은 것 → 유일한 생성자 → record/Kotlin 주 생성자 → 기본 생성자 |
| 없을 때 | required면 NoSuchBeanDefinitionException, 아니면 null로 두고 필드는 건드리지 않는다 |
| 여럿일 때 | 고를 수 없으면 required와 관계없이 NoUniqueBeanDefinitionException |
다음에 볼 것
4편에서 "getBean()으로 가져온다"고만 했던 자리를 이번 글에서 열어 봤습니다. 필드 하나가 질문서가 되고, 그 질문서로 후보를 모으고, 하나로 줄이는 과정이었습니다.
이 글에서 @Autowired를 처리하는 후처리기가 populateBean과 createBeanInstance 두 곳에 끼어드는 것을 봤습니다. 4편에서는 초기화 앞뒤의 훅을 봤고요. 그렇다면 컨테이너가 빈을 만드는 동안 끼어들 수 있는 자리는 전부 몇 개이고, 어떤 순서로 불리는지가 다음 질문입니다. 6편: 생명주기 콜백과 PostProcessor — 컨테이너에 끼어드는 자리에서 BeanPostProcessor의 여러 변종과 @PostConstruct, InitializingBean, Aware 인터페이스가 불리는 순서를 정리합니다.