개발자로 후회없는 삶 살기

spring PART.의존관계 자동 주입 본문

[백엔드]/[spring | 학습기록]

spring PART.의존관계 자동 주입

몽이장쥰 2023. 3. 31. 00:15

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

본론

- 다양한 의존관계 주입 방법

 

1. 생성자 주입

지금까지 한 방법입니다. 빈에 등록될 때 생성자를 호출하는데 그때 Autowired가 있으면 생성자의 인자의 타입에 해당하는 스프링 빈을 컨테이너에서 찾아서 딱 주입합니다. 

@Component
public class OrderServiceImpl implements OrderService{
    private final MemberRepository memberRepository;
//    private final DiscountPolicy discountPolicy = new FixDiscounPolicy();
    private final DiscountPolicy discountPolicy;
    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

-> 특징

1) 생성자 호출시점에서 딱 1번만 호출되는 것이 보장되고 그 다음부터는 변하지 않게 불변 의존관계에 사용합니다. 가급적이면 바꾸면 안돼!라고 할 때 생성자 주입을 씁니다.

2) 필수 의존관계에서 많이 사용합니다. 주문 서비스는 무조건 멤버레포가 있어야합니다. 생성자에 있으면 웬만하면 값을 무조건 채워 넣어야합니다.

3) 생성자가 하나면 Autowired 생략가능

4) 생성자 주입은 주문 서비스를 빈에 등록하기 위해 생성자를 호출해야해서 인자를 받게 되어서 빈 등록과 의존 주입이 동시에 일어납니다.

 

2. 세터(수정자) 주입

생성자 주입을 할때 멤버 레포를 필드로 만들고 단축키로 생성자 주입했었습니다. 이를 setMemberRepository로 하는 것입니다. 여기에 Autowired를 붙이면 됩니다. 이걸 하려면 final을 뗘야하고 이게 어떻게 동작하냐면 스프링 컨테이너가 주문 서비스를 스프링 빈에 등록합니다. 그리고 자동 주입할 때 Autowired가 있으니 주입이 됩니다.

★ 스프링이 빈을 생성하는 단계와 의존관계를 주입하는 단계가 나뉘어져있는데 빈을 한번에 전체를 다 생성하고 그 다음 주입을 해서 이런 순서로 진행이 됩니다.(생성자 주입은 예외)

private MemberRepository memberRepository;
//    private final DiscountPolicy discountPolicy = new FixDiscounPolicy();
private DiscountPolicy discountPolicy;

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

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

-> 특징

1) 선택적 의존관계에 씁니다. 생성자에는 무조건 있어야하는 거가 있을 때하고 얘는 주입이 반드시 일어나지 않아도 될 때 씁니다.

2) 변경 가능성이 있는 주입에 씁니다. 만약 배우를 바꾸고 싶으면 외부에서 강제로 세터를 호출하면되지만 이렇게 안할 것입니다.

 

3. 필드 주입

이름 그대로 필드에 값을 빡 넣어버리는 것입니다. 코드가 아주 심플하게 됩니다.

@Autowired private MemberRepository memberRepository;
@Autowired private DiscountPolicy discountPolicy;

-> 특징

간결하지만 외부에서 변경이 불가능해서 테스트하기 힘들다는 치명적인 단점입니다. 주문 서비스 impl을 메모리 멤버 레포를 jdbc 멤버 레포로 바꾸고 싶을 때 바꿀 수 있는 방법이 없습니다. 결국 세터를 열어야합니다. 필드 주입은 컨테이너가 관리하는 스프링 빈을 쓰는게 아닙니다. 그냥 쓰지 않아야 합니다.

 

 

4. 일반 아무 메서드 주입

아무 메서드에 대고 Autowired를 쓸 수 있습니다. 잘 사용하지 않습니다.

 

 

- 옵션처리

