기술블로그

Spring 의 다양한 의존성 주입(Dependency Injection) 방법 본문

Spring/Spring 개념 및 핵심

Spring 의 다양한 의존성 주입(Dependency Injection) 방법

hc_Jo 2021. 9. 6. 21:50

 의존성 주입(Dependency Injection), 객체지향 프로그래밍을 하다보면 한번 쯤 듣게 되는 용어중 하나인데요.

이번 글에서는 의존성 주입이 무엇인지, Spring 프레임워크와 같은 DI 프레임워크를 이용하면 다양한 의존성 주입을 이용하는 방법이 있는데, 각각의 방법에 대해 알아보도록 하자.

의존성 주입(DI, Dependency Injection)

 의존성 주입(Dependency Injection)하나의 객체가 다른 객체의 의존성을 제공하는 기술이다. 예를 들어 "의존성"은 서비스로 사용할 수 있는 객체이다. 클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것이다.

 "주입"은 의존성(서비스)을 사용하려는 객체(클라이언트)로 전달하는 것을 의미한다. 서비스는 클라이언트 상태의 일부이다. 클라이언트가 서비스를 구축하거나 찾는 것을 허용하는 대신 클라이언트에게 서비스를 전달하는 것이 패턴의 기본 요건이다.

 

 쉽게 생각해보면 배터리가 들어가는 어떠한 기계가 있다고 생각해보자. 설계할때 어떠한 배터리를 넣을지 정하는 시점에서 일체형 배터리와 분리형 배터리에서 어떤 것이 나중에 배터리를 교체할 때 더 편리할 것인가? 교체한다는건 소프트웨어에서 말하는 유지보수다. 그리고 일체형 배터리에서 기계와 배터리와의 관계는 결합도가 높다. 나중을 생각하면 이 둘을 교체할 것을 대비했을 때 둘의 결합도를 약하게 하는 것이 맞다.

 

 의존성 주입은 IoC(Inversion of Control, 의존성 역전) 원칙과 SRP(Single Reponsibility Principle, 단일 책임 원칙)을 따르며 객체 간의 결합을 약하게 해주고 유지보수가 좋은 코드를 만들어 준다. 

 

다양한 의존성 주입 방법

1. 생성자 주입(Constructor Injection)

 생성자 주입은 이름 그대로 생성자를 통해서 의존 관계를 주입 받는 방법이다.

@Component
public class UserServiceImpl implements UserService { 

  private final UserRepository userRepository; 
  private final MemberService memberService; 
    
  @Autowired 
  public UserServiceImpl(UserRepository userRepository, MemberService memberService) { 
  	this.userRepository = userRepository; 
  	this.memberService = memberService; 
  } 
}

특징

  • 생성자 주입은 생성자의 호출 시점에 1회 호출 되는 것이 보장된다. 
  • 주입받은 객체가 변하지 않거나(불변), 반드시 객체의 주입이 필요한(필수) 경우에 강제하기 위해 사용할 수 있다. 

 Spring 프레임워크에서는 생성자 주입을 적극 권장하고 있기 때문에, 생성자가 1개만 있을 경우에 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공하고 있다. 그렇기 때문에 위의 코드는 아래의 코드와 동일하다.

@Component
public class UserServiceImpl implements UserService { 

  private final UserRepository userRepository; 
  private final MemberService memberService; 

  public UserServiceImpl(UserRepository userRepository, MemberService memberService) { 
  	this.userRepository = userRepository; 
  	this.memberService = memberService; 
  } 
}

 

2. 수정자 주입(Setter 주입, Setter Injection)

 수정자 주입은 Setter라 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 방법이다.

@Component 
public class UserServiceImpl implements UserService { 
  private UserRepository userRepository; 
  private MemberService memberService; 

  @Autowired 
  public void setUserRepository(UserRepository userRepository) { 
    this.userRepository = userRepository; 
  } 
  @Autowired 
  public void setMemberService(MemberService memberService) { 
    this.memberService = memberService; 
  } 
}

