HJW's IT Blog

컴포넌트 스캔과 의존성 자동 주입 본문

SPRING

컴포넌트 스캔과 의존성 자동 주입

kiki1875 2024. 12. 4. 11:30
 

컴포넌트 스캔 / Dependency Injection

컴포넌트 스캔과 의존관계 자동 주입

이전 포스팅 까지는, Spring Bean 등록시, @Bean 을 사용해 설정 정보에 직접 등록 해 주었다. 하지만, 프로젝트 규모가 커질 수록, 일일이 등록하기도 힘들고, 누락하는 문제도 발생한다. 이번 포스팅에선, 자동으로 Spring Bean 을 등록하는 방법에 대해 알아보자

우선, AutoAppConfig.java 파일을 생성.

package hello.core;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import static org.springframework.context.annotation.ComponentScan.*;
@Configuration
@ComponentScan(
 excludeFilters = @Filter(type = FilterType.ANNOTATION, classes =
Configuration.class))
public class AutoAppConfig {
 
}

 

@ComponentScan 을 사용하게 되면, @Component 어노테이션이 붙은 클래스를 스캔하여 스프링 빈으로 등록하게 된다.

이제, 다음과 같이 @Component 등록을 해주자.


@Component
public class MemoryMemberRepository implements MemberRepository {}
@Component
public class RateDiscountPolicy implements DiscountPolicy {}
@Component
public class MemberServiceImpl implements MemberService {}

 

 

근데 잘 생각해보자, MemberServiceImpl 은, AppConfig 를 통해 의존관계 주입을 해주었다.


 

// 이전 AppConfig

    @Bean
    public MemberService memberService(){
        System.out.println("Calling memberService");
        return new MemberServiceImpl(memberRepository());
    }

 

하지만, 방금 새로 만든 AutoAppConfig.java 는 그 어떠한 정보도 명시하고 있지 않다. 오로지 @Component 에 의해 자동으로 Spring Bean 으로 등록이 되는데, 그렇다면 어떻게 의존관계를 주입할 수 있을까?


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

 

생성자에 위와 같이 @Autowired 어노테이션을 붙여주자. 이렇게 되면 Spring 은 MemberRepository 타입에 맞는 클래스를 찾아와서 의존관계 주입을 자동으로 해준다.


@Component
public class OrderServiceImpl implements OrderService{

    private final MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;

    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

 

OrderServiceImpl 도 같은 방식으로 component 등록과 의존성 주입을 해준다.

그럼 이제 테스트를 통해 잘 등록되고 동작하는지 알아보자.


package hello.core.scan;

import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class AutoAppConfigTest {

    @Test
    void basicScan(){
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);

        MemberService memberService = ac.getBean(MemberService.class);

        Assertions.assertThat(memberService).isInstanceOf(MemberService.class);
    }
}

원리 알아보기

@ComponentScan 은 @Component 가 붙은 모든 클래스를 Bean 으로 등록한다. 이때, Bean 의 기본 이름은 클래스 명을 사용하되, 맨 앞글자만 소문자를 사용한다. 기존 AppConfig 와 마찬가지로 이름을 부여할 수도 있다.

 

@Autowired 가 지정되어 있으면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입한다. 기본 조회 전략은 타입이 같은 bean 을 찾아 주입하는 것이다.

 

 

ComponentScan 탐색 위치

모든 JAVA 클래스를 컴포넌트 스캔하게 될 경우, 시간이 오래 걸린다. 그래서 꼭 필요한 위치부터 탐색하도록 시작 위치를 지정할 수 있는데, 다음과 같이 선언해주면 된다.

@ComponentScan(basePackages = "hello.core")
  • 탐색할 패키지의 시작 위치를 지정하며, 해당 패키지부터 하위 패키지를 모두 탐색하게 된다.
  • basePackages = {"hello.core", "hello.service"} 처럼 여러 시작 위치를 지정할 수도 있다.

ComponentScan 은 Component 를 포함하는 모든 어노테이션을 Bean 으로 등록한다

  • Controller, Service, Repository, Configuration

필터

includeFilters : 컴포넌트 스켄 대상 추가 지정

excludeFilters : 컴포넌트 스켄 제외 대상 지정

예시를 통해 빠르게 알아보자.

Annotation 클래스로 다음과 같이 두가지를 만들었다.

package hello.core.scan.filter;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyExcludeComponent {
}


package hello.core.scan.filter;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyIncludeComponent {
}

 

위에서 생성한 어노테이션을 통해 bean 클래스도 생성해 주자


package hello.core.scan.filter;

@MyIncludeComponent
public class BeanA {
}


package hello.core.scan.filter;

@MyExcludeComponent
public class BeanB {
}
package hello.core.scan.filter;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

import static org.junit.jupiter.api.Assertions.*;

