namalzi

← 글 목록

Spring 소스 파헤치기 5: @Autowired는 넣을 빈을 어떻게 고르나 — 의존성 주입 해석

Spring · 게시

@Autowired가 붙은 필드와 생성자에 Spring이 어떤 빈을 넣는지, 그 빈을 어떻게 찾고 여럿이면 어떻게 고르는지를 실제 소스에서 따라갑니다. 후보 찾기, @Primary·@Qualifier·이름 일치의 우선순위, List와 Map 주입, 생성자 선택 규칙, 그리고 없을 때의 처리를 예제를 돌려 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 다섯 번째 글입니다. 4편에서는 getBean() 한 번에 빈이 인스턴스화 → 의존성 주입 → 초기화의 세 단계를 거쳐 만들어지는 것을 봤습니다. 그때 두 번째 단계인 의존성 주입은 "@Autowired가 붙은 자리에 후처리기가 다른 빈을 getBean()으로 가져와 넣는다"고만 하고 넘어갔습니다. 이번 글은 그 한 문장 안에 있는 일, 곧 어떤 빈을 넣을지 찾고 고르는 과정을 따라갑니다. 1편의 지도에서는 U03 단위입니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java 192–205행

세 가지입니다. 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행).

@Autowired 필드가 채워지는 흐름. populateBean이 후처리기 훅 postProcessProperties를 부르고, AutowiredAnnotationBeanPostProcessor가 캐시해 둔 InjectionMetadata에서 필드마다 AutowiredFieldElement.inject를 실행한다. 필드 하나가 DependencyDescriptor가 되어 beanFactory.resolveDependency로 넘어가고, 거기서 고른 빈을 getBean으로 가져와 field.set으로 넣는다. 필드 하나가 값을 받기까지. 노란 상자는 클래스마다 한 번만 만들어 두는 목록, 파란 상자는 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편의 생성 여정이 거기서 시작됩니다.

헷갈리기 쉬운 곳

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);
		// ...
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java 1662–1776행

doResolveDependency의 분기. 캐시된 바로가기나 @Value 값이 있으면 바로 돌려주고, 필드 이름이나 @Qualifier 값이 어떤 빈의 이름과 같으면 그 빈을 돌려준다. List·Map·배열이면 맞는 빈을 전부 모아 돌려준다. 그렇지 않으면 findAutowireCandidates로 후보를 찾아, 0개면 NoSuchBeanDefinitionException, 1개면 그 빈, 2개 이상이면 determineAutowireCandidate로 하나를 고르고 못 고르면 NoUniqueBeanDefinitionException을 던진다. Step 1부터 5까지. 초록은 값을 돌려주는 끝, 빨강은 예외로 끝나는 길입니다.

순서대로 읽으면 이렇습니다.

  1. Step 1·2 — 빠른 길. 같은 자리를 전에 한 번 풀어서 빈 이름을 기억해 두었으면(1절 후처리기가 ShortcutDependencyDescriptor로 캐시합니다, 910–923행) 그 이름으로 바로 가져옵니다. @Value가 붙었으면 빈이 아니라 값을 변환해서 돌려줍니다. 이 글은 @Value를 다루지 않습니다.
  2. Step 3 — 이름이 같으면 바로. 필드 이름(또는 @Qualifier의 값)과 똑같은 이름의 빈이 있고 타입도 맞으면, 후보를 모으지도 않고 그 빈을 돌려줍니다(1696–1715행). 3절의 예제 두 개가 사실 여기서 끝납니다.
  3. Step 4a — 여럿을 받는 자리. 타입이 배열, List, Set, Map이면 맞는 빈을 전부 모아 돌려줍니다. 4절입니다.
  4. Step 4b — 후보 모으기. findAutowireCandidates()가 "타입이 맞고 후보 자격이 있는" 빈을 이름 → 객체 맵으로 돌려줍니다. 비어 있으면, 꼭 필요한 자리일 때 NoSuchBeanDefinitionException을 던집니다.
  5. Step 5 — 하나 고르기. 후보가 둘 이상이면 determineAutowireCandidate()가 고릅니다(3절). 못 고르면 resolveNotUnique(), 곧 NoUniqueBeanDefinitionException입니다(DependencyDescriptor 191–193행).
  6. Step 6 — 가져오기. 고른 이름으로 resolveCandidate(), 곧 getBean()을 부릅니다.

후보 자격 — findAutowireCandidates

후보를 모으는 findAutowireCandidates()는 세 단계입니다(1959–2005행).

  1. 타입으로 이름을 모읍니다. BeanFactoryUtils.beanNamesForTypeIncludingAncestors()(1962–1963행)는 3편의 5-3절에서 본 getBeanNamesForType()을 부르고, 부모 컨테이너가 있으면 거기까지 묻습니다(BeanFactoryUtils 261–274행). 설계도를 처음부터 끝까지 돌며 타입을 맞춰 보는 그 조회입니다.
  2. 자기 자신은 뺍니다. isSelfReference()는 지금 만들고 있는 빈이 자기 자신을 후보로 찾는 경우를 걸러 냅니다(1977행, 2269–2273행).
  3. 후보 자격을 묻습니다. isAutowireCandidate()는 1절에서 컨테이너에 꽂힌 AutowireCandidateResolver에게 묻습니다. 가장 기본적인 자격은 설계도의 autowireCandidate 플래그이고(AutowireCandidateResolver 45–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행).

