디자인패턴

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

hongyb 2024. 8. 23. 00:57

반복자 패턴과 컴포지트 패턴을 알아보자.

 

반복자 패턴과 컴포지트 패턴

반복자 패턴

컬렉션의 구현 방법을 노출하지 않으면서 집합체 내의 모든 항목에 접근하는 방법을 제공한다.

 

컴포지트 패턴

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

 

H3 중제목

배열, 리스트, 맵 자료구조를 각각 3개의 클래스에서 저장한다고 가정해 보자. Client라는 객체에서 배열, 리스트, 맵 3개를 모두 조회하려면 3개의 자료구조 형태에 맞춰 코드로 구현해야 할 것이다.

 

Client가 배열, 리스트, 맵 등의 자료구조를 직접 조회해야 한다면, 클라이언트 코드에서 각각의 자료구조에 대해 다른 방식으로 요소에 접근해야 한다. 이는 코드의 복잡성을 증가시키고 유지보수를 어렵게 만든다. 아래는 이러한 문제를 나타내는 Java 코드 예시이다.

 

배열 저장 클래스

public class ArrayData {
    private String[] data;

    public ArrayData(String[] data) {
        this.data = data;
    }

    public String[] getData() {
        return data;
    }
}

 

리스트 저장 클래스

import java.util.List;

public class ListData {
    private List<String> data;

    public ListData(List<String> data) {
        this.data = data;
    }

    public List<String> getData() {
        return data;
    }
}

 

 

맵 저장 클래스

import java.util.Map;

public class MapData {
    private Map<String, String> data;

    public MapData(Map<String, String> data) {
        this.data = data;
    }

    public Map<String, String> getData() {
        return data;
    }
}

 

클라이언트 코드

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ClientWithoutIteratorPattern {

    public static void main(String[] args) {
        // 배열 데이터
        String[] array = {"Apple", "Banana", "Cherry"};
        ArrayData arrayData = new ArrayData(array);

        // 리스트 데이터
        List<String> list = new ArrayList<>();
        list.add("Dog");
        list.add("Elephant");
        list.add("Fox");
        ListData listData = new ListData(list);

        // 맵 데이터
        Map<String, String> map = new HashMap<>();
        map.put("Key1", "Grapes");
        map.put("Key2", "Honeydew");
        map.put("Key3", "Iced Tea");
        MapData mapData = new MapData(map);

        // 배열 요소 조회
        System.out.println("Array Data:");
        for (int i = 0; i < arrayData.getData().length; i++) {
            System.out.println(arrayData.getData()[i]);
        }

        // 리스트 요소 조회
        System.out.println("\nList Data:");
        for (int i = 0; i < listData.getData().size(); i++) {
            System.out.println(listData.getData().get(i));
        }

        // 맵 요소 조회
        System.out.println("\nMap Data:");
        for (Map.Entry<String, String> entry : mapData.getData().entrySet()) {
            System.out.println(entry.getKey() + " = " + entry.getValue());
        }
    }
}

이러한 구조는 여전히 클라이언트 코드가 각 자료구조의 내부 구현에 의존하고 있으며, 자료구조가 추가되거나 변경될 때마다 클라이언트 코드를 수정해야 하는 문제가 있다.

 

이러한 문제를 해결하기 위해 반복자 패턴이 만들어졌다.

 

반복자 패턴 클래스 다이어그램 및 구현

반복자 패턴(Iterator Pattern)은 컬렉션의 구현 방법을 노출하지 않으면서 집합체 내의 모든 항목에 접근하는 방법을 제공한다. 반복자 패턴을 사용하면 집합체 내에서 어떤 식으로 일이 처리되는지 전혀 모르는 상태에서 그 안에 들어있는 모든 항목을 대상으로 반복 작업을 수행할 수 있다. 

 

반복자 패턴 클래스 다이어그램은 다음과 같다.

