본문 바로가기

컴포넌트 스캔

기준 패키지에서 후보 클래스를 찾아 기본 빈 이름을 만들고 정의로 등록하는 과정과, 같은 이름의 스캔 후보가 등록 전에 충돌하는 경계를 검증합니다.

@Component를 붙인 클래스가 빈으로 보이지 않거나, 서로 다른 구현이 같은 빈 이름을 내세워 애플리케이션이 시작하지 않는 문제는 스캔 범위와 등록 단계를 나누어 보면 원인을 찾기 쉽습니다. 컴포넌트 스캔(component scan)은 클래스 경로 전체를 무작정 뒤지지 않습니다. 먼저 기준 패키지 아래에서 스테레오타입 애노테이션이 붙은 후보를 찾고, 후보마다 빈 이름을 정한 뒤, 같은 이름의 정의와 충돌하지 않을 때 빈 정의로 등록합니다. 후보 클래스를 찾았다는 것이 그 클래스의 인스턴스가 이미 만들어졌다는 뜻은 아닙니다. 이번 글은 찾는 범위, 이름 정하기, 정의 등록까지만 다룹니다.

기준 패키지는 탐색 범위를 정하고 후보 클래스는 이름을 얻어 빈 정의가 된다

기준 패키지 탐색·stereotype 후보·이름 생성·빈 정의 등록의 산출물이 순차로 바뀐다.

board 기준 패키지 아래의 stereotype 후보만 탐색하고 이름을 계산해 빈 정의로 등록하는 순서와, 스캔 후보·BoardConfig의 @Bean 정의·범위 밖 external.audit를 구분합니다.

BASE PACKAGE · CANDIDATE · BEAN DEFINITION

기준 패키지는 탐색 범위를 정하고 후보 클래스는 이름을 얻어 빈 정의가 된다

탐색 경계와 등록 결과는 같은 말이 아닙니다. 기준 패키지는 살펴볼 범위를 정하고, 그 안의 stereotype 후보만 이름을 얻어 BeanDefinitionRegistry에 등록됩니다.

범위 안의 후보 표식과 이름을 확인한 뒤 정의로 등록한다board 기준패키지 아래를 탐색하되 stereotype표식에 맞는 클래스만후보가된다. 표식있는후보는기본이름을계산해정의로등록하며, 표식없는production MemberRegistrationService는클래스스캔후보가아니다. 이 서비스는BoardConfig의Bean메서드로별도조립된다. 형제external.audit는기본board범위밖이다. 등록된정의가곧생성된인스턴스라는그림이아니다.board 기준 패키지 · 범위와 후보를 따로 검사있음없음board 아래 탐색클래스 경로의 후보 검사표식 필터@Component·메타 표식후보 이름 계산기본 이름 또는 명시 이름빈 정의 등록객체 생성과 다른 단계표식 없는 클래스자동 스캔 후보가 아님external.auditboard 기본 범위 밖
범위 안의 후보 표식과 이름을 확인한 뒤 정의로 등록한다board 기준패키지 아래를 탐색하되 stereotype표식에 맞는 클래스만후보가된다. 표식있는후보는기본이름을계산해정의로등록하며, 표식없는production MemberRegistrationService는클래스스캔후보가아니다. 이 서비스는BoardConfig의Bean메서드로별도조립된다. 형제external.audit는기본board범위밖이다. 등록된정의가곧생성된인스턴스라는그림이아니다.board · 범위 안 검사있음없음board 아래 탐색클래스 경로 검사표식 필터@Component또는 그 메타 표식후보 이름 계산기본 또는 명시 이름빈 정의 등록객체 생성과 다른 단계표식 없는 클래스자동 스캔 후보가 아님기준 패키지 밖external.auditboard의 형제 패키지기본 탐색 범위 밖

필터의 두 선은 표식 유무에 따른 후보 포함·제외입니다. external.audit는 그 필터 전에 기준 범위 밖입니다. 표식 없는 production 회원 서비스는 원 BoardConfig의 @Bean 경로로 조립됩니다.

PACKAGE BOUNDARY

board를 루트로 삼으면 하위 패키지만 기본 탐색 범위다

board/ · 기준 패키지
  • board.web의 stereotype 클래스는 스캔 후보가 될 수 있습니다.
  • board.member처럼 하위 패키지여도 stereotype이 없는 클래스는 자동 후보가 아닙니다.
  • board.BoardConfig의 @Bean factory 정의는 스캔 후보의 기본 이름 계산과 분리해 읽습니다.
external.audit/ · 범위 밖
board의 형제 패키지는 기본 탐색에 포함되지 않습니다. 필요한 통합은 @Import나 명시적 @Bean으로 경계를 드러냅니다.

@SpringBootApplication을 board에 두면 그 패키지가 기본 시작점입니다. 직접 @ComponentScan을 쓰면 선언한 기준 패키지가 시작점입니다.

ORDERED REGISTRATION PIPELINE

발견한 클래스가 곧바로 객체가 되는 것이 아니라 정의 등록을 거친다

  1. 탐색 시작점 확정

    board와 그 하위 패키지로 클래스 경로 탐색을 제한합니다.

  2. 후보 필터 통과

    @Component 또는 그 메타 애노테이션을 지닌 독립 클래스를 스캔 후보로 읽습니다.

  3. 후보 이름 계산

    명시 이름이 없으면 AnnotationBeanNameGenerator가 짧은 클래스 이름으로 기본 이름을 만듭니다.

  4. 빈 정의 등록

    후보 메타데이터를 BeanDefinition으로 만들어 레지스트리 키에 연결합니다. 객체 생성은 이후 컨테이너 단계입니다.

등록 경로 구분: 스캔 후보는 클래스 이름에서 기본 이름을 얻지만, BoardConfig의 @Bean 정의는 기본적으로 factory 메서드 이름을 사용합니다.