스프링 컨테이너에 주입할 스프링 빈이 없어도 동작해야할 때가 있습니다. 그니깐 등록되지 않은 빈을 주입하려고 할 때 어떻게 해야하나를 알아보고자합니다. 그런데 Autowired만 사용하면 required 옵션의 기본값이 true라서 자동 주입 대상이 컨테이너에 없으면 오류가 발생합니다. 자동 주입 대상을 옵션으로 처리하는 방법은 다음과 같습니다. > 패키지를 만들고 자동 주입 대상을 옵션으로 처리하는 테스트 코드를 작성해겠습니다.

 

1. Autowired하고 required = false 하는 것

static class TestBean {
    @Autowired(required = false)
    public void setNoBean1(Member noBean1){
        System.out.println("noBean1 = " + noBean1);
    }

자동 주입할 대상이 없으면 수정자 메서드 자체가 호출이 안됩니다. 지금 자동 주입을 해줄 빈이 TestBean인 것이고 그 빈을 컨테이너에 등록을 하고 자동주입을 Member 클래스를 하려고 하는데 Member는 스프링이 관리하는 빈이 아닌 상황입니다.

 

require true로 하면 Member가 스프링 빈이 관리하는 객체가 아니라서 예외가 터집니다. false로 하면 sout 문이 안 나오고 set 문 자체를 실행을 안해서 자동 주입을 아예 안해버립니다. 즉 스프링 빈이 관리하지 않는 객체를 주입하려고 하면 안해버립니다.

 

2. @Nullable

@Autowired
public void setNoBean2(@Nullable Member noBean2){
    System.out.println("noBean2 = " + noBean2);
}

 

자동 주입할 대상이 없으면 null 입력합니다. 호출은 된다. 대신 null이 들어갑니다.

 

3. Optional

@Autowired
public void setNoBean3(Optional<Member> noBean3){
    System.out.println("noBean3 = " + noBean3);
}

 

Optional은 값이 있을수고 없을수도 있다의 상태를 감싸서 가지고 있는 것이라서 빈이 없다면 empty가 입력됩니다. 대신 empty가 들어갑니다.

 

 

- 생성자 주입을 선택해라!

생성자 주입을 사용하면 좋은 점을 말해보겠습니다.

 

1. 불변

대부분의 의존관계 주입은 어플리케이션의 배역이 정해지면 어플리케이션이 종료할 때까지 의존관계 주입을 변경할 일이 없습니다. 아니 변하면 안된다! 목적 자체가 바꾸지 않으려고 하면 생성자 주입이 최고입니다.

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
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

수정자 주입으로 하면 누군가 변경할 수도 있고 변경하면 안되는 메서드를 열어두는 것은 좋은 설계 방법이 아닙니다. 생성자 주입은 객체를 생성할 때 딱 1번만 호출되므로 불변합니다.

 

 

2. 누락

프레임워크 없이 순수한 자바 코드를 단위 테스트 하는 경우가 진짜 많습니다. 예를들어서 주문 서비스 impl의 create oreder만 테스트를 하고 싶습니다. 주문 서비스 impl을 테스트하고 싶은 거니깐 멤버 서비스 때처럼 테스트할 클래스인 주문 서비스 impl을 생성하고 createOrder 메서드를 실행합니다.

 

// 수정자 주입시

class OrderServiceImplTest {
    @Test
    void createOrder() {
        OrderServiceImpl orderService = new OrderServiceImpl(); // 에러가 발생하지 않고 통과
        orderService.createOrder(1L, "itemA", 10000);
    }
}

// 생성자 주입시
@Test
void createOrder() {
	// 에러 발생하여 고쳐야함
    OrderServiceImpl orderService = new OrderServiceImpl(new MemoryMemberRepository(), new RateDiscountPolicy());
    orderService.createOrder(1L, "itemA", 10000);
}

실행해보면 createOrder가 멤버 레포와 할인 정책 객체가 필요한데 사용하지 않았다고 nullpoint 예외가 터집니다. 즉 위에서 말한 것처럼 나는 createOrder 메서드만 단위 테스트를 하고 싶은 상황인데 null을 만난 것입니다. 

> 근데 수정자 주입을 쓰면 null이니깐 이렇게 쓰면 안 된다고 말하지 않고 그냥 누락해버리고 null이 떴다고 말합니다. 반면 생성자 주입을 쓰면 필수로 필요한 것인데 안 넣었으니 이러면 null이 뜬다고 컴파일 오류를 띄어서 실행이 안되게 합니다. 이럴 때 "임의로 가짜 객체를 만들어서 넣으면 되겠구나~"(new 메모리 멤버)라고 생각할 수 있습니다.

> 이런식으로 순수한 자바 코드로 단위 테스트를 할 수 있고 생성자를 해야 빨리 오류 코드를 감지할 수 있습니다.

 

3. final 키워드를 넣을 수 있다.

# final 뺌
private MemberRepository memberRepository;
private DiscountPolicy discountPolicy;

@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {

}

# final 넣음
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;

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

값이 한 번들어가면 안 바뀐다는 의미로 생성자에서만 값을 셋팅할 수 있습니다. 다른 경우는 값을 바꿀 수 없습니다. final을 안 붙이면 생성자에 필드값 초기화하는 것을 누락해도 에러가 아닙니다.

> 이를 막을 수 있는 방법이 final을 넣는 것으로 final을 넣으면 필드 초기화가 되어야하는데 안 들어왔다고 컴파일러가 알려줍니다. ★ 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아줍니다. 오직 생성자 주입만 final 키워드를 쓸 수 있습니다.

 

 

- 롬복과 최신 트랜드

생성자 주입이 다 좋은데 코드가 길다 최근 롬복 라이브러리를 써보겠습니다. 막상 개발을 해보면 대부분이 다 불변이라서 생성자와 final을 사용하게 됩니다. 그런데 코드가 길다! 필드 주입처럼 좀 편리하게 사용하는 방법이 없을까? 하는 마음에 나왔습니다.

라이브러리를 추가하고 의존관계를 추가하고 플러그인을 깝니다.

 

얘의 대표적인 기능이 뭐냐면 아무 클래스를 만들고 name, age 필드를 만들고 @Getter @Setter를 하면 게터 세터를 코드에는 안보이지만 자동으로 만들어줍니다. 원래는 게터 세터를 직접 짜야했는데 이를 해줍니다. @ToString도 하면 toString 오버라이딩도 다 예쁘게 해줍니다. 실무에서 진짜 많이 쓰는 것입니다.

 

-> 롬복을 생성자 주입에 적용

롬복의 RequiredArgsConstructor 어노를 쓰면 생성자 코드를 고대로 만들어줍니다. 필드값인 final이 붙은 애들을 초기화하는 생성자를 만들어줍니다. @ToString하면 tostring을 만들어주는 것처럼 생성자를 만들어주는 것입니다. ctrl + f12하면 진짜로 만들어져 있습니다.

> 그러면 굉장히 편리하게 깔끔하게 생성자와 final을 해줍니다. 실무에서는 무조건 이렇게 씁니다. 의존관계를 추가하려고 할때 그냥 필드 추가만하면 바로 생성자에 적용이 됩니다. Autowired는 생성자가 하나만 있으면 생략해도 되니 룸북을 쓰면 기능은 다 제공하면서 코드는 깔끔하게 할 수 있습니다.

 

- 조회 빈이 2개 이상 문제

조회할 빈이 2개 이상인 문제를 보자 @Autowired는 타입으로 조회를 하여 주입을 합니다. 근데 주문 서비스 impl이 할인 정책을 주입받는데 fix와 rate가 둘 다 스프링 빈에 등록되어있으면 타입으로 조회할 때 같은 타입이라서 문제가 발생합니다.

 


fix와 rate에 둘 다 Component를 붙입니다. 이러면 문제가 생깁니다. 오류 메세지가 "DiscountPolicy가 found 2를 했다." 라고 나옵니다. 하위타입으로 컨테이너에 지정할 수도 있지만 그건 DIP를 위반하는 구현에 의존하는 것입니다. 이를 해결해보겠습니다.

 

1. Autowired의 필드명 매칭

오토는 처음 타입 매칭을 시도하고 이때 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭합니다.

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

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

 

멤버 서비스 impl의 생성자에 필드명이 discountPolicy였는데 이것을 rateDiscountPolicy로 바꾸면 같은 타입의 스프링 빈 중에 필드명 혹은 파라미터 명 매칭되는 빈 객체를 가져옵니다.

-> 정리

autowired는 부모와 자식 타입의 빈을 다 끌고 옵니다. 만약 타입 매칭이 하나면 그 하나의 스프링 객체만 가져옵니다. 근데 타입 매칭 결과가 여러개라면 필드명으로 가져옵니다. 즉 오토는 타입으로 먼저 빈 객체를 가져오고 그 다음 필드명, 생성자 파라미터 명으로 가져옵니다.

 

2. Qualifier끼리 매칭

추가 구분자를 붙여주는 방법으로 주입 시 추가적인 옵션을 제공하는 것이지 빈 이름을 변경하는 것은 아닙니다.

// 스프링 빈에 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;
}

