참고: Head First Design Patterns
마을에서 제일 잘나가는 브런치 식당인 팬케익 하우스(Pancake House)와 가장 인기있는 레스토랑 다이너(Diner)가 합병하기로 했다! 이제 하나의 식당에서 아침식사와 점심, 저녁 식사를 모두 해결할 수 있게 됐다.
그러나, 팬케익 하우스는 ArrayList를 이용해 메뉴들을 저장하지만 다이너는 배열을 이용한다.
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; }
}
적어도 둘은 MenuItem
에 대해서는 같은 클래스를 사용한다. 이름, 설명, 채식주의 여부, 가격의 필드를 갖는다.
public class PancakeHouseMenu {
ArrayList menuItems;
public PancakeHouseMenu() {
addItem("Regular Pancakes", ... );
addItem("Blueberry Pancakes", ... );
...
}
public void addItem(String name, String description,
boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
menuItems.add(menuItem);
}
public ArrayList getMenuItems() { return menuItems; }
}
팬케익 하우스의 메뉴는 위와 같이 ArrayList
로 구현되어있으며, 생성자가 메뉴를 추가해준다.
public class DinerMenu {
static final int MAX_ITEMS = 6;
int numberOfItems = 0;
MenuItem[] menuItems;
public DinerMenu() {
menuItems = new MenuItem[MAX_ITEMS];
addItem("BLT", ... );
addItem("Soup", ... );
...
}
public void addItem(String name, String description,
boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
if (numberOfItems >= MAX_ITEMS) {
System.err.println(“Menu is full.”);
} else {
menuItems[numberOfItems] = menuItem;
numberOfItems = numberOfItems + 1;
}
}
public MenuItem[] getMenuItems() { return menuItems; }
}
반면 다이너의 메뉴는 배열로 구현되었으며, 마찬가지로 생성자가 메뉴를 추가한다. 다만, 배열이기에 크기에 한계가 있으므로 이를 체크하는 조건문을 addItem()
메소드에 추가해주었다.
자, 이런 상황에서 두 식당을 합친 메뉴를 출력하는 printMenu()
메소드를 어떻게 구현할 수 있을까?
public void printMenu() {
PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
ArrayList breakfastItems = pancakeHouseMenu.getMenuItems();
DinerMenu dinerMenu = new DinerMenu();
MenuItem[] lunchItems = dinerMenu.getMenuItems();
for (int i = 0; i < breakfastItems.size(); i++) {
MenuItem menuItem = breakfastItems.get(i);
System.out.print(menuItem.getName() + “ “);
System.out.println(menuItem.getPrice() + “ “);
System.out.println(menuItem.getDescription());
}
for (int i = 0; i < lunchItems.length; i++) {
MenuItem menuItem = lunchItems[i];
System.out.print(menuItem.getName() + “ “);
System.out.println(menuItem.getPrice() + “ “);
System.out.println(menuItem.getDescription());
}
}
당연하지만, 이 방법이 정답은 아닐 것이다. 합병할 식당이 늘어난다면? 식당의 수 만큼 반복문의 수도 늘어날 것이며, 그만큼 중복되는 로직을 갖는 코드가 불필요하게 늘어나며 유지보수의 불편함과 그에 따라 수정해야 되는 코드의 영역 모든 부분에서 비합리적이다.
반복문의 구조를 잘 살펴보자. 각 자료구조의 size
혹은 length
만큼 순회를 하며 출력을 하고 있다. 그렇다면, 이 기능을 하나의 객체로 추출해보자!
public interface Iterator {
boolean hasNext();
Object next();
}
위와 같이 Iterator
라는 이름의 인터페이스를 만들었다. 자료구조의 다음 객체의 존재 여부를 반환하는 hasNext()
메소드와, 다음 객체를 반환하는 next()
메소드를 갖는다.
while(iterator.hasNext()) {
Object object = iterator.next();
}
Iterator
인터페이스를 이용하면 위와 같은 간결한 반복문으로 자료구조의 순회가 가능해진다.
public class DinerMenuIterator implements Iterator {
MenuItem[] items;
int position = 0;
public DinerMenuIterator(MenuItem[] items) { this.items = items; }
public Object 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; }
}
}
우선 배열을 사용한 다이너의 Iterator
이다. 현재 순회중인 위치를 저장할 position
을 가지며, next()
메소드를 이용해 다음 객체를 반환할 때 마다 position
을 1씩 증가시켜준다.
hasNext()
메소드는 position
이 배열의 길이를 초과하지 않음을 체크함과 동시에 null
값이 아님을 확인한다. 자료구조가 배열이므로, 초기화되지 않은 원소는 고려하지 않아야 하기 때문이다.
public class PancakeHouseIterator implements Iterator {
ArrayList items;
int position = 0;
public PancakeHouseIterator(ArrayList items) { this.items = items; }
public Object next() {
MenuItem menuItem = items.get(position);
position = position + 1;
return menuItem;
}
public boolean hasNext() {
if (position >= items.size()) { return false; }
else { return true; }
}
}
위는 ArrayList
를 사용한 팬케익 하우스의 Iterator
이다. null
체크를 하지 않아도 된다는 점을 제외하고는 다이너와 동일하다.
public class DinerMenu {
...
// public MenuItem[] getMenuItems() { return menuItems; }
public Iterator createIterator() {
return new DinerMenuIterator(menuItems);
}
}
이제 우리는 더이상 메뉴 배열에 대한 getter
메소드는 필요없다. 따라서, getMenuItems()
를 없애고 Iterator
를 생성해주는 createIterator()
메소드를 추가한다.
MenuItems
의 내부 구현을 클라이언트에게 노출시키지 않을 수 있게 된다.public void printMenu() {
Iterator pancakeIterator = pancakeHouseMenu.createIterator();
Iterator dinerIterator = dinerMenu.createIterator();
printMenu(pancakeIterator());
printMenu(dinerIterator());
}
private void printMenu(Iterator iterator) {
while(iterator.hasNext()) {
MenuItem item = iterator.next();
System.out.println(item.getName());
System.out.println(item.getPrice());
System.out.println(item.getDescription());
}
}
자, 이제 다시 printMenu()
메소드로 돌아와보자. 이제 여러개의 반복문을 사용할 필요 없이, Iterator
를 이용해 적절한 형식으로 출력하는 로직을 하나의 메소드로 추출한 뒤, 클라이언트가 호출 할 printMenu()
메소드는 추출된 메소드에 적절한 Iterator
구현체를 파라미터로 전달해 호출하면 그만이다. 위의 예시에서는 오버로딩을 이용하였다.
놀랍게도 Java는 java.util.Iterator
패키지를 통해 이미 Iterator
를 제공하고 있다.
클래스 다이어그램을 살펴보니, 우리의 Iterator
와 크게 다르지 않다. 다만, remove()
메소드를 추가로 가지고있다.
사실 우리는 Iterator
인터페이스를 직접 만들지 않고 이를 사용했어도 됐다. 아니, 애초에 ArrayList
자체가 iterator()
메소드를 지원하기 때문에 Iterator
를 구현 할 필요도 없다!
다만, 배열은 iterator()
메소드를 지원하지 않기 때문에 DinerMenuIterator
는 구현해주어야 한다.
import java.util.Iterator;
public class DinerMenuIterator implements Iterator {
...
public void remove() {
if (position <= 0) {
throw new IllegalStateException(“한 번 이상 next()가 호출되어야 합니다.”);
}
if (list[position - 1] != null) {
for (int i = position - 1; i < (list.length - 1); i++) {
list[i] = list[i + 1];
}
list[list.length - 1] = null;
}
}
}
java.util.Iterator
를 구현하였다. 나머지 코드는 그대로이나, remove()
함수만 구현해주었다.
next()
호출과 동시에 position
은 바로 1 증가하기 때문에, 현재 반환된 원소를 삭제하기 위해서는 position-1
에 해당하는 원소를 삭제해야 한다. 따라서, 모든 인덱스를 한칸씩 앞으로 당긴 뒤 마지막 원소를 null
로 만들어주면 된다.
마지막으로, DinerMenu
와 PancakeHouseMenu
를 위한 공통된 인터페이스를 만들어보자.
public interface Menu {
public Iterator createIterator();
}
자, 이제 printMenu()
를 담당할 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() {
Iterator pancakeIterator = pancakeHouseMenu.createIterator();
Iterator dinerIterator = dinerMenu.createIterator();
printMenu(pancakeIterator);
printMenu(dinerIterator);
}
private void printMenu(Iterator iterator) { // overloaded
while (iterator.hasNext()) {
MenuItem menuItem = iterator.next();
System.out.print(menuItem.getName() + “, “);
System.out.print(menuItem.getPrice() + “ -- “);
System.out.println(menuItem.getDescription());
}
}
}
Iterator 패턴은 아래와 같이 정의한다.
내부 구조를 노출시키지 않고 객체의 집합의 원소들을 순차적으로 접근할 수 있도록 하는 디자인 패턴.
Iterator 패턴으로 더이상 getter
를 통해 Menu
객체의 자료구조에 직접 접근하지 않는다. 또한, Iterator는 그 어떤 객체의 집합에도 적용할 수 있기에 다형성에 입각한 프로그램을 짤 수 있게 된다.
더 나아가 자료구조의 순회 로직을 추출함으로써 Menu
의 책임을 덜어줄 수 있다. 여기서 또 한가지 디자인 원칙을 배울 수 있다.
단일 책임 원칙(Single Responsibility)
하나의 클래스는 오직 한가지 변경되어야 할 이유만을 가져야 한다.
다이너와 팬케익 하우스의 병합 이후, 장사가 잘 되자 인기 카페와도 합병하기로 한다.
public class CafeMenu implements Menu {
Hashtable menuItems = new Hashtable();
public CafeMenu() {
addItem("Burger", ... );
addItem(“Soup”, ... );
addItem(“Burrito”, ... );
}
public void addItem(String name, String description,
boolean vegetarian, double price) {
MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
menuItems.put(menuItem.getName(), menuItem);
}
public Hashtable getItems() { return menuItems; }
}
카페는 배열도 리스트도 아닌 HashTable
을 사용하고 있지만, 우리는 이제 Iterator
가 있기 때문에 문제 없어 보인다.
public class CafeMenu implements Menu {
...
//public Hashtable getItems() { return menuItems; }
public Iterator createIterator() { return menuItems.values().iterator(); }
}
마찬가지로 getter
를 없애고 iterator
을 반환하도록 코드를 수정하였다.
HashTable.values
는 key
와 별개로 value
만 컬렉션(Collection) 타입으로 반환해주며, 이 컬렉션은 iterator()
을 지원하므로 이를 반환하도록 구현하였다.public void printMenu() {
Iterator pancakeIterator = pancakeHouseMenu.createIterator();
Iterator dinerIterator = dinerMenu.createIterator();
Iterator cafeIterator = cafeMenu.createIterator();
printMenu(pancakeIterator());
printMenu(dinerIterator());
printMenu(cafeIterator());
}
메뉴 출력 로직도 위와 같이 단순하게 수정할 수 있겠다. 그런데 뭔가 좀 맘에 안든다. 3개의 Iterator
선언과 3번의 오버로딩된 printMenu()
의 호출이 영 코드가 못나보인다. 더 발전시킬 수 없을까?
public class Waitress {
ArrayList menus;
public Waitress(ArrayList menus) { this.menus = menus; }
public void printMenu() {
Iterator menuIterator = menus.iterator();
while(menuIterator.hasNext()) {
Menu menu = menuIterator.next();
printMenu(menu.createIterator());
}
}
void printMenu(Iterator iterator) {
while (iterator.hasNext()) {
MenuItem menuItem = iterator.next();
System.out.print(menuItem.getName() + “, “);
System.out.print(menuItem.getPrice() + “ -- “);
System.out.println(menuItem.getDescription());
}
}
}
어차피 각 메뉴 Iterator
의 호출, 이를 인자로 전달한 printMenu()
의 호출 두 가지 로직의 반복이다. 그렇다면, 위 처럼 Iterator
의 Iterator
를 이용해보자!
자, 그런데 다이너가 디저트 메뉴를 런칭하기로 한다. 이 디저트 메뉴는 서브메뉴가 될 것인데, 디저트 메뉴라는 컬렉션을 통채로 menuItems
에 삽입해서 관리하고 싶어한다.
그러나, 다이너는 메뉴를 MenuItem[]
타입의 배열로 관리하고 있다. 타입이 맞지 않는다. 어떡하면 좋을까?
우리는 이런 구조를 원한다. 이럴 때 사용할 수 있는 패턴이 바로 Composite 패턴이다.
Composite 패턴은 아래와 같이 정의한다.
객체들의 관계를 트리 구조로 구성하여 부분-전체 계층을 표현하는 디자인 패턴. 사용자로 하여금 단일 객체와 복합 객체 모두 동일하게 다룰 수 있게 한다.
이론은 이렇다. 트리 구조인데, 같은 깊이의 노드가 리프 노드(단일 객체)일 수도 있고, 부모 노드(복합 객체)일 수도 있는데 이들을 동일하게 취급 할 수 있도록 하는 디자인 패턴이 바로 Composite 패턴이다.
우선 Composite 패턴을 적용한 클래스 다이어그램부터 보자. Menu
와 MenuItem
이 공통으로 가질 MenuComponent
인터페이스를 정의하고, Waitress
는 이 인터페이스를 통해 접근 할 것이다.
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(); }
}
위와 같이 구상할 수 있겠다. 그런데, 메소드들이 전부 기본적으로 UnsupportedOperationException
을 던지도록 세팅되어있다. 왜 그럴까?
클래스 다이어그램에서 알 수 있다시피 Menu
와 MenuItem
은 Component
의 모든 메소드를 오버라이딩 하지는 않는다. 오직 필요한 메소드만 오버라이딩 하여 구현할 수 있도록 디폴트 액션으로 예외를 던지도록 만들어둔 것이다.
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; }
public void print() {
System.out.print(“ “ + getName());
if (isVegetarian())
System.out.print(“(v)”);
System.out.println(“, “ + getPrice());
System.out.println(“ -- “ + getDescription());
}
}
다음으로 MenuItem
이다. Composite 패턴을 적용하기 이전과 크게 달라진건 없지만, 차이점이라면 print()
메소드가 MenuItem
으로 넘어왔다. 즉, 출력에 대한 책임이 옮겨졌다고 할 수 있다.
public class Menu extends MenuComponent {
List<MenuComponent> menuComponents = new ArrayList<>();
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.print(“\n” + getName());
System.out.println(“, “ + getDescription());
System.out.println(“---------------------”);
}
}
Menu
의 코드이다. DinerMenu
나 PancakeHouseMenu
와 달리 메뉴판에 해당하는 클래스 자체도 name
과 description
필드를 가지도록 구현했다. 그 이유는 컨테이너 필드(menuComponents)에서 찾아볼 수 있는데, 기존과 달리 MenuComponent
자체를 담도록 구현하였다. 즉, Menu
인스턴스 역시 또다른 Menu
인스턴스의 컨테이너에 요소로 담길 수 있다는 것이다.
print()
를 구현하면 Menu
가 담고 있는 컴포넌트를 출력할 수 없잖아?정확하다. 그래서 아래와 같이 print()
메소드를 수정해 줄 것이다.
public void print() {
System.out.print(“\n” + getName());
System.out.println(“, “ + getDescription());
System.out.println(“---------------------”);
Iterator iterator = menuComponents.iterator();
while (iterator.hasNext()) {
MenuComponent menuComponent = iterator.next();
menuComponent.print();
}
}
앞서 살펴보았던 Iterator 패턴을 적용해보자! 이렇게 한다면 Menu
의 리스트가 담고 있는 모든 컴포넌트의 print()
를 호출할 수 있을 것이다. 물론, 담고 있는 컴포넌트가 또다른 Menu
더라도 마찬가지로 iterator
를 통해 순회하게 되므로 문제 없다.
자, 그럼 우리의 Waitress
는 어떻게 구현해야 할까?
public class Waitress {
MenuComponent allMenus;
public Waitress(MenuComponent allMenus) {
this.allMenus = allMenus;
}
public void printMenu() {
allMenus.print();
}
}
놀라울 정도로 간결해졌다. 이전에 DinerMenu
, PancakeHouseMenu
, CafeMenu
인스턴스를 모두 가지며 일일이 print()
를 호출해야 했던 것에 비해 이 모든 메뉴판들을 담은 allMenus
인스턴스 하나만을 가져, print()
를 한번만 호출하면 나머지는 iterator
가 알아서 해 줄 것이다.
이제 마지막으로 iterator
을 호출해 줄 메소드만 구현하면 되겠다.
public class Menu extends MenuComponent {
Iterator iterator = null;
public Iterator createIterator() {
if (iterator == null) {
iterator = new CompositeIterator(menuComponents.iterator());
}
return iterator;
}
}
public class MenuItem extends MenuComponent {
public Iterator createIterator() { return new NullIterator(); }
}
잠깐, Menu
는 가지고 있는 리스트의 iterator
을 반환하고, MenuItem
은 iterator
을 반환하지 않는다는 건 알겠다. 그런데, CompositeIterator
은 뭐고 NullIterator
은 또 뭘까?
import java.util.*;
public class CompositeIterator implements Iterator {
Stack<Iterator> stack = new Stack<>();
public CompositeIterator(Iterator iterator) { stack.push(iterator); }
public Object next() {
if (hasNext()) {
Iterator iterator = stack.peek();
MenuComponent component = iterator.next();
if (component instanceof Menu) { stack.push(component.createIterator()); }
return component;
} else {
return null;
}
}
public boolean hasNext() {
if (stack.empty()) {
return false;
} else {
Iterator iterator = stack.peek();
if (!iterator.hasNext()) {
stack.pop();
return hasNext();
} else {
return true;
}
}
}
public void remove() { throw new UnsupportedOperationException(); }
}
단순한 iterator
인 줄 알았더니 왜 이렇게 복잡한걸까? Menu
인지 MenuItem
인지 판별하여 재귀적으로 iterator
을 호출하는 로직이 컴포넌트가 아닌 iterator
에게 있기 때문이다. 스택을 이용하여 재귀적으로 Menu
의 iterator
을 담으면서 컴포넌트를 반환한다.
import java.util.Iterator;
public class NullIterator implements Iterator {
public Object next() { return null; }
public boolean hasNext() { return false; }
public void remove() { throw new UnsupportedOperationException(); }
}
NullIterator
은 말 그대로 아무 것도 하지 않도록 구현된 iterator
이다. hasNext()
메소드가 무조건 false
값을 반환하므로, print()
메소드를 통해 호출되더라도 아무 행동도 하지 않도록 구현되었다.
이전에 다루었던 커맨드 패턴의 NoCommand
객체와 비슷하다.
Composite 패턴과 Iterator 패턴에 대해 다루면서 이 둘을 결합하여 더 효율적으로 자료구조를 순회하도록 구현해보았다. 마지막으로, isVegetarian()
메소드로 Waitress
에게 채식 메뉴만을 출력하는 기능을 추가해보자.
public void printVegetarianMenu() {
Iterator iterator = allMenus.createIterator();
System.out.println(“\nVEGETARIAN MENU\n----”);
while (iterator.hasNext()) {
MenuComponent menuComponent = iterator.next();
try {
if (menuComponent.isVegetarian()) { menuComponent.print(); }
} catch (UnsupportedOperationException e) {}
}
}
isVegetarian()
메소드의 디폴트 액션이 예외를 던지는 것이므로, 예외가 발생 시 무시하고 순회를 계속 하도록 try-catch
문을 이용하여 구현하였다. 컴포넌트를 상속/구현하는 객체는 모든 메소드를 구현할 필요는 없다는 점을 기억하자.