DEFAULT NAME RULE

첫 문자를 기계적으로 소문자로 바꾸는 규칙에는 두 대문자 예외가 있다

PostController
일반적인 이름은 postController가 됩니다.
MemoryMemberRepository
일반적인 이름은 memoryMemberRepository가 됩니다.
URLMappingRegistry
첫 두 문자가 모두 대문자이므로 URLMappingRegistry를 그대로 유지합니다.

명시적 stereotype 이름이 있으면 그 값이 우선합니다. 기본 이름은 등록 키를 설명할 때 확인하되, 타입 기반 협력을 문자열 이름에 종속시키지 않습니다.

이 다이어그램은 탐색·이름 생성·빈 정의 등록까지만 다룹니다. 여러 후보 중 주입 대상을 고르는 규칙은 다음 문서의 별도 문제입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • board 기준 패키지 아래를 탐색하되 stereotype 표식에 맞는 클래스만 후보가 된다. 표식 있는 후보는 기본 이름을 계산해 정의로 등록하며, 표식 없는 production MemberRegistrationService는 클래스 스캔 후보가 아니다. 이 서비스는 BoardConfig의 Bean 메서드로 별도 조립된다. 형제 external.audit는 기본 board 범위 밖이다. 등록된 정의가 곧 생성된 인스턴스라는 그림이 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
9board 기준 패키지 아래를 탐색하되 stereotype 표식에 맞는 클래스만 후보가 된다. 표식 있는 후보는 기본 이름을 계산해 정의로 등록하며, 표식 없는 production MemberRegistrationService는 클래스 스캔 후보가 아니다. 이 서비스는 BoardConfig의 Bean 메서드로 별도 조립된다. 형제 external.audit는 기본 board 범위 밖이다. 등록된 정의가 곧 생성된 인스턴스라는 그림이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
기준 패키지 탐색

board 아래에서 stereotype 클래스를 탐색합니다.

범위: board · external.audit: 범위 밖스캔 후보 범위가 좁혀집니다.
후보와 이름

표식 후보만 기본 이름을 얻습니다.

표식 있음: 후보 · 표식 없는 서비스: 스캔 후보 아님클래스 전체가 빈 후보가 되지 않습니다.
빈 정의 등록

이름을 가진 후보를 정의로 등록합니다.

산출물: 빈 정의 · 인스턴스: 아직 별도 생성후보 탐색과 객체 생성을 구분합니다.

기준 패키지는 탐색 범위를 정하고 후보 클래스는 이름을 얻어 빈 정의가 된다

기준 패키지 탐색·stereotype 후보·이름 생성·빈 정의 등록의 산출물이 순차로 바뀐다.

board 기준 패키지 아래의 stereotype 후보만 탐색하고 이름을 계산해 빈 정의로 등록하는 순서와, 스캔 후보·BoardConfig의 @Bean 정의·범위 밖 external.audit를 구분합니다.

BASE PACKAGE · CANDIDATE · BEAN DEFINITION

기준 패키지는 탐색 범위를 정하고 후보 클래스는 이름을 얻어 빈 정의가 된다

탐색 경계와 등록 결과는 같은 말이 아닙니다. 기준 패키지는 살펴볼 범위를 정하고, 그 안의 stereotype 후보만 이름을 얻어 BeanDefinitionRegistry에 등록됩니다.

범위 안의 후보 표식과 이름을 확인한 뒤 정의로 등록한다board 기준패키지 아래를 탐색하되 stereotype표식에 맞는 클래스만후보가된다. 표식있는후보는기본이름을계산해정의로등록하며, 표식없는production MemberRegistrationService는클래스스캔후보가아니다. 이 서비스는BoardConfig의Bean메서드로별도조립된다. 형제external.audit는기본board범위밖이다. 등록된정의가곧생성된인스턴스라는그림이아니다.board 기준 패키지 · 범위와 후보를 따로 검사있음없음board 아래 탐색클래스 경로의 후보 검사표식 필터@Component·메타 표식후보 이름 계산기본 이름 또는 명시 이름빈 정의 등록객체 생성과 다른 단계표식 없는 클래스자동 스캔 후보가 아님external.auditboard 기본 범위 밖
범위 안의 후보 표식과 이름을 확인한 뒤 정의로 등록한다board 기준패키지 아래를 탐색하되 stereotype표식에 맞는 클래스만후보가된다. 표식있는후보는기본이름을계산해정의로등록하며, 표식없는production MemberRegistrationService는클래스스캔후보가아니다. 이 서비스는BoardConfig의Bean메서드로별도조립된다. 형제external.audit는기본board범위밖이다. 등록된정의가곧생성된인스턴스라는그림이아니다.board · 범위 안 검사있음없음board 아래 탐색클래스 경로 검사표식 필터@Component또는 그 메타 표식후보 이름 계산기본 또는 명시 이름빈 정의 등록객체 생성과 다른 단계표식 없는 클래스자동 스캔 후보가 아님기준 패키지 밖external.auditboard의 형제 패키지기본 탐색 범위 밖

필터의 두 선은 표식 유무에 따른 후보 포함·제외입니다. external.audit는 그 필터 전에 기준 범위 밖입니다. 표식 없는 production 회원 서비스는 원 BoardConfig의 @Bean 경로로 조립됩니다.

PACKAGE BOUNDARY

board를 루트로 삼으면 하위 패키지만 기본 탐색 범위다

board/ · 기준 패키지
  • board.web의 stereotype 클래스는 스캔 후보가 될 수 있습니다.
  • board.member처럼 하위 패키지여도 stereotype이 없는 클래스는 자동 후보가 아닙니다.
  • board.BoardConfig의 @Bean factory 정의는 스캔 후보의 기본 이름 계산과 분리해 읽습니다.
