아무튼, 쓰기

@Qualifier를 붙였는데 왜 무시될까? (스프링 빈 선택 코드 뜯어보기) 본문

스프링

@Qualifier를 붙였는데 왜 무시될까? (스프링 빈 선택 코드 뜯어보기)

순원이 2026. 1. 3. 22:56

최근 프로젝트에서 Spring Boot 3Lombok을 조합해 사용하던 중, 의아한 문제를 겪었습니다. 분명히 @Qualifier를 붙였는데, 엉뚱한 빈이 주입되는 현상이었습니다.

단순히 생성자를 직접 만들어서 해결했다로 끝내기엔 찜찜했습니다. 도대체 왜 이런 일이 벌어지는지, 스프링 프레임워크 내부 코드를 뜯어보며 그 원리를 꼬리에 꼬리를 무는 질문으로 파헤쳐 보았습니다.


상황: 16MB짜리 WebClient가 필요해

@Configuration
public class WebClientConfig {

    @Bean
    @Primary
    public WebClient webClient() {
        // 일반적인 요청용 (기본 설정)
        return WebClient.builder()
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    @Bean
    public WebClient aladinWebClient() {
        // 알라딘 크롤링용 - 큰 응답을 처리하기 위해 버퍼 사이즈 증가 (16MB)
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024))
                .build();

        return WebClient.builder()
                .exchangeStrategies(exchangeStrategies)
                .build();
    }
}

두 개의 WebClient 빈이 있습니다.

  1. webClient: @Primary가 붙은 기본 빈.
  2. aladinWebClient: 크롤링을 위해 버퍼를 늘린 특수 목적 빈.
@Service
@RequiredArgsConstructor
public class AladinCrawlerService {

    @Qualifier("aladinWebClient") // 분명히 얘를 달라고 했다!
    private final WebClient aladinWebClient;
}

제 의도는 aladinWebClient를 주입받는 것이었지만, 실제로는 버퍼 제한(256KB)이 걸린 @Primary 빈(webClient)이 주입되어 에러가 났습니다.

Q1. Lombok이 생성자를 만들어주니까 당연히 되는 거 아닌가요?

결론부터 말하면, 롬복은 어노테이션을 복사하지 않습니다.

Lombok의 @RequiredArgsConstructor는 컴파일 시점에 자바의 AST(추상 구문 트리)를 조작해 생성자 코드를 삽입합니다. 하지만 Lombok 입장에서 필드에 붙은 @Qualifier가 생성자 파라미터에도 필요한 정보인지는 알 길이 없습니다.

Lombok은 필드에 붙은 어노테이션을 생성자 파라미터로 복제하지 않고, 오직 타입과 변수명만 가지고 생성자를 만듭니다.

// Lombok이 실제로 만든 코드 (우리의 눈엔 안 보이지만)
public AladinCrawlerService(WebClient aladinWebClient) { 
    // 파라미터 앞에 @Qualifier가 없음!
    this.aladinWebClient = aladinWebClient;
}

스프링이 주입을 위해 생성자를 들여다봤을 때, 정작 주입 지점(파라미터)에는 @Qualifier가 글자 한 자 없이 깨끗하게 비어 있게 됩니다.

Q2. 그럼 이름(aladinWebClient)이 똑같은데 왜 매칭이 안 되죠?

Qualifier는 날아갔다고 쳐요. 하지만 변수명이 aladinWebClient잖아요? 스프링은 변수명으로 빈을 찾지 않나요?

잠시, 스프링이 변수명을 알아내는 방법부터 짚고 넘어갑시다.

Spring Boot 2 vs 3

  • 과거 (Spring Boot 2): 자바 리플렉션은 원래 파라미터 이름을 모릅니다(arg0, arg1). 그래서 스프링은 ASM이라는 라이브러리로 바이트코드를 뜯어 변수명을 알아냈습니다.
  • 현재 (Spring Boot 3): 성능과 표준화를 위해 ASM을 포기했습니다. 대신 -parameters 컴파일 옵션을 기본으로 사용하여 표준 리플렉션(Parameter.getName())으로 이름을 알아냅니다.

