포스트

Spring

spring-internals-lab로 다시 읽는 Spring 4 - 의존성 주입과 후보 선택

시리즈 spring-internals-lab로 다시 읽는 Spring 9편 중 4편 spring-internals-lab로 다시 읽는 Spring
  1. 1 spring-internals-lab로 다시 읽는 Spring 1 - 이 프로젝트는 무엇을 검증하려는가
  2. 2 spring-internals-lab로 다시 읽는 Spring 2 - BeanDefinition과 등록 단계
  3. 3 spring-internals-lab로 다시 읽는 Spring 3 - refresh()와 Bean 생성 파이프라인
  4. 4 spring-internals-lab로 다시 읽는 Spring 4 - 의존성 주입과 후보 선택
  5. 5 spring-internals-lab로 다시 읽는 Spring 5 - Bean lifecycle과 후처리기
  6. 6 spring-internals-lab로 다시 읽는 Spring 6 - 프록시와 AOP
  7. 7 spring-internals-lab로 다시 읽는 Spring 7 - @Transactional의 실체
  8. 8 spring-internals-lab로 다시 읽는 Spring 8 - DispatcherServlet과 MVC 요청 흐름
  9. 9 spring-internals-lab로 다시 읽는 Spring 9 - Spring Boot는 무엇을 자동으로 조립하는가

GitHub 저장소

이번 글의 질문

의존성 주입은 흔히 한 덩어리로 설명되지만, 실제로는 두 단계로 나뉜다.

  1. 어떤 생성자를 쓸 것인가
  2. 그 생성자의 각 파라미터를 무엇으로 채울 것인가

같은 타입의 후보가 여러 개이면 별도 규칙이 더 붙는다.

  • @Primary
  • @Qualifier
  • 파라미터 이름 일치
  • 순환 참조 여부

이번 글은 dependency-resolution-matrix와 circular-dependency-lab 실험을 함께 묶어서, 생성자 선택과 후보 선택은 다른 문제라는 점부터 확인한다.

먼저 생성자 선택이 있고, 그다음 후보 선택이 있다

Spring은 처음부터 “이 타입의 Bean 뭐 넣지?”를 고민하지 않는다. 먼저 “이 클래스는 어떤 생성자로 만들지?”를 결정한다.

flowchart TD
    A["Bean 클래스"] --> B["생성자 선택"]
    B --> C["선택된 생성자의 각 파라미터 순회"]
    C --> D["타입/이름/Qualifier 기반 후보 탐색"]
    D --> E["최종 인자 확정"]
    E --> F["생성자 호출"]

생성자가 정해져야 채울 파라미터 목록이 생기므로 생성자 선택이 먼저 온다.

생성자 선택 규칙은 생각보다 보수적이다

dependency-resolution-matrix에서 예상과 다르게 나온 케이스는 다음과 같다.

1
2
3
4
public class MultiConstructorNoAutowiredBean {
    public MultiConstructorNoAutowiredBean() { ... }
    public MultiConstructorNoAutowiredBean(Dependency dependency) { ... }
}

직관적으로는 “등록된 Dependency가 있으니 파라미터 있는 생성자를 고르겠지”라고 생각하기 쉽다. 실제로는 그렇지 않다.

Spring은 생성자가 여러 개인데 @Autowired가 하나도 없으면, 휴리스틱으로 가장 그럴듯한 생성자를 고르지 않는다. 기본 생성자가 있으면 거기로 떨어진다.

실험 결과를 상황별로 모으면 다음과 같다.

상황선택 결과
생성자 1개그 생성자
생성자 여러 개 + @Autowired 1개그 생성자
생성자 여러 개 + @Autowired(required=true) 여러 개예외
생성자 여러 개 + @Autowired 없음 + 기본 생성자 있음기본 생성자
생성자 여러 개 + @Autowired 없음 + 기본 생성자 없음모호성 예외