external.audit/ · 범위 밖
board의 형제 패키지는 기본 탐색에 포함되지 않습니다. 필요한 통합은 @Import나 명시적 @Bean으로 경계를 드러냅니다.

@SpringBootApplication을 board에 두면 그 패키지가 기본 시작점입니다. 직접 @ComponentScan을 쓰면 선언한 기준 패키지가 시작점입니다.

ORDERED REGISTRATION PIPELINE

발견한 클래스가 곧바로 객체가 되는 것이 아니라 정의 등록을 거친다

  1. 탐색 시작점 확정

    board와 그 하위 패키지로 클래스 경로 탐색을 제한합니다.

  2. 후보 필터 통과

    @Component 또는 그 메타 애노테이션을 지닌 독립 클래스를 스캔 후보로 읽습니다.

  3. 후보 이름 계산

    명시 이름이 없으면 AnnotationBeanNameGenerator가 짧은 클래스 이름으로 기본 이름을 만듭니다.

  4. 빈 정의 등록

    후보 메타데이터를 BeanDefinition으로 만들어 레지스트리 키에 연결합니다. 객체 생성은 이후 컨테이너 단계입니다.

등록 경로 구분: 스캔 후보는 클래스 이름에서 기본 이름을 얻지만, BoardConfig의 @Bean 정의는 기본적으로 factory 메서드 이름을 사용합니다.

DEFAULT NAME RULE

첫 문자를 기계적으로 소문자로 바꾸는 규칙에는 두 대문자 예외가 있다

PostController
일반적인 이름은 postController가 됩니다.
MemoryMemberRepository
일반적인 이름은 memoryMemberRepository가 됩니다.
URLMappingRegistry
첫 두 문자가 모두 대문자이므로 URLMappingRegistry를 그대로 유지합니다.

명시적 stereotype 이름이 있으면 그 값이 우선합니다. 기본 이름은 등록 키를 설명할 때 확인하되, 타입 기반 협력을 문자열 이름에 종속시키지 않습니다.

이 다이어그램은 탐색·이름 생성·빈 정의 등록까지만 다룹니다. 여러 후보 중 주입 대상을 고르는 규칙은 다음 문서의 별도 문제입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • board 기준 패키지 아래를 탐색하되 stereotype 표식에 맞는 클래스만 후보가 된다. 표식 있는 후보는 기본 이름을 계산해 정의로 등록하며, 표식 없는 production MemberRegistrationService는 클래스 스캔 후보가 아니다. 이 서비스는 BoardConfig의 Bean 메서드로 별도 조립된다. 형제 external.audit는 기본 board 범위 밖이다. 등록된 정의가 곧 생성된 인스턴스라는 그림이 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
9board 기준 패키지 아래를 탐색하되 stereotype 표식에 맞는 클래스만 후보가 된다. 표식 있는 후보는 기본 이름을 계산해 정의로 등록하며, 표식 없는 production MemberRegistrationService는 클래스 스캔 후보가 아니다. 이 서비스는 BoardConfig의 Bean 메서드로 별도 조립된다. 형제 external.audit는 기본 board 범위 밖이다. 등록된 정의가 곧 생성된 인스턴스라는 그림이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
기준 패키지 탐색

board 아래에서 stereotype 클래스를 탐색합니다.

범위: board · external.audit: 범위 밖스캔 후보 범위가 좁혀집니다.
후보와 이름

표식 후보만 기본 이름을 얻습니다.

표식 있음: 후보 · 표식 없는 서비스: 스캔 후보 아님클래스 전체가 빈 후보가 되지 않습니다.
빈 정의 등록

이름을 가진 후보를 정의로 등록합니다.

산출물: 빈 정의 · 인스턴스: 아직 별도 생성후보 탐색과 객체 생성을 구분합니다.

그림은 board 기준 패키지 아래를 찾되, 스테레오타입 표시가 있는 클래스만 후보가 되어 이름을 얻고 빈 정의로 등록되는 순서를 보여 줍니다. 표시가 없는 클래스는 같은 패키지 안에 있어도 자동 후보가 아니며, 형제 패키지인 external.audit는 처음부터 찾는 범위 밖입니다. 정의가 등록되었다고 곧 인스턴스가 만들어진 것은 아니라는 점도 그림 아래쪽의 단계에서 확인할 수 있습니다.


기준 패키지가 정하는 탐색 범위

@SpringBootApplication에는 @ComponentScan이 들어 있어서, 따로 기준을 정하지 않으면 이 애노테이션이 붙은 클래스의 패키지와 그 아래 패키지를 찾습니다. 게시판의 Boot 진입점을 board에 두면 board 아래는 범위에 들어가고, 형제 패키지인 external.audit는 들어가지 않습니다. 이미 있는 board.BoardApplication은 Spring 컨테이너 전환에서 AnnotationConfigApplicationContext를 직접 시작하는 @Component로 바꾼 진입 객체이므로, 이름이 겹치지 않도록 Boot 진입점에는 다른 이름을 씁니다.

board-core/src/main/java/board/BoardBootApplication.java
package board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BoardBootApplication {
    public static void main(String[] args) {
        SpringApplication.run(BoardBootApplication.class, args);
    }
}

패키지가 범위 안에 있다고 그 안의 모든 클래스가 후보가 되지는 않습니다. 운영 코드의 board.member.MemberRegistrationService에는 스테레오타입이 없고, board.BoardConfig의 @Bean 메서드가 두 역할을 생성자에 넘겨 조립합니다. 루트 스캔은 @Configuration이 붙은 BoardConfig를 후보로 찾고, 구성 클래스를 처리하면서 그 안의 @Bean 정의를 등록합니다. MemberRegistrationService 클래스를 스캔해 직접 등록하는 것이 아닙니다. 이 범위는 운영 그래프를 다시 선언하지 않고 다음 테스트 전용 픽스처로 확인할 수 있습니다.

