모잇지 프로젝트 스프링 빈 충돌 에러 트러블슈팅

Frog Lemon·2025년 9월 16일
1

프로젝트

목록 보기
1/1
post-thumbnail

서론

미션과 퀘스트를 병행하면서 모잇지 프로젝트의 유지보수를 하기위해 오랜만에 개발 브랜치를 가져와 로컬에서 여러 테스트를 하려고 하였다. 그동안 내가 확인하지 못했던 코드들이 반영되었기에 여러 문제 상황을 마주했는데 그중 하나인 빈충돌 트러블 슈팅에 대해 공유하려고 한다.

문제 상황

Spring Boot 프로젝트를 실행했을 때, 애플리케이션 컨텍스트 초기화 단계에서 아래와 같은 에러가 발생했다.

UnsatisfiedDependencyException: Error creating bean with name 'openApiClient'
No qualifying bean of type 'org.springframework.web.client.RestClient' available:
expected single matching bean but found 3: kakaoRestClient, odsayRestClient, openRestClient

처음에는 단순히 의존성 주입 실패 정도로만 보였지만, 로그를 따라가면서 원인을 추적해보았다.


기존 코드

@Slf4j
@RequiredArgsConstructor
@Component
public class OdsayClient {

    private static final String ODSAY_ROUTE_SEARCH_URL = "/searchPubTransPathT?SX=%f&SY=%f&EX=%f&EY=%f&apiKey=%s&searchPathType=%d";
    private static final int SEARCH_PATH_TYPE = 1;

    private final RestClient odsayRestClient;
    private final ObjectMapper objectMapper;

    @Value("${odsay.api.key}")
    private String odsayApiKey;

기존 코드에서는 생성자 파라미터이름 매칭을 사용하여 빈을 찾아서 등록해주었다.

이때 RestClient 타입의 여러빈들이 등록되어 있다면, 스프링은 파라미터 이름 odsayRestClient 과 동일한 이름을 가진 빈을 우선적으로 주입한다.

따라서 빈 이름과 생성자 파라미터 이름이 정확히 일치해야 원하는 빈이 주입된다.

여기서 궁금증이 생겼다.

해당 코드는 운영환경에서도 배포가 되어 있었고, 서비스가 정상적으로 운영되고 있었다.

하지만 나의 로컬 환경에서는 빈충돌 에러가 생기는 이유가 뭘까?


원인 분석

문제의 핵심은 RestClient 타입의 빈이 여러 개 등록되어 있었던 점이었다.

ClientConfig 클래스에서 다음과 같이 세 개의 빈을 등록했다.

@Bean
public RestClient kakaoRestClient() {
    return RestClient.builder()
            .baseUrl("https://dapi.kakao.com/v2/local/search")
            .build();
}

@Bean
public RestClient odsayRestClient() {
    return RestClient.builder()
            .baseUrl("https://api.odsay.com/v1/api")
            .build();
}

@Bean
public RestClient openRestClient() {
    return RestClient.builder()
            .baseUrl("https://apis.data.go.kr/B553766/path")
            .build();
}

각각 카카오 API, Odsay API, 공공 데이터 API 호출을 위한 클라이언트였다.

그러나 OdsayClient, KakaoMapClient, OpenApiClient 같은 클래스에서 단순히 RestClient 타입만 의존성 주입을 시도했기 때문에, 스프링은 어떤 빈을 넣어야 하는지 판단할 수 없어 에러를 발생시켰다.


해결 방법

빈 충돌 문제를 해결하기 위해 선택할 수 있는 방법은 크게 세 가지였다.

1. @Qualifier 사용 (권장)

빈 주입 시 어떤 빈을 사용할지 명확히 지정한다.

@Component
public class OdsayClient {

    private final RestClient odsayRestClient;
    private final ObjectMapper objectMapper;