Client 입장에서 Iterator 인터페이스에만 의존하고 있기 때문에 Iterator 객체만 있으면 배열로 저장되어 있든 ArrayList로 저장되어 있든 신경 쓰지 않고 작업을 처리할 수 있다.

또한  반복자 패턴을 사용하면 모든 항목에 일일이 접근하는 작업을 컬렉션 객체가 아닌 반복자 객체가 맡게 된다.

 

 

반복자 패턴 클래스 다이어그램을 참고하여 코드로 구현해 보았다.

 

Aggregate 인터페이스이다. createIterator()에서 Iterator를 반환해야 한다. Iterator 인터페이스는 직접 구현하지 않고, Java에 기본적으로 내장된 인터페이스를 사용했다.

import java.util.Iterator;

public interface Aggregate {
    public Iterator<MyItem> createIterator();
}

 

 

 

MyItem 클래스를 Aggregate를 구현한 객체(ConcreteAggregateA, ConcreteAggregateB, ConcreteAggregateC)에서 각자 다른 자료구조로 갖고 있게 된다. ConcreteAggregateA, ConcreteAggregateB, ConcreteAggregateC 모두 Aggregate 인터페이스를 구현했기 때문에 createIterator()를 오버라이드 해 iterator를 반환해야 한다.

public class MyItem {
    private String name;
    private String description;

    public MyItem(String name, String description) {
        this.name = name;
        this.description = description;
    }

    public String getName() {
        return name;
    }

    public String getDescription() {
        return description;
    }
}
public class ConcreteAggregateA implements Aggregate {
    private MyItem[] myItems;
    private final int MAX_VALUE = 10;
    private int position = 0;

    public ConcreteAggregateA() {
        myItems = new MyItem[MAX_VALUE];
    }

    public void add(String name, String description) {
        if(position >= MAX_VALUE) return;
        myItems[position] = new MyItem(name, description);
        position++;
    }

    @Override
    public Iterator<MyItem> createIterator() {
        return new ConcreteAIterator(myItems);
    }
}
public class ConcreteAggregateB implements Aggregate {
    List<MyItem> myItemList;

    public ConcreteAggregateB() {
        myItemList = new ArrayList<>();
        myItemList.add(new MyItem("hihi", "hello hello"));
        myItemList.add(new MyItem("name", "descriptioin"));
    }

    @Override
    public Iterator<MyItem> createIterator() {
        return myItemList.iterator();
    }
}
public class ConcreteAggregateC implements Aggregate {
    Map<String, MyItem> map;

    public ConcreteAggregateC() {
        this.map = new HashMap<>();

        map.put("hihi", new MyItem("map", "map description"));
    }

    @Override
    public Iterator<MyItem> createIterator() {
        return map.values().iterator();
    }
}

 

 

위 코드에서 중요한 점은 ConcreteAggregateA와 ConcreteAggregateB, ConcreteAggregateC의 차이점이다.

 

Java에서 Collection 인터페이스는 Iterable 인터페이스를 확장하며, 이로 인해 모든 컬렉션 클래스(ArrayList, LinkedList, HashSet 등)는 iterator() 메서드를 제공하고 있다. 따라서 Collection 인터페이스를 쓰고 있는 ConcreteAggregateB(List 사용), ConcreteAggregateC(Map 사용)은 iterator를 구현할 필요 없이 자신이 구현한 iterator() 메서드를 호출하면 된다.

 

이와 달리 ConcreteAggregateA의 배열(myItems)은 자체적으로 구현한 iterator를 반환하는 메서드가 없기 때문에 만들어줘야 한다. 코드는 다음과 같다.

import java.util.Iterator;

public class ConcreteAIterator implements Iterator<MyItem> {
    private MyItem[] myItems;
    int position = 0;

    public ConcreteAIterator(MyItem[] myItems) {
        this.myItems = myItems;
    }

