커피메이커 예제를 통해 SOLID 원칙과 DI, IoC를 실습해보았다.
CoffeeMaker 클래스는 Setterr 메소드인 setCoffe()를 통해 의존성을 주입받고 있다. 이를 통해 CoffeeMaker 클래스는 Coffee가 어떻게 만들어지는 건지 알 필요없이 coffee의 동작 방식만 알고 있으면 된다.
// CoffeeMaker.java
public class CoffeeMaker {
Coffee coffee;
public void setCoffee(Coffee coffee){
this.coffee=coffee;
}
public void makeCoffee(){
System.out.println(coffee.prepare());
}
}
Coffee와 CoffeeMachine을 인터페이스로 정의하였는데, 이는 느슨한 결합을 하기 위해서이다. 만약 필드를 가져야 한다면 추상 클래스로도 구현할 수 있다. 지금의 코드는 Coffee를 행동 중심의 객체로 봐, CoffeeMaker가 주문을 의뢰하면 Coffee 스스로가 커피를 만들도록 하였다.
구체적인 구현 클래스들이 인터페이스에만 의존하도록 하여 서로의 내부 사정을 전혀 몰라도 사용할 수 있게 만든 것이다.
// Coffee.java
public interface Coffee {
public String prepare();
}
// CoffeeMachine.java
public interface CoffeeMachine {
public String brew();
}
아래 구현 클래스들은 CoffeeMachine, Espresso 등을 직접 생성하지 않고 외부에서 전달받아 사용하고 있다. 이는 강한 결합을 피하고 느슨한 결합을 만들어 유지보수와 확장에 유리하게 하기 위해서이다.
// Americano.java
public class Americano implements Coffee {
private CoffeeMachine machine;
public String prepare(){
return machine.brew()+" How water";
}
public Americano(CoffeeMachine machine) {
this.machine = machine;
}
}
// Latte.java
public class Latte implements Coffee {
private CoffeeMachine machine;
private MilkFrother milkFrother;
public Latte(CoffeeMachine machine, MilkFrother milkFrother) {
this.machine = machine;
this.milkFrother = milkFrother;
}
public String prepare(){
return machine.brew() + milkFrother.frothMilk()+"a little milk foam";
}
}
// DripCoffeeMachine.java
public class DripCoffeeMachine implements CoffeeMachine {
public String brew(){
return "Dripping...☕";
}
}
// MilkFrother.java
public class MilkFrother {
public String frothMilk(){
return "Frothing Milk...🥛";
}
}
다음과 같이 새로운 커피 메뉴를 추가하고 싶을 때는 Coffee를 구현한 클래스만 작성해주면 된다.
public class Cappuccino implements Coffee {
private CoffeeMachine machine;
private MilkFrother milkFrother;
public Cappuccino(CoffeeMachine machine, MilkFrother milkFrother) {
this.machine=machine;
this.milkFrother=milkFrother;
}
@Override
public String prepare() {
return machine.brew() + milkFrother.frothMilk()+"lots of milk foam";
}
}
main 메소드를 보면 1. 직접 제어하는 버전과 2. DI+IoC를 적용한 버전이 있다. main 메소드를 바리스타라고 생각하면 이해하기 쉬운데
1. 직접 제어: 바리스타가 에스프레소를 만들 땐 에스프레소 머신을, 아메리카노를 만들 땐 드립 커피 머신을 직접 선택해 커피를 만드는 것과 같다
2. DI + IoC 적용: 바리스타가 자동화 기계를 사용하는 것과 같다. 바리스타가 '라떼' 커피를 완성시켜서 기계에 넣어주고(setCoffee), 시작 버튼(makeCoffee)만 누르면 기계는 내부 사정은 잘 모르지만 주어진 역할을 수행할 수 있다. 기계를 제어하는 주도권이 main 메소드에 있는 것이 '제어의 역전'이 일어난 것이다.
// Main.java
public class Main {
public static void main(String[] args) {
// 필요한 기기 생성
CoffeeMachine machine1 = new EspressoCoffeeMachine();
CoffeeMachine machine2 = new DripCoffeeMachine();
MilkFrother frother = new MilkFrother();
// 1. 직접 제어(
// 에스프레소 만들기
Coffee espresso = new Espresso(machine1);
System.out.println(espresso.prepare());
// 아메리카노 만들기
Coffee americano = new Americano(machine2);
System.out.println(americano.prepare());
// 2. DI + IoC 적용
CoffeeMaker maker = new CoffeeMaker();
maker.setCoffee(new Latte(machine1, frother));
maker.makeCoffee();
}
}
SRP
각 클래스는 prepare(), frothMilk(), brew()의 역할을 하나씩만 가지고 있다.
OCP
새로운 coffee 메뉴나 coffeeMachine을 추가할 때 기존 코드 수정 없이 인터페이스를 구현한 클래스를 추가하기만 하면 된다.
LSP
Latte, Americano와 같은 하위 클래스들이 상위 인터페이스인 Coffee의 규약을 어기지 않고 그대로 구현했기 때문에, CoffeeMaker와 같은 외부 코드가 Coffee 타입을 Latte나 Americano로 바꿔 사용해도 문제없이 동작한다
ISP
각 인터페이스가 자신의 역할과 관련된 최소한의 메소드만 갖도록 분리되어 있기 때문에, 본인과 관련 없는 메소드를 구현해야 할 클래스는 없다.
SOLID 원칙을 처음 배울 때는 굉장히 애매모호하게 느껴지는 개념들이 실습을 통해 직접 체험해보니 많이 이해되고 체감이 되었다. 구체적으로, 자프실1에서 인터페이스를 배울 때는 인터페이스가 단순히 객체를 연결해 주는 징검다리 역할만 한다고 생각했는데, 이번 실습을 통해 객체 간의 느슨한 결합을 가능하게 해준다는 것을 알게 되었다.
그리고 이런 공용적인 원칙을 배우게 되니 다른 과목의 코드를 짤 때도 자연스럽게 원칙을 의식하며 짜게 되는게 참 신기하고 재밌었다. 아직은 원칙을 적용하는 게 쉽지 않지만 많은 실습을 통해 '나의 것'으로 만들기 위해 노력해야겠다.