    public OdsayClient(@Qualifier("odsayRestClient") RestClient odsayRestClient,
                       ObjectMapper objectMapper) {
        this.odsayRestClient = odsayRestClient;
        this.objectMapper = objectMapper;
    }
}

이 방식이 가장 안전하며, 이름과 빈이 매칭되기 때문에 의도한 대로 주입이 이루어진다.

➡️ 가장 명확한 방법.

➡️ 빈 이름은 `@Bean 메서드명(odsayRestClient)`과 동일해야 한다.


2. @Primary 지정

여러 개의 빈 중 하나를 기본값으로 지정할 수 있다.

@Bean
@Primary
public RestClient openRestClient() {
    return RestClient.builder()
            .baseUrl("https://apis.data.go.kr/B553766/path")
            .build();
}

그러면 @Qualifier가 없는 경우 자동으로 openRestClient가 주입된다.

하지만 이번 경우처럼 빈마다 목적이 다른 상황에서는 적합하지 않았다.


3. 생성자 파라미터이름 매칭 (기존 코드)⭐⭐⭐⭐⭐

스프링은 생성자 파라미터 이름과 빈 이름이 일치하면 자동으로 매칭을 시도한다.

@RequiredArgsConstructor
@Component
public class OdsayClient {
    private final RestClient odsayRestClient; // 필드명 == 빈 이름
    private final ObjectMapper objectMapper;
}

기존 코드에 적용했던 방법이다. 이 방법을 도입하였기에 이번 빈충돌 에러를 겪었다..ㅠㅠ


왜 운영에선 되고 로컬에선 안 됐을까?

운영 서버에서는 Gradle을 사용하여 빌드/배포가 이루어진다.

이때 Gradle은 기본적으로 파라미터 이름을 클래스 파일에 유지하도록 컴파일 옵션을 적용하기 때문에,

스프링이 생성자 파라미터 이름(odsayRestClient)을 정확히 읽어낼 수 있었다.

하지만 내 로컬 환경(IntelliJ)에서는 빌드 도구 설정이 Gradle이 아니라 IntelliJ IDEA로 되어 있었다.

IntelliJ 기본 컴파일러는 파라미터 이름 정보를 class 파일에 포함하지 않는다.

즉, 런타임에서 스프링이 파라미터 이름을 읽지 못하고 단순히 타입만 가지고 주입하려고 하다 보니,

동일한 타입(RestClient)의 여러 빈이 등록된 상황에서 빈 충돌 오류가 발생했던 것이다.

정리하면

  • 운영 (Gradle 빌드) → 파라미터 이름 정보 유지 → 이름 기반 매칭 정상 동작 ✅
  • 로컬 (IntelliJ 빌드) → 파라미터 이름 정보 누락 → 타입만 보고 주입 시도 → 다중 빈 충돌 발생 ❌

최종 선택

이번 프로젝트에서는 생성자를 직접 정의하고 @Qualifier를 명시적으로 붙이는 방식을 선택했다.

이렇게 했을 때:

  • 어떤 빈이 주입되는지 명확히 알 수 있었고
  • Lombok이 @Qualifier를 복사하지 않는 문제도 피할 수 있었으며
  • 코드 가독성 측면에서도 의도 전달이 분명해졌다.
  • 가장 중요한건 로컬 환경 세팅에 영향을 받지 않는다는 것이다.

배운 점

  1. 동일한 타입의 빈이 여러 개 등록되면 충돌이 발생한다.
  2. @Qualifier를 사용하여 명확하게 주입 대상을 지정할 수 있다.
  3. @Primary, 파라미터 이름 매칭 등 다른 방법도 있지만, 가장 안정적인 방법은 @Qualifier를 활용하는 것이다.
  4. Lombok의 @RequiredArgsConstructor@Qualifier를 복사하지 않으므로, 이 경우에는 생성자를 직접 작성하는 편이 안전하다.
  5. 로컬 환경 세팅에 따라 다른 결과가 나올 수 있으므로 팀 컨벤션을 확실히 하자.

마무리

이번 트러블슈팅을 통해 스프링 빈 주입 과정에서 모호성이 생기면 반드시 해결해야 한다는 점을 다시 한번 깨달았다.

같은 코드라도 각자의 로컬 환경 세팅에 따라 다른 결과가 나오기 때문에 협업시 팀 컨벤션을 정하는 것이 이러한 문제를 안겪는 핵심이라 생각한다.

앞으로 비슷한 상황이 발생을 때는 로컬 환경 세팅에 영향을 받지 않는 @Qualifier를 적극적으로 활용할 것이다.

profile
도전하며 굴러가는 돌멩이, 인생 마라톤 중 😎

1개의 댓글

comment-user-thumbnail
약 8시간 전

역시 레몬 ... 글도 잘 쓰시네요

답글 달기