@ComponentScan — 스프링은 빈을 어떻게 자동으로 찾아낼까
스프링은 수백 개의 클래스 중에서 어떤 것이 빈이고 어떤 것이 아닌지를 어떻게 알아낼까요? 일일이 등록하지 않아도 자동으로 찾아내는 원리는 무엇일까요?
개념 정의
@ComponentScan 은 지정된 패키지 범위에서 @Component(및 그 파생 어노테이션)가 붙은 클래스를 자동으로 찾아 빈으로 등록하는 메커니즘입니다. XML 시절의 수동 빈 등록을 대체하는 핵심 기능입니다.
왜 필요한가
빈을 일일이 수동으로 등록하면 이런 일이 벌어집니다.
@Configuration
public class AppConfig {
@Bean public UserService userService() { return new UserService(userRepository()); }
@Bean public UserRepository userRepository() { return new UserRepository(); }
@Bean public OrderService orderService() { return new OrderService(orderRepository()); }
@Bean public OrderRepository orderRepository() { return new OrderRepository(); }
// ... 수십~수백 개의 빈을 수동으로 등록
}
클래스가 늘어날 때마다 설정 파일도 수정해야 합니다. @ComponentScan은 이 문제를 해결합니다. 클래스에 @Component만 붙이면 자동으로 빈이 됩니다.
내부 동작
스캐닝 과정
스프링이 빈을 자동으로 찾아내는 과정은 다음과 같습니다.
@ComponentScan의basePackages를 확인합니다. 미지정 시 현재 클래스의 패키지가 기본값입니다.- 해당 패키지와 하위 패키지의 모든
.class파일을 탐색합니다. - 각 클래스에
@Component계열 어노테이션이 있는지 검사합니다. includeFilters/excludeFilters조건을 확인하여 대상을 걸러냅니다.- 통과한 클래스를
BeanDefinition으로 등록합니다. - 빈 이름은 클래스명의 camelCase로 결정됩니다 (예:
UserService->userService).
스테레오타입 어노테이션
@Component // 범용 컴포넌트
├── @Service // 비즈니스 로직 계층
├── @Repository // 데이터 접근 계층 (+ 예외 변환)
├── @Controller // 웹 MVC 컨트롤러
│ └── @RestController // @Controller + @ResponseBody
└── @Configuration // 설정 클래스 (+ CGLIB 프록시)
이들은 모두 @Component를 메타 어노테이션으로 포함하고 있습니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // ← @Service 안에 @Component가 있음
public @interface Service {
String value() default "";
}
@Repository의 특별한 기능
@Repository
public class UserRepository {
public User findById(Long id) {
// JDBC에서 SQLException이 발생하면
// → 스프링의 DataAccessException으로 자동 변환
}
}
@Repository는 @Component의 기능에 더해 PersistenceExceptionTranslationPostProcessor 가 데이터 접근 예외를 스프링의 통합 예외 계층으로 변환해줍니다. @Service나 @Component는 이 기능이 없습니다.
basePackages와 basePackageClasses
// 문자열로 패키지 지정 (오타 위험)
@ComponentScan(basePackages = "com.example.app")
// 클래스 기반으로 패키지 지정 (타입 안전)
@ComponentScan(basePackageClasses = AppConfig.class)
// 여러 패키지 지정
@ComponentScan(basePackages = {"com.example.service", "com.example.repository"})
basePackageClasses를 사용하면 리팩토링 시 패키지 이름이 변경되어도 컴파일러가 잡아줍니다.
코드 예제
기본 사용법
@Configuration
@ComponentScan("com.example.app")
public class AppConfig {
// @Bean 메서드 없이도 com.example.app 하위의 모든 컴포넌트가 빈으로 등록됨
}
@SpringBootApplication과의 관계
@SpringBootApplication // 내부에 @ComponentScan 포함
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
@SpringBootApplication은 @ComponentScan을 포함하므로, 메인 클래스의 패키지가 자동으로 basePackage가 됩니다. 이것이 메인 클래스를 루트 패키지에 두라고 권장하는 이유입니다.
com.example.app
├── MyApplication.java ← 여기에 @SpringBootApplication
├── controller/
│ └── UserController.java ← 스캔됨
├── service/
│ └── UserService.java ← 스캔됨
└── repository/
└── UserRepository.java ← 스캔됨
필터 사용
@Configuration
@ComponentScan(
basePackages = "com.example",
// 특정 어노테이션이 붙은 클래스만 포함
includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = MyCustomAnnotation.class
),
// 특정 패턴의 클래스를 제외
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.example\\.legacy\\..*"
)
)
public class AppConfig {}
FilterType 종류
// 어노테이션 기반
@ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Deprecated.class)
// 타입(클래스) 기반
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = LegacyService.class)
// 정규식 기반
@ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*Stub.*")
// AspectJ 패턴
@ComponentScan.Filter(type = FilterType.ASPECTJ, pattern = "com.example..*Service+")
// 커스텀 필터
@ComponentScan.Filter(type = FilterType.CUSTOM, classes = MyTypeFilter.class)
커스텀 필터 구현
public class MyTypeFilter implements TypeFilter {
@Override
public boolean match(MetadataReader metadataReader,
MetadataReaderFactory metadataReaderFactory) {
// 클래스 메타데이터를 기반으로 포함 여부 결정
String className = metadataReader.getClassMetadata().getClassName();
return className.contains("Special");
}
}
빈 이름 충돌 처리
// com.example.service 패키지
@Service
public class UserService { }
// com.example.admin 패키지 (다른 패키지에 같은 이름)
@Service
public class UserService { }
같은 이름의 빈이 두 개 등록되면 ConflictingBeanDefinitionException이 발생합니다. 해결 방법은 다음과 같습니다.
// 방법 1: 빈 이름 명시
@Service("adminUserService")
public class UserService { }
// 방법 2: 하나를 @Primary로 지정
@Primary
@Service
public class UserService { }
컴포넌트 스캔이 실제로 빈을 찾는 과정 디버깅
# application.yml
logging:
level:
org.springframework.context.annotation: DEBUG
이 로그 레벨을 설정하면 어떤 클래스가 스캔되어 빈으로 등록되는지 로그에서 확인할 수 있습니다.
수동 등록 vs 자동 등록 우선순위
@Component
public class MyService {
// 자동 등록
}
@Configuration
public class AppConfig {
@Bean
public MyService myService() {
return new CustomMyService(); // 수동 등록
}
}
스프링부트에서는 수동 등록(@Bean)이 자동 등록(@Component)을 오버라이드 합니다. 다만 스프링부트 2.1부터는 기본적으로 이 오버라이드가 금지되어 에러가 발생합니다. spring.main.allow-bean-definition-overriding=true로 허용할 수 있습니다.
주의할 점
1. 메인 클래스 위치가 잘못되면 빈을 못 찾는다
@SpringBootApplication이 하위 패키지가 아닌 곳에 있으면 @ComponentScan의 기본 basePackage가 엉뚱한 패키지가 됩니다. 예를 들어 메인 클래스가 com.example.config에 있고 서비스가 com.example.service에 있으면 서비스 빈이 등록되지 않아 NoSuchBeanDefinitionException이 발생합니다. 메인 클래스는 반드시 최상위 패키지(예: com.example)에 두어야 합니다.
2. 같은 이름의 빈이 다른 패키지에 있으면 충돌한다
서로 다른 패키지에 동일한 클래스명의 @Service가 있으면 ConflictingBeanDefinitionException이 발생합니다. 빈 이름은 기본적으로 클래스명의 camelCase이므로, 패키지가 다르더라도 클래스명이 같으면 충돌합니다. 멀티 모듈 프로젝트에서 각 모듈에 UserService가 있을 때 흔히 발생하며, @Service("adminUserService")처럼 명시적 이름을 지정해야 합니다.
3. excludeFilters를 잘못 설정하면 운영에서 빈이 사라진다
테스트용으로 excludeFilters를 추가했다가 프로덕션 설정에 그대로 남겨두면, 필요한 빈이 등록되지 않아 애플리케이션이 정상 동작하지 않습니다. 특히 FilterType.REGEX로 광범위한 패턴을 지정하면 의도치 않은 클래스까지 제외될 수 있으므로, 제외 대상을 최소한으로 유지하고 테스트에서 반드시 빈 등록 여부를 검증해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 기본 동작 | @Component 계열 어노테이션이 붙은 클래스를 자동 빈 등록 |
| basePackage | @SpringBootApplication 위치의 패키지가 기본값 |
| 스테레오타입 | @Service, @Repository, @Controller는 @Component 특수화 |
@Repository 차이 | 데이터 접근 예외를 DataAccessException으로 자동 변환 |
| 필터 | includeFilters / excludeFilters로 스캔 범위 제어 |
| 이름 충돌 | 명시적 이름(@Service("name")) 또는 @Primary로 해결 |