컴포넌트 스캔
컴포넌트 스캔의 시작 패키지와 기본 빈 이름 규칙을 게시판 구조에 적용하고 누락·중복 등록을 컨텍스트 테스트로 고정합니다.
@Component를 붙였는데 주입되지 않거나, 분명 구현은 하나라고 생각했는데 같은 빈 이름 충돌로 애플리케이션이 시작하지 않는 문제가 자주 생깁니다.
컴포넌트 스캔은 클래스 경로 전체를 무작정 찾는 기능이 아닙니다.
지정한 기반 패키지 아래의 후보를 탐색하고 이름을 부여해 빈 정의로 등록하는 과정입니다.
컴포넌트 스캔 경계
@SpringBootApplication에는 @ComponentScan이 포함됩니다.
별도 범위를 지정하지 않으면 이 애노테이션이 붙은 클래스의 패키지부터 하위 패키지를 탐색합니다.
게시판의 메인 클래스를 최상위 업무 패키지에 두면 웹, 애플리케이션, 인프라를 자연스럽게 포함합니다.
package board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
}board.web.PostController와 board.application.MemberRegistrationService는 탐색되지만, 형제 패키지인 external.audit.AuditClient는 탐색되지 않습니다.
이를 해결하려고 메인 클래스를 기본값 패키지에 두면 거의 모든 클래스 경로가 후보가 되어 범위가 너무 커집니다.
필요한 외부 구성을 @Import하거나 명시적 @Bean으로 연결하는 편이 경계가 보입니다.
package board.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import board.application.MemberRegistrationService;
class ScanBoundaryTest {
@Test
void 지정한_package_아래만_탐색한다() {
try (var context =
new AnnotationConfigApplicationContext()) {
context.scan("board.application");
context.refresh();
assertThat(context.getBeansOfType(
MemberRegistrationService.class)).hasSize(1);
assertThat(context.containsBean("auditClient"))
.isFalse();
}
}
}이 테스트를 실행하려면 MemberRegistrationService가 요구하는 리포지토리도 같은 픽스처에서 제공해야 합니다.
스캔 경계만 확인하려는 테스트가 실제 DB 자동 설정까지 끌어오지 않도록 테스트 구성으로 최소 협력을 등록합니다.
스테레오타입과 빈 이름
@Component가 일반 후보이고 @Controller, @Service, @Repository는 역할을 드러내는 특화 애노테이션입니다.
기본 빈 이름은 클래스의 짧은 이름 첫 글자를 소문자로 바꾼 값입니다.
| 클래스 | 애노테이션 | 기본 빈 이름 |
|---|---|---|
MemberRegistrationService | @Service | memberRegistrationService |
PostController | @RestController | postController |
MemoryMemberRepository | @Repository | memoryMemberRepository |
URLMappingRegistry | @Component | 이름 변환 규칙을 직접 확인해야 함 |
애플리케이션 의존성 주입은 타입을 우선 사용하므로 기본 이름을 업무 API처럼 외우지 않습니다.
이름을 명시해야 한다면 @Component("memoryRepository")처럼 줄 수 있지만, 문자열 이름이 설정과 테스트 곳곳에 퍼지지 않게 합니다.
@Repository는 단순 표식에 그치지 않고 영속성 예외 변환과 연결될 수 있습니다.
다만 메모리 구현에 무조건 영속성 기술 애노테이션을 붙이기보다 인터페이스와 구현 등록 위치가 어디인지 보고 선택합니다.
Java 구성에서 @Bean으로 인프라 어댑터를 등록해도 충분합니다.
빈 이름 충돌
서로 다른 패키지의 클래스라도 같은 명시 이름을 사용하면 한 레지스트리 키를 차지하려고 충돌합니다.
충돌 재현에 저장 기술의 세부 동작은 필요하지 않으므로 픽스처의 계약은 구현 식별자 하나로 제한합니다. 세 파일을 그대로 두면 별도 의존성 없이 컴파일되고, 두 구현을 함께 스캔하는 순간 같은 빈 이름 때문에 컨텍스트가 거부됩니다.
package board.domain;
public interface MemberRepository {
String storageKind();
}package board.infrastructure.memory;
import org.springframework.stereotype.Repository;
import board.domain.MemberRepository;
@Repository("memberRepository")
public final class MemoryMemberRepository
implements MemberRepository {
@Override
public String storageKind() {
return "memory";
}
}package board.infrastructure.jdbc;
import org.springframework.stereotype.Repository;
import board.domain.MemberRepository;
@Repository("memberRepository")
public final class JdbcMemberRepository
implements MemberRepository {
@Override
public String storageKind() {
return "jdbc";
}
}두 패키지를 함께 스캔하면 후보 클래스는 다르지만 제안된 빈 이름과 레지스트리 키가 같습니다.
package board.config;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class ComponentNameCollisionTest {
@Test
void 같은_명시_이름의_component는_등록을_거부한다() {
assertThatThrownBy(() -> {
try (var context =
new AnnotationConfigApplicationContext()) {
context.scan("board.infrastructure.memory",
"board.infrastructure.jdbc");
context.refresh();
}
})
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("memberRepository")
.hasMessageContaining("conflicts");
}
}실제 내부 예외 이름은 ConflictingBeanDefinitionException이지만 공개 API 타입이 아니므로 애플리케이션 테스트가 그 클래스를 직접 가져오기하지 않습니다.
메시지의 빈 이름과 두 컴포넌트 클래스를 확인해 충돌 원인을 고정합니다.
오류를 피하려고 전역 오버라이딩을 켜면 등록 순서에 따라 구현이 바뀔 수 있습니다.
메모리와 JDBC 중 하나를 선택하는 문제는 프로필, 속성 조건, 명시적 구성 같은 정책으로 표현해야 합니다.
ComponentNameCollisionTest
> 같은_명시_이름의_component는_등록을_거부한다() PASSED
cause: ConflictingBeanDefinitionException
bean name: memberRepository테스트 픽스처 스캔 방지
테스트 소스 집합도 테스트 실행 클래스 경로에 포함됩니다.
src/test/java/board 아래에 범용 @Component 픽스처를 만들고 메인 애플리케이션 전체를 띄우면 운영 컴포넌트와 함께 탐색될 수 있습니다.
package board.support;
@Component
class FakeClock implements Clock {
// test에서만 쓸 고정 시계
}이 픽스처가 우연히 전체 스캔에 잡히면 “운영에는 시계 빈이 하나인데 테스트에서는 둘”이 됩니다. 해결 순서는 다음과 같습니다.
- 순수 단위 테스트에서는 애노테이션 없이 생성자로 가짜를 직접 전달합니다.
- 컨텍스트 테스트가 필요하면 중첩
@TestConfiguration에서 테스트 빈을 명시합니다. - 여러 테스트가 공유하면 운영 패키지와 분리된 테스트 구성을
@Import합니다. - 넓은 제외 필터는 마지막 수단으로 쓰고 제외 이유를 테스트합니다.
@TestConfiguration(proxyBeanMethods = false)
class FixedClockTestConfig {
@Bean
Clock fixedClock() {
return Clock.fixed(
Instant.parse("2026-07-13T10:00:00Z"),
ZoneOffset.UTC);
}
}
@SpringBootTest
@Import(FixedClockTestConfig.class)
class RegistrationTimeIntegrationTest {
// 필요한 테스트에서만 고정 시계를 추가한다.
}@TestConfiguration은 일반 @Configuration과 달리 자동 스캔의 대상이 되지 않아 명시적으로 가져온 테스트에서만 적용됩니다.
운영 구현을 교체해야 한다면 무작정 같은 이름을 덮기보다 테스트 슬라이스의 구성과 @Primary 테스트 빈 중 어떤 계약을 검증하는지 분명히 합니다.
스캔·수동 등록 혼합
모든 클래스를 컴포넌트로 만들 필요는 없습니다. 업무 서비스처럼 역할이 명확하고 자동 탐색이 편한 객체는 스캔할 수 있고, 기술 구현 선택이나 외부 클라이언트 생성처럼 환경별 정책이 필요한 객체는 구성에서 등록할 수 있습니다.
| 대상 | 권장 등록 | 이유 |
|---|---|---|
| 컨트롤러, 애플리케이션 서비스 | 컴포넌트 스캔 | 패키지 역할이 안정적 |
| 순수 도메인 엔티티/값 | 등록하지 않음 | 요청마다 코드가 직접 생성 |
| 메모리/JDBC 구현 선택 | @Bean 또는 조건부 구성 | 환경 선택이 명시적 |
| 외부 SDK 클라이언트 | @Bean | 빌더·타임아웃·인증 정보 필요 |
| 테스트 대역 | 테스트 구성 | 운영 스캔과 분리 |
스캔 범위를 여러 기능 패키지에 세밀하게 나열하면 패키지 이동 때 설정이 쉽게 누락됩니다. 메인 클래스를 적절한 루트에 두고, 경계 밖 통합만 명시적으로 가져오기하는 구조가 읽기 쉽습니다. 반대로 거대한 루트 하나 아래에 운영 코드와 마이그레이션 도구, 실험 코드를 모두 두면 의도하지 않은 후보가 늘어납니다.
연습 문제
board.feature.member과 board.feature.report 두 패키지에 각각 하나의 서비스를 만들고 FeatureScanConfig가 두 패키지만 스캔하게 하세요.
board.experimental의 컴포넌트는 제외되어야 합니다.
이어 두 서비스의 클래스 이름을 모두 SummaryService로 만들었을 때 기본 이름 충돌을 재현하고 해결하세요.
해설 보기
탐색 경계를 표시 클래스로 지정하면 문자열 패키지 오타를 줄일 수 있습니다.
@Configuration(proxyBeanMethods = false)
@ComponentScan(basePackageClasses = {
PostFeatureMarker.class,
ReportFeatureMarker.class
})
class FeatureScanConfig {
}서로 다른 패키지의 SummaryService는 기본 빈 이름 summaryService가 같아 충돌합니다.
단지 충돌을 피하려고 클래스 이름 뒤에 임의 숫자를 붙이지 않습니다.
역할을 PostSummaryService와 ReportSummaryService로 명확히 하거나, 둘이 같은 인터페이스의 전략이라면 의미 기반 한정자와 명시적 선택 정책을 둡니다.
assertThat(context.containsBean("postSummaryService")).isTrue();
assertThat(context.containsBean("reportSummaryService")).isTrue();
assertThat(context.containsBean("experimentalService")).isFalse();이 검증은 포함 대상뿐 아니라 제외 대상도 고정해 스캔 범위가 우연히 넓어지는 회귀를 잡습니다.
다음 문서에서는 후보를 찾은 뒤 실제 객체에 전달하는 주입 방식 가운데 생성자 주입을 기본으로 삼고, 반드시 있어야 하는 의존성과 없어도 되는 확장점을 타입으로 구분합니다.