관리 메뉴

SW

[개발서적] 헤드퍼스트 디자인 패턴 Ch9. 반복자 패턴과 컴포지트 패턴 본문

개발서적/헤드퍼스트 디자인패턴

[개발서적] 헤드퍼스트 디자인 패턴 Ch9. 반복자 패턴과 컴포지트 패턴

SWKo 2024. 9. 4. 01:37

1. 객체 마을 식당과 팬케이스 하우스 합병

팬케이스 하우스에서 파는 아침 메뉴, 객체마을 식당에서 파는 점심 메뉴를 한 곳에서 먹을 수 있게 되었습니다만, 문제가 생겼습니다.

아침, 점심에 각각 다른 메뉴를 써야합니다. 일단 MenuItem 클래스의 구현 방법은 합의했습니다.

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;
    }
}

 

두 사람은 각자 메뉴 항목을 저장하는 방식에서 차이가 있으며, 그로 인해 다툼이 생겼습니다. 

 

팬케이스 하우스

public class PancakeHouseMenu {
    List<MenuItem> menuItems;

    public PancakeHouseMenu() {
        menuItems = new ArrayList<MenuItem>();
        addItem("K&B 팬케이크 세트", "스크램블 에그와 토스트가 곁들여진 팬케이크", false, 2.99);
        addItem("레귤러 팬케이크 세트", "달걀 프라이와 소시지가 곁들여진 팬케이크", false, 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 List<MenuItem> getMenuItems() {
        return menuItems;
    }
}

 

  • 저장 방식: ArrayList<MenuItem>을 사용하여 메뉴 항목을 동적으로 관리합니다.
  • 유연성: 메뉴 항목을 추가하는 데 제한이 없으며, ArrayList는 크기가 동적으로 조정됩니다.
  • 추가 기능: addItem() 메서드를 사용해 언제든지 새로운 항목을 추가할 수 있습니다.
  • 확장성: ArrayList를 사용하므로 항목 개수에 제한이 없고 유연한 확장이 가능합니다.

객체마을 식당

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.err.println("죄송합니다, 메뉴가 꽉 찼습니다. 더 이상 추가할 수 없습니다.");
        } else {
            menuItems[numberOfItems] = menuItem;
            numberOfItems++;
        }
    }

    public MenuItem[] getMenuItems() {
        return menuItems;
    }
}

 

  • 저장 방식: 배열(MenuItem[])을 사용하여 메뉴 항목을 저장하며, 최대 항목 개수를 MAX_ITEMS = 6으로 제한합니다.
  • 제한점: 배열을 사용하기 때문에 메뉴 항목의 개수가 고정되어 있으며, 6개를 초과할 수 없습니다.
  • 효율성: 배열을 사용하여 메모리 관리가 직관적이지만, 메뉴 항목을 동적으로 추가하는 데는 어려움이 있습니다.
  • 추가 기능: addItem() 메서드를 통해 항목을 추가할 수 있으나, 최대 개수를 넘으면 항목을 추가할 수 없습니다.

2. 종업원 구현하기

종업원은 주문 내용에 맞춰 주문 메뉴를 출력하고, 어떤 메뉴가 채식주의자용인지 알아내는 능력도 갖춰야 합니다.

 

printMenu 메소드를 구현하는 방법을 보겠습니다.

 

1. 각 메뉴에 들어있는 항목을 모두 출력하려면 두 클래스의 getMenuItems() 메소드를 호출해서 메뉴 항목을 가져와야 합니다.

두 메소드의 리턴 형식이 다름에 주의해야합니다.

PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
ArrayList<MenuItem> breakfastItems = pancakeHouseMenu.getMenuItems();

DinerMenu dinerMenu = new DinerMenu();
MenuItem[] lunchItems = dinerMenu.getMenuItems();

 

 

2. breakfastItems ArrayList에 들어있는 모든 항목에 순환문을 돌려 PancakeHouse Menu 항목을 출력합니다.

그리고 DinerMenu에 들어있는 항목을 출력할 때는 배열에 순환문을 돌립니다.

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

 

3. 종업원은 항상 두 메뉴를 사용하고, 2개의 순환문을 사용해야 합니다. 만약 다른 구현법을 사용하는 레스토랑과 또 합병한다면 3개의 순환문이 필요하게 됩니다.

 

이처럼 확장성이 좋지 않습니다. 어떻게 인터페이스를 통합할 수 있을까요?

3. 반복자 패턴

