섹션 7. 의존관계 자동 주입

Zion Yu·2021년 3월 31일
0
post-thumbnail

본 시리즈는 우아한형제들 개발 팀장이신 김영한님의 스프링 핵심 원리 - 기본편 강의를 들으며 개인적으로 정리한 내용을 담고 있습니다. 제가 들은 강의는 인프런에 등록되어 있습니다. 모든 다이어그램을 포함한 사진의 출처는 위 강의의 강의록임을 밝힙니다. 개인적으로 정리한 내용이기 때문에 글 내용에 오류가 있을 수 있으며 이에 대한 피드백은 댓글로 부탁드립니다.

이번 섹션에서 다룰 내용

  • 의존관계 자동 주입 방법, 옵션 처리
  • 최신 트렌드, 실무에서 어떻게 운영하는지

참고: 스프링 컨테이너의 생명 주기는 크게 두 단계이다.

  1. 스프링 빈 생성
  2. 의존관계 주입



다양한 의존관계 주입 방법

의존관계 주입은 크게 네 가지 방법이 있다.

  • 생성자 주입
  • 수정자 주입(setter 주입)
  • 필드 주입
  • 일반 메소드 주입

생성자 주입

  • 생성자를 통해 의존관계를 주입받는 방식
    • 생성자 위에 @Autowired를 붙인다.
  • 특징
    • 생성자 호출 시점에 딱 한 번만 호출되는 것이 보장된다.
    • 불변, 필수 의존관계에 사용
@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;
    }

}
  • 불변 - 생성자로 한 번 주입을 하고나면 그 뒤로는 수정 불가
  • 필수 - 생성자의 매개변수이므로 필수적으로 필요

중요 - 생성자가 하나만 있는 경우엔 @Autowired를 지정하지 않아도 자동 주입 된다. (스프링 빈에만)

수정자 주입

  • Setter를 통해 의존관계를 주입받는 방식
  • 특징
    • 선택, 변경 가능성이 있는 의존관계에 사용
@Component
public class OrderServiceImpl implements OrderService {
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

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

    @Autowired
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

참고: @Autowired는 주입할 대상이 없으면 오류가 발생시킨다. 주입할 대상이 없어도 동작하게 하려면 @Autowired(required=false)로 지정하면 된다.

참고: 자바빈 프로퍼티

자바에서는 과거부터 필드의 값을 직접 변경하지 않고, setXxx, getXxx 등의 메소드를 통해 값을 읽거나 수정하는 규칙을 만들었는데, 그것이 자바빈 프로퍼티 규약이다.

필드 주입

  • 필드에 의존관계를 바로 주입하는 방식이다.
  • 특징
    • 코드가 간결하지만 외부에서 변경이 불가능해서 (자바 코드만으로) 테스트하기 힘들다.
      • 테스트가 스프링에 의존적이게 된다.
    • DI 프레임워크가 없으면 아무것도 할 수 없다.
    • 웬만하면 사용하지 말자. 다음은 예외사항
      • 애플리케이션의 실제 코드와 관계 없는 테스트 코드
      • 스프링 설정을 목적으로 하는 @Configuration같은 곳에서만 특별한 용도로 사용
@Component
public class OrderServiceImpl implements OrderService {
    @Autowired private MemberRepository memberRepository;
    @Autowired private DiscountPolicy discountPolicy;
}

일반 메소드 주입

  • 일반 메소드를 통해 의존관계를 주입받는 방식이다.
  • 특징
    • 한 번에 여러 필드를 주입받을 수 있다. (생성자 주입과 유사)
    • 일반적으로 잘 사용하지 않는다.

참고: 의존관계 자동 주입은 스프링 컨테이너가 관리하는 스프링 빈이어야 동작한다. 스프링 빈이 아닌 Member 같은 클래스에서 @Autowired 코드를 적용해도 아무 기능도 동작하지 않는다.




옵션 처리

어떤 경우에는 주입할 스프링 빈이 없어도 동작해야하는 경우가 있다.

그러나 @Autowired만 사용하면 자동 주입 대상이 없을 때 오류가 발생한다.

이를 막기 위해 다음과 같은 방법이 있다.

  • @Autowired(required=true): 자동 주입할 대상이 없으면 수정자 메소드 자체가 호출되지 않음
  • org.springframework.lang.@Nullable: 자동 주입할 대상이 없으면 null이 입력된다.
  • Optional<>: 자동 주입할 대상이 없으면 Optional.empty가 입력된다. (Java8)

코드로 확인해보자

public class AutowiredTest {
    @Test
    void AutowiredOption() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);

    }
    static class TestBean {
        @Autowired(required = false)
        public void setNoBean1(Member noBean1) {
            System.out.println("noBean1 = " + noBean1);
        }
        @Autowired
        public void setNoBean2(@Nullable Member noBean2) {
            System.out.println("noBean2 = " + noBean2);
        }
        @Autowired
        public void setNoBean3(Optional<Member> noBean3) {
            System.out.println("noBean3 = " + noBean3);
        }
    }
}

