본문 바로가기

컴포넌트 스캔

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

@Component를 붙인 클래스가 보이지 않거나, 서로 다른 구현이 같은 빈 이름을 제안해 애플리케이션이 시작하지 않는 문제는 스캔 범위와 등록 단계를 나누면 진단하기 쉽습니다.

컴포넌트 스캔은 클래스 경로 전체를 무작정 찾지 않습니다.

먼저 기준 패키지 아래에서 스테레오타입 후보를 찾고, 각 후보에 빈 이름을 부여한 뒤, 같은 레지스트리 키와 충돌하지 않을 때 빈 정의로 등록합니다.

후보 클래스가 발견되었다는 사실과 그 클래스의 인스턴스가 이미 만들어졌다는 뜻은 다릅니다.

이번 문서는 발견 범위, 이름 생성, 정의 등록까지만 다룹니다.

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

기준 패키지 탐색·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: 범위 밖스캔 후보 범위가 좁혀집니다.
후보와 이름

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

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

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

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

기준 패키지가 정하는 탐색 경계

@SpringBootApplication에는 @ComponentScan이 포함됩니다.

별도 기준을 지정하지 않으면 애노테이션이 붙은 클래스의 패키지부터 하위 패키지를 탐색합니다.

게시판의 Boot 진입점을 board에 두면 board 아래는 범위에 들어가고 형제 패키지인 external.audit는 들어가지 않습니다.

기존 순수 Java 진입 객체인 board.BoardApplication과 FQN이 겹치지 않도록 Boot 진입점에는 다른 이름을 사용합니다.

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);
    }
}

패키지가 범위 안에 있다는 이유만으로 모든 클래스가 후보가 되지는 않습니다.

실제 production board.member.MemberRegistrationService에는 스테레오타입이 없고, board.BoardConfig의 @Bean 메서드가 생성자에 두 역할을 전달해 조립합니다.

루트 스캔은 @Configuration인 BoardConfig를 후보로 발견하고 구성 클래스 처리가 그 @Bean 정의를 등록할 수 있지만, MemberRegistrationService 클래스를 직접 스캔해 등록하는 것은 아닙니다.

경계 자체는 운영 그래프를 다시 선언하지 않고 다음 test 전용 fixture로 확인할 수 있습니다.

src/test/java/example/scanning/inside/ScannedRegistrationService.java
package example.scanning.inside;

import org.springframework.stereotype.Service;

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

import org.springframework.stereotype.Component;

@Component
public final class AuditClient {
}
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();
        }
    }
}

문자열 "board"를 여러 설정에 반복하는 대신 적절한 루트에 진입점을 두거나 표시 클래스의 패키지를 사용하면 패키지 이동을 컴파일 가능한 참조로 드러낼 수 있습니다.

경계 밖의 통합은 루트를 불필요하게 넓히지 말고 필요한 구성만 명시적으로 가져옵니다.


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

@Component가 일반 후보이고 @Controller, @Service, @Repository, @Configuration은 역할을 드러내는 특화 스테레오타입입니다.

명시 이름이 없으면 애노테이션 기반 이름 생성기는 짧은 클래스 이름에 JavaBeans의 Introspector.decapitalize 규칙을 적용합니다.

앞 두 글자가 모두 대문자이면 첫 글자를 억지로 소문자로 바꾸지 않습니다.

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

이 표의 첫 행은 이름 규칙을 설명하는 일반 예시이며, production board.member.MemberRegistrationService의 실제 등록 경로를 바꾼다는 뜻이 아닙니다.

그 production 서비스의 빈 이름은 BoardConfig에 선언한 @Bean 메서드 이름에서 옵니다.

@Repository도 애노테이션 하나만으로 모든 예외를 바꾸지는 않습니다.

적용 가능한 영속성 예외 변환에는 PersistenceExceptionTranslationPostProcessor가 등록되어 있어야 하고, 사용 기술에 맞는 PersistenceExceptionTranslator도 컨텍스트에 있어야 합니다.

Boot의 데이터 구성이 이 기반을 제공할 수 있지만, 단순 메모리 fixture에 @Repository를 붙였다는 이유만으로 예외 변환이 보장되지는 않습니다.


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

서로 다른 패키지에 있는 클래스라도 짧은 이름이 같으면 같은 기본 빈 이름을 제안할 수 있습니다.

다음 세 파일은 production의 board.member API를 복제하지 않는 test 전용 계약과 두 후보입니다.

두 구현의 짧은 이름은 모두 MemberRepository이므로 기본 이름도 모두 memberRepository입니다.

src/test/java/example/scanning/fixture/MemberStore.java
package example.scanning.fixture;

public interface MemberStore {
    String storageKind();
}
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";
    }
}
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과 진단에 필요한 이름을 확인합니다.

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()에서 두 타입 후보 중 하나를 고르는 문제가 아닙니다.

두 스캔 후보가 같은 레지스트리 키를 차지하려다 정의 등록 전에 충돌한 것입니다.

따라서 전역 빈 정의 오버라이딩을 켜도 이 scanned-vs-scanned 충돌을 “나중 후보가 구현을 교체한다”는 정책으로 바꿀 수 없습니다.

서로 다른 패키지의 두 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가 있어야 적용 가능한 예외를 변환합니다.

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


구현 선택은 구성으로 분리

메모리와 JDBC 중 하나를 선택해야 한다면 두 구현을 같은 이름으로 모두 스캔한 뒤 등록 순서에 기대지 않습니다.

