콘텐츠로 이동

컴포넌트 스캔 (@ComponentScan)


왜 필요한가

@Bean으로 빈을 등록하면 클래스마다 직접 작성해야 한다.

@Configuration
public class AppConfig {
    @Bean public MemberService memberService() { return new MemberServiceImpl(memberRepository()); }
    @Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); }
    @Bean public OrderService orderService() { return new OrderServiceImpl(memberRepository(), discountPolicy()); }
    @Bean public DiscountPolicy discountPolicy() { return new RateDiscountPolicy(); }
    // 빈이 수십, 수백 개가 되면?
    // 클래스 만들 때마다 여기에도 추가해야 하고, 실수로 누락하기 쉬움
}

프로젝트가 커지면 이 방식은 유지보수가 매우 힘들어진다.

@ComponentScan은 이 문제를 해결한다. 클래스패스를 탐색해서 @Component가 붙은 클래스를 자동으로 빈으로 등록해준다. AppConfig에 일일이 @Bean을 작성할 필요가 없다.


동작 방식

1. @ComponentScan이 선언된 위치를 기준으로 하위 패키지 전체를 탐색
2. @Component가 붙은 클래스를 발견
3. 스프링 빈으로 자동 등록
   (빈 이름: 클래스명 첫 글자를 소문자로 변환 — MemberServiceImpl → memberServiceImpl)
4. @Autowired 어노테이션을 발견하면 타입에 맞는 빈을 찾아서 자동 주입
// 1. @ComponentScan 선언
@Configuration
@ComponentScan // 이 클래스가 있는 패키지부터 하위 전체를 탐색
public class AutoAppConfig {
    // @Bean 없이 비어있어도 됨 — 클래스들의 @Component를 보고 자동 등록
}

// 2. 등록하고 싶은 클래스에 @Component 추가
@Component
public class MemoryMemberRepository implements MemberRepository {
    // ...
}

// 3. 의존관계는 @Autowired로 자동 주입
@Component
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;

    @Autowired // 스프링이 MemberRepository 타입의 빈을 찾아서 여기에 주입
    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}
// 사용 — 기존과 동일하게 ApplicationContext를 통해 빈 조회
ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);
MemberService memberService = ac.getBean(MemberService.class);

빈 이름 규칙

기본적으로 클래스 이름의 첫 글자를 소문자로 바꾼 이름이 빈 이름이 된다.

MemberServiceImpl    → memberServiceImpl
MemoryMemberRepository → memoryMemberRepository
RateDiscountPolicy   → rateDiscountPolicy

직접 이름을 지정할 수도 있다.

@Component("myMemberService") // 빈 이름: myMemberService
public class MemberServiceImpl implements MemberService { ... }

@Component 계열 어노테이션

실무에서는 @Component를 직접 쓰는 경우는 드물다. 역할을 명확히 표현하는 특화 어노테이션을 사용한다.

내부적으로 모두 @Component를 포함하고 있어서 컴포넌트 스캔의 대상이 된다.

// @Service 내부를 보면
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // ← @Component가 들어있어서 컴포넌트 스캔 대상이 됨
public @interface Service { ... }
어노테이션 사용 계층 추가 기능
@Component 범용 (특정 계층에 속하지 않는 경우) 없음
@Controller 웹 컨트롤러 계층 Spring MVC에서 HTTP 요청 처리 대상으로 인식
@Service 비즈니스 로직 계층 없음 (역할 명시 목적, AOP 포인트컷 활용)
@Repository 데이터 접근 계층 DB 예외를 Spring의 DataAccessException으로 변환
@Configuration 설정 클래스 CGLIB으로 싱글톤 처리 (@Bean 메서드 오버라이딩)

@Repository가 DB 예외를 변환해주는 이유: JPA, MyBatis, JDBC 등 기술마다 예외 클래스가 다르다. (JpaException, SQLException 등) @Repository가 있으면 스프링 AOP가 이 예외들을 DataAccessException으로 통일시켜준다. 서비스 계층에서 DB 기술에 종속되지 않은 예외 처리를 할 수 있게 된다.

계층별 어노테이션 전체 예제

// 웹 계층 — HTTP 요청을 받고 응답을 반환
@Controller
public class MemberController {
    private final MemberService memberService;

    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/members/{id}")
    @ResponseBody
    public MemberDto getMember(@PathVariable Long id) {
        return memberService.findMember(id);
    }
}