    @Override
    public boolean hasNext() {
        if(myItems.length <= position || myItems[position] == null) {
            return false;
        }
        return true;
    }

    @Override
    public MyItem next() {
        MyItem myItem = myItems[position];
        position++;
        return myItem;
    }

    @Override
    public void remove() {
        throw new UnsupportedOperationException();
    }
}

 

마지막으로 Client 클래스이다.

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

public class Client {
    public static void main(String[] args) {
        List<Aggregate> aggregates = new ArrayList<>();

        ConcreteAggregateA concreteAggregateA = new ConcreteAggregateA();
        concreteAggregateA.add("aa", "aaa");
        concreteAggregateA.add("bb", "bbb");

        aggregates.add(concreteAggregateA);
        aggregates.add(new ConcreteAggregateB());
        aggregates.add(new ConcreteAggregateC());

        Client client = new Client(aggregates);
        client.printAll();
    }

    private List<Aggregate> aggregateList;
    public Client(List<Aggregate> aggregateList) {
        this.aggregateList = aggregateList;
    }

    public void printAll() {
        Iterator<Aggregate> aggregateIterator = aggregateList.iterator();
        while(aggregateIterator.hasNext()) {
            Aggregate next = aggregateIterator.next();
            print(next.createIterator());
        }
    }

    private void print(Iterator<MyItem> iterator) {
        while(iterator.hasNext()) {
            MyItem myItem = iterator.next();
            System.out.println("name : " + myItem.getName() + ", description : " + myItem.getDescription());
        }
    }
}

 

단일 역할 원칙

집합체에서 내부 컬렉션 관련 기능과 반복자용 메서드 관련 기능을 전부 구현한다면 컬렉션이 변경되거나, 반복자 관련 기능이 바뀌었을 때 클래스가 바뀌어야 한다. 이러한 설계는 단일 역할 원칙을 무시게 된다.

 

단일 책임 원칙은 "클래스는 하나의 책임만 가져야 한다"는 것을 의미한다. 더 쉽게 설명하자면 클래스가 변경되는 이유는 오직 하나여야 한다는 원칙이다. 이는 클래스가 하나의 기능만을 수행하도록 설계되어야 한다는 것을 강조한다.

 

어떤 클래스에서 맡고 있는 모든 역할은 나중에 코드 변화를 불러올 수 있다. 역할이 2개 이상 있으면 바뀔 수 있는 부분은 2개 이상 되는 것이다. 이 원칙에 따라 하나의 클래스는 하나의 역할만 맡아야 한다.

 

 

간단한 예시를 들어보겠다. 직원의 정보를 관리하고, 월급을 계산하며, 그 데이터를 파일로 저장하는 시스템을 개발하면 Employee 클래스는 다음과 같다.

public class Employee {
    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    // 직원의 데이터를 가져오는 메서드
    public String getName() {
        return name;
    }

    public double getSalary() {
        return salary;
    }

    // 월급을 계산하는 메서드
    public double calculateMonthlySalary() {
        return salary / 12;
    }

    // 직원 데이터를 파일로 저장하는 메서드
    public void saveToFile() {
        // 파일 저장 로직
    }
}

 

위 클래스에는 여러 기능이 포함되어 있다:

  1. 직원의 기본 정보 관리 (name, salary 등의 필드와 getter 메서드).
  2. 월급 계산 (calculateMonthlySalary 메서드).
  3. 데이터를 파일로 저장 (saveToFile 메서드).

이제 "이 클래스가 왜 변경될 수 있을까?"를 생각해 보자:

  1. 급여 계산 방법이 변경: 예를 들어, 회사에서 새로운 급여 계산 규칙을 도입할 수 있다.
  2. 파일 저장 형식이 변경될 수 있다: 데이터를 파일로 저장할 때, 예를 들어, 파일 형식을 CSV에서 JSON으로 변경해야 할 수 있다.
  3. 직원의 기본 정보가 변경될 수 있다: 예를 들어, 직원의 주소나 전화번호 같은 추가 정보가 필요해질 수 있다.