콘솔 출력은 다음과 같다.

noBean2 = null
noBean3 = Optional.empty
  • Member는 스프링 빈이 아니므로 자동 주입이 안된다.
  • setNoBean1()@Autowired(required=false)이므로 호출 자체가 안된다.

참고: @Nullable이나 Optional은 스프링 전반에 걸쳐 지원되기 때문에 생성자 자동 주입에서도 특정 필드에만 적용할 수 있다.




생성자 주입을 선택해라!

과거에는 수정자 주입과 필드 주입을 많이 사용했지만 최근에는 스프링을 포함한 다른 DI 프레임워크도 생성자 주입을 권장한다. 그 이유는 다음과 같다.

불변

  • 대부분의 의존관계 주입은 한 번 일어나면 app 종료 시점까지 의존 관계를 변경할 일이 없다. 변경해서도 안되는 경우가 많다.
  • 수정자 주입을 사용하면 Setter 메소드를 public으로 열어둬야 하는데, 이는 개발자의 실수를 유발할 수 있기에 좋은 설계가 아니다.
  • 생성자 주입은 인스턴스 생성 시에 딱 한 번만 호출되므로 불변성을 가지도록 설계할 수 있다. 이는 큰 장점이다.

누락

  • 프레임워크 없이 순수한 Java 코드로 단위 테스트를 하는 경우를 생각해보자.
    • 의존관계 주입이 누락되는 경우 수정자 주입을 사용하면 NullPointerException이 발생한다.
    • 반면 생성자 주입을 사용하면 컴파일 오류가 발생한다. 이는 오류 원인 파악이 훨씬 쉽다는 것을 의미한다.

final 키워드

  • 생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있다.
  • 이는 생성자에서 값이 설정되지 않는 오류를 컴파일 단계에서 막아준다는 뜻이다.
  • 수정자 주입을 포함한 나머지 주입 방식은 모두 생성자 이후에 호출되므로, 필드에 final 키워드를 사용할 수 없다. 오직 생성자 주입 방식만 final을 사용할 수 있다.

기억하자! 컴파일 오류는 세상에서 가장 빠르고, 좋은 오류다!

정리

  • 생성자 주입 방식을 선택하는 이유는
    • 프레임워크에 의존하지 않기 위해서
    • 순수한 Java의 특징을 잘 살리기 위해서 이다!
  • 기본적으로 생성자 주입을 사용하고, 필수값이 아닌 경우에는 수정자 주입 방식을 옵션으로 부여하면 된다. 생성자 주입과 수정자 주입을 동시에 사용할 수 있다.
  • 항상 생성자 주입을 선택해라! 가끔 옵션이 필요하면 수정자 주입을 선택해라. 필드 주입은 사용하지 않는 게 좋다.
    • 필드 주입을 사용하면 app이 딱딱해지고 프레임워크 없이는 테스트도 불가하다.



롬복과 최신 트렌드

실제 개발을 해보면 대부분이 다 불변이고 생성자+final 키워드를 주로 사용하게 된다.

근데 매번 생성자 쓰기가 귀찮다보니 최적화하는 방법이 등장했다.

OrderSerivceImpl을 예시로 보자. 기존 코드는 다음과 같다.

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

생성자가 딱 하나만 있으면 @Autowired를 생략해도 스프링 컨테이너가 의존성 자동 주입을 해준다.

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

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

여기서 더 간편하게 작성하는 방법이 있는데, 바로 Lombok 라이브러리를 이용하는 것이다.

lombok 사용 환경 설정

  1. build.gradle에 lombok 관련 의존성을 추가한다.
//lombok 설정 추가 시작
configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}
//lombok 설정 추가 끝

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'

	//lombok 라이브러리 추가 시작
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
	//lombok 라이브러리 추가 끝

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
  1. lombok 플러그인을 설치한다.

  1. 옵션에서 Enable annotation processing을 체크해준다.

lombok이 잘 설치되면 라이브러리 목록에서 찾을 수 있다!

이제 lombok을 사용한 간소화된 코드를 보자.

@RequiredArgsConstructor
@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
	...
}
  • lombok 라이브러리가 제공하는 @RequiredArgsContructorfinal이 붙은 필드를 모아서 생성자를 자동으로 만들어준다.
  • 생성자가 하나뿐이니 @Autowired를 굳이 붙이지 않아도 스프링 컨테이너가 의존관계 자동 주입을 해준다!
  • 결론적으로 코드가 아주 간결해졌다.
    • 위 코드는 이전의 코드와 완전히 동일한 작동을 한다. out 폴더에 있는 .class 파일을 열어보면 생성자가 추가되어 있다.

