헤드퍼스트 디자인패턴
- 본 책을 읽고 책의 내용을 간략하게 정리한 글입니다.
Chapter 9. 컬렉션 잘 관리하기 - 반복자 패턴과 컴포지트 패턴
예제 프로젝트 - 객체마을 식당과 팬케이크 하우스 합병
- 객체마을 식당에서는 점심 식사를, 팬케이크 하우스에서는 아침 식사를 노리고 메뉴를 만든다고 한다.
- 모든 메뉴 항목에는 이름, 설명, 가격이 써 있다.
public class MenuItem {
String name;
String description;
boolean vegetarian;
double price;
public MenuItem(String name,
String description,
boolean vegetarian,
double price) {
this.name = name;
this.description = description;
this.vegetarian = vegetarian;
this.price = price;
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
public double getPrice() {
return price;
}
public boolean isVegetarian() {
return vegetarian;
}
}
- 각 식당은 메뉴 구현법에서 다음과 같은 차이점이 존재한다.
- 팬케이크 하우스에서는 메뉴에 새로운 항목을 추가하기 쉽게 하려고 ArrayList를 사용했다.
- 객체마을 식당에서는 메뉴에 들어가는 항목의 최대 개수를 정해 놓고 배열을 사용했다.
public class PancakeHouseMenu {
List<MenuItem> menuItems;
public PancakeHouseMenu() {
menuItems = new ArrayList<MenuItem>();
addItem("K&B 펜케이크 세트",
"스크램블 에크와 토스트가 곁들어진 팬케이크",
true,
2.99);
addItem("레귤러 팬케이크 세트",
"달걀 프라이 소시지가 곁들어진 팬케이크",
true,
2.99);
addItem("블루베리 팬케이크",
"신선한 블루베리와 블루베리 시럽으로 만든 팬케이크",
true,
3.49);
addItem("와플",
"취향에 따라 블루베리나 딸기를 얹을 수 있는 와플",
true,
3.59);
}
public void addItem(String name, String description, boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
menuItems.add(menuItem);
}
public ArrayList<MenuItem> getMenuItems() {
return menuItems;
}
}
public class DinerMenu {
static final int MAX_ITEMS = 6;
int numberOfItems = 0;
MenuItem[] menuItems;
public DinerMenu() {
menuItems = new MenuItem[MAX_ITEMS];
addItem("채식주의자용 BLT",
"통밀 위에 콩고기 베이컨, 상추, 토마토를 곁들인 메뉴",
true,
2.99);
addItem("BLT",
"통밀 위에 베이컨, 상추, 토마토를 곁들인 메뉴",
false,
2.99);
addItem("오늘의 스프",
"감자 샐러드를 곁들인 오늘의 스프",
false,
3.29);
addItem("핫도그",
"샤워크라우트, 갖은 양념, 양파, 치즈가 곁들여진 핫도그",
false,
3.05);
// 기타 메뉴 추가되는 부분
}
public void addItem(String name, String description, boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
if (numberOfItems >= MAX_ITEMS) {
System.out.println("죄송합니다. 메뉴가 꽉 찼습니다. 더 이상 추가할 수 없습니다.");
} else {
menuItems[numberOfItems] = menuItem;
numberOfItems++;
}
}
public MenuItem[] getMenuItems() {
return menuItems;
}
}
- 메뉴 구현 방식이 다르면 아래의 기능 요건들을 어떻게 구현할 수 있을까?
printMenu()
- 메뉴에 있는 모든 항목 출력
printBreakfastMenu()
- 아침 식사 항목만 출력
printLunchMenu()
- 점심 식사 항목만 출력
printVegeterianMenu()
- 채식주의자 메뉴 항목만 출력
isItemVegetarian(name)
- 해당 항목이 채식주의자 메뉴인지 아닌지 출력
추가 기능요건 구현하기
printMenu()
메서드를 구현한다고 가정해본다.- 각 메뉴에 들어있는 모든 항목을 출력하려면
PancakeHouseMenu
와DinerMenu
클래스의getMenuItems()
메서드를 호출하여 메뉴 항목을 가져와야 한다.
- 각 메뉴에 들어있는 모든 항목을 출력하려면
PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
ArrayList<MenuItem> breakfastItems = pancakeHouseMenu.getMenuItems();
DinerMenu dinerMenu = new DinerMenu();
MenuItem[] lunchItems = dinerMenu.getMenuItems();
breakfastItems
,lunchItems
각각의 항목에 들어있는 Menu 항목을 출력한다.
for (MenuItem menuItem : breakfastItems) {
System.out.println(menuItem.getName() + " ");
System.out.println(menuItem.getPrice() + " ");
System.out.println(menuItem.getDescription() + " ");
}
for (MenuItem menuItem : lunchItems) {
System.out.println(menuItem.getName() + " ");
System.out.println(menuItem.getPrice() + " ");
System.out.println(menuItem.getDescription() + " ");
}
- 항상 두 메뉴를 사용하고, 각 항목에서 반복 작업을 수행하려면 2개의 순환문을 써야 한다. 만약 다른 메뉴 구현법을 사용하는 레스토랑과 합병하면 3개의 순환문이 필요하게 될 것이다.
반복을 캡슐화하기
- 바뀌는 부분은 캡슐화하라
- 위 문제에서 바뀌는 부분은 반복 작업 처리 방법이다.
- 객체 컬렉션의 반복 작업 처리 방법을 캡슐화한 Iterator 객체를 만들면 어떨까?
- ArrayList에 적용하기
Iterator iterator = breakfastMenu.createIterator();
while (iterator.hasNext()) {
MenuItem menuItem = iterator.next();
}
- 배열에 적용하기
Iterator iterator = lunchMenu.createIterator();
while (iterator.hasNext()) {
MenuItem menuItem = iterator.next();
}
반복자 패턴 알아보기
- 위 디자인 패턴을 반복자 패턴이라고 한다.
- 반복자 패턴이 Iterator 인터페이스에 의존한다.
- Iterator 인터페이스가 있으면 배열, 리스트, 해시테이블 등 모든 종류의 객체 컬렉션에 반복자를 구현할 수 있다.
객체마을 식당 메뉴에 반복자 추가 및 사용
- DinerMenu 클래스에 먼저 Iterator 인터페이스를 정의한다.
public interface Iterator {
boolean hasNext();
MenuItem next();
}
- DinerMenu 클래스에 사용할 구상 Iterator 클래스를 만든다.
public class DinerMenuIterator implements Iterator {
MenuItem[] items;
int position = 0;
public DinerMenuIterator(MenuItem[] items) {
this.items = items;
}
public MenuItem next() {
MenuItem menuItem = items[position];
position = position + 1;
return menuItem;
}
public boolean hasNext() {
if (position >= items.length || items[position] == null) {
return false;
} else {
return true;
}
}
}
- 만든 반복자를 사용하는 건 DinerMenuIterator를 생성하고 클라이언트에게 반환하는 코드만 추가하면 바로 사용한다.
public class DinerMenu {
static final int MAX_ITEMS = 6;
int numberOfItems = 0;
MenuItem[] menuItems;
// 생성자
// addItem 메서드 호출
public Iterator createIterator() {
return new DinerMenuIterator(menuItems);
}
// 기타 메뉴 관련 메서드
}
종업원 코드에서 반복자 적용하기
- 반복자로 인자를 받는
printMenu()
메서드를 만들고 각 메뉴의getIterator()
메서드로 반복자를 받은 후 새로운 메서드에 넘긴다.
public class Waitress {
PancakeHouseMenu pancakeHouseMenu;
DinerMenu dinerMenu;
public Waitress(PancakeHouseMenu pancakeHouseMenu, DinerMenu dinerMenu) {
this.pancakeHouseMenu = pancakeHouseMenu;
this.dinerMenu = dinerMenu;
}
public void printMenu() {
Iterator pancakeIterator = pancakeHouseMenu.createIterator();
Iterator dinerIterator = dinerMenu.createIterator();
System.out.println("메뉴\n---\n아침 메뉴");
printMenu(pancakeIterator);
System.out.println("\n점심 메뉴");
printMenu(dinerIterator);
}
private void printMenu(Iterator iterator) {
// 이제 순환문이 하나만 있어도 된다.
while (iterator.hasNext()) {
MenuItem menuItem = iterator.next();
System.out.println(menuItem.getName() + ", ");
System.out.println(menuItem.getPrice() + " -- ");
System.out.println(menuItem.getDescription());
}
}
}
반복자 패턴의 특징
- 반복자(Iterator)만 구현된다면 다형성을 활용하여 어떤 컬렉션이든 1개의 순환문으로 처리할 수 있다.
- 클라이언트는 Iterator 인터페이스만 알면 된다.
java.util.Iterator 적용하여 인터페이스 개선하기
- 반복자를 어떻게 만드는지 알았으니 Java의 Iterator를 사용한다.
- 반복자용 Iterator를 직접 만드는 것보다 자바에서 제공하는 인터페이스를 활용하면 더 편리하기 때문이다.
- 우선 PancakeHouseIterator 클래스를 지워버리고 PancakeHouseMenu 코드 윗줄에 import.java.util.Iterator를 추가한 후 아래 코드로 고치면 된다.
public Iterator<MenuItem> createIterator() {
return menuItems.iterator();
}
- DinerMenu에서 java.util.Iterator를 사용하도록 고쳐보자.
import java.util.Iterator;
public class DinerMenu implements Iterator<MenuItem> {
MenuItem[] items;
int position = 0;
public DinerMenu(MenuItem[] items) {
this.items = items;
}
public MenuItem next() {
// 기타 코드
}
public boolean hasNext() {
// 기타 코드
}
// Iterator 인터페이스에서 remove() 메서드는 필수가 아니다.
// 종업원 마음대로 지울 수 없게 예외를 던지는 식으로 처리
public void remove() {
throw new UnsupportedOperationException("메뉴 항목은 지우면 안 됩니다.");
}
}
- 메뉴 인터페이스를 통일
public interface Menu {
public Iterator<MenuItem> createIterator();
}
- PancakeHouseMenu와 DinerMenu 클래스를 정의하는 부분에 implements Menu를 추가하고 Waitress 클래스도 수정한다.
import java.util.Iterator;
public class Waitress {
Menu pancakeHouseMenu;
Menu dinerMenu;
public Waitress(Menu pancakeHouseMenu, Menu dinerMenu) {
this.pancakeHouseMenu = pancakeHouseMenu;
this.dinerMenu = dinerMenu;
}
public void printMenu() {
// 이 부분은 그대로 둠
}
private void printMenu(Iterator iterator) {
// 이 부분은 그대로 둠
}
}
반복자 패턴 정의
- 반복자 패턴은 컬렉션의 구현 방법을 노출하지 않으면서 집합체 내의 모든 항목에 접근하는 방법을 제공한다.
- 즉 반복자 패턴을 사용하면 집합체 내에서 어떤 식으로 일이 처리되는지 전혀 모르는 상태에서 그 안에 들어있는 모든 항목을 대상으로 반복 작업을 수행할 수 있다.
단일 역할 원칙
디자인 원칙 8
- 어떤 클래스가 바뀌는 이유는 하나뿐이어야 한다.
- 클래스가 바뀌는 일은 최대한 피해야 한다.
- 해당 원칙에 따르면 하나의 역할은 하나의 클래스에서만 맡아야 한다.
- 클래스가 바뀌는 부분의 역할이 2가지 이상이 아닌지 생각해 봐야 한다.
객체마을 카페 메뉴
- 객체마을 카페에서는 메뉴 항목을 HashMap 으로 구현했다고 한다.
public class CafeMenu {
Map<String, MenuItem> menuItems = new HashMap<String, MenuItem>();
public CafeMenu() {
addItem("베지 버거와 에어 프라이",
"통밀빵, 상추, 토마토, 감자 튀김이 첨가된 베지 버거",
true, 3.99);
addItem("오늘의 스프",
"샐러드가 곁들어진 오늘의 스프",
false, 3.69);
addItem("부리또",
"통 핀토콩과 살사, 구아카몰이 곁들어진 부리또",
true, 4.29);
}
public void addItem(String name, String description, boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
menuItems.put(name, menuItem); // 항목 이름이 키로 쓰인다.
}
public Map<String, MenuItem> getMenuItems() {
return menuItems;
}
}
- 카페의 메뉴를 기존 프레임워크에 추가해보자.
- HashMap 또한 Iterator를 지원하는 컬렉션이기 때문에 코드를 수정하는 일은 간단할 것이다.
public class CafeMenu implements Menu {
Map<String, MenuItem> menuItems = new HashMap<String, MenuItem>();
public CafeMenu() {
// 생성자 코드
}
public void addItem(String name, String description, boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
menuItem.put(name, menuItem);
}
public Iterator<MenuItem> createIterator() {
// HashMap 전체를 대상으로 반복자를 반환하는 것이 아니라 값을 대상으로 반복자를 반환한다.
return menuItems.values().iterator();
}
}
종업원 코드에 카페 메뉴 추가하기
public class Waitress {
Menu pancakeHouseMenu;
Menu dinerMenu;
Menu cafeMenu;
public Waitress(Menu pancakeHouseMenu, Menu dinerMenu, Menu cafeMenu) {
this.pancakeHouseMenu = pancakeHouseMenu;
this.dinerMenu = dinerMenu;
this.cafeMenu = cafeMenu;
}
public void printMenu() {
Iterator<MenuItem> pancakeMenuIterator = pancakeHouseMenu.createIterator();
Iterator<MenuItem> dinerMenuIterator = dinerMenu.createIterator();
Iterator<MenuItem> cafeMenuIterator = cafeMenu.createIterator();
System.out.println("메뉴\n---\n아침 메뉴");
printMenu(pancakeMenuIterator);
System.out.println("\n점심 메뉴");
printMenu(dinerMenuIterator);
System.out.println("\n저녁 메뉴");
printMenu(cafeMenuIterator);
}
private void printMenu(Iterator iterator) {
while (iterator.hasNext()) {
MenuItem menuItem = iterator.next();
System.out.println(menuItem.getName() + ", ");
System.out.println(menuItem.getPrice() + " -- ");
System.out.println(menuItem.getDescription);
}
}
}
종업원 코드 개선하기
- 현재 종업원 코드에서는
printMenu()
를 여러 번 호출해야 한다는 점과 새로운 메뉴를 추가할 때마다 코드를 추가해야 한다는 문제가 남아 있다.
리팩토링 준비
- 리팩토링이 필요한 사항들 정리
- 메뉴, 서브메뉴, 메뉴 항목 등을 모두 넣을 수 있는 트리 형태의 구조가 필요하다.
- 각 메뉴에 있는 모든 항목을 대상으로 특정 작업을 할 수 있는 방법을 제공해야 하며, 그 방법은 적어도 지금 사용 중인 반복자만큼 편리해야 한다.
- 더 유연한 방법으로 아이템을 대상으로 반복 작업을 수행할 수 있어야 한다.
- 객체마을 식당에 껴있는 디저트 메뉴를 대상으로만 반복 작업을 할 수 있으면서 디저트 서브메뉴까지 포함한, 모든 객체마을 식당 메뉴를 대상으로 반복 작업을 할 수 있어야 한다.
컴포지트 패턴 정의
- 컴포지트 패턴으로 객체를 트리구조로 구성해서 부분-전체 계층구조를 구현한다.
- 컴포지트 패턴을 사용하면 클라이언트에서 개별 객체와 복합 객체를 똑같은 방법으로 다룰 수 있다.
메뉴 구성 요소 (MenuComponent) 구현
// MenuComponent에서는 모든 메서드를 기본적으로 구현해 놓았다.
public abstract class MenuComponent {
public void add(MenuComponent menuComponent) {
throw new UnsupportedOperationException();
}
public void remove(MenuComponent menuComponent) {
throw new UnsupportedOperationException();
}
public MenuComponent getChild(int i) {
throw new UnsupportedOperationException();
}
public String getName() {
throw new UnsupportedOperationException();
}
public String getDescription() {
throw new UnsupportedOperationException();
}
public double getPrice() {
throw new UnsupportedOperationException();
}
public boolean isVegetarian() {
throw new UnsupportedOperationException();
}
public void print() {
throw new UnsupportedOperationException();
}
}
메뉴 항목 구현
public class MenuItem extends MenuComponent {
String name;
String description;
boolean vegetarian;
double price;
public MenuItem (String name, String description, boolean vegetarian, double price) {
this.name = name;
this.description = description;
this.vegetarian = vegetarian;
this.price = price;
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
public double getPrice() {
return price;
}
public boolean isVegetarian() {
return vegetarian;
}
// MenuItem에서 print() 메서드를 호출하면 메뉴에 수록해야 할 모든 내용이 출력된다.
public void print() {
System.out.println(" " + getName());
if (isVegetarian()) {
System.out.println("(v)");
}
System.out.println(", " + getPrice());
System.out.println(" -- " + getDescription());
}
}
메뉴 구현
// Menu도 MenuItem과 마찬가지로 MenuComponent를 구현한다.
public class Menu extends MenuComponent {
// Menu 에서는 MenuComponent 형식의 자식을 몇 개든 저장할 수 있다. (예제에서는 ArrayList에 저장)
List<MenuComponent> menuComponents = new ArrayList<MenuComponent>();
String name;
String description;
public Menu(String name, String description) {
this.name = name;
this.description = description;
}
public void add(MenuComponent menuComponent) {
menuComponents.add(menuComponent);
}
public void remove(MenuComponent menuComponent) {
menuComponents.remove(menuComponent);
}
public MenuComponent getChild(int i) {
return menuComponents.get(i);
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
public void print() {
System.out.println("\n" + getName());
System.out.println(", " + getDescription());
System.out.println("------------------");
// Menu와 MenuItem에서 모두 print()를 구현하므로 그냥 print()를 호출하게 구현한다.
for (MenuComponent menuComponent : menuComponents) {
menuComponent.print();
}
}
}
종업원 코드에 컴포지트 적용하기
public class Waitress {
MenuComponent allMenus;
// 종업원에게는 다른 메뉴를 포함하고 있는 최상위 메뉴 구성 요소만 넘겨주면 된다.
public Waitress(MenuComponent allMenus) {
this.allMenus = allMenus;
}
// 메뉴 전체의 계층 구조를 출력하고 싶다면 최상위 메뉴의 print()만 호출하면 된다.
public void printMenu() {
allMenus.print();
}
}
컴포지트 적용한 메뉴 코드 테스트
public class MenuTestDrive {
public static void main(String[] args) {
MenuComponent pancakeHouseMenu = new Menu("팬케이크 하우스 메뉴", "아침 메뉴");
MenuComponent dinerMenu = new Menu("객체마을 식당 메뉴", "점심 메뉴");
MenuComponent cafeMenu = new Menu("객체마을 카페 메뉴", "저녁 메뉴");
MenuComponent dessertMenu = new Menu("디저트 메뉴", "디저트 메뉴");
MenuComponent allMenus = new Menu("전체 메뉴", "전체 메뉴");
// 메뉴 추가하는 부분
dessertMenu.add(new Menu(
"애플 파이",
"크러스트에 바닐라 아이스크림이 얹혀 있는 애플 파이",
true,
1.59));
dinerMenu.add(dessertMenu);
dinerMenu.add(new MenuItem(
"파스타",
"마리나라 소스 스파게티, 효모빵 제공",
true,
3.89));
allMenus.add(pancakeHouseMenu);
allMenus.add(dinerMenu);
allMenus.add(cafeMenu);
Waitress waitress = new Waitress(allMenus);
waitress.printMenu();
}
}