이 클래스는 이 세 가지 이유 각각으로 인해 변경될 가능성이 있다. 이제 이 클래스의 한 부분이 변경되면, 의도하지 않게 다른 부분에 영향을 줄 수 있다. 예를 들어, 파일 저장 형식을 변경할 때, 급여 계산과 관련된 코드가 의도치 않게 수정될 수 있다.

 

이를 방지하기 위해, 클래스를 각각의 변경 이유에 따라 분리해 보자.

 

1. 직원 정보 관리

public class Employee {
    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    public String getName() {
        return name;
    }

    public double getSalary() {
        return salary;
    }
}

 

2. 급여 계산

public class SalaryCalculator {
    public double calculateMonthlySalary(Employee employee) {
        return employee.getSalary() / 12;
    }
}

 

3. 파일 저장

public class EmployeeFileSaver {
    public void saveToFile(Employee employee) {
        // 파일 저장 로직
    }
}

 

이렇게 분리함으로써, 예를 들어 급여 계산 방법이 변경되더라도, 파일 저장 관련 코드를 전혀 건드리지 않아도 된다. 반대로, 파일 저장 형식을 변경해야 할 때는 급여 계산 로직에 전혀 영향을 미치지 않는다. 각 클래스는 하나의 책임만 가지고 있기 때문에, 하나의 기능을 변경할 때 다른 기능에 영향을 주지 않게 된다.

 

컴포지트 패턴

다음으로 컴포지트 패턴을 알아보도록 하겠다. 컴포지트 패턴이란 객체를 트리구조로 구성해서 부분-전체 계층구조를 구현한다. 컴포지트 패턴을 사용하면 클라이언트에서 개별 객체와 복합 객체를 똑같은 방법으로 다룰 수 있다. 컴포지트 패턴으로 부분-전체 계층 구조를 생성할 수 있다. 이 패턴을 사용하면 클라이언트가 개별 객체와 객체들의 그룹을 동일하게 다룰 수 있다. 컴포지트 패턴은 복잡한 구조를 간단하게 표현하고, 계층 구조를 유연하게 다루기 위한 강력한 도구이다.

 

클래스 다이어그램은 다음과 같다.

컴포지트 패턴의 주요 개념

  1. 구성 요소(Component):
    • Component는 모든 객체들이 가져야 하는 공통 인터페이스를 정의한다. 이 인터페이스에는 트리 구조의 개별 객체와 그 집합을 동일하게 다룰 수 있는 메서드들이 포함된다.
  2. 잎(Leaf):
    • Leaf는 트리 구조의 가장 작은 구성 요소로, 자식이 없는 노드이다. Leaf는 실제 작업을 수행하며, 일반적으로 재귀적으로 호출되는 메서드를 구현한다. Leaf는 더 이상 자식을 추가할 수 없는 가장 말단의 객체이다.
  3. 컴포지트(Composite):
    • Composite는 자식 구성 요소(다른 Leaf 또는 Composite)를 포함할 수 있는 복합 객체이다. Composite는 자식 구성 요소를 관리하고, 트리 구조에서의 작업을 재귀적으로 수행하는 역할을 한다.

클래스 다이어그램을 참고하여 코드로 구현해 보도록 하겠다.

 

컴포지트 패턴 코드 구현

Component 코드는 다음과 같다.

public abstract class Component {
    public void operation() {
        throw new UnsupportedOperationException();
    }

    public String getName() {
        throw new UnsupportedOperationException();
    }

    public String getDescription() {
        throw new UnsupportedOperationException();
    }

    public void print() {
        throw new UnsupportedOperationException();
    }

    public void add(Component component) {
        throw new UnsupportedOperationException();
    }

    public void remove(Component component) {
        throw new UnsupportedOperationException();
    }

    public Component getChild(int idx) {
        throw new UnsupportedOperationException();
    }
}

