Search
🌱

[스프링] 컴포넌트 스캔과 의존관계 자동 주입

Tags
Study
Spring
Last edited time
2025/03/30 09:05
2 more properties

1. 서론

지금까지 스프링에서 빈을 등록하기 위해 @Bean이나 XML 설정을 통한 명시적인 방법을 살펴보았습니다. 하지만 실제 프로젝트에서는 등록해야 할 빈이 수십, 수백 개까지 늘어날 수 있으며, 이를 일일이 등록하는 것은 번거로울 뿐만 아니라 실수로 누락할 가능성도 있습니다. 스프링은 이런 문제를 해결하기 위해 컴포넌트 스캔(Component Scan) 기능과 의존관계 자동 주입(Autowiring) 기능을 제공합니다.
이번 포스팅에서는 컴포넌트 스캔의 기본 개념부터 다양한 의존관계 자동 주입 방법, 그리고 실무에서 활용하는 베스트 프랙티스까지 자세히 알아보겠습니다.

2. 컴포넌트 스캔

2.1. 컴포넌트 스캔이란?

컴포넌트 스캔은 스프링이 직접 클래스를 검색하여 빈으로 등록하는 기능입니다. @Component 애노테이션이 붙은 클래스를 스캔하여 스프링 빈으로 자동 등록합니다.

2.2. 컴포넌트 스캔 설정 예제

먼저 컴포넌트 스캔을 사용하기 위한 설정 클래스를 만들어 보겠습니다.
@Configuration @ComponentScan( excludeFilters = @Filter(type = FilterType.ANNOTATION, classes = Configuration.class) ) public class AutoAppConfig { }
Java
복사
위 코드에서는 @ComponentScan 애노테이션을 설정 정보에 붙여주고 있습니다. 기존의 AppConfig와는 다르게 @Bean으로 등록한 클래스가 하나도 없다는 것이 특징입니다.
참고: excludeFilters를 이용해 설정 정보는 컴포넌트 스캔 대상에서 제외했습니다. 이는 기존 예제 코드와의 충돌을 피하기 위한 것입니다.

2.3. 컴포넌트 스캔 대상 클래스 설정

이제 각 클래스가 컴포넌트 스캔의 대상이 되도록 @Component 애노테이션을 추가해 봅시다.
@Component public class MemoryMemberRepository implements MemberRepository {} @Component public class RateDiscountPolicy implements DiscountPolicy {} @Component public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; @Autowired public MemberServiceImpl(MemberRepository memberRepository) { this.memberRepository = memberRepository; } } @Component public class OrderServiceImpl implements OrderService { private final MemberRepository memberRepository; private final DiscountPolicy discountPolicy; @Autowired public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; } }
Java
복사
여기서 @Autowired는 의존관계를 자동으로 주입해주는 역할을 합니다. 이전에는 AppConfig에서 의존관계를 직접 명시했지만, 이제는 해당 클래스 안에서 의존관계를 해결합니다.
테스트를 실행해보면 컴포넌트 스캔이 정상적으로 동작하는 것을 확인할 수 있습니다. 로그를 살펴보면 다음과 같이 컴포넌트가 스캔되는 것을 볼 수 있습니다.
public class AutoAppConfigTest { @Test void basicScan() { ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class); MemberService memberService = ac.getBean(MemberService.class); assertThat(memberService).isInstanceOf(MemberService.class); } }
Java
복사
ClassPathBeanDefinitionScanner - Identified candidate component class: ... RateDiscountPolicy.class ... MemberServiceImpl.class ... MemoryMemberRepository.class ... OrderServiceImpl.class
Plain Text
복사

2.5. 컴포넌트 스캔과 자동 의존관계 주입 동작 과정

1. @ComponentScan
@ComponentScan@Component가 붙은 모든 클래스를 스프링 빈으로 등록합니다. 이때 스프링 빈의 기본 이름은 클래스명을 사용하되 맨 앞글자만 소문자로 바꿉니다(예: MemberServiceImpl → memberServiceImpl).
2. @Autowired 의존관계 자동 주입
생성자에 @Autowired를 지정하면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입합니다. 기본 조회 전략은 타입이 같은 빈을 찾아서 주입하는 것입니다.