반복자 패턴(Iterator Pattern)을 도입하면, ArrayList나 배열이 어떻게 저장되어 있든 상관없이 동일한 방식으로 반복 작업을 처리할 수 있습니다. 즉, size()나 length에 의존하지 않고, Iterator를 사용하여 항목을 순차적으로 처리할 수 있게 됩니다.

 

  • Iterator 인터페이스 구현: ArrayList나 배열 모두에 대해 Iterator 인터페이스를 구현하면, 클라이언트 코드에서 순회 방법에 의존하지 않고 항목을 처리할 수 있습니다.
  • createIterator 메서드 추가: 각 메뉴 클래스에 createIterator() 메서드를 추가하여 반복자를 반환하도록 합니다.
  • 클라이언트 코드의 변경: size()나 length를 직접 호출하는 대신, Iterator를 사용해 항목을 하나씩 가져옵니다.
// ArrayList를 사용하는 PancakeHouseMenu
Iterator<MenuItem> breakfastIterator = pancakeHouseMenu.createIterator();
while (breakfastIterator.hasNext()) {
    MenuItem menuItem = breakfastIterator.next();
    System.out.println(menuItem.getName() + " " + menuItem.getPrice() + " " + menuItem.getDescription());
}

// 배열을 사용하는 DinerMenu
Iterator<MenuItem> lunchIterator = dinerMenu.createIterator();
while (lunchIterator.hasNext()) {
    MenuItem menuItem = lunchIterator.next();
    System.out.println(menuItem.getName() + " " + menuItem.getPrice() + " " + menuItem.getDescription());
}

 

 

반복자(Iterator) 패턴은 컬렉션(JAVA List, Set, Map 등 객체를 담는 컨테이너) 내의 객체들에 순차적으로 접근하는 방법을 제공하면서, 그 객체들의 내부 표현 방식에는 의존하지 않는 디자인 패턴입니다. 이 패턴은 다양한 자료구조에서 일관된 방식으로 객체를 순회할 수 있도록 도와줍니다.

 

4. 두 식당에 반복자 패턴 적용

먼저 Iterator 인터페이스를 정의합니다. 이 인터페이스는 두 가지 중요한 메서드를 포함하고 있습니다:

  • hasNext(): 컬렉션에 더 탐색할 항목이 있는지 확인합니다.
  • next(): 다음 항목을 반환합니다.
public interface Iterator {
    boolean hasNext();  // 다음 항목이 있는지 확인
    MenuItem next();    // 다음 항목을 반환
}

 

DinerMenuIterator 클래스는 Iterator 인터페이스를 구현하며, DinerMenu의 배열을 순회하는 역할을 합니다.

 

public class DinerMenuIterator implements Iterator {
    MenuItem[] items;  // 메뉴 항목 배열
    int position = 0;  // 현재 반복 작업의 위치

    public DinerMenuIterator(MenuItem[] items) {
        this.items = items;
    }

    // 다음 항목을 반환하고, position을 증가시킴
    public MenuItem next() {
        MenuItem menuItem = items[position];
        position = position + 1;
        return menuItem;
    }

    // 배열에 더 이상 항목이 없거나, null을 만나면 false 반환
    public boolean hasNext() {
        if (position >= items.length || items[position] == null) {
            return false;
        } else {
            return true;
        }
    }
}

 

DinerMenu 클래스에서 반복자를 사용해봅니다.

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.err.println("죄송합니다, 메뉴가 꽉 찼습니다. 더 이상 추가할 수 없습니다.");
        } else {
            menuItems[numberOfItems] = menuItem;
            numberOfItems++;
        }
    }

    // DinerMenuIterator를 생성하고 반환
    public Iterator createIterator() {
        return new DinerMenuIterator(menuItems);
    }
}

 

PancakeHouseMenu에도 반복자를 적용해보겠습니다.

PancakeHouseMenu는 ArrayList를 사용하기 때문에, 기본적으로 제공되는 Iterator를 사용할 수 있습니다. 여기에도 동일하게 createIterator() 메서드를 추가합니다.

public class PancakeHouseMenu {
    List<MenuItem> menuItems;

    public PancakeHouseMenu() {
        menuItems = new ArrayList<>();
        addItem("K&B 팬케이크 세트", "스크램블 에그와 토스트가 곁들여진 팬케이크", false, 2.99);
        addItem("레귤러 팬케이크 세트", "달걀 프라이와 소시지가 곁들여진 팬케이크", false, 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);
    }

    // ArrayList의 기본 Iterator 반환
    public Iterator<MenuItem> createIterator() {
        return menuItems.iterator();
    }
}

 

이제 종업원 코드에 반복자를 적용해보겠습니다.

public class Waitress {
    PancakeHouseMenu pancakeHouseMenu;
    DinerMenu dinerMenu;