public class ComponentFilterAppConfigTest {

    @Test
    void filterScan(){
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ComponentFilterAppConfig.class);
        BeanA beanA = ac.getBean("beanA", BeanA.class);
        Assertions.assertThat(beanA).isNotNull();

        assertThrows(
            NoSuchBeanDefinitionException.class,
            () -> ac.getBean("beanB", BeanB.class));
    }

    @Configuration
    @ComponentScan(
        includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
        excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)
    )

    static class ComponentFilterAppConfig{

    }
}

 

 

위 테스트의 ComponentFilterAppConfig 에 include, exclude 필터를 통해 scan 할 대상을 지정해 주었다. exclude 에 BeanB의 annotation 인 MyExcludeComponent이 붙어있기에, 해당 요소는 ComponentScan 의 대상에서 제외되고, bean 조회시, 정상적으로 에러가 터지는 것을 확인할 수 있다.

 

 

FilterType 옵션

  • ANNOTATION : annotation 을 인식하여 동작
  • ASSIGNABLE_TYPE : 지정한 type 과 자식 type 을 인식하여 동작
  • ASPECTJ : AspectJ 패턴 사용
  • REGEX : 정규 표현식
  • CUSTOM : TypeFilter 이라는 인터페이스를 구현하여 처리

 

중복 등록 충돌

 

ComponentScan 에서 같은 Bean 이름이 등록된다면?

  • 자동 등록 vs 자동 등록의 경우 → ConflictingBeanDefinitionException 발생
  • 수동 등록 vs 자동 등록 → 수동 Bean 이 우선권을 가지게 된다

 

다양한 의존관계 주입 방법

의존관계 주입 4가지 방법

생성자 주입

이름 그래도 생성자를 통해 의존관계를 주입받는 방법으로, 생성자 호출 시점에 딱 1번 호출되는 것이 보장된다. 불변 / 필수 의존관계에 사용된다.

@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;
 }
}

생성자가 딱 1개있는 클래스의 경우, Autowired 를 생략해도 된다.

수정자 (Setter) 주입

Setter 메서드를 통해 의존관계를 주입하는 방법이다. 선택과 변경 가능성이 있는 의존관계에 사용된다.

 

@Component
public class OrderServiceImpl implements OrderService {
	 private MemberRepository memberRepository;
	 private DiscountPolicy discountPolicy;
	 @Autowired
	 public void setMemberRepository(MemberRepository memberRepository) {
		 this.memberRepository = memberRepository;
	 }
	 @Autowired
	 public void setDiscountPolicy(DiscountPolicy discountPolicy) {
		 this.discountPolicy = discountPolicy;
	 }
}

주입할 대상이 없어도 동작하게 하려면, @Autowired(required = false) 로 지정하자

 

필드 주입

필드에서 직접 주입하는 방식인데, 외부에서 변경이 불가능하다. 그렇게에 테스트 하기 힘들다는 단점이 있으며, DI 프레임 워크가 필수적이다. 왠만하면 사용하지 말자.

@Component
public class OrderServiceImpl implements OrderService {
	 @Autowired
	 private MemberRepository memberRepository;
	 @Autowired
	 private DiscountPolicy discountPolicy;
}

 

일반 메서드 주입

일반 메서드를 통해 주입 받는 방식으로, 여러 필드를 한번에 주입 받을 수 있다. 일반적으로 잘 사용되지 않는 방식이다.

@Component
public class OrderServiceImpl implements OrderService {
 private MemberRepository memberRepository;
 private DiscountPolicy discountPolicy;
 @Autowired
 public void init(MemberRepository memberRepository, DiscountPolicy
discountPolicy) {
 this.memberRepository = memberRepository;
 this.discountPolicy = discountPolicy;
 }

옵션 처리

주입할 Spring Bean 이 없어도 동작해야 할 때가 있다.

  • @Autowired(required=false) : 이 방식은 자동 주입할 대상이 없다면 setter 메서드 자체가 호출이 안된다.
  • @Nullable : 자동 주입할 대상이 없다면 null 이 입력된다
  • Optional<> : 자동 주입할 대상이 없다면 Optional.empty 가 입력된다.

생성자 주입을 선택하라

생성자 주입을 사용하게 되면 다음과 같은 이점이 있다.

불변성

  • 대부분의 의존관계 주입은 한번 일어나면 어플리케이션 종료시점까지 변할 이유가 없다. 오히려 변해서는 안된다
  • 수정자 주입을 사용하면 setXxx 메서드를 public 으로 열어두어야 한다 → 좋은 설계가 아니다
  • 객체가 생성될 때 딱 1변만 호출되면 된다

누락

