아무튼, 쓰기
@Qualifier를 붙였는데 왜 무시될까? (스프링 빈 선택 코드 뜯어보기) 본문
최근 프로젝트에서 Spring Boot 3와 Lombok을 조합해 사용하던 중, 의아한 문제를 겪었습니다. 분명히 @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 빈이 있습니다.
webClient:@Primary가 붙은 기본 빈.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 확인 등)
}
[빈 선택 순서]
- @Qualifier 확인: (롬복 때문에 증발해서 0순위 탈락)
- @Primary 확인: (기본 빈에
@Primary가 붙어 있네? 당첨!) - 이름 매칭 (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을 붙였다면, 이름을 아무리 똑같이 맞춰도 절대 주입받을 수 없었을 겁니다.
최종 우선순위 정리
- @Qualifier (조건 필터링)
- @Primary (최강자)
- !@Fallback (유일한 일반 빈은 이름 매칭보다 강하다)
- 이름 매칭 (변수명 = 빈 이름)
- @Priority
- @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
'스프링' 카테고리의 다른 글
| Spring Batch로 187초 → 6.65초: 28배 성능 개선 전과정 (0) | 2025.12.04 |
|---|---|
| 실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기 (0) | 2025.11.28 |
| 왜 내 readOnly는 슬레이브로 가지 않는가? (0) | 2025.11.12 |
| [디프만, 밥토리] Hibernate 벡터, "묻고 double[]로 가!" 가 아니라.. float[]로 가! (0) | 2025.11.07 |
| 100명 동시 요청 25분 → 1분, Gemini API 병목 96% 개선기 (feat. Virtual Thread의 함정) (0) | 2025.11.06 |