    public Waitress(PancakeHouseMenu pancakeHouseMenu, DinerMenu dinerMenu) {
        this.pancakeHouseMenu = pancakeHouseMenu;
        this.dinerMenu = dinerMenu;
    }

    public void printMenu() {
        Iterator<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();

        System.out.println("아침 메뉴:");
        printMenu(pancakeIterator);

        System.out.println("점심 메뉴:");
        printMenu(dinerIterator);
    }

    private void printMenu(Iterator<MenuItem> iterator) {
        while (iterator.hasNext()) {
            MenuItem menuItem = iterator.next();
            System.out.print(menuItem.getName() + " ");
            System.out.println(menuItem.getPrice() + " ");
            System.out.println(menuItem.getDescription());
        }
    }
}

 

main 실행 코드를 보겠습니다.

public class MenuTestDrive {
    public static void main(String args[]) {
        // 메뉴 생성
        PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
        DinerMenu dinerMenu = new DinerMenu();

        // 종업원 생성 (두 메뉴를 인자로 전달)
        Waitress waitress = new Waitress(pancakeHouseMenu, dinerMenu);

        // 메뉴 출력
        waitress.printMenu();
    }
}

 

5. 반복자 패턴의 특징 알아보기

두 식당 모두 원래의 코드를 활용할 수 있게 되었습니다. 

관리하기 힘든 종업원 코드 Iterator가 장착된 신형 종업원 코드
메뉴가 캡슐화되어 있지 않아서 각 식당에서 어떤 자료구조를 썼는지 알 수 있습니다. 메뉴 구현법이 캡슐화되어 있어서 종업원은 메뉴에서 메뉴 항목의 컬렉션을 어떤 식으로 저장하는지 알 수 없습니다.
MenuItems로 반복 작업을 하려면 2개의 순환문이 필요합니다. 반복자만 구현하면 다형성을 활용해서 어떤 컬렉션이든 1개의 순환문으로 처리할 수 있습니다.
종업원이 구상 클래스(MenuItem[], ArrayList)에 직접 연결되어 있습니다. 종업원은 인터페이스(반복자)만 알면 됩니다.
유사한 인터페이스를 가졌음에도 2개의 서로 다른 구상 메뉴 클래스에 묶여 있습니다. Menu 인터페이스가 약간 다르지만, 통일시킬 수 있습니다.

 

6. 인터페이스 개선하기

PancakeHouseMenu와 DinerMenu에서 사용하는 반복자 인터페이스를 자바의 기본 java.util.Iterator 인터페이스로 통일하고, 기존에 만든 반복자 인터페이스를 대체하여 코드를 개선해봅시다.

 

1. PancakeHouseMenu와 DinerMenu가 공통으로 사용하는 Menu 인터페이스는 자바의 Iterator를 반환하도록 수정합니다.

import java.util.Iterator;

public interface Menu {
    Iterator<MenuItem> createIterator();  // 자바의 Iterator 인터페이스 사용
}

 

2. DinerMenuIterator 클래스는 자바의 Iterator 인터페이스를 구현하여 배열을 순회합니다.

import java.util.Iterator;

// 자바의 Iterator 인터페이스를 구현
public class DinerMenuIterator implements Iterator<MenuItem> {
    MenuItem[] items;
    int position = 0;

    public DinerMenuIterator(MenuItem[] items) {
        this.items = items;
    }

    // 배열에 다음 항목이 있는지 확인
    @Override
    public boolean hasNext() {
        return position < items.length && items[position] != null;
    }

    // 배열의 다음 항목 반환
    @Override
    public MenuItem next() {
        MenuItem menuItem = items[position];
        position++;
        return menuItem;
    }

    // remove()는 지원하지 않음
    @Override
    public void remove() {
        throw new UnsupportedOperationException("remove()는 지원되지 않습니다.");
    }
}

 

3. 이제 Waitress 클래스는 PancakeHouseMenu와 DinerMenu의 구체적인 클래스에 의존하지 않고, Menu 인터페이스에 의존하게 됩니다. => "구현보다는 인터페이스에 맞춰서 프로그래밍한다."

따라서 더 유연하게 작동하며, 두 메뉴를 같은 방식으로 처리할 수 있습니다.

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<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();

        System.out.println("아침 메뉴:");
        printMenu(pancakeIterator);
        System.out.println("\n점심 메뉴:");
        printMenu(dinerIterator);
    }

    private void printMenu(Iterator<MenuItem> iterator) {
        while (iterator.hasNext()) {
            MenuItem menuItem = iterator.next();
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " -- ");
            System.out.println(menuItem.getDescription());
        }
    }
}

 

