DIP & DI
개요
이번 글의 테마는 이렇다.
누구나 시간을 들이면 결국 이렇게 할 것이다. - It’s me!
설거지를 예로 들어보면 설거지를 할 때 많이 해보지 않은 사람이라면 그릇 하나 거품 묻히고 씻고 또 다른 그릇을 가져와 거품을 묻히고 씻을 것이다.
그러나 하다보면 결국 한 번에 다 거품을 묻혀놨다가 한 번에 모두 씻을 것이다. 누구나 이런 결론을 낸다.
DIP
DIP는 Dependency Inversion Principle의 약자로 의존 역전 원칙이다.
첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
무슨 뜻인지 살펴보자.
다음과 같은 코드가 있다.
public class Chef {
private Pizza pizza;
public void cook() {
pizza = new pizza();
pizza.cook();
}
}
피자만 단일 메뉴로 파는 레스토랑이다. 그러다 손님들의 요구로 Pasta를 개발해 팔기로 했다고 해보자.
위의 코드는 다음과 같이 수정될 것이다.
public class Chef {
private Pizza pizza;
private Pasta pasta;
public void cook() {
pasta = new Pasta();
pasta.cook();
}
}
근데 쉐프는 주문이 들어오면 주문에 따라 다른 음식을 만들어야하는 것 아닌가.
이런 경우 문제가 생긴다.
주문이 들어올 때마다 쉐프 클래스의 코드가 변경되어야하는 것이다. 이러면 곤란하다.
지금의 구조는 Chef가 음식 메뉴에 해당하는 클래스에 의존하고 있다.
(A가 B의 도움을 받아서 자신의 할 일을 하는 상황을 A는 B에 의존한다고 한다.)
어떻게 하면 메뉴가 추가될 때마다 코드를 바꾸지 않고 요리를 할 수 있을까?
가만히 보니 피자나 파스타나 현재 식당에서 판매중인 메뉴라는 공통점이 있다. 이들의 공통점을 뽑아내 추상화하는 방법은 어떨까?
public interface Menu {
void cook();
}
public class Pizza implements Menu {
@Override
void cook() {
System.out.println("피자를 요리합니다.");
};
}
public class Pasta implements Menu {
@Override
void cook() {
System.out.println("파스타를 요리합니다.");
};
}
그렇다면 이전의 코드는 다음과 같이 바뀔 것이다.
public class Chef {
private Menu menu;
public void cook() {
menu = new Pasta();
menu.cook();
}
}
이렇게 하면 new 키워드로 생성하는 인스턴스만 바꾸어주면 되기 때문에 이전 코드보다 수정의 범위가 줄어들었다.
그러나 여전히 문제가 생기는데 클래스 내부에서 직접 인스턴스를 생성하고 있기 때문이다.
메뉴가 추가되어도, 다른 주문이 들어와도 요리할 수 있게 하고 싶은 건데 이러면 여전히 Chef코드가 변경될 수 밖에 없다.
위의 코드를 우리가 의도한대로 바꾸려면 다음과 같이 바꿔볼 수 있다.
public class Chef {
private Menu menu;
public void cook(Menu menu) {
this.menu = menu;
menu.cook();
}
}
외부에서 만들어야하는 메뉴를 주입시켜주는 것이다.
이러면 메뉴가 추가되어도, 다른 주문이 들어와도 쉐프 클래스는 변경할 부분이 없다.
이러한 방식의 문제 해결 패턴을 디자인 패턴이라고 하고 그중에서도 DI 패턴을 적용한 것이다.
이렇게 의존관계에 있는 인스턴스를 자동으로 주입시켜주는 것이 바로 스프링 프레임워크다.
문제 해결 과정을 정리해보면 다음과 같다.
- 상위 모듈인 Chef가 하위 모듈인 구체적인 메뉴에 의존하는 경우 유연성이 떨어지는 문제가 생긴다.
- 하위 모듈인 메뉴를 추상화하여 상위 모듈인 Chef가 추상화된 인터페이스에 의존하도록 만든다(DIP).
- 아직 다른 주문이 들어올 경우 Chef 코드를 변경해줘야하는 문제가 남아있다.
- Chef클래스 내부에서 인스턴스를 생성하던 것을 외부에서 메뉴 인스턴스를 주입받아 사용하도록 변경한다.(DI)
지금은 쉐프 한 명이 새롭게 주문을 받아야하는 경우를 예로 들었지만 실제론 다음과 같이 사용하는 경우가 더 많을 것이다.
public class Chef {
private final Menu menu;
public Chef(Menu menu) {
this.menu = menu;
}
public void cook()) {
menu.cook();
}
}
DI도 여러 종류가 있는데 위와 같은 방식을 생성자 주입 방식이라고 부른다.
결국 DIP와 DI는 셋트라고 생각이 든다.
상위 모듈이 하위 모듈의 추상화에 의존하도록 하위 모듈을 추상화 해야했고(DIP) 이러한 상황에서 구현체가 변하더라도 상위 모듈의 코드가 변경될 일이 없게 하기 위해서 DI패턴을 적용했다.
ㄴ> 결합도 감소, 유연성 향상
결합도는 코드상에 구체적으로 명시되어있을수록 높아지고 모호하게 나와있을수록 낮아지는 경향이 있다.
이러한 설계가 의존성 역전 원칙이라고 불리는 이유는 상위 클래스 -> 하위 클래스로 향하는 의존성을 중간에 인터페이스를 두어 위에서 아래로 향하던 의존성을 다음과 같이 역전시켰기 때문이다.
사용 예로는 자바에서 데이터베이스를 사용하는 방법을 정의해둔 JDBC가 있다. 데이터베이스 벤더사는 여러가지가 있고 다른 데이터베이스로 변경해야한다면 기존 데이터베이스를 사용하는 방법과 다른 부분들을 모두 수정해주어야한다. 그러나 이들을 추상화하여 JDBC API로 정의했고 덕분에 데이터베이스를 변경해도 기존에 작성한 코드를 수정할 필요가 없어진다.
사실 보다 엄밀한 DIP를 적용시키려면 인터페이스 위치를 상위에 위치시켜야한다.
인터페이스를 하위 계층의 시점에서 정의할 경우 새로운 기능이 추가되거나 한다면 자신의 계층을 자신이 정의한다는 특성 때문에 하위 계층의 인터페이스가 변경된다면 상위 계층또한 영향을 받을 수 밖에 없다. 따라서 이는 확장에는 열려있어야하며 변경에는 닫혀있어야한다는 OCP에 어긋나는 설계가 된다.
자바 프로그램을 작성할 때 일반적으론 개발자가 main메소드에서 상위 인스턴스를 생성하고 여기에 구체적인 하위 인스턴스를 생성해서 주입시킨다.
그렇게 된다면 인스턴스를 변경해야할 때 main메소드에서 수정이 일어나게된다.
따라서 이러한 동작을 프레임워크에게 위임한다.
사용자가 프로그램의 흐름을 직접 작성하는 것이 아니라 프레임워크가 의존관계를 인지하고 의존성을 주입시켜줌으로써 좀 더 유연한 설계가 된다.
Spring은 클래스를 돌아다니며 의존관계를 확인하고 필요한 의존성을 런타임시점에 동적으로 생성해서 주입시켜준다.
이 것이 Spring의 핵심 기능이다.
그리고 테스트도 용이하다고 하는데 이건 무슨 이유일까?
이건 테스트시 외부에서 가짜 객체를 주입하여 테스트할 수 있기 때문이다.
슬라이스 테스트시 해당 계층이 의도한 대로 잘 작동하는지를 테스트해야할 것인데
만약 내부적으로 인스턴스를 생성하는 경우 의존 관계에 있는 인스턴스를 컨트롤 할 수 없게된다.
DI패턴을 이용하면 해당 계층 내부에서 사용하는 인스턴스를 주입시켜주기 때문에 외부 의존 인스턴스를 모킹(정해진 동작을 하도록 설계)하여 주입시켜줌으로써 내가 테스트하기를 원하는 계층만 테스트할 수 있다.