board-core/src/test/java/example/scanning/inside/ScannedRegistrationService.java
package example.scanning.inside;

import org.springframework.stereotype.Service;

@Service
public final class ScannedRegistrationService {
}
board-core/src/test/java/external/audit/AuditClient.java
package external.audit;

import org.springframework.stereotype.Component;

@Component
public final class AuditClient {
}
board-core/src/test/java/example/scanning/ScanBoundaryTest.java
package example.scanning;

import static org.assertj.core.api.Assertions.assertThat;

import example.scanning.inside.ScannedRegistrationService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

final class ScanBoundaryTest {
    @Test
    void 기준_package_아래의_stereotype만_등록한다() {
        try (var context = new AnnotationConfigApplicationContext()) {
            context.scan(
                    ScannedRegistrationService.class.getPackageName());
            context.refresh();

            assertThat(context.getBeansOfType(
                    ScannedRegistrationService.class)).hasSize(1);
            assertThat(context.containsBean("auditClient")).isFalse();
        }
    }
}

기준 패키지를 ScannedRegistrationService.class.getPackageName()처럼 클래스에서 얻으면, "board" 같은 문자열을 여러 설정에 되풀이하지 않아도 되고 패키지를 옮겼을 때 컴파일러가 참조를 따라갑니다. 범위 밖의 클래스가 필요하면 루트를 넓히지 말고 필요한 구성만 @Import나 @Bean으로 가져옵니다.


후보 클래스에서 빈 이름과 정의로

@Component가 일반적인 후보 표시이고, @Controller, @Service, @Repository, @Configuration은 역할을 드러내는 특화된 스테레오타입입니다. 이름을 따로 주지 않으면 AnnotationBeanNameGenerator가 짧은 클래스 이름에 JavaBeans의 Introspector.decapitalize 규칙을 적용해 기본 이름을 만듭니다. 이 규칙은 첫 두 글자가 모두 대문자이면 첫 글자를 소문자로 바꾸지 않습니다.

선언등록 경로기본 빈 이름
@Service class MemberRegistrationService스캔 후보memberRegistrationService
@RestController class PostController스캔 후보postController
@Component class URLMappingRegistry스캔 후보URLMappingRegistry
@Component("auditClient")스캔 후보지정한 auditClient
@Bean MemberStore memberRepository()구성 메서드메서드 이름 memberRepository

표의 첫 행은 이름 규칙을 보여 주는 예일 뿐, 운영 코드의 board.member.MemberRegistrationService를 스캔으로 등록하도록 바꾼다는 뜻이 아닙니다. 그 서비스의 빈 이름은 BoardConfig에 선언한 @Bean 메서드의 이름에서 옵니다.

@Repository도 애노테이션 하나로 모든 예외를 바꾸어 주지는 않습니다. 영속성 예외 변환이 적용되려면 PersistenceExceptionTranslationPostProcessor가 등록되어 있고, 쓰는 기술에 맞는 PersistenceExceptionTranslator도 컨텍스트에 있어야 합니다. Boot의 데이터 구성이 이 기반을 마련해 줄 수 있지만, 단순한 메모리 픽스처에 @Repository를 붙였다고 예외가 변환되지는 않습니다.

더 깊이: 스캐너는 클래스를 로드하지 않고 바이트코드를 읽습니다

스캐너가 후보인지 판단할 때 쓰는 것은 Class 객체가 아니라 .class 파일에서 읽은 메타데이터입니다. MetadataReader가 바이트코드를 읽어 만든 AnnotationMetadata는 API 문서의 설명대로 클래스를 아직 로드하지 않은 상태에서 그 클래스의 애노테이션에 접근하게 해 줍니다. 그래서 기준 패키지 안에 있지만 후보가 아닌 클래스는 스캔 때문에 JVM에 로드되지 않고, 그 클래스의 정적 초기화 블록도 실행되지 않습니다.

바이트코드를 읽는 구현은 버전에 따라 바뀌었습니다. Spring은 오랫동안 내부에 포함한 작은 ASM 포크로 클래스 파일을 읽었는데, Spring Framework 7.0 M4 발표에 따르면 7.0부터는 Java 24 이상에서 JDK의 Class-File API(JEP 484)를 쓰는 ClassFileMetadataReader를 자동으로 고릅니다. Spring Boot도 ASM 방식에 묶여 있던 자체 캐싱 리더 팩토리를 4.0.0-RC1에서 걷어 냈으므로, 이 교재의 Java 25와 Boot 4.1.2 조합에서는 새 구현이 쓰이는 쪽입니다.

이 방식에는 알아 둘 한계가 하나 있습니다. 애노테이션의 속성이 클래스 경로에 없는 Class나 열거형을 가리키면, 그 애노테이션은 AnnotationMetadata로 보이지 않습니다. 예를 들어 @Component를 메타 애노테이션으로 가진 사용자 정의 스테레오타입이 속성으로 빠진 의존성의 클래스를 가리키면, 후보 표시 자체가 보이지 않아 그 클래스가 조용히 후보에서 빠질 수 있습니다. API 문서는 이런 경우를 찾을 때 org.springframework.core.annotation.MergedAnnotation 로거의 수준을 DEBUG, INFO, WARN 가운데 하나로 설정하라고 안내합니다.


같은 이름의 스캔 후보는 등록 전에 충돌

서로 다른 패키지의 클래스라도 짧은 이름이 같으면 같은 기본 빈 이름을 내세웁니다. 다음 세 파일은 운영 코드의 board.member를 흉내 내지 않는 테스트 전용 인터페이스와 두 후보입니다. 두 구현의 짧은 이름이 모두 MemberRepository이므로 기본 이름도 둘 다 memberRepository입니다.

