DI (의존성 주입) - 제어의 역전
의존성 주입(Dependency Injection)이란?
외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴으로, 인터페이스를 사이에 둬 클래스 레벨에서는 의존 관계가 고정되지 않도록 하고 런타임 시에 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮출 수 있게 해준다.
다음과 같이 Store 객체가 Pencil 객체를 사용하고 있는 경우에 우리는 Store 객체가 Pencil 객체에 의존성이 있다고 표현한다.
1
2
3
4
5
6
7
8
public class Store {
private Pencil pencil;
public Store() {
this.pencil = new Pencil();
}
}
위와 같은 구조는 다음과 같은 특정 문제점을 가지고 있다.
1. 두 Class가 강하게 결합되어 있다.
두 클래스가 강하게 결합되어 있어서 만약 Store에서 Pencil이 아닌 Food와 같은 다른 상품을 판매하고자 한다면 Store 클래스의 생성자에 변경이 필요하다. 즉, 유연성
이 떨어진다.
단순히 상속으로 해결 할 수도 있겠지만, 상속은 상속대로 제약이 많고 확장성이 떨어지는 면이 있어 지양하는 것이 좋다.
2. 객체들 간의 관계가 아니라 클래스 간의 관계가 맺어진다.
위 소스를 보자면 Store와 Pencil은 객체들 간의 관계가 아니라 Class들 간의 관계가 맺어져 있다.
올바른 객체지향적 설계라면 객체들 간에 관계가 맺어져야 한다. 객체들 간에 관계가 맺어졌다면 다른 객체의 구체 클래스(Pencil인지 Food인지)를 전혀 알지 못하더라도 해당 클래스가 인터페이스를 구현했다면 인터페이스의 타입(Product)로 사용할 수 있다.
의존성 주입(Dependency Injection)을 통한 문제 해결
위와 같은 문제를 해결하기 위해서는 우선 다형성이 필요하다. 쉽게말해 Pencil / Food 등 여러 가지 제품을 하나로 표현하기 위해서는 Product라는 대표격인 interface가 필요하다. 그리고 Pencil에서 Product 인터페이스를 구현한다.
이제 Store는 외부에서 상품객체를 주입(Injection)
받아 구체 클래스에 직접적으로 의존하지 않게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface Product {
}
public class Pencil implements Product {
}
public class Store {
private Product product;
public Store(Product product) {
this.product = product;
}
}
이러한 이유로 우리는 Spring이라는 DI container를 필요로 한다. Store에서 Product 객체를 주입하기 위해서는 Application 실행 시점에 필요한 객체(Bean)를 생성해야 하며, 의존성이 있는 두 객체를 연결하기 위해 한 객체를 다른 객체로 주입시켜야 한다.
1
2
3
4
5
6
7
8
9
10
11
public class BeanFactory {
public void store() {
// Bean의 생성
Product pencil = new Pencil();
// 의존성 주입
Store store = new Store(pencil);
}
}
이러한 부분은 스프링 프레임워크가 완벽하게 지원을 해준다. Spring은 특정 위치부터 클래스를 탐색하고, 객체를 만들며 객체들의 관계까지 설정해준다. 이러한 이유로 Spring은 DI Container라고도 불린다.
이러한 개념은 제어의 역전(Inversion of Control) - IoC라고 불리기도 한다. 어떠한 객체를 사용할지에 대한 책임은 프레임워크에게 넘어갔고, 자신은 수동적으로 주입받는 객체를 사용 할 뿐이기 때문이다.
다양한 의존성 주입 방법
1. 생성자 주입(Constructor Injection)
생성자 주입은 생성자의 호출 시점에 1회 호출 되는 것이 보장된다. 그렇기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class UserService {
private UserRepository userRepository;
private MemberService memberService;
@Autowired
public UserService(UserRepository userRepository, MemberService memberService) {
this.userRepository = userRepository;
this.memberService = memberService;
}
}
2. 수정자 주입(Setter 주입, Setter Injection)
수정자 주입(Setter 주입, Setter Injection)은 필드 값을 변경하는 Setter를 통해서 의존 관계를 주입하는 방법이다. Setter 주입은 생성자 주입과 다르게 주입받는 객체가 변경될 가능성이 있는 경우에 사용한다. (실제로 변경이 필요한 경우는 극히 드물다.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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;
}
}
생성자 주입을 사용해야 하는 이유
다음과 같은 이유로 Spring 에서는 생성자 주입 방식을 권장하고 있다.
객체의 불변셩 확보
테스트 코드의 작성
final 키워드 작성및 Lombok과의 결합
Spring에 비침투적인 코드 작성
순환 참조 에러 방지
1. 객체의 불변성 확보
실제로 개발을 하다 보면 의존 관계의 변경이 필요한 상황은 거의 없다. 수정자 주입 방식으로 개발을 하게 되면 불필요하게 수정 가능성을 열어두어 유지보수성을 떨어뜨리게 된다. 그러므로 불변성을 보장하는 것이 좋다.
2. 테스트 코드의 작성
테스트가 특정 프레임워크에 의존하는 것은 침투적이므로 좋지 못하다. 그러므로 가능한 순수 자바로 테스트를 작성하는 것이 가장 좋은데, 생성자 주입이 아닌 다른 주입으로 작성된 코드는 순수한 자바 코드로 단위 테스트를 작성하는 것이 어렵다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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 호출 시 NullPointException 이 발생할 것이다. 이를 해결하기 위해 Setter를 사용하면 변경가능성을 열어두게 되는 단점을 갖게 된다.
반대로 테스트 코드에서 @Autowired
를 사용하기 위해 스프링을 사용하면 단위 테스트가 아닐 뿐만 아니라, 컴포넌트들을 등록하고 초기화하는 시간 때문에 테스트 비용이 증가하게 된다. 그렇다고 대안으로 리플렉션을 사용하면 깨지기 쉬운 테스트가 된다.
반면에 생성자 주입을 사용하면 컴파일 시점에 객체를 주입받아 테스트 코드를 작성할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다. 심지어 우리가 테스트를 위해 만든 Test객체를 생성자로 넣어 편리함을 얻을 수도 있다.
3. final 키워드 작성 및 Lombok과의 결합
생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며, 컴파일 시점에 누락된 의존성을 확인할 수 있다. 반면에 다른 주입 방법들은 객체의 생성(생성자 호출) 이후에 호출되므로 final 키워드를 사용할 수 없다.
또한 final 키워드를 붙이면 Lombok과 결합되어 @RequiredArgsConstructor 를 통해 코드를 간결하게 작성할 수 있다.
1
2
3
4
5
6
7
8
9
10
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final MemberService memberService;
public void register(String name) {
userRepository.add(name);
}
}
4. 스프링에 비침투적인 코드 작성
필드 주입을 사용하려면 @Autowired를 이용해야 하는데, 이것은 스프링이 제공하는 어노테이션이다. 그러므로 @Autowired를 사용하면 다음과 같이 UserService에 스프링 의존성이 침투하게 된다.
우리가 사용하는 프레임워크는 언제 바뀔지도 모를 뿐만 아니라, 사용자와 관련된 책임을 지는 UserService에 스프링 코드가 박혀버리는 것은 바람직하지 않다. 프레임워크는 비즈니스 로직을 작성하는 서비스 계층에서 알아야 할 대상이 아니다.
1
2
3
4
5
6
7
8
9
10
11
import org.springframework.beans.factory.annotation.Autowired;
// 스프링 의존성이 UserService에 import되어 코드로 박혀버림
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private MemberService memberService;
}
5. 순환 참조 에러 방지
생성자 주입을 사용하면 애플리케이션 구동 시점(객체의 생성 시점)에 순환 참조 에러를 예방할 수 있다.
아래의 코드는 UserSerivce가 이미 MemberService에 의존하고 있는데, MemberService 역시 UserService에 의존하는 코드이다.
위의 두 메소드는 서로를 계속 호출할 것이고, 메모리에 함수의 CallStack이 계속 쌓여 StackOverflow 에러가 발생하게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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);
}
}