"헤더퍼스트 디자인패턴" + "면접을 위한 CS 전공 지식 노트"에 개인적인 의견과 생각들을 추가하여 작성한 포스팅이므로 틀린 내용이 있을 수 있습니다. (있다면 지적 부탁 드립니다.)
📕 목차
1. Legacy Client
2. Simple Factory
3. Factory Method Pattern
4. Dependency Injection
5. Dependency Inversion Principle
6. Abstract Factory Pattern
1. Legacy Client
📌 클라이언트가 피자 주문하기
public class LegacyPizzaStore {
public Pizza orderPizza(String type) {
Pizza pizza = null;
if (type.equals("cheese")) {
pizza = new CheesePizza(10);
} else if (type.equals("pepperoni")) {
pizza = new PepperoniPizza(12);
} else if (type.equals("clam")) {
pizza = new ClamPizza(11);
}
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
}
클라이언트가 치즈, 페퍼로니, 조개 피자 중 하나를 선택하면, 주문 내용에 대응하는 피자를 만드는 피자 가게 클래스를 구현하면 위와 같을 것이다.
📌 피자 메뉴가 변경된다면?
public class LegacyPizzaStore {
public Pizza orderPizza(String type) {
Pizza pizza = null;
if (type.equals("cheese")) {
pizza = new CheesePizza(10);
}
// else if (type.equals("pepperoni")) {
// pizza = new PepperoniPizza(12);
// }
else if (type.equals("clam")) {
pizza = new ClamPizza(11);
} else if (type.equals("veggie")) {
pizza = new VeggiePizza(9);
} else if (type.equals("greek")) {
pizza = new GreekPizza(8);
}
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
}
어느 날 갑자기 페퍼로니 피자가 수익이 안 난다고 판단되어 더이상 생산이 안 된다고 치자.
대신 야채, 그리스 피자가 신메뉴로 출시되었다!
변경에 대응하기 위해, 가게의 코드를 다시 수정해줘야 한다.
지금 당장은 큰 문제가 되지 않을 수 있지만 다음의 경우엔 어떨까?
- 피자 장사가 너무 잘 되어서 Store를 10개로 만드는 경우
- 10개의 Store를 만들었는데, 갑자기 10개의 신메뉴 피자가 추가된 경우
이렇게 되면 개발자는 10*10만큼의 노가다 작업을 수행해야만 한다.
📌 현재 방식의 문제점
- Pizza Store가 각 클래스에 의존적이 됨으로써, 강하게 결합하고 있다.
- 생산자로 Pizza를 생산하고, 포장하는 작업까지 수행하고 있으므로 단일 책임 원칙도 지켜지지 않고 있다.
2. Simple Factory
📌 간단한 팩토리
- 간단한 팩토리(Simple Factory)는 디자인 패턴보다는 프로그래밍에서 자주 사용하는 관용구에 가깝다.
- 위와 같은 다이어그램으로 분리하면 Pizza를 생산하는 역할은 PizzaFactory가 수행하므로 책임이 분리된다.
- 피자 가게가 10개가 되어도, Pizza를 생산하는 Factory 내부의 코드만 수정하면 된다.
- 객체 생성 부분을 인터페이스(혹은 추상 클래스)로 분리하는 방법
📌 피자 공장 제조하기
public class SimplePizzaFactory {
public Pizza createPizza(String type) {
Pizza pizza = null;
if (type.equals("pepperoni")) {
pizza = new PepperoniPizza(10);
} else if (type.equals("clam")) {
pizza = new ClamPizza(10);
} else if (type.equals("veggie")) {
pizza = new VeggiePizza(10);
} else if (type.equals("greek")) {
pizza = new GreekPizza(10);
} else if (type.equals("cheese")) {
pizza = new CheesePizza(10);
}
return pizza;
}
}
피자 생성 로직을 SimplePizzaFactor 클래스로 가져오자.
생성자에서 처리해줄 수도 있겠지만, 그건 본인이 원하는 설계 디자인에 맞게 선택하면 된다.
📌 개선된 클라이언트 코드
public class AdvancedPizzaStore {
private final SimplePizzaFactory factory;
public AdvancedPizzaStore(SimplePizzaFactory factory) {
this.factory = factory;
}
public Pizza orderPizza(String type) {
Pizza pizza = factory.createPizza(type);
prepareForSale(pizza);
return pizza;
}
private void prepareForSale(Pizza pizza) {
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
}
}
클라이언트는 Pizza를 생성하기 위한 로직에 관여하지 않는다.
따라서, 공장에 사정이 생겨서 메뉴가 바뀌더라도 Store가 10개든, 100개든 의존성이 없기 때문에 코드를 수정할 필요가 없다.
물론, type에 제한을 걸어둘 수 있다면 더 좋을 것 같다.
관심이 있다면 enum 타입을 사용하여 type을 받으면 된다.
3. Factory Method Pattern
📌 팩토리 메서드 패턴
- 객체를 사용하는 코드에서 객체 생성 부분을 떼어내 추상화한 패턴
- 상위 클래스가 중요한 뼈대를 결정한다.
- 하위 클래스에서 객체 생성에 관한 구체적인 내용을 결정한다.
- 상위 클래스는 인스턴스 생성 방식에 관여하지 않으므로 더 많은 유연성을 갖는다.
- 객체 생성 로직이 분리되어 있기 때문에, 코드를 리팩토링하더라도 한 곳만 수정하면 된다.
- 사용하는 서브 클래스에 따라 생성되는 객체 인스턴스가 결정되며, 생산자 클래스는 실제 생산될 제품을 전혀 모르는 상태로 만들어진다.
📌 서브 클래스
피자 장사가 너무 잘 되어서, 더 이상 하나의 피자 공장이 감당할 수 없는 지경에 이르렀다고 치자.
SimplePizzaFactory의 책임이 너무 무거워지므로, 앞서 사용했던 방식을 그대로 응용하여 뉴욕 피자, 시카고 피자, 캘리포니아 피자를 각각 다루는 3개의 공장을 세우면 어떨까?
🤔 공장 표준화가 필요하다!
NYPizzaFactory nyFactory = new NYPizzaFactory();
PizzaStore nyStore = new PizzaStore(nyFactory);
nyStore.orderPizza("Veggie");
ChicagoPizzaFactory chicagoFactory = new ChicagoPizzaFactory();
PizzaStore chicagoStore = new PizzaStore(chicagoFactory);
chicagoStore.orderPizza("Veggie");
- 앞서 내용을 상기하자면 PizzaStore는 주입받은 factory로 피자를 받아 판매할 과정을 거친다.
- 하지만 이전의 방식은 피자를 생성하는 방식이 PizzaStore과 직접 연관되어 유연성이 떨어진 상태였다. 이 방식을 그대로 고수하면 다음의 케이스에서 문제가 발생할 우려가 있다.
- 인스턴스 관점: NYCheesePizza와 ChicagoCheesePizza는 Pizza의 기본 정의 동작 방식이 다를 수 있다. (더 굽거나, 포장지가 다르거나 등)
- 생성자 관점: Factory를 클라이언트 측에서 주입해주고, PizzaStore라는 구체 클래스를 사용하기 때문에 유연성이 떨어짐. (가능과 불가능의 문제가 아니다.)
📌 Store 팩토리 메서드
🟡 인스턴스 생성 메서드 추상화
public abstract class AbstractPizzaStore {
public final Pizza orderPizza(String type) {
Pizza pizza = createPizza(type);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
protected abstract Pizza createPizza(String type);
}
- 팩토리 객체가 아닌 팩토리 메서드로서 createPizza()를 호출한다.
- 해당 추상 클래스를 구현하는 모든 Store는 Pizza에 대해 일관된 작업을 보장한다.
- 재정의하고 싶어도 final 한정자로 재정의를 막았다.
- orderPizza()는 어떤 피자가 만들어질지 전혀 알 수 없다. 그저 피자를 준비하고, 굽고, 자르고, 포장하는 일관된 순서를 보장하는 데에만 관심이 있다.
- Pizza는 추상 클래스 이므로 orderPizza()가 실제로 어떤 구체 클래스에서 수행되는지 알 수 없다. (PizzaStore와 Pizza는 완전히 분리되어 있다.)
🟡 서브 클래스에서 인스턴스 생성 로직 구현
public class ChicagoPizzaStore extends AbstractPizzaStore {
@Override
protected Pizza createPizza(String type) {
Pizza pizza;
switch (type) {
case "cheese":
pizza = new ChicagoStyleCheesePizza();
break;
case "pepperoni":
pizza = new ChicagoStylePepperoniPizza();
break;
case "clam":
pizza = new ChicagoStyleClamPizza();
break;
case "veggie":
pizza = new ChicagoStyleVeggiePizza();
break;
default:
throw new IllegalArgumentException("No such pizza.");
}
return pizza;
}
}
- 시카고, 캘리포니아, 뉴욕 피자를 만드는 각각의 Store에서 실제 생성할 로직을 담당한다.
- 생성되는 피자의 종류는 서브클래스가 실시간으로 결정하는 것이 아닌, 어떤 서브 클래스를 선택했느냐에 따라 결정된다. (orderPizza() 관점에선 서브 클래스가 피자 종류를 결정하고 있다.)
▶️ 피자 생성 과정
PizzaStore nyStore = new NYPizzaFactory();
nyStore.orderPizza("Veggie");
PizzaStore chicagoStore = new ChicagoPizzaFactory();
chicagoStore.orderPizza("Veggie");
- 더 이상 PizzaStore가 외부에서 주입되는 Factory 인스턴스에 종속되지 않는다.
- PizzaStore라는 추상 클래스를 이용함으로써 유연성이 높아졌다.
📌 Pizza 팩토리
🟡 추상 클래스 정의
public abstract class Pizza {
String name;
String dough;
String sauce;
List<String> toppings = new ArrayList<>();
public void prepare() {
System.out.println("Preparing " + name);
System.out.println("Tossing dough...");
System.out.println("Adding sauce...");
System.out.println("Adding toppings:");
for (String topping : toppings) {
System.out.println(" " + topping);
}
}
public void bake() {
System.out.println("Bake for 25 minutes at 350");
}
public void cut() {
System.out.println("Cutting the pizza into diagonal slices");
}
public void box() {
System.out.println("Place pizza in official PizzaStore box");
}
public String getName() {
return name;
}
}
- 모든 피자에 공통적으로 필요한 속성들과 기본적인 피자 준비 작업에 대한 기본값을 세팅한다.
🟡 서브 클래스의 생성자 정의
public class NYStyleCheesePizza extends AbstractPizza {
public NYStyleCheesePizza() {
name = "NY Style Sauce and Cheese Pizza";
dough = "Thin Crust Dough";
sauce = "Marinara Sauce";
toppings.add("Grated Reggiano Cheese");
}
@Override
public void cut() {
System.out.println("Cutting the pizza into square slices");
}
}
- 마찬가지로 각각의 Pizza들에 대한 구체적인 정보를 받아 생성자를 정의한다.
- 이렇게 하면 뉴욕, 시카고, 캘리포니아 피자 공장은 모두 제각각의 피자를 만들면서, Store에서 피자에 대한 작업은 일관적으로 수행할 수 있게 된다.
사실 이 부분 제일 이해 안 가는 건, Store는 그냥 냅두고 Factory를 팩토리 메서드 패턴으로 바꿔도 됐던 거 아닌가.
그런데 아마 추상 클래스의 정의대로 구현하는 방식을 보여주려고 좀 억지를 부린 것도 같다.
아직 납득이 안 가는 부분도 좀 있지만, 좀 더 많은 예시와 사용 경험을 쌓아야 익숙해질 것 같다.
4. Dependency Injection
📌 Dependency
- "A가 B에 의존성이 있다"라는 말은 B의 변경 사항에 대해 A 또한 변해야 한다는 것을 의미한다.
- 싱글턴 패턴은 쉽고 실용적이지만, 모듈 간의 결합을 강하게 만들 수 있다는 단점이 있었다.
- DI는 메인 모듈이 직접 다른 하위 모듈에 대한 의존성을 주기보다, 중간에 의존성 주입자가 간접적으로 의존성을 주입한다.
📌 장점
- 모듈들을 쉽게 교체할 수 있는 구조가 되어 테스팅과 마이그레이션하기가 수월해진다.
- 추상화 레이어를 넣고 이를 기반으로 구현체를 넣어주므로 애플리케이션 의존성 방향이 일관된다.
- 애플리케이션 또한 의존성 방향을 쉽게 추론할 수 잇다.
- 모듈 간 관계가 조금 더 명확해진다.
📌 단점
- 모듈들이 더 분리되므로 클래스 수가 증가하여 복잡성이 증가될 수 있다.
📌 DI 원칙
- 상위 모듈은 하위 모듈에서 어떠한 것도 가져오지 않아야 한다.
- 둘 다 추상화에 의존해야 한다.
- 추상화는 세부 사항에 의존하지 말아야 한다.
5. Dependency Inversion Principle
📌 의존성 뒤집기 원칙
추상화된 것에 의존하게 만들고
구상 클래스에 의존하지 않게 만든다.
- 프로그래밍에서 추상화에는 추상화 수준이라는 것이 존재한다.
- 추상화 수준이 높을 수록, 고수준 구성 요소(추상화 수준이 높다)라고 한다.
- 고수준 구성 요소(PizzaStore)는 다른 저수준 구성 요소(각각의 Pizza 클래스)에 의해 정의되는 행동이 들어있다.
- 고수준 구성 요소는 저수준 구성 요소에 의존하면 안 되며, 언제나 추상화에 의존하게 만들어야 한다.
📌 의존성 뒤집기 원칙을 지키는 방법
- 변수에 구상 클래스의 레퍼런스를 저장하지 마라
- new 연산자를 사용하면 구상 클래스 레퍼런스를 사용하게 된다.
- Factory를 사용하여 구상 클래스 레퍼런스를 변수에 저장하는 일을 미리 방지하라
- 구상 클래스에서 유도된 클래스를 만들지 마라
- 구상 클래스에서 유도된 클래스를 만들면 특정 구상 클래스에 종속된다.
- 인터페이스나 추상 클래스처럼 추상화된 것으로 부터 클래스를 만들어라
- 베이스 클레스에 이미 구현되어 있는 메서드를 오버라이드하지 마라
- 이미 구현된 메서드를 오버라이드 하면 베이스 클래스가 제대로 추상화되지 않는다.
- 베이스 클래스에서 메서드를 정의할 때는 반드시 모든 서브 클래스에서 공유할 수 있는 것만 정의하라.
6. Abstract Factory
📌 추상 팩토리 패턴
- 구상 클래스에 의존하지 않고도 서로 연관되거나 의존적인 객체로 이루어진 제품군을 생산하는 인터페이스를 제공한다. (구상 클래스는 서브 클래스에서 만든다.)
- 구성 요소
- AbstractFactory: 최상위 Factory 클래스. 여러 개의 제품들을 생성하는 여러 메서드들을 추상화한다.
- ConcreteFactory: 서브 Factory 클래스. 타입에 맞는 제품 객체를 반환하도록 메서드들을 재정의 (제품군 별로)
- AbstractProduct: 각 타입의 제품들을 추상화
- ConcreteProduct: 각 타입의 제품 구현체들로써, 팩토리 객체로부터 생성
- Client: Client 입장에서는 공장 내부 사정이 바뀌어도 동일한 요청에 대한 결과를 돌려받는다.
📌 Abstract Factory vs Factory Method
Factory Method Pattern | Abstract Factory Pattern | |
공통점 | 객체 생성 과정을 추상화한 인터페이스 제공 객체 생성을 캡슐화하여 구체적인 타입을 감추고 느슨한 결합 구조를 가짐. 클라이언트와 구상 형식을 분리하는 것이 목적 |
|
차이점 | 상속을 통해 객체를 생성한다. 구상 형식을 서브 클래스에서 처리해주므로, 클라이언트는 자신이 사용할 추상 형식만 알면 된다. |
객체 구성(composition)으로 객체를 생성한다. 제품군을 만드는 추상 형식을 제공하여 생산 방법은 해당 형식의 서브 클래스에서 사용한다. Factory 인스턴스를 추상 형식에 전달하기만 하면 된다. |
구체적인 객체 생성 과정을 하위 또는 구체 클래스로 옮기는 것이 목적 | 관련된 여러 객체들을 구체 클래스에 의존하지 않고 생성할 수 있도록 하는 것이 목적 | |
한 Factory당 한 종류의 객체 생성 | 한 Factory에서 서로 연관된 여러 종류의 객체 생성 지원 (제품군 개념 도입) | |
메서드 레벨에 포커싱. Client의 ConcreteProduct 인스턴스 생성 및 구성에 대한 의존도 감소 |
Factory 레벨에 포커싱 Client의 ConcreteProduct 인스턴스 군의 생성 및 구성에 대한 의존도 감소 |
- 추상 팩토리 패턴이 팩토리 메서드의 상위 호환이 아니다. (둘은 엄연히 다르다)
- 추상 팩토리 패턴의 메서드가 팩토리 메서드로 구현되는 경우도 종종 있다.
📌 지역 별로 원재료 군 처리하기
지금까진 모든 지역의 Pizza는 동일한 재료만을 사용했다.
그래서 Pizza의 구체 클래스들은 지역에 상관없이 단순히 어떤 원재료를 사용하는지만 포커싱해도 충분했다.
하지만 원재료의 품질까지 고려한다면 어떻게 될까?
바닷가 근처에 있는 뉴욕은 신선한 조개를 사용해 ClamPizza를 만들 수 있겠지만, 시카고는 내륙에 있기 때문에 냉동 조개를 사용해야 할 것이다.
그렇다면 NYStyleClamPizza, ChicagoStyleClamePizza에 직접 문자열 상수로 명시해줄 수는 있겠지만, 문자열 상수를 사용하는 것은 그다지 좋은 방법이 아니다.
혹은 같은 원재료를 사용하던 지역에서 서로 다른 원재료를 사용하기로 결정하게 됐을 때, 해당 리팩토링 과정은 개발자에게 심각한 스트레스를 줄 것이다.
드디어 원재료 군을 다루는 팩토리를 만들 차례가 되었다.
- Store는 여전히 뉴욕, 시카고, 캘리포니아 지점별로 PizzaStore를 각각 구현하면 된다.
- NYStyleCheesePizza, ChicagoStyleCheesePizza, CaliforniaStyleCheesePizza의 차이는 원재료의 차이에서 비롯하므로 CheesePizza라는 구체 클래스로 묶어주면 된다.
- CheesePizza, ClamPizza, VeggiePizza 등을 추상 클래스 Pizza를 구현하도록 만들어 추상화할 수 있다.
- 각 지점은 특정 피자(ex. 치즈 피자)를 만들기 위해, 생산을 맡길 Factory를 인스턴스로 넘겨주면 된다.
- 각 지역의 Factory 또한 추상화 단계로 묶을 수 있다.
🟡 최종 설계
- PizzaStore는 각 지점 세부 항목을 모두 추상화하고, 추상화된 Pizza 클래스만을 의존한다.
- 각 Pizza 구체 클래스는 팩토리 메서드 패턴으로 분리하였고, 해당 피자를 만들 추상화된 Factory를 주입받는다.
- 각 Store 구체 클래스 또한 추상화된 PizzaIngredientFactory를 사용해 원하는 공장을 생성하고, 일관된 동작을 수행할 수 있다.
- 각 공장에서 사용하는 원재료는 서브 IngredientFactory에서 결정하며, 원재료들 또한 모두 추상화되어 있다.
다이어그램 그려보니 이해는 간다만, 이거 대체 어떻게 처음부터 설계하는 거냐.
솔직히 혼자 구현 해보라고 하면 아직 자신없다.
📌 원재료 팩토리
public interface PizzaIngredientFactory {
Dough createDough();
Sauce createSauce();
Cheese createCheese();
Veggies[] createVeggies();
Pepparoni createPepparoni();
Clams createClams();
}
- 모든 Factory에서 필수적으로 구현해야 하는 기능들을 인터페이스로 생성한다.
- 이후 Client는 인터페이스를 사용하되, 사용하고자 하는 서브 Factory로 호출하면 된다.
🟡 뉴욕 원재료 팩토리
public class NYPizzaIngredientFactory implements PizzaIngredientFactory {
@Override
public Dough createDough() {
return new ThinCrustDough();
}
@Override
public Sauce createSauce() {
return new MarinaraSouce();
}
@Override
public Cheese createCheese() {
return new ReggianoCheese();
}
@Override
public Veggies[] createVeggies() {
return new Veggies[] {new Garlic(), new Onion(), new Mushroom(), new RedPepper()};
}
@Override
public Pepparoni createPepparoni() {
return new SlicedPepparoni();
}
@Override
public Clams createClams() {
return new FreshClams();
}
}
- 각 원재료들 또한 추상화된 클래스이며, 구체 클래스는 따로 구현했다고 가정한다.
📌 Pizza 추상 클래스 재정의
public abstract class Pizza {
String name;
Dough dough;
Sauce sauce;
Cheese cheese;
Veggies veggies[];
Pepparoni pepparoni;
Clams clams;
public abstract void prepare();
public void bake() {
System.out.println("Baking " + name);
}
public void cut() {
System.out.println("Cutting " + name);
}
public void box() {
System.out.println("Boxing " + name);
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override public String toString() {
return name;
}
}
- 여러 종류의 피자에 대한 공통적인 속성과 동작을 정의한다.
🟡 치즈 피자 구체 클래스
public class CheesePizza extends Pizza {
PizzaIngredientFactory ingredientFactory;
public CheesePizza(PizzaIngredientFactory ingredientFactory) {
this.ingredientFactory = ingredientFactory;
}
@Override
public void prepare() {
System.out.println("Preparing " + name);
dough = ingredientFactory.createDough();
sauce = ingredientFactory.createSauce();
cheese = ingredientFactory.createCheese();
}
}
🟡 조개 피자 구체 클래스
public class ClamPizza extends Pizza {
PizzaIngredientFactory ingredientFactory;
public ClamPizza(PizzaIngredientFactory ingredientFactory) {
this.ingredientFactory = ingredientFactory;
}
@Override
public void prepare() {
System.out.println("Preparing " + name);
dough = ingredientFactory.createDough();
sauce = ingredientFactory.createSauce();
cheese = ingredientFactory.createCheese();
clams = ingredientFactory.createClams();
}
}
📌 지역 Store에서 올바른 재료 공장 사용하기
public class NYPizzaStore extends AbstractPizzaStore {
@Override
public Pizza createPizza(String type) {
Pizza pizza;
PizzaIngredientFactory ingredientFactory = new NYPizzaIngredientFactory();
if (type.equals("cheese")) {
pizza = new CheesePizza(ingredientFactory);
pizza.setName("New York Style Cheese Pizza");
} else if (type.equals("pepperoni")) {
pizza = new PepperoniPizza(ingredientFactory);
pizza.setName("New York Style Pepperoni Pizza");
} else if (type.equals("clam")) {
pizza = new ClamPizza(ingredientFactory);
pizza.setName("New York Style Clam Pizza");
} else if (type.equals("veggie")) {
pizza = new VeggiePizza(ingredientFactory);
pizza.setName("New York Style Veggie Pizza");
} else {
throw new IllegalArgumentException("No such pizza.");
}
return pizza;
}
}
- 각 지점의 Store에서 알고 싶은 것은 두 가지 정보 뿐이다.
- 어떤 피자를 만들래?
- 어떤 공장에서 만들래?
- 즉, 모든 것이 추상화된 클래스에만 의존하고 있다.
- 설령 공장에서 원자재를 다른 걸 사용하게 된다고 하더라도, Pizza나 Store 클래스에서 코드를 수정하게 될 일은 없다.