board-core/src/test/java/example/scanning/fixture/MemberStore.java
package example.scanning.fixture;

public interface MemberStore {
    String storageKind();
}
board-core/src/test/java/example/scanning/fixture/memory/MemberRepository.java
package example.scanning.fixture.memory;

import example.scanning.fixture.MemberStore;
import org.springframework.stereotype.Repository;

@Repository
public final class MemberRepository implements MemberStore {
    @Override
    public String storageKind() {
        return "memory";
    }
}
board-core/src/test/java/example/scanning/fixture/jdbc/MemberRepository.java
package example.scanning.fixture.jdbc;

import example.scanning.fixture.MemberStore;
import org.springframework.stereotype.Repository;

@Repository
public final class MemberRepository implements MemberStore {
    @Override
    public String storageKind() {
        return "jdbc";
    }
}

ClassPathBeanDefinitionScanner는 두 번째 후보를 레지스트리에 넣기 전에 checkCandidate에서 같은 이름의 기존 정의와 호환되는지 비교하고, 호환되지 않으면 거부합니다. 이때 던지는 ConflictingBeanDefinitionException은 애플리케이션이 기대어 쓸 공개 API가 아니므로, 테스트는 공개된 상위 타입인 IllegalStateException과 원인을 찾는 데 필요한 이름만 확인합니다.

board-core/src/test/java/example/scanning/ComponentNameCollisionTest.java
package example.scanning;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

final class ComponentNameCollisionTest {
    private static final String MEMORY_PACKAGE =
            "example.scanning.fixture.memory";
    private static final String JDBC_PACKAGE =
            "example.scanning.fixture.jdbc";

    @Test
    void 같은_기본_이름의_scan_후보는_등록_전에_충돌한다() {
        try (var context = new AnnotationConfigApplicationContext()) {
            context.getDefaultListableBeanFactory()
                    .setAllowBeanDefinitionOverriding(true);

            assertThatThrownBy(() -> context.scan(
                    MEMORY_PACKAGE, JDBC_PACKAGE))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessageContaining("memberRepository")
                    .hasMessageContaining(
                            "fixture.memory.MemberRepository")
                    .hasMessageContaining(
                            "fixture.jdbc.MemberRepository");
        }
    }
}

이 실패는 refresh()에서 두 타입 후보 중 하나를 고르지 못한 문제가 아니라, 두 스캔 후보가 같은 이름을 차지하려다 정의를 등록하기도 전에 부딪힌 것입니다. 그래서 테스트처럼 빈 정의 덮어쓰기(overriding)를 켜도, 스캔 후보끼리의 이 충돌을 "나중 후보가 구현을 바꾼다"는 정책으로 바꿀 수 없습니다.

서로 다른 패키지의 두 MemberRepository 스캔 후보가 같은 기본 빈 이름 memberRepository를 제안해 checkCandidate 단계에서 두 번째 정의 등록 전에 거부되는 경로와, 구성에서 구현 하나만 빈 정의로 등록하는 경로를 비교합니다.

SCAN COLLISION · EXPLICIT CONFIGURATION

같은 이름의 스캔 후보는 등록 전에 충돌하고 구현 선택은 구성으로 분리한다

충돌과 구현 선택은 서로 다른 문제입니다. 스캐너는 같은 키를 요구하는 서로 다른 후보를 등록 전에 거부하고, 사용할 구현은 현재 실행 구성에서 한 정의만 내놓도록 표현합니다.

같은 스캔 키의 충돌과 구성에서 한 구현을 고르는 경로원MemberRepository충돌fixture는memory와jdbc의서로다른후보가동일memberRepository를제안한다. 두번째비호환정의를등록전checkCandidate가거부해scan호출에서IllegalStateException이발생하며refresh객체생성전이다. 독립선택구성fixture는메모리구현하나를Bean정의로등록하고factory를통해그객체를얻는다. 두경로는실패후자동복구연속흐름이아니다.스캔 후보의 같은 키 충돌독립 선택 구성 · 한 정의memory 후보MemberRepositoryjdbc 후보MemberRepository같은 빈 이름memberRepositorycheckCandidate비호환 정의의 같은 키등록 전 거부scan 호출 예외refresh 전에 실패선택 구성메모리 구현 하나 선택정의 등록@Bean 정의 하나 등록객체 생성선택한 factory 실행조회 결과메모리 구현 객체 반환
같은 스캔 키의 충돌과 구성에서 한 구현을 고르는 경로원MemberRepository충돌fixture는memory와jdbc의서로다른후보가동일memberRepository를제안한다. 두번째비호환정의를등록전checkCandidate가거부해scan호출에서IllegalStateException이발생하며refresh객체생성전이다. 독립선택구성fixture는메모리구현하나를Bean정의로등록하고factory를통해그객체를얻는다. 두경로는실패후자동복구연속흐름이아니다.원 두 스캔 후보의 충돌memory 후보MemberRepositoryjdbc 후보MemberRepository같은 빈 이름memberRepositorycheckCandidate같은 키의 비호환 정의등록 전 거부scan 호출 예외refresh 전에 실패독립 선택 구성 · 한 정의선택 구성메모리 구현 하나 선택정의 등록@Bean 정의 하나 등록객체 생성선택한 factory 실행조회 결과메모리 구현 객체 반환

두 영역은 서로 다른 fixture입니다. 실패한 스캔 다음에 컨테이너가 자동으로 구현을 선택하는 선은 없습니다. 원 memory/jdbc 클래스·이름은 그대로 남기고, 구현 선택은 별도 구성의 한 정의로 표현합니다.

TRACE A · SCANNED VS SCANNED

