IoC (제어의 역전)
정의
제어 흐름이 반전되는 디자인 원칙으로, 시스템의 제어 흐름을 외부 컨테이너나 프레임워크로 전환하는 것을 말합니다.
이것은 라이브러리와 프레임워크의 큰 차이점이기도 합니다.
라이브러리는 개발자가 언제 사용할지 결정하는 제어권을 갖지만 프레임워크는 내가 작성한 코드를 제어하고, 대신 실행해주므로 제어권을 프레임워크가 갖게 됩니다.
쉽게 말해, 제어권이 누구한테 있냐의 문제라고 보시면 됩니다.
IoC가 필요한 이유
class PotatoPizza {
public void make() {
System.out.println("포테이토 피자를 만듭니다!");
}
}
class PizzaShop {
private PotatoPizza potatoPizza;
public PizzaShop() {
potatoPizza = new PotatoPizza();
}
}
PizzaShop과 같이 한 클래스 내에서 다른 클래스들을 사용하는 클래스를 상위 모듈이라고 하고, 사용되는 클래스인 PotatoPizza는 하위 모듈이라고 불리게 됩니다. 그리고 PizzaShop 모듈은 PotatoPizza 모듈에 의존하게 됩니다. 즉, PizzaShop을 만들면 PotatoPizza가 만들어지게 됩니다.
하지만, PizzaShop에서는 PotatoPizza만 만들지 않습니다. ShrimpPizza도 추가해보겠습니다.
class PotatoPizza {
public void make() {
System.out.println("포테이토 피자를 만듭니다!");
}
}
class ShrimpPizza {
// 포테이토 피자와는 다르게 피자를 만드는 메소드명이 다릅니다!
public void makeShrimpPizza() {
System.out.println("쉬림프 피자를 만듭니다!");
}
}
class PizzaShop {
private PotatoPizza potatoPizza;
// 피자를 추가할 때마다 멤버 변수가 추가 됩니다.
private ShrimpPizza shrimpPizza;
public PizzaShop() {
potatoPizza = new PotatoPizza();
// 피자 가게에서 새로운 피자를 추가했습니다.
shrimpPizza = new ShrimpPizza();
}
// 포테이토 피자와 쉬림프 피자의 제조 메소드가 달라 분기점을 만들어야 합니다.
public void make(String pizzaType) {
if (pizzaType.equals("potato")) {
potatoPizza.make();
} else if (pizzaType.equals("shrimp")) {
shrimpPizza.makeShrimpPizza();
}
}
}
피자 하나만 추가했을 뿐인데 코드가 복잡해진 것을 볼 수 있습니다. 코드에서 발생하는 문제점은 다음과 같습니다.
- PotatoPizza와 ShrimpPizza를 만드는 메소드명이 다르게 정의되었습니다. 이는 각각의 피자가 어떻게 구현되어야 한다는 규칙이 존재하지 않아 코드의 복잡성을 증가시킵니다.
- PotatoPizza와 ShrimpPizza의 제조 메소드명이 달라 PizzaShop의 make 메소드에서는 분기점을 만들어 제어해야 합니다. 코드의 유지 보수성을 증가시킵니다.
- 생성자 내부에서 필드에 대한 제어권을 갖게 되므로, 새로운 피자를 만들어 추가하는데 어렵습니다. 즉, 새로운 피자를 만들기 위해서는 필드와 생성자를 추가해야 합니다.
즉, 제어의 역전을 적용하지 않은 이 코드들은 다음과 같은 문제와 직면하게 됩니다.
- 의존 관계를 갖는 하위 모듈들이 많아질 경우 상위 모듈에서 제어하고 관리하기 힘들어집니다.
- 하위 모듈에서 변경 사항이 생긴다면 상위 모듈에서도 변경 사항에 따른 수정이 생길 수있습니다. 만약, 피자의 라지 사이즈가 추가된다면 모든 피자의 라지 사이즈에 대한 메소드가 생기고 그 메소드명이 동일하다는 보장이 없습니다.
이러한 코드의 공통점은 코드의 유지보수성, 재사용성, 유연성을 떨어뜨리고 상위 모듈과 하위 모듈 간의 결합도를 증가시킵니다.
IoC 적용
class PotatoPizza {
public void make() {
System.out.println("포테이토 피자를 만듭니다!");
}
}
class ShrimpPizza {
public void makeShrimpPizza() {
System.out.println("쉬림프 피자를 만듭니다!");
}
}
class PizzaShop {
private PotatoPizza potatoPizza;
private ShrimpPizza shrimpPizza;
public PizzaShop(PotatoPizza potatoPizza, ShrimpPizza shrimpPizza) {
this.potatoPizza = potatoPizza;
this.shrimpPizza = shrimpPizza;
}
public void make(String pizzaType) {
if (pizzaType.equals("Potato")) {
potatoPizza.make();
} else if (pizzaType.equals("Shrimp")) {
shrimpPizza.makeShrimpPizza();
}
}
}
이제는 PizzaShop 생성자에서 하위 모듈인 PotatoPizza와 ShrimpPizza를 만들지 않게 되었습니다.
PizzaShop 클래스 내에서 필드에 대한 제어권이 있던 걸 외부에게 제어권이 넘어간 것을 볼 수 있습니다. 즉, IoC는 제어권이 내부에서 외부로 역전되는 것을 말합니다.
하지만, 저희는 아직 메소드명의 통일성이 지켜지지 않았습니다. (물론 개발자들이 규칙을 잘 지킨다면 메소드가 동일하게 되겠지만 그럴 일은 거의 없습니다.)
DIP (의존 역전 원칙)
정의
상위 레벨의 모듈은 절대 하위 레벨 모듈에 의존하지 않는다.
둘 다 추상화에 의존해야 한다.
위와 같은 원칙을 가진 디자인 원칙입니다.
원래 코드의 의존 관계를 알기 쉽게 그림으로 보겠습니다.
이와 같이 상위 모듈인 PizzaShop은 하위 모듈의 PotatoPizza와 ShrimpPizza와 직접적인 의존 관계를 가지고 있다면 DIP에 위반하게 됩니다. 그렇기에 다음 같이 관계도를 재정의할 수 있습니다.
PizzaShop은 더 이상 하위 모듈에 직접적인 의존을 하지 않게 되며 추상화된 Pizza에 의존하게 됩니다. PotatoPizza와 ShrimpPizza 또한 Pizza에 의존하게 됩니다. 추상화된 Pizza를 통해 상위 모듈 및 하위 모듈이 의존하고 있는 형태가 됩니다.
PizzaShop에 통해 만들어진 Pizza에는 PotatoPizza와 ShrimpPizza가 의존하고 있습니다. 즉, 하위 모듈이 상위 모듈에 의존하게 되는 것을 볼 수 있는데 이것을 DIP (의존 역전 원칙)라고 합니다.
DIP 적용
그렇다면 DIP를 적용한 코드를 확인해보겠습니다.
interface Pizza {
public void make();
}
class PotatoPizza implements Pizza {
@Override
public void make() {
System.out.println("포테이토 피자를 만듭니다!");
}
}
class ShrimpPizza implements Pizza {
@Override
public void make() {
System.out.println("쉬림프 피자를 만듭니다!");
}
}
class PizzaShop {
private PotatoPizza potatoPizza;
private ShrimpPizza shrimpPizza;
public PizzaShop(PotatoPizza potatoPizza, ShrimpPizza shrimpPizza) {
this.potatoPizza = potatoPizza;
this.shrimpPizza = shrimpPizza;
}
public void makePotatoPizza() {
this.potatoPizza.make();
}
public void makeShrimpPizza() {
this.shrimpPizza.make();
}
}
와우! IoC와 DIP를 적용하니 코드가 엄청 깔끔해졌습니다.
이제는 PotatoPizza와 ShrimpPizza는 Pizza라는 인터페이스에 의존하게 되고, PizzaShop도 의존하게 됩니다. 또한 Pizza를 통해 새로운 피자를 마음껏 만들 수 있게 되었습니다. 물론, make라는 메소드로 모든 피자의 제조 메소드명이 통일되고, 세부 내용은 각각의 클래스에서 정의할 수 있게 됐습니다.
DI (의존성 주입)
IoC를 구현하는 다양한 디자인 패턴 중 하나입니다. 필요로 하는 오브젝트를 스스로 생성하는 것이 아닌 외부로부터 주입받는 기법이다. DI를 만드는 대표적인 2가지만 알아보겠습니다.
Constructor Injection (생성자 주입)
생성자를 통해 주입하는 방식입니다. 의존성의 존재 여부가 보장되고, 의존성을 불변하게 정의할 수가 있습니다.
Spring에서도 이 방식을 적극 권장합니다.
// 쉬운 설명을 위해 코드를 간단화 했습니다.
class PizzaShop {
private PotatoPizza potatoPizza;
public PizzaShop(PotatoPizza potatoPizza) {
this.potatoPizza = potatoPizza;
}
public void make() {
this.potatoPizza.make();
}
}
여태까지 봤다면 익숙한 코드일 것입니다. 지금까지 저희는 DI의 생성자 주입을 통해 코드를 구현했기 때문입니다!
Setter Injection
Setter 메소드를 이용하여 주입하는 방식입니다. 의존성을 다시 주입하는 경우에 유용하게 사용할 수 있지만 기본 값을 주지 않으면 null이 존재할 수 있기 때문에 조심해서 사용해야 합니다.
class PizzaShop {
private PotatoPizza potatoPizza;
public void setPotatoPizza(PotatoPizza potatoPizza) {
this.potatoPizza = potatoPizza;
}
public void make() {
potatoPizza.make();
}
}
DI 적용
만약, 여러 개의 피자를 가진 PizzaShop을 간단하게 구현하고싶다면 다음과 같이 변형해서 사용할 수도 있습니다.
Map의 키 값을 불변하게 만들어서 해당 클래스와 매핑하여 넣을 수 있는 코드입니다.
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
interface Pizza {
void make();
}
class PotatoPizza implements Pizza {
@Override
public void make() {
System.out.println("포테이토 피자를 만듭니다!");
}
}
class ShrimpPizza implements Pizza {
@Override
public void make() {
System.out.println("쉬림프 피자를 만듭니다!");
}
}
// 피자 종류를 나타내는 열거형
enum PizzaType {
POTATO, SHRIMP
}
class PizzaShop {
private final Map<PizzaType, Pizza> pizzas;
// 생성자를 통한 의존성 주입, 불변성을 유지하기 위해 copyOf 사용
public PizzaShop(Map<PizzaType, Pizza> pizzas) {
this.pizzas = Map.copyOf(pizzas);
}
public void make(PizzaType pizzaType) {
// 해당 피자 종류에 해당하는 객체가 없다면 예외 처리
Pizza pizza = pizzas.get(pizzaType);
if (pizza == null) {
throw new IllegalArgumentException("해당 피자 종류는 존재하지 않습니다.");
}
pizza.make();
}
}
public class Main {
public static void main(String[] args) {
PotatoPizza p = new PotatoPizza();
ShrimpPizza s = new ShrimpPizza();
// 불변한 키를 가진 Map 생성
Map<PizzaType, Pizza> map = Map.of(PizzaType.POTATO, p, PizzaType.SHRIMP, s);
// 불변한 키를 가진 Map을 이용하여 PizzaShop 생성
PizzaShop ps = new PizzaShop(map);
// 상수를 사용하여 make 메서드 호출
ps.make(PizzaType.SHRIMP);
}
}
참고 자료
'Project 하면서 알아가는 것들' 카테고리의 다른 글
Kafka와 RabbitMQ를 알아보자 (0) | 2024.08.20 |
---|---|
[Nextjs] Tiptap 사용법과 커스텀마이징 기능 구현 (1) | 2024.02.28 |
REST API 확실히 개념 잡기 (0) | 2023.11.02 |
백엔드에서 이미지 업로드는 어떻게 하면 좋을까? (6) | 2023.11.01 |
typescript ?(Optional Parameters)와 | undefined(Union)의 차이 (0) | 2023.02.09 |