공식 문서도 같은 규칙을 적고 있다. 생성자가 하나면 @Autowired가 필요 없고, 생성자가 여럿인데 기본 생성자가 없으면 “at least one of the constructors must be annotated with @Autowired in order to instruct the container which one to use”(어느 생성자를 쓸지 알려 주려면 적어도 하나에 @Autowired를 붙여야 한다)고 한다(Using @Autowired). 같은 문서에 따르면 required=false인 @Autowired가 여럿이면 채울 수 있는 의존성이 가장 많은 생성자가 선택된다.

명시가 없을 때 Spring은 추론으로 맞히기보다 기본 생성자로 물러나는 쪽을 택한다.

왜 이런 보수성이 필요한가

생성자 주입은 객체 구조를 강하게 결정한다. 만약 Spring이 다음 같은 휴리스틱을 쓴다고 가정해 보자.

  • 가장 파라미터가 많은 생성자 선택
  • 가장 많이 매칭되는 생성자 선택
  • 가장 구체적인 타입의 생성자 선택

이 규칙들은 언뜻 편해 보이지만, 코드가 조금만 변해도 어떤 생성자가 선택될지 달라진다. 그래서 Spring은 모호한 상황에서 추측을 최소화하고, 생성자가 여럿일 때 @Autowired를 생성자 선택을 명시하는 수단으로 쓴다.

후보 선택은 또 다른 단계다

생성자가 정해진 뒤에는 각 파라미터를 채워야 한다. 이때는 DefaultListableBeanFactory 쪽 규칙이 들어온다.

가장 단순한 경우는 쉽다.

  • 후보가 0개면 예외
  • 후보가 1개면 그 Bean 주입

문제는 후보가 여러 개인 경우다.

실험과 소스 확인을 바탕으로 그리면 대략 다음 흐름이다.

flowchart TD
    A["후보 2개 이상"] --> B{"@Primary 하나인가?"}
    B -- yes --> C["@Primary 후보 선택"]
    B -- no --> D{"파라미터 이름과 같은 빈 이름이 있는가?"}
    D -- yes --> E["이름 일치 후보 선택"]
    D -- no --> F{"@Qualifier 매칭 후보가 있는가?"}
    F -- yes --> G["Qualifier 후보 선택"]
    F -- no --> H["NoUnique / UnsatisfiedDependencyException"]

실제로는 우선순위/정렬 규칙이 더 있지만, 실무에서 가장 자주 마주치는 건 이 구간이다.

다만 @Qualifier의 위치는 그림과 다르게 읽어야 한다. 공식 문서에서 @Qualifier는 타입이 맞는 후보를 좁히는 수단이고, 이름 일치는 “If there is no other resolution indicator (such as a qualifier, a primary marker, or a fallback marker)”일 때 쓰는 마지막 대체 규칙이다(Fine-tuning Annotation-based Autowiring with Qualifiers). 그래서 @Qualifier가 있으면 그 조건으로 먼저 후보가 걸러진다.

@Primary와 @Qualifier는 같은 문제를 다른 방식으로 푼다

두 애노테이션은 같은 상황에서 등장하지만 성격이 다르다.

애노테이션의도
@Primary“기본 후보는 이거다”
@Qualifier“이번 주입 지점은 이 후보를 원한다”

@Primary는 Bean을 제공하는 쪽에 붙는 힌트고, @Qualifier는 주입받는 쪽에 붙는 힌트다.

그래서 남용했을 때의 비용도 다르다. @Primary를 남발하면 전체 기본값 구조가 꼬일 수 있고, @Qualifier를 남발하면 주입 지점마다 이름 의존이 강해진다.

파라미터 이름 일치도 실제 후보 선택 규칙이다

같은 타입의 빈이 여러 개일 때, 파라미터 이름이 빈 이름과 같으면 그 후보가 선택될 수 있다.

예를 들어 아래 생성자를 보자.

1
public OrderService(PaymentGateway stripeGateway) { ... }

동일 타입 후보가 여러 개 있는데 그중 하나가 stripeGateway라는 이름이면, 이름 자체가 힌트로 작동한다. 위 문서에 따르면 Spring 6.1부터 이 매칭에는 -parameters 컴파일러 플래그가 필요하다.

이 규칙은 실무에서 자주 의식되지는 않지만, 이름을 잘못 맞춰 둔 경우 예상 밖의 후보가 선택되는 원인이 될 수 있다.