7. 반복자 패턴의 정의

반복자 패턴(Iterator Pattern)은 컬렉션의 구현 방법을 노출하지 않으면서
집합체 내의 모든 항목에 접근하는 방법을 제공합니다.

 

 

반복자(Iterator) 패턴의 클래스 다이어그램이 유사한 패턴은 바로 팩토리 메서드(Factory Method) 패턴입니다.

팩토리 메서드 패턴과 반복자 패턴의 유사점:

  1. 역할 분리: 두 패턴 모두 구체적인 구현을 캡슐화하고, 클라이언트가 세부 사항을 알 필요 없이 추상화를 통해 작업을 처리합니다.
  2. 인터페이스를 통해 객체 생성: 팩토리 메서드 패턴에서는 객체 생성을 서브클래스에서 결정하고, 반복자 패턴에서는 컬렉션의 세부 구현을 캡슐화하여 Iterator를 통해 항목을 순회합니다.

팩토리 메서드 패턴:

팩토리 메서드 패턴은 객체 생성을 서브클래스가 결정하도록 하여, 상위 클래스에서는 구체적인 객체 생성에 의존하지 않도록 합니다. 즉, 객체의 생성을 캡슐화하는 패턴입니다.

  • 예시: 팩토리 메서드 패턴에서 상위 클래스는 특정 인터페이스나 추상 클래스를 통해 객체를 생성하지만, 구체적으로 어떤 객체가 생성될지는 서브클래스에서 정의합니다. 이 방식은 반복자 패턴에서 Iterator를 생성하는 과정과 유사합니다.

반복자 패턴:

반복자 패턴은 컬렉션의 내부 구조를 숨기고, 객체의 순회를 Iterator를 통해 처리하도록 캡슐화합니다. createIterator() 메서드를 통해 Iterator 객체를 반환하는 방식이, 팩토리 메서드 패턴의 객체 생성 방식과 유사합니다.

유사점 정리:

  1. 추상화된 객체 생성:
    • 팩토리 메서드 패턴: 객체 생성 방식을 서브클래스가 결정하며, 클라이언트는 추상화된 인터페이스를 통해 객체를 생성합니다.
    • 반복자 패턴: 컬렉션의 세부 구조에 관계없이 Iterator를 통해 객체를 순회합니다.
  2. 캡슐화:
    • 팩토리 메서드 패턴: 객체 생성 과정을 캡슐화하여 클라이언트가 구체적인 클래스에 의존하지 않게 만듭니다.
    • 반복자 패턴: 컬렉션 내부 구조를 숨기고, Iterator를 통해 항목을 캡슐화된 방식으로 처리합니다.

 

8. 단일 역할 원칙

디자인 원칙 - 어떤 클래스가 바뀌는 이유는 하나뿐이어야 한다.

 

집합체에서 컬렉션 관련 기능과 반복자용 메소드 관련 기능을 전부 구현하면 2가지 이유로 그 클래스가 바뀔 수 있습니다.

클래스를 고치는 일은 최대한 피해야하기 때문에 컬렉션 관리와 반복자 생성 기능을 각각 다른 클래스로 분리하여, 각 클래스가 하나의 책임만 갖도록 설계해야 합니다.

 

9. Iterable 인터페이스 알아보기

자바의 모든 컬렉션 유형에서 Iterable 인터페이스를 구현합니다. 

Iterable 인터페이스에는 Iterator 인터페이스를 구현하는, 반복자를 리턴하는 iterator() 메소드가 들어있습니다.

어떤 클래스에서 Iterable을 구현한다면 그 클래스는 Iterator() 메소드를 구현해야합니다.

Iterator() 메소드는 Iterator 객체를 반환합니다.이 객체는 hasNext(), next() 메서드를 제공하며, 컬렉션 내의 요소를 순회할 수 있게 합니다.

또한 Iterable 인터페이스에는 컬렉션에 있는 항목을 대상으로 반복 작업을 수행하는 forEach() 메소드가 기본으로 포함됩니다.

그 외에도 JAVA는 향상된 for 순환문으로 몇 가지 편리한 문법적 기능을 제공합니다.

 

기존 방식

List<MenuItem> menuItems = new ArrayList<>();
// 메뉴 항목을 추가했다고 가정
menuItems.add(new MenuItem("팬케이크", "버터와 시럽이 함께 제공", false, 2.99));

// 기존 방식: 반복자를 사용한 컬렉션 순회
Iterator<MenuItem> iterator = menuItems.iterator();
while (iterator.hasNext()) {
    MenuItem menuItem = iterator.next();
    System.out.print(menuItem.getName() + ", ");
    System.out.print(menuItem.getPrice() + " — ");
    System.out.println(menuItem.getDescription());
}

 

 

