Theme:

스프링은 수백 개의 클래스 중에서 어떤 것이 빈이고 어떤 것이 아닌지를 어떻게 알아낼까요? 일일이 등록하지 않아도 자동으로 찾아내는 원리는 무엇일까요?

개념 정의

@ComponentScan은 지정된 패키지 범위에서 @Component(및 그 파생 어노테이션)가 붙은 클래스를 자동으로 찾아 빈으로 등록하는 메커니즘입니다. XML 시절의 수동 빈 등록을 대체하는 핵심 기능입니다.

왜 필요한가

빈을 일일이 수동으로 등록하면 이런 일이 벌어집니다.

JAVA
@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만 붙이면 자동으로 빈이 됩니다.

내부 동작

스캐닝 과정

PLAINTEXT
1. @ComponentScan의 basePackages 확인
   (미지정 시 → 현재 클래스의 패키지)
2. 해당 패키지와 하위 패키지의 모든 .class 파일 탐색
3. 각 클래스의 어노테이션 검사
   (@Component, @Service, @Repository, @Controller 등)
4. 필터 조건 확인 (include/exclude)
5. 통과한 클래스를 BeanDefinition으로 등록
6. 빈 이름 결정 (클래스명의 camelCase)

스테레오타입 어노테이션

JAVA
@Component                  // 범용 컴포넌트
├── @Service                // 비즈니스 로직 계층
├── @Repository             // 데이터 접근 계층 (+ 예외 변환)
├── @Controller             // 웹 MVC 컨트롤러
│   └── @RestController     // @Controller + @ResponseBody
└── @Configuration          // 설정 클래스 (+ CGLIB 프록시)

이들은 모두 @Component를 메타 어노테이션으로 포함하고 있습니다.

JAVA
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component  // ← @Service 안에 @Component가 있음
public @interface Service {
    String value() default "";
}

@Repository의 특별한 기능

JAVA
@Repository
public class UserRepository {
    public User findById(Long id) {
        // JDBC에서 SQLException이 발생하면
        // → 스프링의 DataAccessException으로 자동 변환
    }
}

@Repository@Component의 기능에 더해 PersistenceExceptionTranslationPostProcessor가 데이터 접근 예외를 스프링의 통합 예외 계층으로 변환해줍니다. @Service@Component는 이 기능이 없습니다.

basePackages와 basePackageClasses

JAVA
// 문자열로 패키지 지정 (오타 위험)
@ComponentScan(basePackages = "com.example.app")

// 클래스 기반으로 패키지 지정 (타입 안전)
@ComponentScan(basePackageClasses = AppConfig.class)

// 여러 패키지 지정
@ComponentScan(basePackages = {"com.example.service", "com.example.repository"})

basePackageClasses를 사용하면 리팩토링 시 패키지 이름이 변경되어도 컴파일러가 잡아줍니다.

코드 예제

기본 사용법

JAVA
@Configuration
@ComponentScan("com.example.app")
public class AppConfig {
    // @Bean 메서드 없이도 com.example.app 하위의 모든 컴포넌트가 빈으로 등록됨
}

@SpringBootApplication과의 관계

JAVA
@SpringBootApplication // 내부에 @ComponentScan 포함
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

@SpringBootApplication@ComponentScan을 포함하므로, 메인 클래스의 패키지가 자동으로 basePackage가 됩니다. 이것이 메인 클래스를 루트 패키지에 두라고 권장하는 이유입니다.

PLAINTEXT
com.example.app
├── MyApplication.java       ← 여기에 @SpringBootApplication
├── controller/
│   └── UserController.java  ← 스캔됨
├── service/
│   └── UserService.java     ← 스캔됨
└── repository/
    └── UserRepository.java  ← 스캔됨

필터 사용

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 종류

JAVA
// 어노테이션 기반
@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)

커스텀 필터 구현

JAVA
public class MyTypeFilter implements TypeFilter {
    @Override
    public boolean match(MetadataReader metadataReader,
                         MetadataReaderFactory metadataReaderFactory) {
        // 클래스 메타데이터를 기반으로 포함 여부 결정
        String className = metadataReader.getClassMetadata().getClassName();
        return className.contains("Special");
    }
}

빈 이름 충돌 처리

JAVA
// com.example.service 패키지
@Service
public class UserService { }

// com.example.admin 패키지 (다른 패키지에 같은 이름)
@Service
public class UserService { }

같은 이름의 빈이 두 개 등록되면 ConflictingBeanDefinitionException이 발생합니다. 해결 방법은 다음과 같습니다.

JAVA
// 방법 1: 빈 이름 명시
@Service("adminUserService")
public class UserService { }

// 방법 2: 하나를 @Primary로 지정
@Primary
@Service
public class UserService { }

컴포넌트 스캔이 실제로 빈을 찾는 과정 디버깅

YAML
# application.yml
logging:
  level:
    org.springframework.context.annotation: DEBUG

이 로그 레벨을 설정하면 어떤 클래스가 스캔되어 빈으로 등록되는지 로그에서 확인할 수 있습니다.

수동 등록 vs 자동 등록 우선순위

JAVA
@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로 허용할 수 있습니다.

정리

  • @ComponentScan은 지정된 패키지에서 @Component 계열 어노테이션이 붙은 클래스를 자동으로 빈으로 등록합니다
  • @SpringBootApplication@ComponentScan이 포함되어 있으므로, 메인 클래스를 루트 패키지에 두면 별도 설정 없이 전체 패키지가 스캔됩니다
  • @Service, @Repository, @Controller@Component의 특수화이며, @Repository만 예외 변환 기능이 추가됩니다
  • includeFiltersexcludeFilters로 스캔 범위를 세밀하게 제어할 수 있습니다
  • 같은 이름의 빈이 충돌하면 명시적 이름 지정이나 @Primary로 해결합니다
댓글 로딩 중...