3. 탐색 위치와 기본 스캔 대상

3.1. 탐색할 패키지의 시작 위치 지정

모든 자바 클래스를 전부 컴포넌트 스캔하면 시간이 오래 걸리므로, 필요한 위치부터 탐색하도록 시작 위치를 지정할 수 있습니다.
@ComponentScan( basePackages = "hello.core", basePackageClasses = AutoAppConfig.class )
Java
복사
basePackages: 탐색할 패키지의 시작 위치를 지정합니다. 이 패키지를 포함해서 하위 패키지를 모두 탐색합니다.
basePackageClasses: 지정한 클래스의 패키지를 탐색 시작 위치로 지정합니다.
만약 지정하지 않으면 @ComponentScan이 붙은 설정 정보 클래스의 패키지가 시작 위치가 됩니다.

3.2. 권장하는 방법

개인적으로 권장하는 방법은 패키지 위치를 지정하지 않고, 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 것입니다. 최근 스프링 부트도 이 방법을 기본으로 제공합니다.
예를 들어 다음과 같은 구조일 때:
com.hello com.hello.service com.hello.repository
Plain Text
복사
com.hello에 AppConfig 같은 메인 설정 정보를 두고, @ComponentScan 애노테이션을 붙이고 basePackages 지정은 생략합니다. 이렇게 하면 com.hello를 포함한 하위 패키지는 모두 자동으로 컴포넌트 스캔의 대상이 됩니다.
참고: 스프링 부트를 사용하면 스프링 부트의 대표 시작 정보인 @SpringBootApplication을 프로젝트 시작 루트 위치에 두는 것이 관례입니다. 그리고 이 설정 안에 바로 @ComponentScan이 들어있습니다!

3.3. 컴포넌트 스캔 기본 대상

컴포넌트 스캔은 @Component뿐만 아니라 다음의 애노테이션도 대상에 포함합니다:
@Component: 컴포넌트 스캔에서 사용
@Controller: 스프링 MVC 컨트롤러에서 사용
@Service: 스프링 비즈니스 로직에서 사용
@Repository: 스프링 데이터 접근 계층에서 사용
@Configuration: 스프링 설정 정보에서 사용
이 애노테이션들의 소스 코드를 보면 모두 @Component 애노테이션을 포함하고 있습니다:
@Component public @interface Controller { } @Component public @interface Service { } @Component public @interface Configuration { }
Java
복사
참고: 사실 애노테이션에는 상속관계라는 것이 없습니다. 이렇게 애노테이션이 특정 애노테이션을 포함하고 있는 것을 인식할 수 있는 것은 자바 언어가 지원하는 기능이 아니라 스프링이 지원하는 기능입니다.
각 애노테이션은 스캔 대상이 될 뿐만 아니라 다음과 같은 부가 기능도 수행합니다
@Controller: 스프링 MVC 컨트롤러로 인식
@Repository: 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환해줍니다
@Configuration: 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 처리를 합니다
@Service: 특별한 처리를 하지 않지만, 개발자들이 핵심 비즈니스 로직이 있는 계층을 인식하는데 도움이 됩니다

4. 필터

컴포넌트 스캔에서 특정 대상을 추가하거나 제외할 수 있는 필터 기능을 제공합니다.
includeFilters: 컴포넌트 스캔 대상을 추가로 지정합니다.
excludeFilters: 컴포넌트 스캔에서 제외할 대상을 지정합니다.

4.1. 필터 사용 예제

먼저 컴포넌트 스캔 대상에 추가할 애노테이션과 제외할 애노테이션을 정의합니다:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface MyIncludeComponent { } @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface MyExcludeComponent { }
Java
복사
그리고 이 애노테이션을 사용하는 클래스를 만듭니다
@MyIncludeComponent public class BeanA { } @MyExcludeComponent public class BeanB { }
Java
복사
이제 필터를 적용한 설정 정보를 작성합니다
@Configuration @ComponentScan( includeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class), excludeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class) ) static class ComponentFilterAppConfig { }
Java
복사
이렇게 하면 BeanA는 스프링 빈에 등록되고, BeanB는 스프링 빈에 등록되지 않습니다.

