[디자인패턴] 9. the Iterator and Composite Patterns

StandingAsh·2024년 11월 30일
3

참고: 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());
	}
}

당연하지만, 이 방법이 정답은 아닐 것이다. 합병할 식당이 늘어난다면? 식당의 수 만큼 반복문의 수도 늘어날 것이며, 그만큼 중복되는 로직을 갖는 코드가 불필요하게 늘어나며 유지보수의 불편함과 그에 따라 수정해야 되는 코드의 영역 모든 부분에서 비합리적이다.

Iterator 패턴


순회 로직의 캡슐화

반복문의 구조를 잘 살펴보자. 각 자료구조의 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의 Iterator

놀랍게도 Javajava.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로 만들어주면 된다.

마지막으로, DinerMenuPancakeHouseMenu를 위한 공통된 인터페이스를 만들어보자.

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)
하나의 클래스는 오직 한가지 변경되어야 할 이유만을 가져야 한다.

Composite 패턴


다이너와 팬케익 하우스의 병합 이후, 장사가 잘 되자 인기 카페와도 합병하기로 한다.

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.valueskey와 별개로 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()의 호출 두 가지 로직의 반복이다. 그렇다면, 위 처럼 IteratorIterator를 이용해보자!

자, 그런데 다이너가 디저트 메뉴를 런칭하기로 한다. 이 디저트 메뉴는 서브메뉴가 될 것인데, 디저트 메뉴라는 컬렉션을 통채로 menuItems에 삽입해서 관리하고 싶어한다.
그러나, 다이너는 메뉴를 MenuItem[] 타입의 배열로 관리하고 있다. 타입이 맞지 않는다. 어떡하면 좋을까?

우리는 이런 구조를 원한다. 이럴 때 사용할 수 있는 패턴이 바로 Composite 패턴이다.

정의

Composite 패턴은 아래와 같이 정의한다.

객체들의 관계를 트리 구조로 구성하여 부분-전체 계층을 표현하는 디자인 패턴. 사용자로 하여금 단일 객체와 복합 객체 모두 동일하게 다룰 수 있게 한다.

이론은 이렇다. 트리 구조인데, 같은 깊이의 노드가 리프 노드(단일 객체)일 수도 있고, 부모 노드(복합 객체)일 수도 있는데 이들을 동일하게 취급 할 수 있도록 하는 디자인 패턴이 바로 Composite 패턴이다.

우선 Composite 패턴을 적용한 클래스 다이어그램부터 보자. MenuMenuItem이 공통으로 가질 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을 던지도록 세팅되어있다. 왜 그럴까?
클래스 다이어그램에서 알 수 있다시피 MenuMenuItemComponent의 모든 메소드를 오버라이딩 하지는 않는다. 오직 필요한 메소드만 오버라이딩 하여 구현할 수 있도록 디폴트 액션으로 예외를 던지도록 만들어둔 것이다.

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의 코드이다. DinerMenuPancakeHouseMenu와 달리 메뉴판에 해당하는 클래스 자체도 namedescription 필드를 가지도록 구현했다. 그 이유는 컨테이너 필드(menuComponents)에서 찾아볼 수 있는데, 기존과 달리 MenuComponent 자체를 담도록 구현하였다. 즉, Menu 인스턴스 역시 또다른 Menu 인스턴스의 컨테이너에 요소로 담길 수 있다는 것이다.

Composite 패턴과 Iterator

  • 그런데, 위 처럼 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을 반환하고, MenuItemiterator을 반환하지 않는다는 건 알겠다. 그런데, 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에게 있기 때문이다. 스택을 이용하여 재귀적으로 Menuiterator을 담으면서 컴포넌트를 반환한다.

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 문을 이용하여 구현하였다. 컴포넌트를 상속/구현하는 객체모든 메소드를 구현할 필요는 없다는 점을 기억하자.

profile
우당탕탕 백엔드 생존기

0개의 댓글