// 서비스 계층 — 비즈니스 로직
@Service
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;

    @Autowired
    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public MemberDto findMember(Long id) {
        Member member = memberRepository.findById(id)
            .orElseThrow(() -> new MemberNotFoundException(id));
        return new MemberDto(member);
    }
}

// 레포지토리 계층 — DB 접근
@Repository
public class JpaMemberRepository implements MemberRepository {
    private final EntityManager em;

    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(em.find(Member.class, id));
    }
}

스캔 범위 설정

기본값 (권장)

@ComponentScan이 선언된 클래스의 패키지부터 하위 전체를 탐색한다.

package hello.core;

@ComponentScan // hello.core 패키지와 그 하위 패키지 전체 탐색
public class AppConfig { ... }

hello.core.member, hello.core.order 등 하위 패키지 전부 스캔됨. hello.core 밖의 패키지는 스캔 안 됨.

직접 지정

@ComponentScan(basePackages = "hello.core")
// hello.core와 그 하위 패키지 탐색

@ComponentScan(basePackages = {"hello.core", "hello.service"})
// 여러 패키지 지정

@ComponentScan(basePackageClasses = AppConfig.class)
// 특정 클래스가 속한 패키지부터 탐색 (문자열 대신 클래스를 지정 — 리팩터링에 안전)

권장 프로젝트 구조

설정 클래스를 프로젝트 최상위 패키지에 두면 하위 전체가 자동으로 탐색된다. 별도로 basePackages를 지정할 필요가 없어 실수를 줄일 수 있다.

com.myapp                        ← AppConfig 위치 (최상위)
├── AppConfig.java                ← @ComponentScan (com.myapp 부터 전체 탐색)
├── member/
│   ├── MemberController.java     ← @Controller
│   ├── MemberService.java
│   ├── MemberServiceImpl.java    ← @Service
│   └── MemberRepository.java
├── order/
│   ├── OrderController.java      ← @Controller
│   ├── OrderServiceImpl.java     ← @Service
│   └── OrderRepository.java      ← @Repository
└── discount/
    ├── DiscountPolicy.java
    └── RateDiscountPolicy.java   ← @Component

Spring Boot의 경우

@SpringBootApplication이 내부에 @ComponentScan을 포함하고 있다. 별도 설정 없이 main 클래스가 있는 패키지부터 전체를 스캔한다.

@SpringBootApplication
// 내부 = @SpringBootConfiguration + @ComponentScan + @EnableAutoConfiguration
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
// Application.java가 com.myapp 패키지에 있으면 → com.myapp 하위 전체 스캔

필터 설정

기본 스캔 규칙에서 특정 클래스를 포함하거나 제외하고 싶을 때 사용한다.

includeFilters — 스캔 대상 추가

@Component가 없어도 스캔 대상에 포함시킨다.

// 커스텀 어노테이션 정의
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyIncludeComponent {}

// 이 어노테이션이 붙은 클래스도 자동 등록
@MyIncludeComponent
public class BeanA { ... }

// 설정
@ComponentScan(
    includeFilters = @ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = MyIncludeComponent.class
    )
)

excludeFilters — 스캔 대상 제외

@Component가 있어도 스캔 대상에서 제외한다.

// 특정 어노테이션이 붙은 클래스 제외
@ComponentScan(
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = Configuration.class // @Configuration 붙은 클래스 제외
    )
)

// 특정 클래스 제외
@ComponentScan(
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        classes = MemoryMemberRepository.class // 이 클래스 제외
    )
)

FilterType 종류

FilterType 설명 예시
ANNOTATION 특정 어노테이션이 붙은 클래스 @MyComponent
ASSIGNABLE_TYPE 특정 클래스(또는 구현체) SomeBean.class
ASPECTJ AspectJ 패턴 hello..*Service+
REGEX 정규표현식 com\.myapp\.Default.*
CUSTOM TypeFilter 직접 구현 커스텀 로직

실무에서는 ANNOTATIONASSIGNABLE_TYPE이 가장 많이 쓰인다.


중복 등록 문제

자동 vs 자동 (항상 오류)

같은 이름의 빈이 자동 등록으로 중복되면 ==ConflictingBeanDefinitionException==이 발생한다.

@Component
public class MemoryMemberRepository implements MemberRepository { ... }
// 빈 이름: memoryMemberRepository

@Component("memoryMemberRepository") // 이름 충돌!
public class OtherRepository implements MemberRepository { ... }
// → ConflictingBeanDefinitionException

수동 vs 자동 (Spring Boot에서는 오류)

// 자동 등록
@Component
public class MemoryMemberRepository implements MemberRepository { ... }
// 빈 이름: memoryMemberRepository