4.2. FilterType 옵션

FilterType에는 다음과 같은 옵션이 있습니다:
ANNOTATION: 기본값, 애노테이션을 인식해서 동작합니다. (예: org.example.SomeAnnotation)
ASSIGNABLE_TYPE: 지정한 타입과 자식 타입을 인식해서 동작합니다. (예: org.example.SomeClass)
ASPECTJ: AspectJ 패턴을 사용합니다. (예: org.example..*Service+)
REGEX: 정규 표현식을 사용합니다. (예: org\\.example\\.Default.*)
CUSTOM: TypeFilter 인터페이스를 구현해서 처리합니다. (예: org.example.MyTypeFilter)
참고: @Component면 충분하기 때문에, includeFilters를 사용할 일은 거의 없습니다. excludeFilters는 여러 이유로 간혹 사용할 때가 있지만 많지는 않습니다. 스프링 부트는 컴포넌트 스캔을 기본으로 제공하는데, 옵션을 변경하면서 사용하기보다는 스프링의 기본 설정에 최대한 맞추어 사용하는 것을 권장합니다.

5. 중복 등록과 충돌

컴포넌트 스캔에서 같은 빈 이름을 등록하면 어떻게 될까요? 다음 두 가지 상황이 있습니다.

5.1. 자동 빈 등록 vs 자동 빈 등록

컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록되는데, 그 이름이 같은 경우 스프링은 오류를 발생시킵니다. 이때 ConflictingBeanDefinitionException 예외가 발생합니다.

5.2. 수동 빈 등록 vs 자동 빈 등록

만약 수동 빈 등록과 자동 빈 등록에서 빈 이름이 충돌된다면, 수동 빈 등록이 우선권을 가집니다. 즉, 수동 빈이 자동 빈을 오버라이딩합니다.
@Component public class MemoryMemberRepository implements MemberRepository {} @Configuration @ComponentScan public class AutoAppConfig { @Bean(name = "memoryMemberRepository") public MemberRepository memberRepository() { return new MemoryMemberRepository(); } }
Java
복사
이 경우 수동 빈 등록이 우선되며, 다음과 같은 로그가 남습니다.
Overriding bean definition for bean 'memoryMemberRepository' with a different definition: replacing
Plain Text
복사
하지만 최근 스프링 부트에서는 수동 빈 등록과 자동 빈 등록이 충돌나면 오류가 발생하도록 기본값을 변경했습니다.
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
Plain Text
복사
이는 의도치 않은 오버라이딩으로 인한 문제를 방지하기 위한 조치입니다. 실무에서는 의도적인 오버라이딩보다 설정이 꼬여서 발생하는 경우가 대부분이기 때문입니다.

6. 옵션 처리

주입할 스프링 빈이 없어도 동작해야 할 때가 있습니다. 이런 경우 다음과 같이 옵션을 처리할 수 있습니다:
// 호출 안됨 @Autowired(required = false) public void setNoBean1(Member member) { System.out.println("setNoBean1 = " + member); } // null 호출 @Autowired public void setNoBean2(@Nullable Member member) { System.out.println("setNoBean2 = " + member); } // Optional.empty 호출 @Autowired public void setNoBean3(Optional<Member> member) { System.out.println("setNoBean3 = " + member); }
Java
복사
@Autowired(required=false): 자동 주입할 대상이 없으면 메서드 자체가 호출되지 않습니다.
@Nullable: 자동 주입할 대상이 없으면 null이 입력됩니다.
Optional<>: 자동 주입할 대상이 없으면 Optional.empty가 입력됩니다.

7. 생성자 주입을 선택해야 하는 이유

최근에는 스프링을 포함한 DI 프레임워크 대부분이 생성자 주입을 권장합니다. 그 이유는 다음과 같습니다.

7.1. 불변성 보장

대부분의 의존관계 주입은 한번 일어나면 애플리케이션 종료 시점까지 의존관계를 변경할 일이 없습니다. 생성자 주입은 객체를 생성할 때 딱 1번만 호출되므로 불변하게 설계할 수 있습니다.