특징

  • Setter 주입은 주입받는 객체가 선택이 필요하거나 변경될 가능성이 있는 의존관계에 사용한다. 
  •  자바빈 프로퍼티 규약의 수정자 메서드 방식을 사용하는 방법이다.

@Autowired 의 기본 동작은 주입할 대상이 없으면 오류가 발생한다. 주입할 대상이 없어도 동작하게 하려면 @Autowired(required = false) 로 지정하면 된다.


3. 필드 주입(Field Injection)

 필드 주입은 이름 그대로 필드에 바로 주입하는 방법이다. IntelliJ에서 필드 인젝션을 사용하면 Field injection is not recommended이라는 경고 문구가 발생한다.

@Component
public class UserServiceImpl implements UserService {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private MemberService memberService;

}

특징

  • 코드가 간결하여 과거에 많이 사용했다.
  • 외부에서 변경이 불가능하다.
  • 스프링 설정을 목적으로 하는 @Configuration 같은 곳에서만 특별한 용도로 사용

 과거에는 많이 사용했으나, 점차 테스트 코드의 중요성이 부각됨에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용하지 않게 되었다. 또한 필드 주입은 DI 프레임워크가 존재해야 사용할 수 있어 사용을 지양한다.

 

4. 일반 메서드 주입

일반 메서드를 통해서 주입하는 방법이다.

@Component
public class UserServiceImpl implements UserService {

  private UserRepository userRepository;
  private MemberService memberService;
    
  public void init(UserRepository userRepository, MemberService memberService) { 
  	this.userRepository = userRepository; 
  	this.memberService = memberService; 
  } 
}

특징 :

  • 한번에 여러 필드를 주입 받을 수 있다.
  • 일반적으로 잘 사용하지 않는다.

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

@NoArgsConstructor
@Getter
@Seeter
public class Member {

  private Long id;
  private String name;
  private Grade grade;
    
}

 

생성자 주입을 사용해야 하는 이유

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

 

1. 객체의 불변성 확보

 대부분의 의존관계 주입은 한번 일어나면 애플리케이션 종료시점까지 의존관계를 변경할 일이 없다. 오히려 대부분의 의존관계는 애플리케이션 종료 전까지 변하면 안된다. (불변해야 함) 하지만 수정자 주입을 사용하면, setXxx 메서드를 public으로 열어두어야 한다.

 누군가 실수로 변경할 수 도 있고, 변경하면 안되는 메서드를 열어두는 것은 좋은 설계 방법이 아니다. 이는 OOP의 5가지 개발 원칙 중 OCP(Open-Closed Principal, 개방-폐쇄의 법칙)를 위반하게 된다. 그러므로 생성자 주입을 통해 변경의 가능성을 배제하고 불변성을 보장하는 것이 좋다.

2. 테스트 코드의 작성

 실제 코드가 필드 주입으로 작성된 경우에는 순수한 자바 코드로 단위 테스트를 작성하는 것이 불가능하다. 예를 들어 아래와 같은 실제 코드가 존재한다고 하자.

@Component
public class UserServiceImpl implements UserService {

    @Autowired private UserRepository userRepository;
    @Autowired private MemberService memberService;

    @Override
    public void register(String name) {
        userRepository.add(name);
    }
}

 위의 코드에 대한 순수 자바 테스트 코드를 작성하면 다음과 같이 작성할 수 있다.

public class UserServiceTest {

    @Test
    public void addTest() {
        UserService userService = new UserServiceImpl();
        userService.register("MangKyu");
    }

}

 위 코드를 보면 테스트 코드가 순수한 자바 코드이며, Spring과 같은 DI 프레임워크 위에서 동작하지 않으므로 의존 관계 주입이 되지 않을 것이고,  userRepository가 null이 되어 userRepository의 add 호출 시 NPE(Null Point Exception)가 발생할 것이다. 이를 해결하기 위해 Setter를 사용하면 OCP(Open-Closed Principal, 개방-폐쇄의 법칙)를 위반하게 된다.

 반면에 생성자 주입을 사용하면 컴파일 시점에 객체를 주입받아 테스트 코드를 작성할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다. 심지어 우리가 테스트를 위해 만든 Test객체를 생성자로 넣어 편리함을 얻을 수도 있다.

 

