헤드퍼스트 디자인패턴 Chapter9 - 반복자 패턴과 컴포지트 패턴

헤드퍼스트 디자인패턴

  • 본 책을 읽고 책의 내용을 간략하게 정리한 글입니다.

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() 메서드를 구현한다고 가정해본다.
    1. 각 메뉴에 들어있는 모든 항목을 출력하려면 PancakeHouseMenuDinerMenu 클래스의 getMenuItems() 메서드를 호출하여 메뉴 항목을 가져와야 한다.
PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
ArrayList<MenuItem> breakfastItems = pancakeHouseMenu.getMenuItems();

DinerMenu dinerMenu = new DinerMenu();
MenuItem[] lunchItems = dinerMenu.getMenuItems();
  1. 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() + " ");
}
  1. 항상 두 메뉴를 사용하고, 각 항목에서 반복 작업을 수행하려면 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();
    }
}