헷갈리기 쉬운 곳

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;
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java 2038–2082행

후보가 둘 이상일 때 하나를 고르는 순서. @Primary인 후보가 하나면 그것, 둘 이상이면 예외. 없으면 필드·파라미터 이름이나 @Qualifier 값과 같은 이름의 후보. 없으면 @Priority 값이 가장 작은 후보, 같은 값이 둘이면 예외. 없으면 유일한 default-candidate나 직접 등록한 의존성. 그래도 없으면 null을 돌려주어 NoUniqueBeanDefinitionException이 된다. 고르는 순서. @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행).

헷갈리기 쉬운 곳

korean -> EnglishGreeter / Hello

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은 정렬하지 않기 때문입니다.

예제 ⑥을 그린 그림. 빈은 korean(@Order(2)), japanese(@Order 없음), english(@Order(1)) 순서로 등록된다. findAutowireCandidates()가 Greeter 타입 빈 셋을 모은 뒤, List·배열 쪽은 dependencyComparator 로 정렬해 EnglishGreeter(1), KoreanGreeter(2), JapaneseGreeter(없음이라 맨 뒤) 순서가 되고, Map 쪽은 정렬하지 않아 열쇠가 등록 순서 그대로 [korean, japanese, english]다 같은 후보 셋이 List에서는 @Order 순서로, Map에서는 등록 순서로 들어갑니다. 순서는 예제 ⑥의 실제 출력 그대로입니다.

헷갈리기 쉬운 곳

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;
					}

spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java 419–446행

생성자를 고르는 순서. createBeanInstance가 후처리기에게 determineCandidateConstructors로 묻는다. @Autowired가 붙은 생성자가 있으면 그 생성자들(기본 생성자가 있으면 예비로 추가), required=true가 둘 이상이면 예외. 없으면 생성자가 딱 하나이고 인자가 있을 때 그 생성자, record나 Kotlin의 주 생성자가 있으면 그것, 그 밖에는 null을 돌려주어 기본 생성자로 new 하고 기본 생성자가 없으면 No default constructor found가 된다. 고른 생성자는 ConstructorResolver.autowireConstructor가 인자마다 resolveDependency로 빈을 찾아 부른다. 생성자 고르기. 회색 상자가 "후처리기가 아무것도 고르지 않은" 길입니다.

위에서부터 읽으면 네 가지 규칙입니다. candidates는 그 앞의 반복문에서 모은 "@Autowired가 붙은 생성자"의 목록입니다(375–418행).

  1. @Autowired가 붙은 생성자가 있으면 그것들. required = true(기본값)인 것은 하나만 허용되고, 둘이면 "Invalid autowire-marked constructor" 예외입니다(397–412행). required = false인 것은 여럿일 수 있고, 그때 기본 생성자가 있으면 예비로 목록 끝에 붙입니다.
  2. 없으면, 생성자가 딱 하나이고 인자가 있으면 그것. 그래서 생성자가 하나뿐인 클래스는 @Autowired 없이도 생성자 주입이 됩니다.
  3. 없으면, record나 Kotlin 클래스의 주 생성자. 위 코드의 else if 둘이 이 규칙입니다. 주 생성자 말고 기본 생성자가 따로 있으면 그 둘을 함께 돌려주고(437–440행), 주 생성자뿐이면 그것만 돌려줍니다(441–443행). BeanUtils.findPrimaryConstructor()가 record의 표준 생성자나 Kotlin의 주 생성자를 찾아 줍니다(278–296행).
  4. 그 밖에는 빈 배열, 곧 "고른 것 없음"(null)입니다(451행). 그러면 createBeanInstance()는 평범하게 기본 생성자로 new 합니다(1228행). 기본 생성자가 없으면 "No default constructor found"입니다(SimpleInstantiationStrategy 98–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>()

헷갈리기 쉬운 곳

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행).

resolveDependency의 분기. 주입 자리의 타입이 Optional이면 required=false로 찾아 Optional에 담고, ObjectProvider나 ObjectFactory면 지금 찾지 않고 getObject() 때 찾는 DependencyObjectProvider를 넣고, @Lazy가 붙었으면 처음 메서드를 부를 때 찾는 프록시를 넣는다. 그 밖의 타입은 doResolveDependency로 가고, 후보가 없으면 required일 때 NoSuchBeanDefinitionException, required=false나 @Nullable이면 null이 되어 필드는 건드리지 않는다. 회색 상자 셋은 "진짜 빈 대신 다른 것을 넣는" 길입니다.

선언 넣어 주는 것 실제로 빈을 찾는 때
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)을 써야 하기 때문입니다.

헷갈리기 쉬운 곳

더 깊이 들어가면

이 글이 뺀 것의 이름과 위치만 적어 둡니다. @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 인터페이스가 불리는 순서를 정리합니다.

다른 글