SOLID 원칙의 효과
를 보려면 극단의 사례를 비교하는게 좋을 것 같다. 아메리카노, 라떼를 판매하는 간단한 커피 머신이 있다.
커피는 아이스/핫의 옵션을 지원하고, 시럽의 추가 유무에 따라 가격이 500원 추가된다.
커피 머신에 돈을 넣고, 커피마다 필요로하는 원두량을 소모하면서 커피를 만들어 판매한다.
public class CoffeeMachine {
private static final int AMERICANO_PRICE = 4000;
private static final int LATTE_PRICE = 4500;
private static final int SYRUP_PRICE = 500;
private int coffeeBean;
private int money;
public CoffeeMachine(int coffeeBean) {
this.coffeeBean = coffeeBean;
}
public void insertMoney(final int money) {
this.money = money;
}
public IceLatte buyIceLatte() {
money -= LATTE_PRICE;
coffeeBean -= 1;
System.out.println("아이스 라떼를 구매하였습니다.");
return new IceLatte();
}
public IceAmericano buyIceAmericano() {
money -= AMERICANO_PRICE;
coffeeBean -= 1;
System.out.println("아이스 아메리카노를 구매하였습니다.");
return new IceAmericano();
}
public HotLatte buyHotLatte() {
money -= LATTE_PRICE;
coffeeBean -= 1;
System.out.println("따뜻한 라떼를 구매하였습니다.");
return new HotLatte();
}
public HotAmericano buyHotAmericano() {
money -= AMERICANO_PRICE;
coffeeBean -= 1;
System.out.println("따뜻한 아메리카노를 구매하였습니다.");
return new HotAmericano();
}
public int getCoffeeBean() {
return coffeeBean;
}
public int getMoney() {
return money;
}
public IceLatte addSyrupToIceLatte(IceLatte iceLatte) {
money -= SYRUP_PRICE;
iceLatte.setSyrup(true);
return iceLatte;
}
}
커피머신 객체는 money와 coffeeBean을 상태값으로 가지고 있고, 돈을 넣고
, 커피를사고(buyIcedAmericano, buyHotAmericano, buyIcedLatte, buyHotLatte)
, 시럽을 넣고(addSyrupToIceLatte)
, 현재 상태를 보여주는 (showStatus)
역할을 하고 있다.
시럽을 넣는 과정은 커피를 먼저 사야 가능하고 여러 책임들이 한 객체에 집중되어 있음을 알 수 있다.
앞서 언급한 절차지향으로 프로그래밍하게 되면 SOLID 원칙이 지켜지지 않은 극단적인 사례가 된다. 상태를 보여주는 기능이나, 커피를 사고, 돈을 넣는 과정에서 한 가지의 요구기능 변경사항이 추가된다면, 지체없이 메인 로직이 있는 coffeeMachine을 손봐야할 것이다. 다른 코드는 변경이 되지 않더라도 말이다.
다음으로 객체를 분리하여 SOLID 원칙을 지켰을 때 어떤 장점이 있을까?
public interface VendingMachine {
void insert(double money);
Coffee buy(Coffee coffee);
void cancel();
}
커피 머신은 동전을 넣고, 커피를 구매하고, 취소하는 책임을 가지고 있다. 하지만 실질적으로 돈을 관리하고, 커피 원두 량을 관리하는 것은 커피머신이 아닌 각각의 객체가 직접한다. 아래 커피머신 코드를 보자. Money 와 CoffeeBean이 래퍼 클래스로 존재한다.
public class CoffeeMachine implements VendingMachine {
private Money money;
private CoffeeBean coffeeBean;
public CoffeeMachine(int coffeeBean) {
this.coffeeBean = new CoffeeBean(coffeeBean);
}
@Override
public void insert(final double money) {
this.money = new Money(money);
System.out.println(String.format("투입 금액 : %.1f, 구매 가능 커피 수 : %d", money, coffeeBean.getCoffeeBean()));
}
@Override
public Coffee buy(final Coffee coffee) {
money.buy(coffee);
coffeeBean.useFor(coffee);
System.out.println(String.format("%s를 구매했습니다. 구매 가능 커피 수 : %d", coffee.getClass().getSimpleName(), coffeeBean.getCoffeeBean()));
return coffee;
}
@Override
public void cancel() {
System.out.println(String.format("거스름돈은 %.1f 입니다.", money.getMoney()));
money.initialize();
}
}
public class CoffeeBean {
private int coffeeBean;
public CoffeeBean(int coffeeBean) {
this.coffeeBean = coffeeBean;
}
public int getCoffeeBean() {
return coffeeBean;
}
public void useFor(final Coffee coffee) {
if (coffeeBean - coffee.beans() <= 0) {
throw new IllegalArgumentException("원두가 부족합니다.");
}
coffeeBean -= coffee.beans();
}
}
public class Money {
private double money;
public Money(double money) {
this.money = money;
}
public void buy(final Coffee coffee) {
money -= coffee.price();
}
public double getMoney() {
return money;
}
public void initialize() {
money = 0;
}
}
이로써 커피머신이 돈과 원두의 상태를 직접 관리하는 책임을 갖는 것이 아닌, Money 객체와 CoffeeBean 객체가 직접 관리하도록 캡슐화를 통해서 단일 책임 원칙
을 지켜냈다.
이번엔 커피와 관련된 책임을 인터페이스로 분리해보자.
커피가격과, 시럽 첨가 여부, 원두 소모량에 대한 getter 메서드를 가지고 있다.
public interface Coffee {
double price();
boolean isSyrup();
int beans();
}
커피들은 hot/ice 상태를 나타내는 Temperature와 syrup을 가지고 있다. 이들은 커피 주문할 때, 즉 생성자와 함께 초기화 되는 놈들이므로 따로 클래스로 분리하지는 않겠다.
public abstract class DefaultCoffee implements Coffee {
protected Temperature temperature;
protected boolean syrup;
public DefaultCoffee(Temperature temperature, boolean syrup) {
this.temperature = temperature;
this.syrup = syrup;
}
public boolean isSyrup() {
return syrup;
}
}
상위 타입을 구현할 수 있도록 틀을 만들어 놓고 구현체에 대한 유연함을 남겨놓았기 때문에, Coffee라는 타입을 사용하여 Americano가 완벽하게 대체될 수 있기 때문에 리스코프 치환원칙
도 지켜지고 있다는 것을 알 수 있다.
public class Americano extends DefaultCoffee {
public Americano(Temperature temperature, boolean syrup) {
super(temperature, syrup);
}
@Override
public double price() {
if (syrup) {
return 4500;
}
return 4000;
}
@Override
public int beans() {
return 1;
}
}
public class Latte extends DefaultCoffee {
public Latte(final Temperature temperature, final boolean syrup) {
super(temperature, syrup);
}
@Override
public double price() {
if (syrup) {
return 5000;
}
return 4500;
}
@Override
public int beans() {
return 2;
}
}
Americano와 Latte 객체를 만들고, 메인함수에서 특정 상황을 만들어 실행해 보자.
public class Application {
public static void main(String[] args) {
CoffeeMachine coffeeMachine = new CoffeeMachine(5);
coffeeMachine.insert(20000);
coffeeMachine.buy(new Americano(Temperature.HOT, true));
coffeeMachine.buy(new Americano(Temperature.COLD, false));
coffeeMachine.buy(new Latte(Temperature.HOT, true));
coffeeMachine.buy(new Latte(Temperature.COLD, false));
coffeeMachine.cancel();
}
}
CoffeeMachine은 절치지향 때처럼 많은 메서드들을 가질 필요가 없다. buy(Coffee coffee)라는 시그니처를 이용해서 Coffee를 구현하는 어떤 메뉴도 대체될 수 있다.
소스코드 상에서는 상위타입인 Coffee에 의존하고 있지만 런타임때는 실제 구현된 객체에 의존하게 되기 때문에, 고모듈인 CoffeMachine이 저모듈인 Americano, Latte등에 직접 의존하는 것이 아니므로 역전 의존 원칙
도 지켜지고 있음을 확인할 수 있다.
Americano의 가격이 바뀐다면?
CoffeeBean의 처리 방식이 바뀐다면?
이들을 사용하는 CoffeeMachine에서도 코드 수정이 이루어져야할까?
얼마든지 기능을 변경하고 확장할 수 있으나, 이들을 협력관점에서 사용하는 CoffeeMachine에서는 코드를 수정할 필요가 없다. 이는 개방-폐쇄 원칙
이 지켜지고 있기 때문에 가능한 것이다.
궁극적으로 절차지향의 코드와 객체지향의 코드를 비교해보면 어떤 점이 가장 많이 달라졌을까?
그것은 바로 요구 기능의 변경 및 추가가 용이해졌다는 점이다.
만약 커피 머신이 10000원이상의 금액은 인식하지 못한다는 조건이 추가된다면 돈과 관련된 로직을 처리하는 Money객체만 코드를 수정하면 된다. 하지만 절차지향이라면 대부분의 로직을 처리하는 CoffeeMachine에서 insert와 관련된 부분을 모두 수정해야하는 번거로움이 발생하게 될 것이다.
또한 객체지향의 큰 특징 중 하나인 다형성을 사용하면서 판매하는 커피의 종류가 추가될 때마다 객체와 동시에 메서드까지 만들지 않아도 된다. 추가된 메뉴의 이름과, 가격 등의 여부만 결정한다면, CoffeeMachine 및 다른 클래스에 별도의 코드 수정없이 기능을 제공할 수 있다.
6에서 SOLID를 다시 정리한 것처럼 각각의 원칙은 완벽하게 독립적인 원칙들이 아니다. 서로에게 영향을 주고 영향을 받는다.
각각의 원칙을 지키는 것 보다 더 중요하고 잊지 말아야하는 것이 있다. 그것은 바로 이 원칙들의 목적이 변경에 용이한 구조와 코드를 만드는 것에 있다는 것이다.