선택 가능한 어댑터를 스캔 후보에서 빼고, 현재 실행 구성이 정확히 한 빈 정의를 등록하게 만들면 선택 근거와 레지스트리 결과가 일치합니다.

다음 test 전용 구성은 앞 절의 작은 MemberStore 계약에 메모리 구현 하나만 연결합니다.

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의 후보를 운영 스캔에서 분리

test source도 테스트 실행 클래스 경로에 들어갑니다.

공유 fixture에 범용 @Component를 붙이고 넓은 직접 스캔을 사용하면 테스트에서만 의도치 않은 후보가 늘 수 있습니다.

java.time.Clock은 인터페이스가 아니라 추상 클래스이므로 직접 만든 시계는 implements Clock이 아니라 extends Clock이어야 하며 세 추상 메서드를 구현해야 합니다.

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;
    }
}

fixture 자체에는 스테레오타입을 붙이지 않고, 필요한 테스트 구성에서만 빈으로 내보냅니다.

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이 그 Boot 제외 필터를 자동 상속한다고 일반화하면 안 됩니다.

직접 넓은 스캔을 꼭 사용한다면 다음처럼 Boot의 TypeExcludeFilter를 그 스캔의 규칙으로 명시합니다.

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하는 것입니다.

이 경우 어떤 테스트가 고정 시계를 추가하는지 선언부에 드러나고, 운영 스캔 경계를 바꿀 필요가 없습니다.


스캔과 수동 등록의 책임

모든 클래스를 컴포넌트로 만들 필요는 없습니다.

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

스캔은 클래스를 찾는 편의이고, 구현 선택 정책이나 객체의 업무 생명 주기를 대신 설명하지 않습니다.

기준 패키지, 후보 스테레오타입, 제안된 빈 이름, 최종 정의의 출처를 차례로 확인하면 누락과 이름 충돌을 같은 문제처럼 다루지 않게 됩니다.


이 문서의 경계

앞 문서는 전체 구성과 경량 구성의 @Bean 호출 경로를 비교했습니다.

이 문서는 컴포넌트 발견, 기본 이름, 같은 이름 후보의 정의 등록 충돌까지만 확정합니다.

생성자·선택형·공급자 주입과 순환 참조는 다음 문서, 같은 타입의 여러 후보를 의도적으로 선택하는 규칙은 그다음 문서에서 다룹니다.

lifecycle callback과 scope도 뒤 문서의 범위이므로 스캔 성공만으로 생성 시점, 파괴 시점, 참조 동일성을 일반화하지 않습니다.


연습 문제

example.feature.post와 example.feature.report에 test 전용 SummaryService를 하나씩 만들고 두 패키지를 함께 스캔해 기본 이름 summaryService 충돌을 재현하세요.

테스트는 공개 예외 타입, 제안된 빈 이름, 두 후보의 FQN을 확인하고 refresh() 전에 실패했음을 보여야 합니다.

그다음 두 클래스를 스캔 후보에서 제거하고 FeatureStoreConfig가 현재 실행에 필요한 구현 하나만 @Bean으로 등록하게 바꾸세요.

임의 접미사나 전역 오버라이딩으로 충돌을 숨기지 말고, 탐색 경계와 구현 선택 구성이 각각 무엇을 결정하는지 테스트 이름에 드러냅니다.

먼저 앞의 ComponentNameCollisionTest와 같은 검사 방식으로 두 연습 패키지를 스캔하세요. 두 클래스에 @Service가 있고 별도 이름을 지정하지 않으면 기대되는 이름은 summaryService입니다. context.scan(...)의 실패를 검사하는 것이며, 실패 뒤 refresh()를 호출해 객체가 생겼는지 확인하는 단계로 넘어가지 않습니다.

충돌 단계와 명시적 선택 단계의 해설

첫 단계에서 공개 예외는 IllegalStateException이고 진단에는 summaryService, example.feature.post.SummaryService, example.feature.report.SummaryService가 들어가야 합니다. 앞의 완전한 충돌 테스트에서 두 기준 패키지와 확인 문자열을 이 연습의 값으로 바꾸면 같은 공개 경계를 검증할 수 있습니다. 오류를 던진 두 번째 후보는 정의로 등록되기 전이며, 객체 초기화 실패와 구별합니다.

두 번째 단계에서는 두 클래스의 @Service와 그 import를 제거합니다. 이때 첫 단계의 “충돌해야 한다”는 기대를 유지하지 않습니다. 다음 최종 두 클래스와 테스트는 스캔 후보가 사라졌다는 사실과 선택 구성은 한 구현만 등록한다는 사실을 각각 확인합니다. 첫 단계와 최종 단계의 소스 상태를 따로 기록하고, 서로 다른 기대를 같은 상태에서 동시에 통과시키려 하지 마세요.

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

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

public final class SummaryService {
    public String feature() {
        return "report";
    }
}
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 구현 하나를 얻습니다. 앞의 운영 게시판 구성과 원 충돌 fixture는 그대로 두고, 이 연습의 패키지에서만 비교합니다.

Spring 스캐너의 후보 검사도 같은 이름의 비호환 정의를 공개 IllegalStateException 경계로 설명합니다. 스캔 후보를 이름으로 등록하는 일과 실행에 필요한 구현을 선택하는 일의 책임을 분리하는 것이 이 연습의 목적입니다.

다음 문서에서는 등록된 정의를 바탕으로 객체의 필수·선택 의존성을 전달하는 방법과 공급자 조회의 실패 시점을 구분합니다.