...
Composite Pattern
복합체 패턴(Composite Pattern)은 복합 객체(Composite) 와 단일 객체(Leaf)를 동일한 컴포넌트로 취급하여, 클라이언트에게 이 둘을 구분하지 않고 동일한 인터페이스를 사용하도록 하는 구조 패턴이다.
복합체 패턴은 전체-부분의 관계를 갖는 객체들 사이의 관계를 트리 계층 구조로 정의해야 할때 유용하다. 윈도우나 리눅스의 파일 시스템 구조를 떠올려보면 쉽게 이해할 수 있다.
폴더(디렉토리) 안에는 파일이 들어 있을수도 있고 파일을 담은 또 다른 폴더도 들어있을 수 있다. 이를 복합적으로 담을수 있다 해서 Composite 객체라고 불리운다. 반면 파일은 단일 객체 이기 때문에 이를 Leaf 객체라고 불리운다. 즉 Leaf는 자식이 없다.
복합체 패턴은 바로 이 폴더와 파일을 동일한 타입으로 취급하여 구현을 단순화 시키는 것이 목적이다. 폴더 안에는 파일 뿐만 아니라 서브 폴더가 올수 있고 또 서브 폴더안에 서브 폴더가 오고.. 이런식으로 계층 구조를 구현하다 보면, 자칫 복잡해 질 수 도 있는 복합 객체를 재귀 동작을 통해 하위 객체들에게 작업을 위임한다. 그러면 복합 객체와 단일 객체를 대상으로 똑같은 작업을 적용할 수 있어 단일 / 복합 객체를 구분할 필요가 거의 없어진다.
윈도우에선 폴더와 파일은 엄연히 다른 놈이지만, 리눅스(Unix)에선 디렉토리와 파일은 모두 파일로 취급된다. 어찌보면 리눅스 OS가 복합체(Composite) 패턴을 통해 계층 구조를 구현하였다고 볼 수 있다.
당연히 실제 설계는 다르겠지만, 복합체 패턴은 입문하기 어려운 패턴중 하나이기에 이러한 비유를 통해 좀더 패턴에 친숙하게 접근하도록 해보자.
정리하자면, Composite 패턴은 그릇과 내용물을 동일시해서 재귀적인 구조를 만들기 위한 디자인 패턴이라고 말할 수 있다.
Composite 패턴 구조
- Component : Leaf와 Compsite 를 묶는 공통적인 상위 인터페이스
- Composite : 복합 객체로서, Leaf 역할이나 Composite 역할을 넣어 관리하는 역할을 한다.
- Component 구현체들을 내부 리스트로 관리한다
- add 와 remove 메소드는 내부 리스트에 단일 / 복합 객체를 저장
- Component 인터페이스의 구현 메서드인 operation은 복합 객체에서 호출되면 재귀 하여, 추가 단일 객체를 저장한 하위 복합 객체를 순회하게 된다.
- Leaf: 단일 객체로서, 단순하게 내용물을 표시하는 역할을 한다.
- Component 인터페이스의 구현 메서드인 operation은 단일 객체에서 호출되면 적절한 값만 반환한다
- Client : 클라이언트는 Component를 참조하여 단일 / 복합 객체를 하나의 객체로서 다룬다.
복합체 패턴의 핵심은 Composite 와 Leaf가 동시에 구현하는 operation() 인터페이스 추상 메서드를 정의하고, Composite 객체의 operation() 메서드는 자기 자신을 호출하는 재귀 형태로 구현하는 것이다. 왜냐하면 폴더 안에 폴더를 넣고, 그 안에 또 폴더를 넣고 파일을 넣는 트리 구조를 생각해보면, 재귀적으로 반복되는 형식이 나타나기 때문이다. 그래서 단일체와 복합체를 동일한 개체로 취급하여 처리하기 위해 재귀 함수 원리를 이용한다.
Composite 패턴 흐름
클래스 구성
interface Component {
void operation();
}
class Leaf implements Component {
@Override
public void operation() {
System.out.println(this + " 호출");
}
}
class Composite implements Component {
// Leaf 와 Composite 객체 모두를 저장하여 관리하는 내부 리스트
List<Component> components = new ArrayList<>();
public void add(Component c) {
components.add(c); // 리스트 추가
}
public void remove(Component c) {
components.remove(c); // 리스트 삭제
}
@Override
public void operation() {
System.out.println(this + " 호출");
// 내부 리스트를 순회하여, 단일 Leaf이면 값을 출력하고,
// 또다른 서브 복합 객체이면, 다시 그 내부를 순회하는 재귀 함수 동작이 된다.
for (Component component : components) {
component.operation(); // 자기 자신을 호출(재귀)
}
}
public List<Component> getChild() {
return components;
}
}
클래스 흐름
class Client {
public static void main(String[] args) {
// 1. 최상위 복합체 생성
Composite composite1 = new Composite();
// 2. 최상위 복합체에 저장할 Leaf와 또다른 서브 복합체 생성
Leaf leaf1 = new Leaf();
Composite composite2 = new Composite();
// 3. 최상위 복합체에 개체들을 등록
composite1.add(leaf1);
composite1.add(composite2);
// 4. 서브 복합체에 저장할 Leaf 생성
Leaf leaf2 = new Leaf();
Leaf leaf3 = new Leaf();
Leaf leaf4 = new Leaf();
// 5. 서브 복합체에 개체들을 등록
composite2.add(leaf2);
composite2.add(leaf3);
composite2.add(leaf4);
// 6. 최상위 복합체의 모든 자식 노드들을 출력
composite1.operation();
}
}
클라이언트에서 operation 메서드를 호출하게 되면, 단일체일 경우 값이 호출 되고, 복합체일 경우 자기 자신을 호출하는 재귀 함수에 의해 저장하고 있는 하위 Leaf 객체들을 순회하여 호출하게 된다.
Composite 패턴 특징
패턴 사용 시기
- 데이터를 다룰때 계층적 트리 표현을 다루어야 할때
- 복잡하고 난해한 단일 / 복합 객체 관계를 간편히 단순화하여 균일하게 처리하고 싶을때
패턴 장점
- 단일체와 복합체를 동일하게 여기기 때문에 묶어서 연산하거나 관리할 때 편리하다.
- 다형성 재귀를 통해 복잡한 트리 구조를 보다 편리하게 구성 할 수 있다.
- 수평적, 수직적 모든 방향으로 객체를 확장할 수 있다.
- 새로운 Leaf 클래스를 추가하더라도 클라이언트는 추상화된 인터페이스 만을 바라보기 때문에 개방 폐쇄 원칙(OCP)을 준수 한다. (단일 부분의 확장이 용이)
패턴 단점
- 재귀 호출 특징 상 트리의 깊이(depth)가 깊어질 수록 디버깅에 어려움이 생긴다.
- 설계가 지나치게 범용성을 갖기 때문에 새로운 요소를 추가할 때 복합 객체에서 구성 요소에 제약을 갖기 힘들다.
- 예를들어, 계층형 구조에서 leaf 객체와 composite 객체들을 모두 동일한 인터페이스로 다루어야하는데, 이 공통 인터페이스 설계가 까다로울 수 있다.
- 복합 객체가 가지는 부분 객체의 종류를 제한할 필요가 있을 때
- 수평적 방향으로만 확장이 가능하도록 Leaf를 제한하는 Composite를 만들때
예제를 통해 알아보는 Composite 패턴
아이템을 담는 가방을 담은 가방
복합(composite) 객체와 단일(leaf) 객체를 상자와 아이템으로 비유하자면 아래 그림과 같이 표현할 수 있을 것이다. 상자 안에 아이템을 넣고, 다시 그 상자를 상자 안에 넣는 식으로 말이다.
이러한 구조를 객체 지향 프로그래밍으로 보다 유지보수가 용이하게 컴포지트 패턴을 통해 구현해보자.
다음 Item 클래스와 그를 담는 Bag 클래스가 있다고 하자. 가방안에 아이템을 담는 형식이니, Item 클래스는 Leaf가 되고 Bag 클래스는 Composite가 된다.
아래 코드에서 우리가 구현하고 싶은 것은 Bag 속 리스트에 담겨진 Item 객체들의 총 가격(price) 값을 추출하고 싶다고 한다. 그런데 단순히 가방 안에 아이템들이 들어있을 뿐 아니라 복수의 아이템을 담은 또다른 가방들이 여러개 들어 있을 수 있고 그 가방안에 가방이 들어있을 수 있다. 그렇다면 루트 가방일 경우 하위 계층까지 순회하여 아이템의 price값을 합산해야 되는데 이러한 계층 트리 구조를 컴포지트 패턴으로 클래스를 구성을 하면 아래와 같이 되게 된다.
- Composite와 Leaf 객체를 공용으로 묶는 ItemComponent 인터페이스를 정의하고, Composite와 Leaf 객체에서 동시에 쓰이는 추상 메소드를 정의한다.
- Composite 객체인 Bag 클래스에서 ItemComponent 타입의 공용 아이템을 담는 내부 리스트를 정의한다.
- Component 인터페이스의 공통적인 operation인
getPrice()메서드는 Item일 경우 그대로 price값을 반환하면 되지만, Bag일 경우 가방은 단순히 아이템을 담는 그릇일 뿐이기 때문에 자기 자신을 호출하여 현재 가방에 들어있는 Item을 순회하는 재귀 동작을 행하게 된다.
// Component 인터페이스
interface ItemComponent {
int getPrice();
String getName();
}
// Composite 객체
class Bag implements ItemComponent {
// 아이템들과 서브 가방 모두를 저장하기 위해 인터페이스 타입 리스트로 관리
List<ItemComponent> components = new ArrayList<>();
String name; // 가방 이름
public Bag(String name) {
this.name = name;
}
// 리스트에 아이템 & 가방 추가
public void add(ItemComponent item) {
components.add(item);
}
// 현재 가방의 내용물을 반환
public List<ItemComponent> getComponents() {
return components;
}
@Override
public int getPrice() {
int sum = 0;
for (ItemComponent component : components) {
// 만일 리스트에서 가져온 요소가 Item이면 정수값을 받을 것이고, Bag이면 '재귀 함수' 동작이 되게 된다 ☆
sum += component.getPrice(); // 자기 자신 호출(재귀)
}
return sum; // 그렇게 재귀적으로 돌아 하위 아이템들의 값을 더하고 반환하게 된다.
}
@Override
public String getName() {
return name;
}
}
// Leaf 객체
class Item implements ItemComponent {
String name; // 아이템 이름
int price; // 아이템 가격
public Item(String name, int price) {
this.name = name;
this.price = price;
}
@Override
public int getPrice() {
return price;
}
@Override
public String getName() {
return name;
}
}
class Client {
public static void main(String[] args) {
// 1. 메인 가방 인스턴스 생성
Bag bag_main = new Bag("메인 가방");
// 2. 아이템 인스턴스 생성
Item armor = new Item("갑옷", 250);
Item sword = new Item("장검", 500);
// 3. 메인 가방에는 모험에 필요한 무구 아이템만을 추가
bag_main.add(armor);
bag_main.add(sword);
// 4. 서브 가방 인스턴스 생성
Bag bag_food = new Bag("음식 가방");
// 5. 아이템 인스턴스 생성
Item apple = new Item("사과", 400);
Item banana = new Item("바나나", 130);
// 6. 서브 가방에는 음식 아이템만을 추가
bag_food.add(apple);
bag_food.add(banana);
// 7. 서브 가방을 메인 가방에 넣음
bag_main.add(bag_food);
// ----------------------------------------------------- //
Client client = new Client();
// 가방 안에 있는 모든 아이템의 총 값어치를 출력 (가방안에 아이템 뿐만 아니라 서브 가방도 들어있음)
client.printPrice(bag_main);
// 서브 가방 안에 있는 모든 아이템의 총 값어치를 출력
client.printPrice(bag_food);
}
public void printPrice(ItemComponent bag) {
int result = bag.getPrice();
System.out.println(bag.getName() + "의 아이템 총합 : " + result + " 골드");
}
}
클라이언트에서는 ItemComponent 만을 사용하기 때문에 Item, Bag 구현체 상관 없이(전체, 개별 구분 없이) 구현 메소드만 호출하면 내부에서 정의된 구현에 따라 원하는 값을 얻을 수 있게 된다.
만일 패턴을 사용하지 계층 트리 구조를 구현하기 위해 매우 하드한 코딩을 해야 할지도 모른다.
파일 디렉토리 시스템 구현
본 포스팅 초반에 에시를 들었던 파일-폴더 시스템을 구현해보자. 예를들어 리눅스에서 ls 나 tree 명령어를 쓰면 파일들이 일괄적으로 출력되는데 이 기능을 비슷하게 컴포지트 패턴을 통해 구성해보고자 한다. 위에서 한번 연습했던 가방-아이템 구조를 폴더-파일 구조로 그대로 치환만 하면 된다.
// Component
interface Node {
// 계층 트리 출력
void print();
void print(String str);
// 파일/폴더 용량 얻기
int getSize();
}
// Composite
class Folder implements Node {
private String name; // 폴더 이름
private ArrayList<Node> list;
public Folder(String name) {
this.name = name;
list = new ArrayList<>();
}
// 리스트에 폴더, 파일 추가
public void add(Node node) {
list.add(node);
}
// 공백 indent 표현 처리를 위한 print 메서드 오버로딩
public void print() {
this.print("");
}
public void print(String str) {
int size = getSize(); // 폴더가 담고 있는 모든 파일에 대한 용량 합산
System.out.println(str + "\uD83D\uDCC2" + name + " (" + size + "kb)");
for (Node node : list) {
// Folder 일 경우 재귀 동작
node.print(str + " "); // 인자로 공백문자를 할당하여 indent 처리
}
}
// 각 파일의 용량(kb) 구하기
public int getSize() {
int sum = 0;
for (Node node : list) {
sum += node.getSize(); // print 로직과 똑같이 재귀 동작
}
return sum;
}
}
// Leaf
class File implements Node {
private String name; // 파일 이름
private int size; // 파일 사이즈
public File(String name, int size) {
this.name = name;
this.size = size;
}
public void print() {
this.print("");
}
public void print(String str) {
System.out.println(str + "\uD83D\uDCDC" + name + " (" + size + "kb)");
}
public int getSize() {
return size;
}
}
class Client {
public static void main(String[] args) {
Folder root = new Folder("root");
File file1 = new File("file1", 10);
Folder sub1 = new Folder("sub1");
Folder sub2 = new Folder("sub2");
root.add(sub1);
root.add(file1);
root.add(sub2);
File file11 = new File("file11", 10);
File file12 = new File("file12", 10);
sub1.add(file11);
sub1.add(file12);
File file21 = new File("file21", 10);
sub2.add(file21);
// 전체 dir 출력
root.print();
}
}
클라이언트에선 폴더든 파일이든 노드 객체를 생성하고 복합체인 폴더에 추가만 해주면 알아서 계층 구조가 형성되게 되고, 이를 순회할때 재귀 동작을 통해 모든 노드들을 접근하게 된다.
참고로 print 메서드를 두개로 오버로딩(overloading) 한게 이상할수도 있지만, 공백 indent를 표현하기 위한 기법 중 하나이다. 문자열 파라미터를 가진 print 메서드를 재귀적으로 호출하여 공백을 추가하는 식이다.
# 참고자료
코딩으로 학습하는 GoF의 디자인 패턴 - 백기선
https://www.youtube.com/watch?v=g96bJvVDZPs
https://refactoring.guru/design-patterns/composite
https://stacktraceguru.com/composite-design-pattern/
http://www-sop.inria.fr/axis/cbrtools/usermanual-eng/Patterns/Composite.html
https://springframework.guru/gang-of-four-design-patterns/composite-pattern/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.