코딩 · 디자인 패턴
예제로 이해하는 컴포지트 패턴
핵심 답변
컴포지트 패턴은 객체를 트리 구조로 구성하고, 개별 객체와 객체의 그룹을 똑같이 다룰 수 있게 하는 구조 패턴입니다. 리프(파일 같은 것)와 컴포지트(파일과 다른 폴더를 담은 폴더 같은 것)가 size() 같은 같은 인터페이스를 구현합니다. 컴포지트는 자식들에게 같은 연산을 호출하는 방식으로 연산을 구현하므로, 클라이언트 코드는 노드의 종류를 확인하지 않고도 트리 전체를 다룰 수 있습니다.
의도
객체를 트리 구조로 구성해 부분-전체 계층을 표현합니다. 컴포지트 패턴은 클라이언트가 개별 객체와 객체의 조합을 일관되게 다룰 수 있게 해 줍니다. Gang of Four(GoF) 책의 일곱 가지 구조 패턴 중 하나입니다.
해결하는 문제
어떤 항목이든 총 크기를 보여 줘야 하는 저장소 앱을 떠올려 보세요. 파일에는 크기가 있습니다. 폴더의 크기는 안에 든 모든 것의 합이며, 폴더는 파일과 다른 폴더를 얼마든지 깊게 중첩해서 담을 수 있습니다.
공통 인터페이스가 없으면 트리를 순회하는 모든 코드가 "파일인가, 폴더인가?"를 묻고 각각을 처리해야 하며, 보통 같은 재귀를 거듭 작성하게 됩니다. 바로가기나 압축 파일 같은 새 노드 타입을 추가하려면 그런 검사를 모두 찾아 고쳐야 합니다. 컴포지트 패턴은 재귀를 노드 자체 안에 넣습니다.
구조와 참여자
| 참여자 | 역할 | 예제에서 |
|---|---|---|
| Component | 트리의 모든 객체에 공통인 인터페이스를 선언. | FsNode |
| Leaf | 자식이 없는 객체로, 연산을 직접 구현. | FileNode |
| Composite | 자식 컴포넌트를 저장하고 그들에게 위임해 연산을 구현. | Folder |
| Client | Component 인터페이스를 통해 어떤 노드든 다룸. | report |
TypeScript 구현
interface FsNode {
readonly name: string;
size(): number;
print(indent?: string): string[];
}
class FileNode implements FsNode {
constructor(readonly name: string, private bytes: number) {}
size(): number { return this.bytes; }
print(indent = ""): string[] { return [`${indent}${this.name} (${this.bytes})`]; }
}
class Folder implements FsNode {
private children: FsNode[] = [];
constructor(readonly name: string) {}
add(...nodes: FsNode[]): this { this.children.push(...nodes); return this; }
size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); }
print(indent = ""): string[] {
const lines = [`${indent}${this.name}/ (${this.size()})`];
for (const child of this.children) lines.push(...child.print(indent + " "));
return lines;
}
}
const root = new Folder("project").add(
new FileNode("README.md", 120),
new Folder("src").add(new FileNode("main.ts", 900), new FileNode("util.ts", 300)),
);
// The client treats a single file and a whole tree the same way.
const report = (node: FsNode) => console.log(node.print().join("\n"));
report(root);
// project/ (1320)
// README.md (120)
// src/ (1200)
// main.ts (900)
// util.ts (300)
report(new FileNode("notes.txt", 42)); // notes.txt (42)report는 노드 타입을 전혀 확인하지 않습니다. 크기를 0으로 보고하는 Symlink 같은 새로운 종류의 노드는 FsNode만 구현하면 됩니다.
설계 결정: 투명성과 안전성
add()와 remove() 같은 자식 관리 메서드는 어디에 둬야 할까요? GoF 책은 이 절충을 설명합니다.
| 선택 | 의미 | 절충 |
|---|---|---|
| 안전성 | 위 예제처럼 Composite만 add()와 remove()를 가집니다. | 타입 안전: 파일에 자식을 추가할 수 없습니다. 하지만 클라이언트는 컴포지트를 들고 있는지 알아야 합니다. |
| 투명성 | Component가 add()와 remove()를 선언하고, 리프는 예외를 던지거나 무시합니다. | 클라이언트에게 모든 노드가 똑같아 보이지만 실수는 런타임에야 드러납니다. |
TypeScript에서는 타입 검사기가 컴파일 시점에 실수를 잡아 주므로 안전성 쪽이 대체로 더 잘 맞습니다.
언제 쓰고 언제 쓰지 말아야 하나
다음과 같을 때 컴포지트 패턴을 사용합니다.
- 데이터가 본질적으로 트리일 때: 파일과 폴더, 컨테이너 안의 UI 위젯, 메뉴와 하위 메뉴, 조직도, 도형과 그룹.
- 클라이언트가 객체 하나와 객체의 그룹의 차이를 무시할 수 있어야 할 때.
다음 경우에는 피하세요.
- 구조가 평평하거나 항상 한 단계 깊이일 때. 단순한 배열이 더 간단합니다.
- 리프와 그룹에 필요한 연산이 매우 다를 때. 하나의 인터페이스에 억지로 넣으면 일부 노드에는 의미 없는 메서드가 생깁니다.
- 컴포지트가 담을 수 있는 자식을 제한해야 할 때, 예를 들어 특정 노드 타입만 허용할 때. 균일한 인터페이스 때문에 타입 시스템으로 이런 규칙을 강제하기가 더 어렵습니다.
실제 사용 사례
- DOM: 모든 요소와 텍스트 노드는
Node입니다. 요소는 다른 노드를 포함할 수 있고,textContent나cloneNode(true)같은 많은 연산이 단일 노드와 하위 트리 전체에서 똑같이 동작합니다. - React, Vue, Angular 같은 UI 컴포넌트 프레임워크는 컴포넌트가 다른 컴포넌트를 렌더링할 수 있는 트리로 인터페이스를 구성합니다.
- Java AWT와 Swing:
Container는Component의 서브클래스이면서 다른 컴포넌트를 담으므로, 버튼으로 가득한 패널 자체가 컴포넌트입니다. - 컴파일러의 추상 구문 트리, 게임 엔진의 씬 그래프, 중첩 메뉴도 모두 같은 발상을 따릅니다.
관련 패턴
- 이터레이터 패턴: 자식이 어떻게 저장되는지 드러내지 않고 컴포지트의 노드를 순회합니다.
- 비지터 패턴: 노드 클래스를 바꾸지 않고 컴포지트 트리에 새 연산(내보내기, 검증, 통계)을 추가합니다.
- 데코레이터 패턴: 이것도 컴포넌트 인터페이스를 가지고 객체를 재귀적으로 감싸지만, 데코레이터는 자식이 정확히 하나이고 동작을 추가하는 반면, 컴포지트는 자식이 여럿이고 그 결과를 합칩니다.
- 빌더 패턴: 복잡한 컴포지트 트리를 단계별로 만드는 데 자주 쓰입니다.
- 책임 연쇄 패턴: 브라우저 이벤트가 DOM을 따라 버블링되듯, 컴포지트의 부모 링크를 이용해 요청을 자식에서 부모로 올려 보내는 경우가 많습니다.
자주 묻는 질문
리프와 컴포지트는 무엇이 다른가요?
리프는 자식이 없고 작업을 직접 수행합니다. 컴포지트는 자식 컴포넌트를 담고, 보통 각 자식에게 같은 연산을 호출하고 결과를 합치는 방식으로 작업합니다.
컴포지트 패턴은 트리에만 쓰나요?
트리 모양의 부분-전체 계층을 위해 설계되었습니다. 순환이 있는 일반 그래프에서는 무한 재귀를 피하기 위해 방문한 노드를 추적하는 등의 추가 주의가 필요합니다.
컴포지트 패턴과 데코레이터 패턴은 무엇이 다른가요?
둘 다 공통 인터페이스와 재귀적 합성을 사용합니다. 데코레이터는 정확히 하나의 객체를 감싸 동작을 추가하고, 컴포지트는 많은 자식을 묶어 하나처럼 다룹니다.
리프 클래스에도 add()를 둬야 하나요?
완전한 투명성을 원하고 리프에 자식을 추가할 때 런타임 오류를 감수할 때만 그렇습니다. 타입이 있는 언어에서는 보통 add()를 컴포지트에만 두는 것이 더 안전합니다.