같은 짧은 클래스 이름은 같은 기본 빈 이름을 제안한다

  1. 서로 다른 패키지 탐색

    fixture.memory.MemberRepository와 fixture.jdbc.MemberRepository를 함께 찾습니다.

  2. 같은 후보 이름 생성

    두 클래스 모두 기본 이름 memberRepository를 제안합니다.

  3. 스캐너가 정의 비교

    checkCandidate가 같은 레지스트리 키에 놓일 서로 다른 정의를 발견합니다.

  4. 두 번째 정의 등록 거부

    context.scan(...) 호출이 공개 경계인 IllegalStateException을 던지고, 테스트는 충돌한 이름·두 클래스 정보를 검증합니다.

첫 정의가 등록된 뒤 두 번째 비호환 정의는 등록되기 전에 거부됩니다. refresh()와 객체 생성에는 도달하지 않습니다.

TRACE B · ONE CONFIGURED DEFINITION

현재 실행 구성이 사용할 구현 하나만 레지스트리에 내놓는다

  1. 현재 실행 구성에서 선택

    현재 실행에 사용할 메모리 또는 JDBC 구현 하나를 구성 코드에서 고릅니다.

  2. 한 빈 정의 등록

    선택한 구성의 @Bean memberRepository()를 빈 정의로 처리해 레지스트리에 등록합니다.

  3. 생성 단계에서 factory 실행

    컨테이너가 그 빈을 생성할 때 memberRepository()를 실행해 선택한 구현 객체를 얻습니다.

  4. 선택 결과를 구성으로 검증

    필요한 컨텍스트 테스트에서는 정의의 개수와 조회된 구현 타입을 확인합니다. 이 문서의 선택 구성 예제는 메모리 정의 하나를 선언합니다.

스캔 순서로 승자를 정하지 않습니다. 한 구성 경계가 한 정의만 제공하게 만들어 선택 의도를 소스에 남깁니다.

REGISTRATION BOUNDARY TABLE

대상의 생성 책임과 변경 경계에 맞춰 등록 방식을 나눈다

스캔·일반 값·명시적 factory·Boot 테스트 구성의 등록 경계
등록 대상 권장 등록 작동 경계 검증 포인트
역할 컴포넌트 stereotype 기반 component scan 기준 패키지 안의 후보 클래스 포함·제외 패키지와 기본 이름 충돌을 함께 검증
도메인 값 객체 빈으로 등록하지 않고 코드에서 생성 요청·유스케이스의 값 수명 컨테이너 없이 생성 규칙과 불변식을 단위 테스트
기술 구현 @Bean 구성 factory에서 구현 하나를 선택 DB·외부 SDK 같은 환경 정책 현재 실행 구성과 최종 구현 타입을 컨텍스트에서 검증
Boot 테스트 구성 필요한 테스트에서 @TestConfiguration을 @Import 테스트 전용 협력 객체 운영 스캔과 분리되고 가져온 테스트에만 존재하는지 검증

스캔·일반 값·명시적 factory·Boot 테스트 구성의 등록 경계

역할 컴포넌트

권장 등록

stereotype 기반 component scan

작동 경계

기준 패키지 안의 후보 클래스

검증 포인트

포함·제외 패키지와 기본 이름 충돌을 함께 검증

도메인 값 객체

권장 등록

빈으로 등록하지 않고 코드에서 생성

작동 경계

요청·유스케이스의 값 수명

검증 포인트

컨테이너 없이 생성 규칙과 불변식을 단위 테스트

기술 구현 @Bean

권장 등록

구성 factory에서 구현 하나를 선택

작동 경계

DB·외부 SDK 같은 환경 정책

검증 포인트

현재 실행 구성과 최종 구현 타입을 컨텍스트에서 검증

Boot 테스트 구성

권장 등록

필요한 테스트에서 @TestConfiguration을 @Import

작동 경계

테스트 전용 협력 객체

검증 포인트

운영 스캔과 분리되고 가져온 테스트에만 존재하는지 검증

테스트 스캔 경계: Boot의 기본 @SpringBootApplication 경로는 TypeExcludeFilter로 @TestConfiguration을 자동 후보에서 제외합니다. 직접 선언한 @ComponentScan은 이 필터를 자동으로 상속하지 않으므로 테스트 구성을 범위 밖에 두거나 명시적 exclude를 둡니다.

영속성 예외 변환: @Repository 표식만으로 충분하다고 가정하지 않습니다. 해당 컨텍스트에 예외 변환 후처리기와 기술에 맞는 PersistenceExceptionTranslator가 있어야 적용 가능한 예외를 변환합니다.

여기서는 빈 정의를 등록할 수 있는지와 구현 선택을 어디에 둘지만 다룹니다. 여러 등록 후보 중 주입 대상을 결정하는 규칙은 다음 문서로 넘깁니다.

그림의 왼쪽은 이 충돌의 경로입니다. memory와 jdbc의 두 후보가 같은 memberRepository를 내세우고, checkCandidate가 두 번째의 호환되지 않는 정의를 등록 전에 거부해 scan 호출에서 IllegalStateException이 납니다. 오른쪽은 다음 절에서 볼 별도의 선택 구성으로, 실패한 스캔 뒤에 컨테이너가 알아서 구현을 고르는 흐름이 아니라는 점에 주의합니다. 아래쪽 표는 등록 대상의 종류마다 어떤 등록 방식을 쓰고 무엇을 확인할지 정리합니다.


구현 선택은 구성으로 분리

메모리와 JDBC 중 하나를 골라야 한다면, 두 구현을 같은 이름으로 모두 스캔해 두고 등록 순서에 기대지 않습니다. 고를 수 있는 어댑터는 스캔 후보에서 빼고, 지금 실행 구성이 빈 정의를 정확히 하나만 등록하게 하면 무엇을 왜 골랐는지가 소스에 남습니다. 다음 테스트 전용 구성은 앞 절의 작은 MemberStore 인터페이스에 메모리 구현 하나만 연결합니다.

