컴포넌트 스캔 (@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이 선언된 클래스의 패키지부터 하위 전체를 탐색한다.
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 직접 구현 |
커스텀 로직 |
실무에서는 ANNOTATION과 ASSIGNABLE_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에 설정을 추가해야 한다:
권장: 수동/자동 빈 이름이 겹치지 않도록 처음부터 명확하게 구분한다.
언제 자동 등록, 언제 수동 등록?
| 상황 | 권장 방식 |
|---|---|
| 비즈니스 로직 빈 (컨트롤러, 서비스, 레포지토리) | 자동 (@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를 직접 찾는 것이 아니라,
애노테이션의 애노테이션(메타 어노테이션)까지 재귀적으로 탐색한다.
이 덕분에 커스텀 어노테이션을 만들고 @Component를 붙이면 자동으로 스캔 대상이 된다: