이번 포스팅에서는 자바 코드로 작성된 예시의 문제점을 파악하고 Spring 프레임워크의 강력한 장점 중 하나인 의존성 주입(Dependency Injection, DI)을 활용하여 문제점을 해결하여 Spring이 이를 지원하는 이유에 대해서 알아보겠습니다.
Spring 프레임워크는 3가지 핵심 프로그래밍 모델을 지원하고 있는데, 그중 하나가 의존성 주입(Dependency Injection, DI)입니다. DI란 외부에서 두 객체 간의 관계를 결정해 주는 디자인 패턴으로, 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않도록 하고 런타임 시에 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮출 수 있게 해줍니다.
public class Store {
private Pencil pencil;
}
의존성이란 한 객체가 다른 객체를 사용할 때 의존성이 있다고 합니다. 예를 들어 다음과 같이 Store 객체가 Pencil 객체를 사용하고 있는 경우에 우리는 Store 객체가 Pencil 객체에 의존성이 있다고 표현합니다.
DI의 필요성을 설명하기 위해 상품을 주문할 수 있는 OrderService
인터페이스를 구현한 OrderServiceImpl
클래스와 상품을 할인하는 DiscountPolicy
인터페이스를 구현한 FixDiscountPolicy
클래스가 있다고 하겠습니다.
public class FixDiscountPolicy implements DiscountPolicy {
private int discountFixAmount = 1000; // 고정적으로 1000원 할인
@Override
public int discount() {
return discountFixAmount;
}
}
public class OrderServiceImpl implements OrderService {
private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
@Override
public Order createOrder(String itemName, int itemPrice) {
int discountPrice = discountPolicy.discount(itemPrice);
return new Order(itemName, itemPrice, discountPrice);
}
}
위와 같은 예시 클래스에서 OrderServiceImpl
클래스가 DiscountPolicy
인터페이스에 의존하여 객체지향적으로 코드가 잘 설계된 것처럼 보이지만 위 코드는 객체지향 5원칙(SOLID)의 OCP와 DIP를 준수하고 있지 않습니다.
OrderServiceImpl
클래스가 DiscountPolicy 인터페이스뿐만 아니라 구현 클래스인 FixDiscountPolicy 클래스도 함께 의존하고 있기 때문에 DIP를 위반하게 됩니다.OrderServiceImpl
클래스는 구현 클래스인 FixDiscountPolicy
클래스를 직접적으로 의존하고 있기 때문에, 만약 할인 정책이 고정 할인 정책 → FixDiscountPolicy
에서 정률 할인 정책 → RateDiscountPolicy
으로 변경된다면 클라이언트 코드인 OrderServiceImpl
클래스를 변경해야 하기 때문에 OCP를 위반하게 됩니다.public class OrderServiceImpl implements OrderService {
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private DiscountPolicy discountPolicy;
}
위 코드를 인터페이스에만 의존하게 위와 같이 코드를 변경하면 당연히 Null Pointer Exception이 발생하게 됩니다.
이 문제를 해결하려면 누군가가 클라이언트인 OrderServiceImpl
클래스에 DiscountPolicy
인터페이스의 구현 객체를 대신 생성하고 주입해 주어야 한다.
이 과정을 의존성 주입(Dependency Injection, DI)이라고 하고, 위 예시 코드를 통해 DI의 필요성에 대해 알게 되었으니 이를 활용하여 위 코드의 문제점을 해결해 보겠습니다.
애플리케이션의 전체 동작 방식을 구성(config) 하기 위해, 구현 객체를 생성하고, 연결하는 책임을 가지는 별도의 설정 클래스를 생성하겠습니다.
public class AppConfig {
public OrderService orderService() {
return new OrderServiceImpl(discountPolicy());
}
public DiscountPolicy discountPolicy() {
// return new RateDiscountPolicy();
return new FixDiscountPolicy();
}
}
AppConfig
는 애플리케이션의 실제 동작에 필요한 구현 객체를 생성합니다.
OrderServiceImpl
FixDiscountPolicy
AppConfig
는 생성한 객체 인스턴스의 참조(레퍼런스)를 생성자를 통해서 주입(연결) 해줍니다.
OrderServiceImpl
→ FixDiscountPolicy
public class OrderServiceImpl implements OrderService {
private final DiscountPolicy discountPolicy;
public OrderServiceImpl(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
@Override
public Order createOrder(String itemName, int itemPrice) {
int discountPrice = discountPolicy.discount(itemPrice);
return new Order(itemName, itemPrice, discountPrice);
}
}
위와 같은 변경으로 OrderServiceImpl
클래스는 DiscountPolicy
인터페이스만 의존하고 구현 클래스인 FixDiscountPolicy
클래스에는 의존하지 않게 됩니다.
OrderServiceImpl
클래스 입장에서 생성자를 통해 어떤 구현 객체가 주입될지는 알 수 없고 OrderServiceImpl
의 생성자를 통해서 어떤 구현 객체를 주입할지는 오직 외부 → AppConfig
에서 결정하게 됩니다.
그리고 이러한 개념은 제어의 역전(Inversion of Control, IoC
다시 한번 정리하자면, 할인 정책을 변경할 경우에 클라이언트 코드인 OrderServiceImpl
클래스는 변경하지 않고 변경할 수 있기 때문에 OCP를 준수하게 됩니다.
또한 직접 구현 클래스인 FixDiscountPolicy
클래스를 의존하는 대신에 인터페이스에만 의존하도록 코드를 수정하였기 때문에 DIP를 준수하게 됩니다.
지금까지는 순수한 자바 코드를 사용하여 DI를 구현하였습니다. 스프링에서 제공하는 DI를 사용하는 코드로 리팩토링을 해보겠습니다.
@Configuration
public class AppConfig {
@Bean
public OrderServiceImpl orderService() {
return new OrderServiceImpl(discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy() {
return new FixDiscountPolicy();
// return new RateDiscountPolicy();
}
}
AppConfig
에 설정을 구성한다는 뜻의 @Configuration
어노테이션을 붙여주고 각 메서드에 @Bean
어노테이션을 붙여줍니다. 이렇게 하면 스프링 컨테이너에 스프링 빈으로 등록됩니다.
public class OrderApp {
public static void main(String[] args) {
// 기존 자바코드 DI 방법
// AppConfig appConfig = new AppConfig();
// OrderService orderService = appConfig.orderService();
// 스프링 컨테이너 생성
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
Order order = orderService.createOrder("itemA", 20000);
System.out.println(order);
}
}
위 코드의 ApplicationContext를 스프링 컨테이너라 합니다. 기존에는 개발자가 AppConfig
를 사용해서 직접 객체를 생성하고 DI 했지만, 이제부터는 스프링 컨테이너를 통해서 사용합니다.
스프링 컨테이너는 @Configuration
어 로테이션이 붙은 AppConfig
를 설정(구성) 정보로 사용합니다. 여기서 @Bean
어 로테이션이 붙은 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록합니다. 이렇게 스프링 컨테이너에 등록된 객체를 스프링 빈이라 합니다.
이전에는 개발자가 필요한 객체를 AppConfig
를 사용해서 직접 조회했지만, 이제부터는 스프링 컨테이너를 통해서 필요한 스프링 빈(객체)를 찾아야 합니다.
스프링 빈은 applicationContext.getBean()
메서드를 사용해서 찾을 수 있습니다.
기존에는 개발자가 직접 자바 코드로 모든 것을 했다면 이제부터는 스프링 컨테이너에 객체를 스프링 빈으로 등록하고, 스프링 컨테이너에서 스프링 빈을 찾아서 사용하도록 변경되었습니다.
단순히 자바 코드를 실행하는 것 같고 코드가 약간 더 복잡해진 것 같은데, 위와 같이 스프링 컨테이너를 사용하면 어떤 장점이 있는지 다음 포스팅에서 알아보도록 하겠습니다.
IoC는 객체의 흐름, 생명주기 관리 등 독립적인 제3자에게 역할과 책임을 위임하는 개념, 즉 원칙 중 하나이고, DI는 IoC를 달성하는 디자인 패턴 중 하나로 좀 더 구체적인 행위라고 할 수 있습니다.