향상된 for 순환문 적용 방식

List<MenuItem> menuItems = new ArrayList<>();
// 메뉴 항목을 추가했다고 가정
menuItems.add(new MenuItem("팬케이크", "버터와 시럽이 함께 제공", false, 2.99));

// 향상된 for 루프를 사용한 컬렉션 순회
for (MenuItem menuItem : menuItems) {
    System.out.print(menuItem.getName() + ", ");
    System.out.print(menuItem.getPrice() + " — ");
    System.out.println(menuItem.getDescription());
}

 

향상된 for 문을 사용할 때 주의사항이 있습니다. 배열은 Iterable이 아닙니다.

팬케이크하우스에서는 Iterator 대신 Iterable을 받고, for-each 순환문을 쓰도록 종업원 코드의 printMenu() 메소드를 고쳐봅니다.

public void printMenu(Iterable<MenuItem> iterable) {
        for (MenuItem menuItem : iterable) {
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " — ");
            System.out.println(menuItem.getDescription());
        }
    }
    
printMenu(lunchItems); // 오류 발생

 

배열은 Iterable이 아니기 때문에 오류가 발생합니다.

따라서 일단은 두 식당에 향상된 for문을 모두 적용하지 않고, 기존 방식으로 둡니다.

10. 객체마을 카페 메뉴 살펴보기

public class CafeMenu {
    // HashMap을 사용하여 메뉴 항목을 저장 (키: 메뉴 이름, 값: MenuItem 객체)
    Map<String, MenuItem> menuItems = new HashMap<>();

    // 생성자: 초기 메뉴 항목 추가
    public CafeMenu() {
        addItem("베지 버거와 에어 프라이", "통밀빵, 상추, 토마토, 감자 튀김이 첨가된 베지 버거", true, 3.99);
        addItem("오늘의 스프", "샐러드가 곁들여진 오늘의 스프", false, 3.69);
        addItem("부리토", "통 핀토콩과 살사, 구아카몰이 곁들여진 푸짐한 부리토", true, 4.29);
    }

    // 메뉴 항목을 HashMap에 추가하는 메서드
    public void addItem(String name, String description, boolean vegetarian, double price) {
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        menuItems.put(name, menuItem);  // 이름을 키로, MenuItem 객체를 값으로 저장
    }

    // 메뉴 항목 전체를 반환하는 메서드 (여기서는 Map 전체를 반환)
    public Map<String, MenuItem> getMenuItems() {
        return menuItems;
    }
}

 

Hashtable도 Iterable을 지원하는 자바 컬렉션이지만, ArrayList와는 조금 다릅니다.

public class CafeMenu implements Menu {
    // 메뉴 항목을 저장하는 HashMap (키: 메뉴 이름, 값: MenuItem 객체)
    Map<String, MenuItem> menuItems = new HashMap<>();

    // 생성자: 메뉴 항목 추가
    public CafeMenu() {
        addItem("베지 버거와 에어 프라이", "통밀빵, 상추, 토마토, 감자 튀김이 첨가된 베지 버거", true, 3.99);
        addItem("오늘의 스프", "샐러드가 곁들여진 오늘의 스프", false, 3.69);
        addItem("부리토", "통 핀토콩과 살사, 구아카몰이 곁들여진 푸짐한 부리토", true, 4.29);
    }

    // 메뉴 항목을 HashMap에 추가하는 메서드
    public void addItem(String name, String description, boolean vegetarian, double price) {
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        menuItems.put(name, menuItem);  // HashMap에 메뉴 항목 저장
    }

    // HashMap에서 메뉴 항목에 대한 Iterator를 반환
    @Override
    public Iterator<MenuItem> createIterator() {
        return menuItems.values().iterator();  // 값들에 대한 Iterator 반환
    }
}

 

종업원 코드에 카페 메뉴를 추가해보겠습니다.

import java.util.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)를 가져옴
        Iterator<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();
        Iterator<MenuItem> cafeIterator = cafeMenu.createIterator(); // 추가된 곳

        // 각 메뉴 출력
        System.out.println("메뉴\n---\n아침 메뉴:");
        printMenu(pancakeIterator);

        System.out.println("\n점심 메뉴:");
        printMenu(dinerIterator);

        System.out.println("\n저녁 메뉴:"); // 추가된 곳
        printMenu(cafeIterator);
    }

    // 개별 메뉴 항목을 출력하는 메서드 => 이 부분은 전혀 바뀔 것이 없음
    private void printMenu(Iterator<MenuItem> iterator) {
        while (iterator.hasNext()) {
            MenuItem menuItem = iterator.next();  // 다음 메뉴 항목을 가져옴
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " — ");
            System.out.println(menuItem.getDescription());
        }
    }
}

 