3. final 키워드 작성 및 Lombok과의 결합

 생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며, 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아준다. 다음 코드를 보자.

@Component
public class UserServiceImpl implements UserService { 

  private final UserRepository userRepository; 
  private final MemberService memberService; 
    
  @Autowired 
  public UserServiceImpl(UserRepository userRepository, MemberService memberService) { 
  	this.userRepository = userRepository; 
  } 
  // ...
}

 잘 보면 필수 필드인 memberService에 값을 설정해야 하는데, 이 부분이 누락되었다. 자바는 컴파일 시점에 다음 오류를 발생시킨다.  java: variable discountPolicy might not have been initialized

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

 

수정자 주입을 포함한 나머지 주입 방식은 모두 생성자 이후에 호출되므로, 필드에 final 키워드를 사용할 수 없다. 오직 생성자 주입 방식만 final 키워드를 사용할 수 있다.

 

또한 final 키워드를 붙임으로써 Lombok과 결합되어 코드를 간결하게 작성할 수 있다. Lombok에는 final 변수를 위한 생성자를 대신 생성해주는 @RequiredArgsConstructor 애노테이션이 있다. (공식 문서

Spring과 같은 DI 프레임워크는 Lombok과 환상적인 궁합을 보여주는데, 위에서 작성했던 생성자 주입 코드를 Lombok과 결합시키면 다음과 같이 간편하게 작성할 수 있다. 

@Component
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final MemberService memberService;

    @Override
    public void register(String name) {
        userRepository.add(name);
    }

}

 

 이러한 코드가 가능한 이유는 앞서 설명하였듯 Spring에서는 생성자가 1개인 경우 @Autowired를 생략할 수 있도록 도와주고 있으며, 해당 생성자를 Lombok으로 구현하였기 때문이다.

 

4. 순환 참조 에러 방지

애플리케이션 구동 시점(객체의 생성 시점)에 순환 참조 에러를 방지할 수 있다.

예를 들어 UserServiceImpl의 register 함수가 memberService의 add를 호출하고, memberServiceImpl의 add함수가 UserServiceImpl 의 register 함수를 호출한다면 어떻게 되겠는가?

 

@Component
public class UserServiceImpl implements UserService {

    @Autowired private MemberServiceImpl memberService;
    
    @Override
    public void register(String name) {
        memberService.add(name);
    }

}
@Component
public class MemberServiceImpl extends MemberService {

    @Autowired private UserServiceImpl userService;

    public void add(String name){
        userService.register(name);
    }

}

 위의 두 메소드는 서로를 계속 호출할 것이고, 메모리에 함수의 CallStack이 계속 쌓여 StackOverflow 에러가 발생하게 된다.

 만약 이러한 문제를 발견하지 못하고 서버가 운영된다면 어떻게 되겠는가? 해당 메소드의 호출 시에 StackOverflow 에러에 의해 서버가 죽게 될 것이다. 하지만 생성자 주입을 이용하면 이러한 순환 참조 문제를 방지할 수 있다.

 

 애플리케이션 구동 시점(객체의 생성 시점)에 에러가 발생하기 때문이다. 그러한 이유는 Bean에 등록하기 위해 객체를 생성하는 과정에서 다음과 같이 순환 참조가 발생하기 때문이다.

 

new UserServiceImpl(new MemberServiceImpl(new UserServiceImpl(new MemberServiceImpl()...)))

 

[ 요약 정리 ]

  • OCP 원칙을 지키며 객체의 불변성을 확보할 수 있다.
  • 테스트 코드의 작성이 용이해진다.
  • final 키워드를 사용할 수 있고, Lombok과의 결합을 통해 코드를 간결하게 작성할 수 있다.
  • 순환 참조 문제를 를 애플리케이션 구동(객체의 생성) 시점에 파악하여 방지할 수 있다.

이러한 이유들로 Spring 같은 DI 프레임워크를 사용하는 경우, 생성자 주입을 사용하는 것이 좋다.