board-core/src/test/java/example/scanning/fixture/SelectedMemberStoreConfig.java
package example.scanning.fixture;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

@TestConfiguration(proxyBeanMethods = false)
public class SelectedMemberStoreConfig {
    @Bean
    public MemberStore memberRepository() {
        return new SelectedMemoryMemberStore();
    }

    private static final class SelectedMemoryMemberStore
            implements MemberStore {
        @Override
        public String storageKind() {
            return "memory";
        }
    }
}

이 구성이 정하는 것은 "지금 구성은 memberRepository 정의 하나를 등록한다"는 사실입니다. 같은 타입의 후보가 여럿일 때 주입 지점에서 무엇을 고르는지는 의존성 후보 선택에서 다루며, 이름 충돌과 후보 선택을 같은 문제로 보지 않습니다.


test source의 후보를 운영 스캔에서 분리

테스트 소스도 테스트를 실행할 때는 클래스 경로에 들어갑니다. 그래서 여러 테스트가 함께 쓰는 픽스처에 @Component를 붙이고 넓게 직접 스캔하면, 테스트에서만 생각지 못한 후보가 늘어날 수 있습니다. 고정 시계를 예로 들어 봅니다. java.time.Clock은 인터페이스가 아니라 추상 클래스이므로, 직접 만드는 시계는 implements Clock이 아니라 extends Clock이어야 하고 세 추상 메서드를 구현해야 합니다.

board-core/src/test/java/example/scanning/support/FakeClock.java
package example.scanning.support;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Objects;

public final class FakeClock extends Clock {
    private final Instant fixedInstant;
    private final ZoneId zone;

    public FakeClock(Instant fixedInstant, ZoneId zone) {
        this.fixedInstant = Objects.requireNonNull(fixedInstant);
        this.zone = Objects.requireNonNull(zone);
    }

    @Override
    public ZoneId getZone() {
        return zone;
    }

    @Override
    public Clock withZone(ZoneId requestedZone) {
        Objects.requireNonNull(requestedZone);
        return zone.equals(requestedZone)
                ? this
                : new FakeClock(fixedInstant, requestedZone);
    }

    @Override
    public Instant instant() {
        return fixedInstant;
    }
}

instant()가 언제나 생성자로 받은 같은 시각을 돌려주므로, 이 시계로 계산한 LocalDate.now(clock)도 늘 같은 날짜가 됩니다. withZone은 같은 시각에 시간대만 바꾼 새 시계를 돌려주고, 이미 같은 시간대라면 자기 자신을 돌려줍니다. 픽스처 자체에는 스테레오타입을 붙이지 않고, 필요한 테스트 구성에서만 빈으로 내보냅니다. 그래서 FakeClock은 어떤 스캔에서도 후보가 되지 않고, 아래 구성을 가져온 테스트에서만 Clock 빈이 됩니다.

board-core/src/test/java/example/scanning/support/FixedClockTestConfig.java
package example.scanning.support;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

@TestConfiguration(proxyBeanMethods = false)
public class FixedClockTestConfig {
    @Bean
    public Clock fixedClock() {
        return new FakeClock(
                Instant.parse("2026-07-13T10:00:00Z"),
                ZoneOffset.UTC);
    }
}

Boot의 기본 @SpringBootApplication 스캔에는 TypeExcludeFilter가 들어 있어서, @TestComponent 계열인 @TestConfiguration을 자동 후보에서 뺍니다. 하지만 따로 선언한 @ComponentScan이 이 제외 필터를 저절로 물려받는다고 생각하면 안 됩니다. 넓은 직접 스캔을 꼭 써야 한다면 다음처럼 TypeExcludeFilter를 그 스캔의 규칙으로 직접 적습니다.

board-core/src/test/java/example/scanning/DirectBoardScanConfig.java
package example.scanning;

import org.springframework.boot.context.TypeExcludeFilter;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;

@TestConfiguration(proxyBeanMethods = false)
@ComponentScan(
        basePackages = "board",
        excludeFilters = @ComponentScan.Filter(
                type = FilterType.CUSTOM,
                classes = TypeExcludeFilter.class))
public class DirectBoardScanConfig {
}

더 작은 방법은 고정 시계가 필요한 테스트에서만 FixedClockTestConfig를 @Import하는 것입니다. 그러면 어떤 테스트가 고정 시계를 추가하는지가 선언에 드러나고, 운영 스캔의 범위를 바꿀 필요도 없습니다.


스캔과 수동 등록의 쓰임

모든 클래스를 컴포넌트로 만들 필요는 없습니다. 대상마다 알맞은 등록 방식과 확인할 질문을 정리하면 다음과 같습니다.

대상등록 방식확인할 질문
안정된 웹 진입 어댑터컴포넌트 스캔기준 패키지 안의 후보인가
운영 회원 서비스BoardConfig의 @Bean스테레오타입 없이 구성에서 조립되는가
메모리와 JDBC 구현 선택고른 구성의 @Bean정의가 정확히 하나인가
순수 엔티티와 값 객체빈으로 등록하지 않음애플리케이션 코드가 필요할 때 만드는가
외부 감사 클라이언트범위 밖의 구성을 명시적으로 연결루트 스캔을 쓸데없이 넓히지 않았는가
테스트용 시계테스트 구성에서 명시적으로 등록운영 후보와 분리되었는가

스캔은 클래스를 찾아 주는 편의 기능일 뿐, 어떤 구현을 고를지나 객체가 업무에서 언제까지 쓰이는지를 설명해 주지 않습니다. 기준 패키지, 후보 스테레오타입, 내세운 빈 이름, 최종 정의가 어디서 왔는지를 차례로 확인하면, 빈이 빠진 문제와 이름 충돌을 같은 문제처럼 다루지 않게 됩니다. 생성자와 선택적 주입, 프로바이더 주입과 순환 참조는 다음 글에서, 같은 타입의 후보 중 하나를 의도적으로 고르는 규칙은 그다음 글에서 다룹니다.