정리

  • 최근에는 생성자를 딱 하나 두고 @Autowired를 생략하는 방법을 주로 사용한다.
  • 따라서 lombok 라이브러리 @RequiredArgsConstructor와 함께 사용하면 원하는 기능은 다 사용하면서(final, 생성자 주입으로 인한 불변 효과), 코드는 깔끔하게 사용할 수 있다.



조회 빈이 두 개 이상 - 문제

@Autowired는 타입을 기반으로 스프링 빈을 조회한다.

  • 즉, @Autowired private DiscountPolicy discountpolicyac.getBean(DiscountPolicy.class)와 유사하게 동작한다.
  • 그런데 스프링 빈 조회에서 학습했듯이 같은 타입의 빈이 두 개 이상일 경우 문제가 발생한다.
  • 예를 들어 DiscountPolicy의 경우 FixDiscountPolicyRateDiscountPolicy가 모두 스프링 빈으로 등록되어 있다고 하면, 다음과 같은 에러가 발생한다.
NoUniqueBeanDefinitionException: No qualifying bean of type 'hello.core.discount.DiscountPolicy' available: expected single matching bean but found 2: fixDiscountPolicy,rateDiscountPolicy
  • 오류 메시지를 요약하면 하나의 빈을 기대했는데, 두 개가 발견되었다는 것이다.

이 때 클라이언트 측에서 하위 타입(구현 클래스)으로 지정해서 스프링 빈을 조회할 수도 있지만, 이렇게 되면 DIP를 위배하게 되고 유연성이 떨어진다. (클라이언트가 구현 클래스에 의존하게 된다.) 그리고 완전히 동일한 타입이면서 이름만 다른 두 개의 빈이 있다면 이 방법은 먹히지 않는다.

스프링 빈을 수동 등록해서 문제를 해결할 수도 있지만, 의존 관계 자동 주입에서 해결하는 방법을 알아보자.

  • 참고: @Autowired가 주입할 스프링 빈을 선택할 때의 두 번째 기준 (링크)

@Autowired 필드명, @Quilifier, @Primary

해결 방법을 하나씩 알아보자.

조회 대상 빈이 두 개 이상일 때 해결 방법

  • @Autowired 필드명 매칭
  • @Quailifier - @Quilifier끼리 매칭 - 빈 이름 매칭
  • @Primary 사용

@Autowired 필드명 매칭

@Autowired는 타입으로 먼저 매칭을 시도하고, 여러 빈이 있으면 필드명으로 빈 이름을 추가매칭 한다. 따라서 필드명을 빈 이름으로 맞춰주면 정상적으로 주입된다.

@Autowired
private DiscountPolicy rateDiscountPolicy

필드명 매칭은 타입 매칭 이후에 동작하는 기능이다.

@Quailifier 사용

@Quailifier추가 구분자를 붙여주는 방법이다.

빈 등록 시 @Qualifier를 붙여준다.

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

주입 시에 @Qualifier를 붙여주고 등록한 이름을 적어준다.

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

@Qualifier로 주입할 때 "mainDiscountPolicy"를 못 찾으면 mainDiscountPolicy라는 이름의 스프링 빈을 찾는다. 그러나 @Qualifier 끼리 매칭시키는 게 좋다.

@Primary 사용

@Primary는 우선순위를 지정한다.

예를들어 rateDiscountPolicy가 우선권을 가지도록 하려면

@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}

@Component
public class FixDiscount

이렇게 하면된다.

@Primary가 간단하기 때문에 보통 @Qualifier보다 많이 사용한다.

@Primary, @Qualifier 활용

코드에서 자주 사용하는 메인 DB의 커넥션을 얻는 스프링 빈과 가끔 사용하는 서브 DB의 커넥션을 얻는 스프링 빈이 있다고 하면 메인 DB엔 @Primary를 적용하고 서브 DB를 쓸 땐 @Qualifier를 지정해서 명시적으로 획득하면 코드를 깔끔하게 쓸 수 있다.

우선순위

@Primary는 기본값처럼 동작하고 @Qualifier는 상세하게 동작한다. 스프링은 대개 넓은 범위의 선택권보다는 좁은 범위의 선택권이, 자동보다는 수동이 우선순위가 높기 때문에 @Qualifier가 우선순위가 높다.

애노테이션 직접 만들기

@Qualifier("mainDiscountPolicy")같은 식으로 string을 직접 적으면 compile time에 type check가 안된다. 이럴 땐 애노테이션을 직접 만들어보자.

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}

