다음과 같은 상황을 가정해보자.
피자 셰프가 레시피를 봐야만 피자를 만들 수 있다. 요리사는 레시피가 변경되면 새로운 피자를 만들게 된다. 따라서, 요리사는 레시피에 의존하는 관계라 할 수 있다. 둘은 함께 바뀔 가능성이 있다 정도로 이해하자.
이를 간단히 코드로 살펴보자.
public class PizzaChef {
private PizzaRecipe pizzarecipe;
public PizzeRecipe() {
this.pizzarecipe = pizzarecipe;
}
...
}
PizzaChef
객체는 PizzaRecipe
객체에 의존하는 것을 알 수 있다.이러한 구조는 다음과 같은 문제가 존재한다.
두 클래스의 결합성이 높다
PizzaChef
클래스는 PizzaRecipe
클래스와 강하게 결합되어 있다는 문제점을 가지고 있다. 만약 PizzaChef
가 새로운 레시피인 CheezePizzaRecipe
클래스를 이용해야 한다면 PizzaChef
클래스의 생성자를 변경해야만 한다. 만약 이후 레시피가 계속해서 바뀐다면 매번 생성자를 바꿔줘야 하는 등, 유연성이 떨어지게 된다.
객체들 간의 관계가 아닌 클래스 간의 관계가 맺어진다
객체 지향 5원칙(SOLID) 중 "추상화(인터페이스)에 의존해야지, 구체화(구현 클래스)에 의존하면 안 된다"라는 DIP 원칙이 존재한다. 현재 PizzaChef
클래스는 PizzaRecipe
클래스와 의존 관계가 있다. 즉, PizzaChef
는 클래스에 의존하고 있다. 이는 객체 지향 5원칙을 위반하는 것으로 PizzaChef
클래스의 변경이 어려워지게 된다.
이러한 문제점을 해결할 수 있는 것이 바로 의존관계 주입(DI)이다.
DI란 의존관계를 외부에서 주입해주는 것을 말한다. 스프링에서는 이러한 DI를 담당하는 DI 컨테이너가 존재한다. 이 DI 컨테이너가 객체들 간의 의존 관계를 주입한다.
위의 문제점들을 DI를 활용하여 해결해보자. 일단, 다양한 피자 레시피를 추상화해야 하므로 PizzaRecipe
를 인터페이스로 만들고, CheezePizzeRecipe
같은 레시피는 구현체로 만들자.
public interface PizzaRecipe {
...
}
public class CheezePizzaRecipe implements PizzaRecipe {
...
}
그리고, 이에 대한 구현체는 외부에서 주입받도록 하자.
public class pizzaChef {
private PizzaRecipe pizzaRecipe;
public PizzaChef(PizzaRecipe pizzaRecipe) {
this.pizzaRecipe = pizzaRecipe;
}
}
이것이 OCP와 DIP를 지켰는지 확인해보자.
PizzaChef
클래스를 수정할 필요가 없기 때문이다. PizzaChef
는 어떤 레시피가 오는지 모르고 묵묵히 피자만 만들 뿐이다.PizzaChef
는 추상화가 아닌 PizzaRecipe
인터페이스에 의존하기 때문이다.위 설계가 어떻게 작동하는지 대충 감이 온다. 일단, 설정 정보와 구현을 완전히 분리했다. PizzaChef
는 어떤 레시피가 오는지 모르고 묵묵히 피자만 만들 뿐이고, 어떤 레시피를 줄지는 다른 것이 결정하기 때문이다.
이렇게, 의존 관계를 내부가 아닌 외부에서 주입하는 것을 의존관계 주입(DI)라 한다.
DI를 사용하면 다음과 같은 장점들이 존재한다.
결합도가 줄어든다
어떤 객체가 다른 객체에 의존한다는 것은, 그 의존 대상의 변화에 취약하다는 뜻이다. DI를 이용하면 주입받는 대상이 바뀔지 몰라도 해당 객체의 구현 자체를 수정할 일은 없어진다.
유연성이 높아진다
기존 PizzaChef 클래스는 피자 레시피를 바꾸는 것이 쉽지 않았다. 생성자 코드 자체를 변경해주어야 했지만, DI를 이용하면 생성자의 인수만 다른 피자 레시피로 바꿔주면 된다.
테스트하기 쉬워진다
DI를 이용한 객체는 자신이 의존하고 있는 인터페이스가 어떤 클래스로 구현되어 있는지 몰라도 된다. 따라서 테스트하기 더 쉬워진다.
가독성이 높아진다