7.2. 누락 방지

프레임워크 없이 순수한 자바 코드로 단위 테스트할 때, 생성자 주입을 사용하면 의존관계 주입이 누락되었을 때 컴파일 오류가 발생합니다. 반면 수정자 주입은 실행은 되지만 NPE(Null Pointer Exception)가 발생할 수 있습니다.

7.3. final 키워드 사용 가능

생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있어, 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아줍니다.
@Component public class OrderServiceImpl implements OrderService { private final MemberRepository memberRepository; private final DiscountPolicy discountPolicy; @Autowired public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; } }
Java
복사

8. 롬복과 최신 트렌드

대부분의 의존관계가 불변이므로, 필드에 final 키워드를 사용하게 됩니다. 그런데 매번 생성자를 만들고 주입받은 값을 대입하는 코드를 작성하는 것은 번거롭습니다.
롬복(Lombok) 라이브러리의 @RequiredArgsConstructor 기능을 사용하면 final이 붙은 필드를 모아서 생성자를 자동으로 만들어줍니다.
@Component @RequiredArgsConstructor public class OrderServiceImpl implements OrderService { private final MemberRepository memberRepository; private final DiscountPolicy discountPolicy; }
Java
복사
이 코드는 다음과 같은 생성자 코드를 자동으로 생성해줍니다:
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
Java
복사
최근에는 생성자를 딱 1개 두고, @Autowired를 생략하는 방법을 주로 사용하며, 여기에 Lombok 라이브러리의 @RequiredArgsConstructor를 함께 사용하면 코드를 깔끔하게 유지할 수 있습니다.

9. 조회 빈이 2개 이상일 때 해결 방법

@Autowired는 타입으로 조회하기 때문에, 선택된 빈이 2개 이상일 때 문제가 발생할 수 있습니다.
@Autowired private DiscountPolicy discountPolicy; @Component public class FixDiscountPolicy implements DiscountPolicy {} @Component public class RateDiscountPolicy implements DiscountPolicy {}
Java
복사
만약 DiscountPolicy의 구현체인 FixDiscountPolicyRateDiscountPolicy 둘 다 스프링 빈으로 등록되어 있다면, NoUniqueBeanDefinitionException 오류가 발생합니다.
이 문제를 해결하는 방법은 다음과 같습니다.

9.1. @Autowired 필드명 매칭

@Autowired는 타입 매칭을 시도하고, 이때 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭합니다.
// AS IS @Autowired private DiscountPolicy discountPolicy // TO BE @Autowired private DiscountPolicy rateDiscountPolicy;
Java
복사

9.2. @Qualifier 사용

@Qualifier는 추가 구분자를 붙여주는 방법입니다.
@Component @Qualifier("mainDiscountPolicy") public class RateDiscountPolicy implements DiscountPolicy {} @Component @Qualifier("fixDiscountPolicy") public class FixDiscountPolicy implements DiscountPolicy {}
Java
복사
주입 시에도 @Qualifier를 붙여주고 등록한 이름을 적어줍니다:
@Autowired public OrderServiceImpl(MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
Java
복사

9.3. @Primary 사용

@Primary는 우선순위를 정하는 방법입니다. @Autowired 시에 여러 빈이 매칭되면 @Primary가 우선권을 가집니다.
@Component @Primary public class RateDiscountPolicy implements DiscountPolicy {} @Component public class FixDiscountPolicy implements DiscountPolicy {}
Java
복사
이렇게 하면 RateDiscountPolicy가 우선적으로 주입됩니다:
@Autowired public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
Java
복사

9.4. 애노테이션 직접 만들기

@Qualifier("mainDiscountPolicy")처럼 문자를 적으면 컴파일 시 타입 체크가 안됩니다. 이를 해결하기 위해 애노테이션을 직접 만들 수 있습니다.
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Qualifier("mainDiscountPolicy") public @interface MainDiscountPolicy { }
Java
복사
이제 이 애노테이션을 사용할 수 있습니다:
@Component @MainDiscountPolicy public class RateDiscountPolicy implements DiscountPolicy {} @Autowired public OrderServiceImpl(MemberRepository memberRepository, @MainDiscountPolicy DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; }
Java
복사

10. 조회한 빈이 모두 필요할 때 - List, Map

의도적으로 해당 타입의 스프링 빈이 모두 필요한 경우, ListMap을 활용할 수 있습니다. 이 방법은 전략 패턴을 구현할 때 유용합니다.
public class DiscountService { private final Map<String, DiscountPolicy> policyMap; private final List<DiscountPolicy> policies; public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) { this.policyMap = policyMap; this.policies = policies; System.out.println("policyMap = " + policyMap); System.out.println("policies = " + policies); } public int discount(Member member, int price, String discountCode) { DiscountPolicy discountPolicy = policyMap.get(discountCode); return discountPolicy.discount(member, price); } }
Java
복사
Map<String, DiscountPolicy>: 맵의 키에 스프링 빈의 이름을 넣어주고, 그 값으로 DiscountPolicy 타입으로 조회한 모든 스프링 빈을 담아줍니다.
List<DiscountPolicy>: DiscountPolicy 타입으로 조회한 모든 스프링 빈을 담아줍니다.
이렇게 하면 클라이언트가 할인 종류를 선택할 수 있는 유연한 설계가 가능합니다.

