본문으로 건너뛰기

안동민 개발노트

본문 시작

컴포넌트 스캔

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

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

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

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

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

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

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

BASE PACKAGE · CANDIDATE · BEAN DEFINITION

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

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

PACKAGE BOUNDARY

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

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

@SpringBootApplicationboard에 두면 그 패키지가 기본 시작점입니다. 직접 @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 이름이 있으면 그 값이 우선합니다. 기본 이름은 등록 키를 설명할 때 확인하되, 타입 기반 협력을 문자열 이름에 종속시키지 않습니다.

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


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

@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 메서드가 생성자에 두 역할을 전달해 조립합니다.

루트 스캔은 @ConfigurationBoardConfig를 후보로 발견하고 구성 클래스 처리가 그 @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

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

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

TRACE A · SCANNED VS SCANNED

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

  1. 서로 다른 패키지 탐색

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

  2. 같은 후보 이름 생성

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

  3. 스캐너가 정의 비교

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

  4. 두 번째 정의 등록 거부

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

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

TRACE B · ONE CONFIGURED DEFINITION

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

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

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

  2. 한 factory만 사용

    현재 구성의 @Bean memberRepository()가 선택한 구현 객체를 만듭니다.

  3. 한 정의를 등록

    memberRepository 레지스트리 키에는 선택된 구현의 정의 하나만 놓입니다.

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

    테스트는 등록된 구현 타입을 확인해 현재 실행 구성의 선택을 고정합니다.

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

REGISTRATION BOUNDARY TABLE

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

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

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

영속성 예외 변환: @Repository 표식만으로 충분하다고 가정하지 않습니다. 해당 컨텍스트에 예외 변환 후처리기가 등록되어야 예외 변환 advisor가 적용됩니다.

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


구현 선택은 구성으로 분리

메모리와 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.postexample.feature.report에 test 전용 SummaryService를 하나씩 만들고 두 패키지를 함께 스캔해 기본 이름 summaryService 충돌을 재현하세요.

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

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

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

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