즉, 스프링은 제 변수명이 aladinWebClient라는 걸 알고 있었습니다. 그런데도 왜 엉뚱한 빈을 줬을까요?

범인은 빈 선택 알고리즘의 우선순위

스프링 컨테이너의 핵심인 DefaultListableBeanFactory 코드를 열어보면 그 답이 있습니다.

// spring-beans/.../DefaultListableBeanFactory.java

protected @Nullable String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
    // Step 1: @Primary 확인 (그리고 숨겨진 유일한 일반 빈 체크)
    String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
    if (primaryCandidate != null) {
        return primaryCandidate; // 찾으면 바로 리턴! (뒤는 보지도 않음)
    }

    // Step 2: 이름 매칭 (Name Matching)
    String dependencyName = descriptor.getDependencyName();
    if (dependencyName != null) {
        for (String beanName : candidates.keySet()) {
            if (matchesBeanName(beanName, dependencyName)) {
                return beanName;
            }
        }
    }

    // ... (Priority, Fallback 확인 등)
}

[빈 선택 순서]

  1. @Qualifier 확인: (롬복 때문에 증발해서 0순위 탈락)
  2. @Primary 확인: (기본 빈에 @Primary가 붙어 있네? 당첨!)
  3. 이름 매칭 (Name Matching): (이미 2단계에서 결정됐으므로 여기까지 오지도 않음 )

결국 -parameters 옵션이 있어도, @Primary가 이름 매칭보다 우선순위가 높기 때문에 우리는 16MB짜리 빈을 만날 수 없었던 것입니다.

 

심층 분석: @Fallback은 언제 쓰일까?

최신 스프링(6.2+)에는 @Fallback이라는 기능도 있습니다. 이건 맨 마지막에 쓰이겠지?라고 생각하기 쉬운데, 소스 코드를 보면 아니였습니다.

determinePrimaryCandidate 메서드 내부를 보면

protected @Nullable String determinePrimaryCandidate(...) {
    // 1. @Primary가 있으면 즉시 반환
    // ...

    // 2. @Primary가 없다면? 유일한 Non-Fallback 빈 찾기
    if (primaryBeanName == null) {
        for (String candidateBeanName : candidates.keySet()) {
            if (!isFallback(candidateBeanName)) { // @Fallback이 아닌 빈
               // ...
            }
        }
    }
}

놀랍게도 @Fallback 여부는 Step 1에서 체크합니다.
즉, 이름 매칭(Step 2)보다 일반 빈 여부(Step 1)가 더 강력합니다. 만약 aladinWebClient 빈에 실수로 @Fallback을 붙였다면, 이름을 아무리 똑같이 맞춰도 절대 주입받을 수 없었을 겁니다.

최종 우선순위 정리

  1. @Qualifier (조건 필터링)
  2. @Primary (최강자)
  3. !@Fallback (유일한 일반 빈은 이름 매칭보다 강하다)
  4. 이름 매칭 (변수명 = 빈 이름)
  5. @Priority
  6. @Fallback (최후의 보루)

결론 및 해결 방법

1. 권장 방법: 직접 생성자 사용

가장 확실한 방법은 롬복에 의존하지 않고 직접 생성자를 작성하여 @Qualifier를 파라미터에 박아주는 것입니다.

public AladinCrawlerService(@Qualifier("aladinWebClient") WebClient aladinWebClient) {
    this.aladinWebClient = aladinWebClient;
}

이건 롬복이 복사를 해주길 기다리는 게 아니라, 처음부터 정보를 그 자리에 박아넣은 것입니다. 스프링은 생성자를 보자마자 @Qualifier를 발견하고, @Primary고 뭐고 다 무시한 채 의도한 빈을 정확히 주입합니다.

2. 대안: lombok.config 설정

롬복이 어노테이션을 복사하도록 강제할 수도 있습니다. 프로젝트 루트에 lombok.config 파일을 만들고 다음을 추가하시면 됩니다.

lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

 

기술의 편리함(Lombok) 뒤에 숨겨진 컴파일러, Annotation Processing, 그리고 Spring의 빈 선택 알고리즘을 이해하는 좋은 기회였습니다.

References