fix와 rate 둘 다 component를 붙여 빈에 등록한 상황에서 rate에 @Qualifier를 붙이고 "이름을 부여합니다." fix는 또 다른 이름을 부여합니다. rate를 불러오려면 주문 서비스 impl의 생성자에서 주입하는 파라미터 명은 DiscountPolicy discountPolicy로 그대로 두고 앞에 @Qualifier("이름 부여한 것")을 붙입니다. 

만약 부여한 이름을 못 찾으면 어떻게 될까요? 스프링 빈을 추가로 찾습니다. 하지만 Qualifier로 지정한 이름을 찾아올 때만 사용하자 이게 명확합니다. Qualifier는 같은 타입의 여러 개 중 특정 빈을 가져올 때만 사용합니다.

 

3. primary 사용

이거를 자주 사용합니다. 우선순위를 지정하는데 오토를 할 때 여러빈이 매칭이 되면 primary가 붙은 애가 선택이 됩니다.

@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy{

rate가 무조건 먼저 선택되게 하려면 Quarifier 이런거 다 때고 원본에서 하나도 변경없이 rate 구현체에 @primary를 붙입니다. 같은 타입이 여러개가 감지되어도 그냥 @primary가 붙은 애가 주입이 됩니다.

 

-> primary와 Qualifier 사용하는 경우

메인 DB가 있고 보조 DB가 있습니다. 보조 DB는 어쩌다 한 번 가져옵니다. 이럴 때 모든 구현체에 Qualifier를 붙이면 그 구현체를 조회할 때마다 Qualifier를 써야합니다. 근데 또 메인에만 Primary를 적어놓으면 가끔 DB를 쓸 때 불편합니다.

> 이럴때 메인 구현체에 primary를 걸어서 거의 대부분 메인 DB가 불려지게 만들고 보조 DB를 사용할 때를 대비하여 Qualifier를 보조 DB 구현체에 붙여놓고 필요할 때만 사용합니다. 아무것도 안 붙이면 primary가 당연히 실행되겠지만 둘 다 있는 경우 보조 DB가 실행되는 것처럼 Qualifier가 우선순위가 됩니다.

 

- 어노테이션 직접 만들기

MainDiscountPolicy Qualifier를 더 깔끔하게 하는 방법이 있습니다.

 annotation 패키지를 만들고 예전처럼 MainDiscountPolicy 이름으로 어노테이션 인터페이스를 만듭니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Indexed
@Qualifier("MainDiscountPolicy")
public @interface MainDiscountPolicy {
}

 @Component에 있는 어노테이션을 붙이고 한 가지 더 Qualifier("MainDiscountPolicy")를 붙입니다.

 

> 이것을 적용해 보면 원래 Rate에 Qualifier("MainDiscountPolicy")를 붙였었는데 이제 아까 직접만든 어노테이션을 붙이면 똑같이 작용합니다. 

 

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

> rate를 가져다 쓰던 주문 서비스에서도 Qualifier를 썼었는데 이제 여기도 직접 만든 어노테이션으로 바꾸면 됩니다. 이런식으로 실무에서도 사용한다고 합니다.

 

- 자동, 수동 빈 등록 올바른 실무 운영 기준

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

어떤 경우에 컴포넌트 스캔과 자동 주입을 사용하고, 어떤 경우에 설정 정보를 통해서 수동으로 빈을 등록하고 의존관계도 수동으로 주입할지 고민이 됩니다. 

결론부터 얘기하면 자동으로 진화하고 있는 추세입니다. 최근 스프링 부트는 컴포넌트 스캔을 기본으로 사용하고 있습니다. 객체 만들고 new하고 의존관계 넣어주는 일을 @Component 하나로 끝낼 수 있습니다. 설정정보 관리하는 것 자체가 부담이 됩니다. 그리고 자동 빈 등록을 해도 OCP, DIP를 다 지킬 수 있습니다.

 

2. 그러면 수동 빈 등록은 언제할까?

어플리케이션은 크게 업무 로직과 기술 지원 로직으로 나눌 수 있습니다. 

-> 업무 로직 빈

웹을 지원하는 컨트롤러, 핵심 비즈니스 로직이 있는 서비스, 레포지토리등이 모두 업무 요구사항에 맞게 작동하는 업무로직입니다.

-> 기술 지원 빈

기술적인 문제나 공통 관심사(AOP)를 처리할 때 주로 사용되고 데이터베이스 연결이나, 공통 로그처리처럼 업무 로직을 지원하기 위한 하부 기술이나 공통 기술들입니다.

이렇게 2가지로 크게 나눌 수 있습니다. 업무 로직은 숫자가 매우 많고 한번 개발해야 하면 컨트롤러, 서비스, 레포처럼 어느 정도 유사한 패턴이 있습니다. 이런 경우 자동 기능을 적극 사용하는 것이 좋습니다. 문제가 발생해도 어떤 곳에서 문제가 발생했는지 명확하게 파악하기 쉽습니다.

기술 지원 로직은 업무 로직과 비교해서 수가 매우 적고, 어플 전반에 광범위하게 영향을 미칩니다. 기술지원 로직은 광범위하게 영향을 미치기에 문제가 어디서 발생한지 파악하기 어렵습니다. 기술 지원 로직은 수동 빈 등록을 통해 명확하게 밖으로 들어내는게 좋습니다.

AppCong는 루트에 두면 좋다고 했는데 어플리케이션 설정 정보를 한 눈에 보기 좋게 하면 좋기 때문입니다. 루트에 둔 것 자체가 한 눈에 보기 좋게 한 것인가 봅니다.

 

3. 비즈니스 로직 중에서 수동 빈 하는 것이 좋은 경우

 

비즈니스 로직 중에서 다형성을 적극 활용할 때입니다. DiscountPolicy 전용 Config를 만들고 수동 등록을 해보겠습니다. 할인 정책은 사용자의 선택에 따라 fix를 할 수도 rate를 할 수도 있는 다형성을 활용하는데 이렇게 수동으로 등록하면 설정 정보가 한 눈에 보입니다.

DiscountPolicy를 사용할 때 이 설정 정보 코드를 직접 눈으로 보고 파악할 것이라는 것입니다. 코드만 보고 DiscountPolicy에 어떤 객체가 주입될지 코드만 보고는 모릅니다. 만약 수동으로 등록하면 "아~ 얘들은 DiscountPolicy에 쓰이고 이렇게 2개가 등록이 되어있구나"하고 머릿속으로 바로 정리가 됩니다. 그게 안된다면 도메인에 맞게 패키지로 묶어놓기라도 해야합니다.

Comments