테스트용 코드

public class MenuTestDrive {
    public static void main(String args[]) {
        // 각 메뉴 객체 생성
        PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
        DinerMenu dinerMenu = new DinerMenu();
        CafeMenu cafeMenu = new CafeMenu();

        // Waitress 생성 시, PancakeHouseMenu, DinerMenu, CafeMenu를 전달
        Waitress waitress = new Waitress(pancakeHouseMenu, dinerMenu, cafeMenu);

        // 3가지 메뉴 모두 출력
        waitress.printMenu();
    }
}

11. 종업원 코드 개선하기

현재, 종업원 코드 printMenu() 메서드에서 printMenu를 3번 호출하고 있습니다.

즉, 새로운 메뉴가 추가될 때마다 printMenu() 메서드를 수정해야 합니다. 이는 **OCP(Open Closed Principle, 개방-폐쇄 원칙)**을 위배하고 있습니다.

이 문제를 해결하기 위해 여러 메뉴를 한꺼번에 관리할 수 있는 방식이 필요합니다.

 

여러 메뉴를 하나의 컬렉션이나 자료구조에 담아 관리하고, 이를 순차적으로 순회하면서 출력하는 방식으로 개선할 수 있습니다.

import java.util.Iterator;
import java.util.List;
import java.util.ArrayList;

public class Waitress {
    List<Menu> menus;

    // 여러 메뉴를 리스트로 받아서 관리
    public Waitress(List<Menu> menus) {
        this.menus = menus;
    }

    // 모든 메뉴를 출력하는 메서드
    public void printMenu() {
        for (Menu menu : menus) {
            printMenu(menu.createIterator());
        }
    }

    private void printMenu(Iterator<MenuItem> iterator) {
        while (iterator.hasNext()) {
            MenuItem menuItem = iterator.next();
            System.out.println(menuItem.getName() + ", " + menuItem.getPrice() + " -- " + menuItem.getDescription());
        }
    }
}

 

Menu 객체들을 List<Menu>와 같은 컬렉션에 담아 관리하면, 새로운 메뉴가 추가되더라도 Waitress 클래스는 수정할 필요가 없습니다. 컬렉션에 메뉴를 추가하기만 하면 되므로 OCP 원칙을 지킬 수 있습니다.

 

그런데, 디저트 메뉴를 특정 메뉴의 서브 메뉴로 넣어달라는 요청이 들어왔습니다.

디저트 메뉴를 DinerMenu 컬렉션의 원소로 넣을 수 있으면 좋겠지만, 현재 코드로는 그럴 수 없습니다.

 

 

 

이를 개선하기 위해서는 아래 작업이 필요합니다.

  • 트리 구조 필요: 메뉴, 서브메뉴, 메뉴 항목을 모두 포함할 수 있는 트리 구조를 만들어야 합니다.
  • 편리한 작업 수행: 각 메뉴의 모든 항목을 대상으로 반복자처럼 편리하게 작업을 수행할 수 있어야 합니다.
  • 유연한 반복 작업: 특정 서브메뉴나 항목만 대상으로 작업할 수 있으면서도, 전체 메뉴를 대상으로 반복 작업을 할 수 있는 유연성을 제공해야 합니다.

12. 컴포지트 패턴

컴포지트 패턴
객체를 트리구조로 구성해서 부분-전체 계층구조를 구현합니다.
컴포지트 패턴을 사용하면 클라이언트에서 개별 객체와 복합 객체를 똑같은 방법으로 다룰 수 있습니다.

 

  • Composite 패턴 사용: 메뉴 관리에 반복자 패턴만으로는 부족하므로, Composite 패턴을 적용하여 트리 구조를 구성하고, 부분-전체 계층 구조를 구현하기로 했습니다.
  • 부분-전체 계층구조: 메뉴와 메뉴 항목을 같은 구조에 넣어, 부분(메뉴 및 항목)들이 계층을 이루면서도 전체로 다룰 수 있는 구조를 만듭니다.
  • 동일한 방식으로 처리: Composite 패턴을 통해, 개별 객체복합 객체를 동일한 방식으로 처리할 수 있습니다.
  • 트리 구조: 메뉴, 서브메뉴, 서브서브메뉴 등 중첩된 메뉴 구조를 트리로 구성하고, 간단한 코드를 반복 적용하여 전체 구조에 똑같이 작업을 수행할 수 있습니다.

 