Optional, List, ObjectProvider는 실패 처리 전략이 다르다

실험에서는 단일 타입뿐 아니라 관용적 주입 형태도 같이 본다.

타입후보 없음일 때
T예외
Optional<T>Optional.empty()
List<T>빈 리스트
ObjectProvider<T>주입은 성공, 실제 조회 시점까지 지연

네 형태 중 Optional, List, ObjectProvider는 모두 “필수 아님”을 표현한다. 그중 ObjectProvider는 후보를 찾는 시점 자체를 실제 조회 때로 미룬다는 점이 다르다.

순환 참조는 주입 방식에 따라 완전히 다른 결과가 난다

circular-dependency-lab은 의존성 탐색이 어디서 막히는지 보여준다.

가장 단순한 생성자 순환은 실패한다.

1
2
3
4
5
6
class A {
    A(B b) { }
}
class B {
    B(A a) { }
}

왜냐하면 생성자 주입에서는 인스턴스를 만들기 전에 인자가 다 필요하기 때문이다. 아직 존재하지 않는 객체를 생성자 인자로 줄 수는 없다. 공식 문서도 이 경우 컨테이너가 순환을 런타임에 감지하고 BeanCurrentlyInCreationException을 던진다고 적는다(Dependency Injection).

반면 setter/field 기반 순환은 Spring이 조기 노출(early reference, 초기화 전 참조를 먼저 넘기는 것)로 풀 수 있다. 같은 문서는 이 방식을 권장하지 않는다.

flowchart LR
    A["A 인스턴스 생성"] --> B["A 조기 노출 가능"]
    B --> C["B 생성 중 A 필요"]
    C --> D["조기 참조 A 주입"]
    D --> E["B 완성"]
    E --> F["A 나머지 주입 후 완성"]

두 경우를 가르는 것은 생성과 주입을 시점상 나눌 수 있는가다.

@Lazy는 순환을 푸는 방식 자체가 다르다

한쪽 생성자 파라미터에 @Lazy를 붙이면, 실제 대상 대신 지연 프록시가 들어간다.

1
2
3
class B {
    B(@Lazy A a) { ... }
}

이 경우에는 A를 즉시 만들 필요가 없다. 프록시만 생성자에 넣고, 실제 A 조회는 나중으로 미룬다.

조기 노출과 @Lazy는 결과는 비슷해 보여도 경로가 다르다.

방식핵심 메커니즘
setter 순환 해결조기 참조 캐시
@Lazy 순환 해결지연 프록시

이 차이를 모르면 “왜 어떤 순환은 풀리고 어떤 순환은 안 풀리는가”가 계속 추상적으로 남는다.

mini-spring은 어디까지 재현했는가

mini-spring/mini-container는 이 두 단계를 코드로 나눠 둔다.

1
private Constructor<?> selectConstructor(String name, Class<?> beanClass) { ... }

여기서는 다음을 직접 구현해 둔다.

  • 단일 생성자 자동 선택
  • @MiniAutowired 선택
  • 기본 생성자 fallback
  • @MiniQualifier
  • primary

그리고 순환 참조는 beanCreationPath로 탐지한다.

1
2
3
if (beanCreationPath.contains(name)) {
    throw new CircularDependencyException(describePath(name));
}

이 mini 구현이 보여주는 건 두 가지다.

  1. 생성자 선택과 후보 선택은 분리된 로직이다.
  2. 순환 해결은 탐지보다 훨씬 어렵다.

그래서 mini는 순환을 해결하지 않고 감지해서 예외를 던지는 데서 멈춘다.

정리

의존성 주입은 타입이 맞는 Bean을 넣는 한 번의 동작이 아니다. Spring은 생성자 선택, 후보 선택, 조기 노출, 지연 프록시를 각각 다른 계층에서 처리한다.

다음 글에서는 이렇게 선택된 Bean이 생성 이후 어떤 생명주기 콜백과 후처리기를 거치는지 본다.

참고

Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

변경이력

1번 수정

  1. docs(notes): cite autowiring docs and fix qualifier order in dependency resolution

댓글

아직 댓글이 없습니다