스프링 프레임워크의 가장 중요한 개념인 DI(Dependency Injection)이란 직역해보자면 의존성 주입이고 토비의 스프링에선 의존관계 주입이라는 말로 해석하기도 합니다. 이는 객체 간의 의존성을 외부에서 주입하여 관리하겠다는 개념입니다.
만약에 주문 서비스 로직인 OrderService가 존재하고 DB에서 데이터를 가져오기 위해 OrderRepository가 필요하다고 가정해보겠습니다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService() {
this.orderRepository = new OrderRepository();
}
...
}
DI를 적용하기 이전에는 OrderRepository에서 사용하는 OrderService에서 OrderRepository를 생성하고 관리해야 합니다.
위의 방법은 OrderService와 OrderRepository와의 의존성이 높습니다. 의존성이 높으면 코드의 재사용성이 떨어지고 변경에 유연하지 못하며 테스트를 하기 어려워집니다.
그렇다면 스프링에서는 어떻게 의존관계를 주입시켜주고 있을까요? 스프링 프레임워크와 같은 DI 프레임워크를 이용하면 다양한 의존관계 주입을 이용하는 방법이 있는데 이에 대해 알아보겠습니다.
필드 주입(Field Injection)은 필드에 바로 의존관계를 주입하는 방법입니다.
@Service
public class OrderService {
@Autowired
private final OrderRepository orderRepository;
}
필드 주입을 이용하면 코드가 간결해져서 과거에 많이 사용했었습니다. 하지만 필드 주입은 외부에서 접근이 불가하다는 단점을 가지고 있습니다. 테스트 코드의 중요성이 커짐에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용하지 않게 되었습니다.
수정자 주입(Setter Injection)은 Spring 3.x 버전까지 가장 권장되던 방식이었습니다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Autowired
public void setOrderRepository(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
선택적인 의존성을 주입할 경우 유용합니다. 만약 필수적인 의존성을 줘야하는 곳에서 수정자 주입(Setter Injection)을 사용하면 null에 대한 검증 로직을 모든 필드에 추가해주어야 합니다. 만약 @Autowired로 주입할 대상이 없는 경우에는 오류가 발생합니다.
생성자 주입(Constructor Injection)은 생성자를 통해 의존관계를 주입하는 방법입니다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Autowired
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
생성자 주입은 생성자의 호출 시점에 1회 호출되는 것이 보장됩니다. 그렇게 때문에 주입받은 객체는 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있습니다. 그리고 Spring 프레임워크에서는 생성자 주입을 적극 지원하고 있기 때문에, 생성자가 1개만 있는 경우에는 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공하기도 합니다.
일반 메서드를 통해 의존 관계를 주입하는 방법입니다. 메서드 주입을 사용하면 한 번에 여러 필드를 주입 받을 수 있도록 메서드를 작성할 수도 있습니다.
단일 책임원칙은 클래스가 하나의 책임만을 가져야 한다고 말합니다. 여기서 책임
은 변경의 이유를 말합니다. 생성자 주입을 사용하지 않는다면 클래스가 필요로 하는 의존성이 명확하지지 않습니다. 따라서 리팩토링하기도 어렵고 유지보수하기도 어려운 코드가 됩니다.
실제 개발을 하게 되면 의존 관계의 변경이 필요한 상황은 거의 없습니다. 하지만 수정자 주입이나 메서드 주입을 사용하게 된다면 불필요하게 수정의 가능성을 열어두어 유지보수성을 떨어뜨립니다. 또한 의존성이 런타임에 언제든지 변경될 수 있어 클래스의 상태가 예측 불가능해질 수 있습니다.
그러므로 *생성자 주입을 이용해 객체 생성 시점에 모든 의존성을 제공하여 생성 시점에 초기화하여 변경의 가능성을 배제하고 불변성을 보장하는 것이 좋습니다.
테스트가 특정 프레임워크에 의존하는 것은 좋지 못합니다. 그러므로 가능한 순수 자바로 테스트하는 것이 가장 좋은데 생성자 주입이 아닌 다른 주입으로 작성된 코드는 순수한 자바코드로 단위 테스트를 작성하기 어렵습니다.
@Service
public class OrderService {
@Autowired
private final OrderRepository orderRepository;
public void addOrderItem(OrderItem orderItem) {
orderRepository.add(orderItem);
}
}
위와 같은 필드 주입을 이용했을때 순수 자바 테스트 코드를 작성하면 아래와 같을 것입니다.
public class OrderServiceTest {
@Test
public void addTest() {
OrderService orderService = new OrderService();
OrderItem orderItem = new OrderItem("사과");
orderService.addOrderItem(orderItem);
}
}
위의 테스트 코드는 Spring 위에서 동작하지 않으므로 의존 관계 주입이 되지 않을 것이고, orderRepository가 null이 되어 add 호출 시 Null Point Exception이 발생할 것입니다. 이를 해결하기 위해 Setter를 사용하면 변경가능성을 열어 두게 되는 단점을 가지게 됩니다.
어떻게든 테스트하기 위해 테스트 코드에서 @Autowired를 사용하기 위해 스프링을 사용하면 단위 테스트가 아닐 뿐만 아니라 컴포넌트들을 등록하고 초기화하는 시간 때문에 테스트 비용이 증가하게 됩니다.
또 이거에 대한 대안으로 자바 리플랙션을 사용하여 클래스의 정보를 런타임에 수정하게 되면 성능저하 및 안정성의 문제와 코드 복잡성이 늘어나게 될 것입니다.
따라서 생성자 주입을 사용하게 된다면 생성자로 의존성을 주입받기 때문에 DI Container에 의존하지 않고 사용할 수 있고 테스트에서도 용이함을 보입입니다.
또한 테스트를 위해 만든 Test 객체를 생성자로 넣어 편리함을 얻을 수도 있습니다.
생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며 컴파일 시점에 누락된 의존성을 확인할 수 있습니다. 반면에 다른 주입 방법들은 객체의 생성(생성자 호출) 이후에 호출되므로 final 키워드를 사용할 수 없습니다.
또한 final 키워드를 붙이면 Lombok을 활용하여 코드를 간결하게 작성할 수 있습니다. Lombok에는 final 키워드가 붙은 멤버 변수에 대한 생성자를 대신 생성해주는 @RequiredArgsConstructor가 있습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// 아래 부분은 Lombok을 통한 생략 가능
// @Autowired -> 생성자가 1개라면 생략 가능
// public OrderService(OrderRepository orderRepository) {
// this.orderRepository = orderRepository;
// }
}
이러한 코드가 가능한 이유는 앞서 설명했듯이 Spring은 생성자가 1개인 경우 @Autowired를 생략할 수 있도록 도와주고 있고 해당 생성자를 Lombok으로 구현했기 때문입니다.
생성자 주입을 사용함으로써 @Autowired 어노테이션이 필요 없게 됩니다. 이는 코드가 스프링 프레임워크에 특화되지 않고 더 순수한 자바 코드를 유지할 수 있도록 도와줍니다.
이는 다른 자바 기반 프레임워크로의 이전이나 다양한 환경에서의 사용을 용이하게 도와줍니다.
순환 참조는 두 개 이상의 클래스나 컴포넌트가 서로를 참조하는 상황을 말합니다. 특히 스프링 프레임 워크 같은 DI 컨테이너를 사용할 때 문제가 발생할 수 있습니다.
@Service
public class FoodOrderService {
@Autowired
private final DrinkOrderService drinkOrderService;
}
@Service
public class DrinkOrderService {
@Autowired
private final FoodOrderService foodOrderService;
}
위 코드에서는 FoodOrderService가 이미 DrinkOrderService를 의존하고 있는데, DrinkOrderService도 역시 FoodOrderService를 의존하는 것입니다. 위와 같은 경우 각 클래스의 인스턴스를 생성하면서 서로의 의존성을 주입해야 하기 때문에 순환 참조가 발생하고, 결국 애플리케이션이 시작되지 않고 오류를 발생시킵니다.
생성자 주입을 사용하면 스프링 컨테이너가 객체를 생성하는 과정에서 발생하여 순환 참조를 방지할 수 있습니다.
참고