애노테이션을 만들었으니 이제 적용하면 된다.

@Component
@MainDiscountPolicy
public class RateDiscountPolicy implements DiscountPolicy {
}
@MainDiscountPolicy private final DiscountPolicy discountPolicy;

애노테이션에는 원래 상속이라는 개념이 없지만 스프링에서 지원해주기 때문에 이런 식으로 쓸 수 있는 것이다.




조회한 빈이 모두 필요할 때, LIst, Map

조회 가능한 빈이 여러 개이고, 모든 빈이 다 필요할 수도 있다.

예를 들어 할인 방식을 클라이언트(고객)이 결정할 수 있는 시스템이라면?

스프링을 이용하면 전략 패턴을 바로 구현할 수 있다.

public class AllBeanTest {
    @Test
    void findAllBean() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);
        DiscountService discountService = ac.getBean(DiscountService.class);
        Member member = new Member(1L, "userA", Grade.VIP);
        int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");

        assertThat(discountService).isInstanceOf(DiscountService.class);
        assertThat(discountPrice).isEqualTo(1000);

        int rateDiscountPolicy = discountService.discount(member, 20000, "rateDiscountPolicy");
        assertThat(rateDiscountPolicy).isEqualTo(2000);

    }

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

        public int discount(Member member, int price, String discountCode) {
            DiscountPolicy discountPolicy = policyMap.get(discountCode);
            return discountPolicy.discount(member, price);
        }
    }
}
  • DiscountService는 모든 DiscountPolicy를 주입받는다. 이 때 fixDiscountPolicy, rateDiscountPolicy가 주입된다.
  • discount() 메소드는 discountCode에 따라 어떤 할인 정책을 사용할지 결정하고, 계산한 값을 반환한다.
  • Map<String, DiscountPolicy>: map의 키에는 빈 이름, 값에는 빈 객체를 담아준다. 모든 스프링 빈을 담아준다.
  • List<DiscountPolicy>: 모든 스프링 빈을 담아준다.
  • 만약 해당하는 스프링 빈이 없으면, 빈 리스트, 빈 map을 주입한다.



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

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

  • 수동 설정 파일 (Config)을 만지는 일 자체가 너무 번거롭다.
  • 설정 정보를 개발자가 직접 관리하는 건 큰 부담이 된다.
  • 자동 빈 등록을 사용해도 OCP, DIP를 지킬 수 있다.

수동 빈 등록은 언제 쓰면 좋을까?

app은 크게 업무 로직기술 지원 로직으로 나눌 수 있다.

  • 업무 로직 빈: 웹을 지원하는 컨트롤러, 비즈니스 로직이 담긴 서비스, 데이터 계층의 로직을 처리하는 리포지토리가 모두 업무 로직이다. 보통 비즈니스 요구사항을 개발할 때 추가하거나 변경된다.

  • 기술 지원 빈: 기술적인 문제나 공통 관심사(AOP)를 처리할 때 주로 사용된다. DB 연결이나, 공통 로그 처리처럼 업무 로직을 지원하기 위한 하부 기술이나 공통 기술이다.

  • 업무 로직은 숫자도 많고 개발할 게 많으므로 자동 빈 등록을 사용하는 게 좋다.

  • 기술 지원 로직은 수가 적은 편이며 app 전반에 영향을 미치는 편이다. 그리고 문제 발생 시 원인 파악이 어려우므로 가급적 수동 빈 등록을 사용하는 게 좋다.

app에 광범위하게 영향을 미치는 기술 지원 객체는 수동 빈으로 등록해서 설정 정보에 딱 나타나게 하는 게 유지보수하기 좋다.

또는 비즈니스 로직에 다형성을 적극 활용하는 경우, 의존 관계 파악을 위해서 수동 설정 정보를 작성하는 게 도움이 되기도 한다. 자동 의존 주입을 사용하게 되면 여러 소스 코드를 봐야하기 때문에 불편하다. 만약 자동으로 쓰고 싶으면 구현체들을 같은 패키지에 모아놓는 게 좋다.

물론 기술 지원 로직이라도 스프링이나 스프링 부트가 직접 자동 등록하는 애들은 그냥 쓰는 게 낫다. 내가 직접 기술 지원 객체를 스프링 빈으로 등록한다면 수동으로 빈 등록을 해서 명확하게 드러내는 게 좋다.

정리

  • 편리한 지동 등록 기능을 잘 활용하자
  • 직접 등록하는 기술 지원 객체는 수동 등록
  • 다형성을 적극 활용하는 비즈니스 로직은 수동 등록을 고려해보자.



사용한 단축키

  • Ctrl+Shift+/: 영역 주석

0개의 댓글