정리

  • 컴포넌트 스캔은 기준 패키지 아래에서 스테레오타입이 붙은 후보만 찾아 이름을 정하고 빈 정의로 등록하며, 인스턴스를 만드는 것은 그 뒤의 단계입니다.
  • @SpringBootApplication이 붙은 클래스의 패키지가 기본 기준이 되므로 진입점은 루트 패키지에 둡니다.
  • 기본 빈 이름은 짧은 클래스 이름의 첫 글자를 소문자로 바꾼 것이며, 첫 두 글자가 대문자이면 그대로 둡니다.
  • 짧은 이름이 같은 두 스캔 후보는 정의를 등록하기 전에 충돌하며, 빈 정의 덮어쓰기를 켜도 해결되지 않습니다.
  • 구현을 골라야 할 때는 둘 다 스캔하지 말고, 구성에서 정의 하나만 등록하게 합니다. 테스트용 구성은 @Import로 필요한 테스트에서만 씁니다.

연습 문제

1. 같은 이름의 서비스 두 개

example.feature.post와 example.feature.report에 테스트 전용 SummaryService를 하나씩 만들고, 두 패키지를 함께 스캔해 기본 이름 summaryService의 충돌을 재현하세요. 두 클래스에 @Service를 붙이고 이름은 따로 주지 않습니다. 테스트는 앞의 ComponentNameCollisionTest처럼 context.scan(...)의 실패에서 공개 예외 타입, 내세운 빈 이름, 두 후보의 전체 클래스 이름을 확인해야 합니다. 그다음 두 클래스를 스캔 후보에서 빼고, FeatureStoreConfig가 지금 실행에 필요한 구현 하나만 @Bean으로 등록하게 바꾸세요. 임의의 접미사나 전역 덮어쓰기로 충돌을 감추지 말고, 찾는 범위와 구현 선택 구성이 각각 무엇을 정하는지 테스트 이름에 드러냅니다.

해설 보기

첫 단계의 공개 예외는 IllegalStateException이고, 메시지에는 summaryService, example.feature.post.SummaryService, example.feature.report.SummaryService가 들어 있어야 합니다. 앞의 충돌 테스트에서 두 기준 패키지와 확인할 문자열만 이 연습의 값으로 바꾸면 됩니다. 예외를 일으킨 두 번째 후보는 정의로 등록되기 전이므로, 객체 초기화에 실패한 것과는 구별합니다.

두 번째 단계에서는 두 클래스의 @Service와 그 임포트를 지웁니다. 이때 첫 단계의 "충돌해야 한다"는 기대는 더 이상 맞지 않으므로 함께 두지 않습니다. 다음 두 클래스와 테스트는 스캔 후보가 사라졌다는 것과, 선택 구성이 구현 하나만 등록한다는 것을 각각 확인합니다.

board-core/src/test/java/example/feature/post/SummaryService.java (최종 단계)
package example.feature.post;

public final class SummaryService {
    public String feature() {
        return "post";
    }
}
board-core/src/test/java/example/feature/report/SummaryService.java (최종 단계)
package example.feature.report;

public final class SummaryService {
    public String feature() {
        return "report";
    }
}
board-core/src/test/java/example/feature/SummaryChoiceExerciseTest.java
package example.feature;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

final class SummaryChoiceExerciseTest {
    @Configuration(proxyBeanMethods = false)
    static class FeatureStoreConfig {
        @Bean
        example.feature.post.SummaryService summaryService() {
            return new example.feature.post.SummaryService();
        }
    }

    @Test
    void 스캔_범위_안이어도_표식_없는_두_클래스는_후보가_아니다() {
        try (var context = new AnnotationConfigApplicationContext()) {
            context.scan("example.feature.post", "example.feature.report");
            context.refresh();

            assertThat(context.getBeansOfType(
                    example.feature.post.SummaryService.class)).isEmpty();
            assertThat(context.getBeansOfType(
                    example.feature.report.SummaryService.class)).isEmpty();
            assertThat(context.containsBean("summaryService")).isFalse();
        }
    }

    @Test
    void 선택_구성은_post_구현_하나만_정의로_등록한다() {
        try (var context = new AnnotationConfigApplicationContext(
                FeatureStoreConfig.class)) {
            var summaries = context.getBeansOfType(
                    example.feature.post.SummaryService.class);

            assertThat(summaries.keySet()).containsExactly("summaryService");
            assertThat(summaries.get("summaryService").feature())
                    .isEqualTo("post");
            assertThat(context.getBeansOfType(
                    example.feature.report.SummaryService.class)).isEmpty();
        }
    }
}

./gradlew test --tests example.feature.SummaryChoiceExerciseTest로 확인합니다. 두 클래스가 패키지 안에 그대로 있어도 스테레오타입이 없으므로 첫 테스트는 아무 구현도 등록하지 않습니다. 둘째 테스트는 스캔하지 않고 FeatureStoreConfig만 처리하므로, 팩토리가 고른 post 구현 하나를 얻습니다. ClassPathBeanDefinitionScanner의 checkCandidate 문서도 같은 이름의 호환되지 않는 정의를 IllegalStateException으로 알린다고 설명합니다. 스캔 후보를 이름으로 등록하는 일과 실행에 필요한 구현을 고르는 일을 나누는 것이 이 연습의 목적입니다.

다음 글 의존관계 주입 방식에서는 등록된 정의를 바탕으로 객체에 필수 의존성과 선택적 의존성을 전달하는 방법과, 프로바이더로 조회할 때 실패가 언제 드러나는지를 구분합니다.