  • 프레임워크 없이 순수한 JAVA 코드를 단위 테스트 하는 경우, 수정자 의존관계인 경우, 실행은 되나, NPE 가 발생한다. 하지만 생성자 주입을 사용한다면, 컴파일 오류가 발생하기에, 어떤 값을 주입해야 하는지 바로 알 수 있다.

Final 키워드

  • 생성자 주입을 사용하면 final 키워드를 사용하여, 혹시라도 값이 설정되지 않는 오류를 막아준다.
  • 생성자 주입이 아닌 다른 방법은 모두 생성자 이후 실행되므로 final 키워드 사용이 불가능 하다.

롬복과 최신 트렌드

막상 개발을 해보면 대부분이 다 불변이기에 final 키워트를 사용하게 된다. 하지만 생성자, 주입, 등등 여러 귀찮은 부분이 많은데, 좀 편리하게 사용할 수 있는 방법은 없을까?

Lombok 을 사용하자 → 여러 기능이 있으니 이후 포스팅에서 다루도록 하겠다.

@RequiredArgsConstructor 를 사용하면 final 이 붙은 필드를 모두 생성자를 자동으로 만들어준다.

최근에는 생성자를 딱 1개 두고, Autowired 를 생략하는 방법을 주로 사용한다.

조회 빈이 2개 이상일때

@Autowired 는 기본적으로 type 으로 조회한다. 같은 type 이 두가지가 있다면 어떻게 될까?


@Component
public class FixDiscountPolicy implements DiscountPolicy {}

@Component
public class RateDiscountPolicy implements DiscountPolicy {}

이렇게 두가지의 DiscountPolicy 가 있을때 자동 의존관계 주입을 사용하면, NoUniqueBeanDefinitionException 오류가 발생한다.

이 때, 하위 타입으로 지정할 수도 있지만, 유연성의 문제, DIP 의 위반 등의 문제가 있다. 그렇다면 어떻게 해결할까?

@Qualifier, @Primary

메서드 명 매치

조회 대상 빈이 2개 이상일때, 필드명 매칭시키는 방법이 있다.

  • @Autowired 는 우선 type matching 을 시도하는데, 여러 bean 이 존재한다면, 필드 이름, 파라미터 이름으로 추가 매칭한다

public class OrderServiceImpl implements OrderService{

    private final MemberRepository memberRepository;
    private final DiscountPolicy rateDiscountPolicy;

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);
        int discountPrice = rateDiscountPolicy.discount(member, itemPrice);
        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
  • 이렇게 변경하고 이전에 실패한 test 를 다시 돌려보면 통화한다.

@Qualifier 사용

@Qualifier 는 추가 구분자를 붙여주는 방법이다. Bean 등록시 @Qualifier 를 붙여주면 되는데, 다음과 같다


@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {}

@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {}
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
 this.memberRepository = memberRepository;
 this.discountPolicy = discountPolicy;
}

@Qualifier 로 주입할 때, matching 되는 qualifer 이름이 없다면, main 이 붙은 spring bean 을 추가로 찾는다.

 

@Primary 사용

@Primary 는 우선 순위를 정하는 방법으로, 만약 여러 bean 이 조회될 시, @Primary 가 우선순위를 가지게 된다.


@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}

@Component
public class FixDiscountPolicy implements DiscountPolicy {}

@Qualifier 는 @Primary 보다 우선순위가 높다.

어노테이션 직접 만들기

@Qualifier("mainDiscountPolicy) 와 같이 문자를 적을 시, 컴파일타임 체크가 안된다. 이 문제는 직접 어노테이션을 만들어 문제를 해결할 수 있다.

package hello.core.annotataion;
import org.springframework.beans.factory.annotation.Qualifier;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, 
ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}

@Component
@MainDiscountPolicy
public class RateDiscountPolicy implements DiscountPolicy {}

@Autowired
public OrderServiceImpl(MemberRepository memberRepository,
 @MainDiscountPolicy DiscountPolicy discountPolicy) {
 this.memberRepository = memberRepository;
 this.discountPolicy = discountPolicy;
}

조회한 빈이 모두 필요할 때

특정 타입의 스프링 빈이 전부 필요한 경우도 있는데, 예를 들어 할인 서비스를 제공하는데, 클라이언트가 할인의 종류를 선택할 수 있다고 가정해 보자. 그렇다면, rate 와 fix discount policy 모두 필요할 것이다.

    static class DiscountService{
        private final Map<String, DiscountPolicy> policyMap;
        private final List<DiscountPolicy> policies;

        @Autowired
        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);
        }

이 때, Spring 은 DiscountPolicy 의 구현체들을 수집하여 policyMap과 policies에 주입하는데, 동작 과정은 다음과 같다.

  • Spring 은 DiscountPolicy 타입의 빈을 모두 검색한다
  • Map 에 주입시, Bean 의 이름을 사용하여 주입한다 (앞글자 소문자)
  • List 주입시 Bean 객체를 사용한다