13. 컴포지트 패턴으로 메뉴 디자인하기

컴포지트 패턴을 메뉴에 적용하기 위해 먼저 Menu(Composite)와 MenuItem(Leaf)에 공통적으로 적용되는 구성 요소 인터페이스를 만들어야 합니다.

이 인터페이스를 통해 Menu와 MenuItem을 동일한 방식으로 처리할 수 있으며, 같은 메소드를 호출할 수 있게 됩니다.

물론, MenuItem(Leaf)에는 적합하지 않은 메소드나 Menu(Composite)에 적합하지 않은 메소드도 있을 수 있지만, 이 문제는 나중에 해결할 것입니다.

우선은 컴포지트 패턴 구조에 Menu를 어떻게 맞출 수 있을지 고민하는 것이 중요합니다.

 

 

 

MenuComponent 추상 클래스는 메뉴 구성 요소로, 잎(개별 항목, Leaf)과 복합 객체(메뉴, Composite) 모두에 적용되는 역할을 합니다.

이 클래스는 각 구성 요소에 공통적으로 필요한 메소드를 정의하지만, 잎과 복합 객체가 모두 사용하지는 않는 메소드도 있습니다.

그런 메소드들은 기본적으로 UnsupportedOperationException 예외를 던지도록 설정하여, 필요한 곳에서만 메소드를 오버라이드해 사용할 수 있게 합니다

이로써 각 구성 요소가 자신에게 맞지 않는 메소드를 반드시 구현하지 않아도 됩니다.

 

public abstract class MenuComponent {

    // Composite 객체에 요소를 추가하는 메소드 (Menu에서는 사용, MenuItem에서는 사용 안 함)
    public void add(MenuComponent menuComponent) {
        throw new UnsupportedOperationException();
    }

    // Composite 객체에서 요소를 제거하는 메소드 (Menu에서는 사용, MenuItem에서는 사용 안 함)
    public void remove(MenuComponent menuComponent) {
        throw new UnsupportedOperationException();
    }

    // Composite 객체에서 하위 요소를 가져오는 메소드 (Menu에서는 사용, MenuItem에서는 사용 안 함)
    public MenuComponent getChild(int i) {
        throw new UnsupportedOperationException();
    }

    // 이름을 가져오는 메소드 (MenuItem에서는 사용, Menu에서는 사용 안 함)
    public String getName() {
        throw new UnsupportedOperationException();
    }

    // 설명을 가져오는 메소드 (MenuItem에서는 사용, Menu에서는 사용 안 함)
    public String getDescription() {
        throw new UnsupportedOperationException();
    }

    // 가격을 가져오는 메소드 (MenuItem에서는 사용, Menu에서는 사용 안 함)
    public double getPrice() {
        throw new UnsupportedOperationException();
    }

    // 채식 여부를 확인하는 메소드 (MenuItem에서는 사용, Menu에서는 사용 안 함)
    public boolean isVegetarian() {
        throw new UnsupportedOperationException();
    }

    // 모든 구성 요소를 출력하는 메소드 (Menu와 MenuItem 모두에서 오버라이드 가능)
    public void print() {
        throw new UnsupportedOperationException();
    }
}

 

MenuItem

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

 

Menu

import java.util.ArrayList;
import java.util.List;

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;
    }

    // 가격이나 채식 여부는 메뉴에는 적용되지 않으므로 기본적으로 예외를 던집니다.
    @Override
    public double getPrice() {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean isVegetarian() {
        throw new UnsupportedOperationException();
    }

    // 메뉴와 메뉴 항목을 출력하는 메소드
    public void print() {
        System.out.print("\n" + getName());
        System.out.println(", " + getDescription());
        System.out.println("---------------------");

        // 하위 구성 요소들도 출력
        for (MenuComponent menuComponent : menuComponents) {
            menuComponent.print();
        }
    }
}

 

Waitress

public class Waitress {
    MenuComponent allMenus;

    // 종업원은 최상위 메뉴 구성 요소를 전달받음
    public Waitress(MenuComponent allMenus) {
        this.allMenus = allMenus;
    }

    // 최상위 메뉴의 print() 메소드를 호출하여 전체 메뉴를 출력
    public void printMenu() {
        allMenus.print();
    }
}

 

Waitress 클래스에 컴포지트 패턴을 적용한 코드는 매우 간단하고 효율적입니다.

이제 종업원은 메뉴의 최상위 MenuComponent만 받으면 되고, 이를 통해 전체 메뉴와 서브메뉴를 한 번에 출력할 수 있습니다.

컴포지트 패턴 덕분에 종업원은 메뉴의 각 항목과 서브메뉴를 별도로 처리할 필요 없이, 일관된 방식으로 메뉴 구조를 출력할 수 있습니다.

 

