Spring 프레임워크의 핵심 기술 중 하나가 바로 DI(Dependency Injection, 의존성 주입)이다. Spring 프레임워크와 같은 DI 프레임워크를 이용하면 다양한 의존성 주입을 이용하는 방법이 있다. 그 중 Spring에서는 DI를 지원하기 위해 IOC를 사용한다.
IOC(Inversion of Control)이란?
개발자가 프로그램의 흐름을 제어하지 않고 프레임워크가 프로그램의 흐름을 주도하는 것을 말한다.
스프링에선 이 개념을 Spring DI 컨테이너를 이용해 빈 생명주기 관리, 의존성 주입 등을 도맡아 처리한다.
IOC 컨테이너라고도 하는데 IOC가 내포하는 범위가 너무 넓어 DI 컨테이너라고 부른다고 한다.
Autowired란?
필요한 의존 객체의 “타입"에 해당하는 빈을 찾아 주입한다.
- 생성자
- setter
- 필드
위의 3가지의 경우에 Autowired를 사용할 수 있다. 그리고 Autowired는 기본값이 true이기 때문에 의존성 주입을 할 대상을 찾지 못한다면 애플리케이션 구동에 실패한다. 그러면 Autowired를 사용할 때의 경우의 수가 존재하는데 각각의 상황에 대해서 정리해보자.
Spring DI 컨테이너가 DI를 제공하는 방법
DI 컨테이너가 DI를 제공하는 방법엔 @Autowired 어노테이션을 붙히는 방법, XML에 Bean을 명세하는 방법 2가지가 있다. XML 방식은 요즘 잘 사용하지 않기 때문에 @Autowired 방식만 설명한다.
Spring DI 컨테이너는 Constructor Injection(생성자 주입), Setter Injection(세터 주입), Field Injection(필드 주입) 3가지 방법으로 DI를 제공한다.
[ 1. 생성자 주입(Constructor Injection) ] 4.x 이상 Documentation에서 추천
* 4.3 이상일 경우 단일 생성자에 @Autowired 생략가능
생성자 주입(Constructor Injection)은 생성자를 통해 의존 관계를 주입하는 방법이다.
@Service
public class UserService {
private UserRepository userRepository;
private MemberService memberService;
@Autowired
public UserService(UserRepository userRepository, MemberService memberService) {
this.userRepository = userRepository;
this.memberService = memberService;
}
}
생성자 주입은 생성자의 호출 시점에 1회 호출 되는 것이 보장된다. 그렇기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있다. 또한 Spring 프레임워크에서는 생성자 주입을 적극 지원하고 있기 때문에, 생성자가 1개만 있을 경우에 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공하고 있다. 그렇기 때문에 위의 코드는 아래와 동일한 코드가 된다.
@Service
public class UserService {
private UserRepository userRepository;
private MemberService memberService;
public UserService(UserRepository userRepository, MemberService memberService) {
this.userRepository = userRepository;
this.memberService = memberService;
}
}
생성자 주입은 생성자에 의존성 주입을 받고자 하는 field를 나열하는 방법으로, 권고되는 방법의 하나이다.
장점
- 필수적으로 사용해야 하는 레퍼런스 없이는 인스턴스를 만들지 못하도록 강제함
- Spring 4.3 이상부터는 생성자가 하나인 경우 @Autowired를 사용하지 않아도 됨
- Circular Dependency / 순환 참조 의존성을 알아 차릴 수 있음
- 생성자에 점차 많은 의존성이 추가 될 경우 리팩토링 시점을 감지 할 수 있음
- 의존성 주입 대상 필드를 final로 불편 객체 선언할 수 있음
- 테스트 코드 작성시 생성자를 통해 의존성 주입이 용이함
단점
- 어쩔 수 없는 순환 참조는 생성자 주입으로 해결하기 어려움
- 이러한 경우에는 나머지 주입 방법 중에 하나를 사용
- 가급적이면 순환 참조가 발생하지 않도록 하는 것이 더 중요
[ 2. 수정자 주입(Setter 주입, Setter Injection) ] 3.x Documentation에서 추천
수정자 주입(Setter 주입, Setter Injection)은 필드 값을 변경하는 Setter를 통해서 의존 관계를 주입하는 방법이다. Setter 주입은 생성자 주입과 다르게 주입받는 객체가 변경될 가능성이 있는 경우에 사용한다. (하지만 실제로 변경이 필요한 경우는 극히 드물다.)
@Service
public class 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;
}
}
@Autowired로 주입할 대상이 없는 경우에는 오류가 발생한다.
위의 예제에서는 XXX 빈이 존재하지 않을 경우에 오류가 발생하는 것이다. 주입할 대상이 없어도 동작하도록 하려면 @Autowired(required = false)를 통해 설정할 수 있다.
스프링 초기에는 수정자 주입이 자주 사용되었는데, 그 이유는 바로 getX, setX 등 프로퍼티를 기반으로 하는 자바 기본 스펙 때문이였다. 하지만 시간이 지나면서 점차 수정자 주입이 아닌 다른 방식이 주목받게 되었다.
장점
- 의존성이 선택적으로 필요한 경우에 사용
- 생성자에 모든 의존성을 기술하면 과도하게 복잡해질 수 있는 것을 선택적으로 나눠 주입 할 수 있게 부담을 덜어줌
- 생성자 주입 방법과 Setter 주입 방법을 적절하게 상황에 맞게 분배하여 사용
단점
- 의존성 주입 대상 필드가 final 선언 불가
[ 3. 필드 주입(Field Injection) ] - 추천X
필드 주입(Field Injection)은 필드에 바로 의존 관계를 주입하는 방법이다. IntelliJ에서 필드 인젝션을 사용하면 Field injection is not recommended이라는 경고 문구가 발생한다.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private MemberService memberService;
}
필드 주입을 이용하면 코드가 간결해져서 과거에 상당히 많이 이용되었던 주입 방법이다. 하지만 필드 주입은 외부에서 접근이 불가능하다는 단점이 존재하는데, 테스트 코드의 중요성이 부각됨에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용되지 않게 되었다. 또한 필드 주입은 반드시 DI 프레임워크가 존재해야 하므로 반드시 사용을 지양해야 한다. 그렇기에 애플리케이션의 실제 코드와 무관한 테스트 코드나 설정을 위해 불가피한 경우에만 이용하도록 하자.
장점
- 가장 간단한 선언 방식
단점
- 의존 관계가 눈에 잘 보이지 않아 추상적이고, 이로 인해 의존성 관계가 과도하게 복잡해질 수 있음
- 반대로 Constructor injection과 Setter injection은 의존성을 명확하게 커뮤니케이션 함
- 이는 SRP / 단일 책임 원칙에 반하는 안티패턴
- DI Container와 강한 결합을 가져 외부 사용이 용이하지 않음
- 단위 테스트시 의존성 주입이 용이하지 않음
- 의존성 주입 대상 필드가 final 선언 불가
[ 4. 일반 메소드 주입(Method Injection) ]
일반 메소드를 통해 의존 관계를 주입하는 방법이다. 수정자 주입과 동일하며 마찬가지로 거의 사용할 필요가 없는 주입 방법이다. 수정자 주입을 사용하면 한 번에 여러 필드를 주입 받을 수 있도록 메소드를 작성할수도 있다.
생성자 주입을 사용해야 하는 이유
[ 생성자 주입을 사용해야 하는 이유 ]
최근에는 Spring을 포함한 DI 프레임워크의 대부분이 생성자 주입을 권장하고 있는데, 자세한 이유를 살펴보도록 하자.
- 객체의 불변성 확보
- 테스트 코드의 작성
- final 키워드 작성 및 Lombok과의 결합
- 스프링에 비침투적인 코드 작성
- 순환 참조 에러 방지
1. 객체의 불변성 확보
실제로 개발을 하다 보면 의존 관계의 변경이 필요한 상황은 거의 없다. 하지만 수정자 주입이나 일반 메소드 주입을 이용하면 불필요하게 수정의 가능성을 열어두어 유지보수성을 떨어뜨린다. 그러므로 생성자 주입을 통해 변경의 가능성을 배제하고 불변성을 보장하는 것이 좋다.
2. 테스트 코드의 작성
테스트가 특정 프레임워크에 의존하는 것은 침투적이므로 좋지 못하다. 그러므로 가능한 순수 자바로 테스트를 작성하는 것이 가장 좋은데, 생성자 주입이 아닌 다른 주입으로 작성된 코드는 순수한 자바 코드로 단위 테스트를 작성하는 것이 어렵다.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private MemberService memberService;
public void register(String name) {
userRepository.add(name);
}
}
예를 들어 위와 같은 필드 주입을 이용한 코드에 대해 순수 자바 테스트 코드를 작성하면 다음과 같이 작성할 수 있다.
public class UserServiceTest {
@Test
public void addTest() {
UserService userService = new UserService();
userService.register("MangKyu");
}
}
하지만 위의 테스트 코드는 Spring 위에서 동작하지 않으므로 의존 관계 주입이 되지 않을 것이고, userRepository가 null이 되어 add 호출 시 NPE가 발생할 것이다. 이를 해결하기 위해 Setter를 사용하면 변경가능성을 열어두게 되는 단점을 갖게 된다.
반대로 테스트 코드에서 @Autowired를 사용하기 위해 스프링을 사용하면 단위 테스트가 아닐 뿐만 아니라, 컴포넌트들을 등록하고 초기화하는 시간 때문에 테스트 비용이 증가하게 된다. 그렇다고 대안으로 리플렉션을 사용하면 깨지기 쉬운 테스트가 된다.
반면에 생성자 주입을 사용하면 컴파일 시점에 객체를 주입받아 테스트 코드를 작성할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다. 심지어 우리가 테스트를 위해 만든 Test객체를 생성자로 넣어 편리함을 얻을 수도 있다.
3. final 키워드 작성 및 Lombok과의 결합
생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며, 컴파일 시점에 누락된 의존성을 확인할 수 있다. 반면에 다른 주입 방법들은 객체의 생성(생성자 호출) 이후에 호출되므로 final 키워드를 사용할 수 없다.
또한 final 키워드를 붙이면 Lombok과 결합되어 코드를 간결하게 작성할 수 있다. Lombok에는 final 변수를 위한 생성자를 대신 생성해주는 @RequiredArgsConstructor이 존재한다. Spring과 같은 DI 프레임워크는 Lombok과 환상적인 궁합을 보여주는데, 위에서 작성했던 생성자 주입 코드를 Lombok과 결합시키면 다음과 같이 간편하게 작성할 수 있다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final MemberService memberService;
public void register(String name) {
userRepository.add(name);
}
}
이러한 코드가 가능한 이유는 앞서 설명하였듯 Spring에서는 생성자가 1개인 경우 @Autowired를 생략할 수 있도록 도와주고 있으며, 해당 생성자를 Lombok( @RequiredArgsConstructor) 으로 구현하였기 때문이다.
4. 스프링에 비침투적인 코드 작성
필드 주입을 사용하려면 @Autowired를 이용해야 하는데, 이것은 스프링이 제공하는 어노테이션이다. 그러므로 @Autowired를 사용하면 다음과 같이 UserService에 스프링 의존성이 침투하게 된다.
import org.springframework.beans.factory.annotation.Autowired;
// 스프링 의존성이 UserService에 import되어 코드로 박혀버림
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private MemberService memberService;
}
우리가 사용하는 프레임워크는 언제 바뀔지도 모를 뿐만 아니라, 사용자와 관련된 책임을 지는 UserService에 스프링 코드가 박혀버리는 것은 바람직하지 않다.
프레임워크는 비즈니스 로직을 작성하는 서비스 계층에서 알아야 할 대상이 아니다. 물론 이는 필요한 자바 파일을 임포트해야 하는 정적 언어인 자바의 한계이기도 하다. 그래도 가능하다면 스프링이 없이 코드가 작성되면 더욱 유연한 코드를 확보하게 된다. 프레임워크가 자주 바뀌는 것도 아니므로 비록 스프링 코드가 침투하는게 치명적인 문제는 아니긴하다. 하지만 그래도 더 좋은 방법(생성자 주입)이 있는데, 굳이 사용할 필요는 없다.
5. 순환 참조 에러 방지
생성자 주입을 사용하면 애플리케이션 구동 시점(객체의 생성 시점)에 순환 참조 에러를 예방할 수 있다. 예를 들어 다음과 같이 필드를 사용해 서로 호출하는 코드가 있다고 하자.
@Service
public class UserService {
@Autowired
private MemberService memberService;
@Override
public void register(String name) {
memberService.add(name);
}
}
@Service
public class MemberService {
@Autowired
private UserService userService;
public void add(String name){
userService.register(name);
}
}
UserSerivce가 이미 MemberService에 의존하고 있는데, MemberService 역시 UserService에 의존하고 있다.
위의 두 메소드는 서로를 계속 호출할 것이고, 메모리에 함수의 CallStack이 계속 쌓여 StackOverflow 에러가 발생하게 된다.
Caused by: java.lang.StackOverflowError: null
at com.mang.example.user.MemberService.add(MemberService.java:20) ~[main/:na]
at com.mang.example.user.UserService.register(UserService.java:14) ~[main/:na]
at com.mang.example.user.MemberService.add(MemberService.java:20) ~[main/:na]
at com.mang.example.user.UserService.register(UserService.java:14) ~[main/:na]
만약 이러한 문제를 발견하지 못하고 서버가 운영된다면?
해당 메소드의 호출 시에 StackOverflow 에러에 의해 서버가 죽게 될 것이다. 하지만 생성자 주입을 이용하면 이러한 순환 참조 문제를 방지할 수 있다.
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| memberService defined in file [C:\Users\Mang\IdeaProjects\build\classes\java\main\com\mang\example\user\MemberService.class]
↑ ↓
| userService defined in file [C:\Users\Mang\IdeaProjects\build\classes\java\main\com\mang\example\user\UserService.class]
└─────┘
애플리케이션 구동 시점(객체의 생성 시점)에 에러가 발생하기 때문이다. 그러한 이유는 Bean에 등록하기 위해 객체를 생성하는 과정에서 다음과 같이 순환 참조가 발생하기 때문이다.
new UserService(new MemberService(new UserService(new MemberService()...)))
@Autowired를 이용한 필드 주입에서 이러한 문제가 애플리케이션 구동 시점에 에러가 발생하지 않는 이유는 빈의 생성과 조립(@Autowired) 시점이 분리되어 있기 때문이다. 생성자 주입은 객체의 생성과 조립(의존관계 주입)이 동시에 실행되다 보니 위와 같은 에러를 사전에 잡을 수 있다.
하지만 @Autowired는 모든 객체의 생성이 완료된 후에 조립(의존관계 주입)이 처리된다. 그러다 보니 위와 같이 호출이 되고 나서야 순환 이슈를 확인할 수 있는 것이다.
참고로 스프링부트 2.6부터는 순환 참조가 기본적으로 허용되지 않도록 변경되었다. 필드 주입을 받아도 순환 참조가 발생한다면 애플리케이션 로딩 시점에 에러가 발생하므로, 이 내용은 스프링부트 2.6 이하의 버전을 사용하는 경우에 발생할 것이다.
생성자 주입 요약 및 정리
- 객체의 불변성을 확보할 수 있다.
- 테스트 코드의 작성이 용이해진다.
- final 키워드를 사용할 수 있고, Lombok과의 결합을 통해 코드를 간결하게 작성할 수 있다.
- 스프링에 침투적이지 않은 코드를 작성할 수 있다.
- 순환 참조 에러를 애플리케이션 구동(객체의 생성) 시점에 파악하여 방지할 수 있다.