// 수동 등록 (같은 이름)
@Configuration
public class AppConfig {
    @Bean(name = "memoryMemberRepository") // 자동 등록된 이름과 충돌
    public MemberRepository memberRepository() { return new MemoryMemberRepository(); }
}

과거 Spring에서는 수동 등록이 자동 등록을 오버라이딩했다. 조용히 덮어씌워지기 때문에 의도치 않은 버그를 유발했다.

Spring Boot는 기본적으로 이 상황을 오류로 처리한다.

BeanDefinitionOverrideException:
Invalid bean definition with name 'memoryMemberRepository' defined in ...

허용하려면 application.properties에 설정을 추가해야 한다:

spring.main.allow-bean-definition-overriding=true

권장: 수동/자동 빈 이름이 겹치지 않도록 처음부터 명확하게 구분한다.


언제 자동 등록, 언제 수동 등록?

상황 권장 방식
비즈니스 로직 빈 (컨트롤러, 서비스, 레포지토리) 자동 (@Controller, @Service, @Repository)
외부 라이브러리 빈 등록 수동 (@Bean) — 내 코드가 아니라 어노테이션 추가 불가
AOP, 공통 기술 빈 수동 (@Bean) — 명확히 드러내는 게 좋음
DataSource, 외부 API 클라이언트 설정 수동 (@Bean)
// 실무 패턴: 비즈니스 빈은 자동, 인프라 빈은 수동
@Configuration
public class InfraConfig {
    @Bean
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        ds.setUsername("root");
        ds.setPassword("password");
        return ds; // 외부 라이브러리 → 수동 등록
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        return template;
    }
}

// 비즈니스 로직은 컴포넌트 스캔
@Service
public class MemberService { ... }

@Repository
public class MemberRepository { ... }

내부 동작 원리

ClassPathBeanDefinitionScanner — 실제 탐색 메커니즘

컴포넌트 스캔은 ClassPathBeanDefinitionScanner가 담당한다. 핵심은 ASM(바이트코드 분석 라이브러리)을 사용한다는 점이다.

 ClassLoader로 classpath(jar 파일, classes 디렉터리) .class 파일 목록 수집
    basePackage="hello.core" 이면 hello/core/ 하위 .class 파일 전부 나열

 .class 파일을 직접 읽어서 애노테이션 확인 (ASM 사용)
    Class.forName()  쓰지 않음! (이유: 클래스 로딩  static 블록 실행 부작용)
    대신 .class 파일 바이트코드를 직접 파싱해서 @Component 존재 여부 확인
    실제 JVM 클래스 로딩 없이 메타데이터만 빠르게 읽음

 @Component(또는 @Service, @Repository  메타 어노테이션) 발견되면
    BeanDefinition 생성 (클래스 정보, 스코프 )
    beanDefinitionMap에 등록

 이후 finishBeanFactoryInitialization() 단계에서 실제 인스턴스 생성

왜 ASM을 쓰는가? Class.forName("hello.core.member.SomeClass")로 클래스를 로딩하면 그 클래스의 static 초기화 블록이 실행된다. 수백 개의 클래스를 스캔할 때 의도치 않은 코드 실행, 메모리 낭비, 느린 시작이 발생할 수 있다. ASM은 .class 파일을 바이트 스트림으로 읽기만 하므로 클래스 로딩 부작용이 없고, 대규모 프로젝트에서도 빠르게 스캔할 수 있다.


@Component 계열 어노테이션의 메타 어노테이션 탐색

@Service, @Controller, @Repository가 컴포넌트 스캔 대상이 되는 이유:

// @Service 내부
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component  // ← @Component를 메타 어노테이션으로 포함
public @interface Service { }

// @Controller 내부
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component  // ← 마찬가지
public @interface Controller { }

스프링의 애노테이션 스캐너는 단순히 @Component를 직접 찾는 것이 아니라, 애노테이션의 애노테이션(메타 어노테이션)까지 재귀적으로 탐색한다.

@Service가 붙은 클래스 발견
  → @Service의 메타 어노테이션 확인
  → @Component 발견
  → 컴포넌트 스캔 대상으로 등록

이 덕분에 커스텀 어노테이션을 만들고 @Component를 붙이면 자동으로 스캔 대상이 된다:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component          // ← 이걸 붙이면 컴포넌트 스캔 대상이 됨
@Transactional      // ← 동시에 트랜잭션 처리도 가능
public @interface MyService { }

@MyService          // ← @Component + @Transactional 효과
public class OrderService { ... }