메뉴 Test Code

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("전체 메뉴", "전체 메뉴");

        // 최상위 메뉴에 각 하위 메뉴 추가
        allMenus.add(pancakeHouseMenu);
        allMenus.add(dinerMenu);
        allMenus.add(cafeMenu);

        // DinerMenu에 메뉴 항목 추가
        dinerMenu.add(new MenuItem("파스타", "마리나라 소스 스파게티, 효모빵도 드립니다.", true, 3.89));

        // DinerMenu에 디저트 메뉴 추가
        dinerMenu.add(dessertMenu);

        // 디저트 메뉴에 메뉴 항목 추가
        dessertMenu.add(new MenuItem("애플 파이", "바삭바삭한 크러스트에 바닐라 아이스크림이 얹혀 있는 애플 파이", true, 1.59));

        // 종업원 생성
        Waitress waitress = new Waitress(allMenus);

        // 전체 메뉴 출력
        waitress.printMenu();
    }
}

 

결과

 

현재 컴포지트 패턴에서는 Leaf와 Composite 2개의 역할을 갖고 있습니다.

컴포지트 패턴은 단일 책임 원칙(SRP)을 일부 희생하고 투명성을 확보하는 패턴입니다.

Component 인터페이스에 자식 관리 기능과 잎의 기능을 모두 포함시켜, 클라이언트가 복합 객체와 잎을 동일한 방식으로 다룰 수 있게 합니다.

이로 인해 클라이언트가 객체의 내부 구조를 신경 쓰지 않아도 되지만, 안전성이 떨어질 수 있습니다.

즉, 부적절한 메소드 호출이 발생할 수 있으며, 이를 예외로 처리합니다. 결국, 투명성과 안전성 사이에서 균형을 맞추는 설계 결정이 필요합니다.

 

14. 컴포지트 패턴에서 추가로 고려할 점들

컴포지트 패턴에서 자식 객체가 부모의 레퍼런스를 가질 수 있습니다.

자식에게 부모의 포인터를 넣으면 트리 구조를 돌아다니기 편리해지며, 자식 객체를 삭제할 때 부모에게 직접 삭제 요청을 할 수 있어 관리가 더 쉬워집니다.

이는 복합 객체를 처리할 때 자주 사용하는 패턴 중 하나입니다. 하지만 몇 가지 더 고려해야 할 점이 있습니다.

추가로 고려할 점들:

  1. 자식의 순서: 복합 객체 내에서 자식들의 순서를 고려해야 할 때, 자식 추가와 삭제를 더 복잡하게 관리해야 할 수 있습니다. 예를 들어, 특정 순서대로 자식 노드를 저장하거나 순회할 필요가 있을 때는 순서 관리 로직을 추가해야 합니다.
  2. 캐시(Caching): 복합 구조가 커지면 자원 소모가 클 수 있습니다. 따라서 계산 결과를 캐싱하는 방법을 고려할 수 있습니다. 계산이나 복잡한 연산이 자주 반복되는 경우, 계산 결과를 캐시해 두면 성능을 향상시킬 수 있습니다.
  3. 계층구조 순회: 트리 구조를 순회할 때도 신중해야 합니다. 자식에게 부모의 레퍼런스를 넣으면 상향식 순회가 가능하지만, 무한 루프메모리 누수 같은 문제가 발생하지 않도록 주의해야 합니다.

컴포지트 패턴의 가장 큰 장점:

컴포지트 패턴의 가장 큰 장점은 클라이언트 코드의 단순화입니다. 클라이언트는 복합 객체와 잎 객체를 구분할 필요 없이 동일한 방식으로 다룰 수 있으며, 객체 구조를 신경 쓰지 않고 일관된 인터페이스를 통해 작업을 처리할 수 있습니다. 이를 통해 코드가 간결해지고, 유연성이 높아집니다.

컴포지트 패턴은 강력한 설계 패턴으로, 복잡한 객체들을 한 번에 관리할 수 있는 방법을 제공합니다. 하지만 그만큼 복잡성이 높아지므로, 효율적인 관리가 필요합니다.

 


 

 

FE 반복자, 컴포지트 패턴

  • 반복자 패턴은 컬렉션에 있는 요소를 순차적으로 처리할 때 사용하며, React의 map() 함수와 같이 리스트를 렌더링할 때 유용하게 사용됩니다.
  • 컴포짓 패턴트리 구조로 컴포넌트를 구성할 때 사용되며, React의 컴포넌트 트리 구조가 이를 잘 나타냅니다.

 

Comments