어떤 메서드는 Leaf 클래스에서 어떤 메서드는 Composit 클래스에서 사용할 수 있기 때문에 모두 UnsupportedOperationException()으로 예외처리를 했다. 각 클래스는 자기 역할에 맞지 않는 케소드는 오버라이드 하지 않고 기본 구현을 사용하면 된다.

 

다음은 Leaf 클래스이다.

public class Leaf extends Component {
    private String name;
    private String description;

    public Leaf(String name, String description) {
        this.name = name;
        this.description = description;
    }

    @Override
    public void print() {
        System.out.println("name : " + name + ", description : " + description);
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public String getDescription() {
        return description;
    }
}

 

다음으로 Composit 클래스이다.

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

public class Composit extends Component {
    private String name;
    private String description;
    private List<Component> childList;

    public Composit(String name, String description) {
        this.name = name;
        this.description = description;
        childList = new ArrayList<>();
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public String getDescription() {
        return description;
    }

    @Override
    public void print() {
        System.out.println("-----composit-----");
        System.out.println("composit name : " + name + ", description : " +description);
        for(Component child : childList) {
            child.print();
        }
    }

    @Override
    public void add(Component component) {
        childList.add(component);
    }

    @Override
    public void remove(Component component) {
        childList.remove(component);
    }

    @Override
    public Component getChild(int idx) {
        return childList.get(idx);
    }
}

List<Component> childList에 자식을 저장할 수 있다.

 

다음으로 Client 클래스와 테스트 코드이다.

public class Client {
    private Component rootComponent;

    public Client(Component rootComponent) {
        this.rootComponent = rootComponent;
    }

    public void print() {
        rootComponent.print();
    }
}
package compositePattern;

public class CompositeTest {
    public static void main(String[] args) {
        Composit rootComposit = new Composit("root composit", "root");
        Client client = new Client(rootComposit);

        Composit child1 = new Composit("child1", "child");
        Composit child2 = new Composit("child2", "child");
        Composit child3 = new Composit("child3", "child");
        Composit child4 = new Composit("child4", "child");


        rootComposit.add(child1);
        rootComposit.add(child2);
        rootComposit.add(child3);
        child3.add(child4);

        child1.add(new Leaf("leafA1", "hi"));
        child1.add(new Leaf("leafA2", "hihi"));

        child2.add(new Leaf("leafB1", "wow"));
        child2.add(new Leaf("leafB2", "wowowow"));

        child3.add(new Leaf("leafC1", "wowowow"));
        child3.add(new Leaf("leafC2", "wowowow"));
        child3.add(new Leaf("leafC3", "wowowow"));

        child4.add(new Leaf("leafD3", "wowowowowwowow"));

        rootComposit.print();
    }
}

 

테스트 결과는 다음과 같다.

 

마무리

컴포지트 패턴과 단일 책임 원칙을 같이 배움으로써 상황에 따라 원칙을 적절하게 사용해야 함을 알 수 있다. 컴포지트 패턴은 단일 책임 원칙을 깨고 있다. 컴포지트 패턴에서 Composite 클래스는 다음과 같은 여러 책임을 가질 수 있다.

  1. 자식 요소 관리: 자식 요소를 추가하고, 제거하고, 접근하는 책임.
  2. 트리 구조 관리: 자식 요소의 순회를 관리하는 책임.
  3. 자신의 작업 수행: Leaf와 마찬가지로 자신의 작업(예: operation() 메서드에서 정의된 작업)을 수행하는 책임.

소프트웨어 설계에서 모든 원칙을 완벽하게 따르는 것은 어려울 수 있으며, 때로는 설계의 목표와 상황에 따라 특정 원칙을 일부 타협할 필요가 있다. 한 가지 원칙만 고수하는 것보다 상황에 따라 유연하게 접근하는 것이 더 좋은 설계를 이끌어낼 수 있다.