11. 자동, 수동의 올바른 실무 운영 기준

11.1. 편리한 자동 기능을 기본으로 사용하자

스프링이 나오고 시간이 갈수록 점점 자동을 선호하는 추세입니다. 특히 최근 스프링 부트는 컴포넌트 스캔을 기본으로 사용하고, 다양한 스프링 빈들도 조건이 맞으면 자동으로 등록하도록 설계되었습니다.

11.2. 수동 빈 등록을 고려해야 하는 경우

1. 업무 로직 빈과 기술 지원 빈의 구분
업무 로직 빈: 웹을 지원하는 컨트롤러, 핵심 비즈니스 로직이 있는 서비스, 데이터 계층의 로직을 처리하는 리포지토리 등이 모두 업무 로직입니다. 이런 경우 자동 기능을 적극 사용하는 것이 좋습니다.
기술 지원 빈: 기술적인 문제나 공통 관심사(AOP)를 처리할 때 주로 사용됩니다. 이런 기술 지원 로직들은 가급적 수동 빈 등록을 사용해서 명확하게 드러내는 것이 좋습니다.
2. 다형성을 적극 활용하는 비즈니스 로직
위에서 살펴본 DiscountPolicy처럼 같은 타입의 여러 빈을 등록하는 경우, 수동으로 등록하면 한눈에 파악하기 쉽습니다.
@Configuration public class DiscountPolicyConfig { @Bean public DiscountPolicy rateDiscountPolicy() { return new RateDiscountPolicy(); } @Bean public DiscountPolicy fixDiscountPolicy() { return new FixDiscountPolicy(); } }
Java
복사
이렇게 하면 설정 정보만 봐도 빈의 이름과 어떤 빈들이 주입될지 쉽게 파악할 수 있습니다.

12. 결론

이번 포스팅에서는 스프링의 컴포넌트 스캔과 다양한 의존관계 자동 주입 방법을 살펴보았습니다. 스프링의 자동화 기능은 개발 생산성을 크게 향상시켜주지만, 적절한 상황에서 수동 빈 등록을 활용하는 균형 있는 접근이 중요합니다.
특히 생성자 주입을 중심으로 하고, 롬복의 @RequiredArgsConstructor를 함께 활용하면 코드를 간결하게 유지하면서도 불변성과 테스트 용이성을 확보할 수 있습니다. 또한 빈이 중복되는 상황에서는 @Qualifier@Primary를 적절히 활용하여 의존관계 주입을 명확하게 제어할 수 있습니다.
스프링의 컴포넌트 스캔과 자동 주입 기능을 잘 이해하고 활용하면, 보다 간결하고 유지보수하기 쉬운 스프링 애플리케이션을 개발할 수 있을 것입니다.
참고 소스코드
8a5346515c549c9c1175f6bf02a31fd2982ee96c
commit