컴포지트 패턴
개요
파일 하나의 크기는 바로 알 수 있지만 폴더의 크기는 안에 있는 파일과 하위 폴더의 크기를 더해야 한다. 호출할 때마다 파일인지 폴더인지 검사하면 트리를 쓰는 곳마다 같은 분기가 생긴다.
컴포지트(Composite)는 하나의 항목과 여러 항목을 담은 묶음에 같은 약속을 둔다. 자식이 없는 항목을 잎(Leaf), 자식들을 가진 묶음을 복합체(Composite)라고 부른다.
UML
FileEntry와 Folder는 Entry를 상속한다. Folder는 다시 Entry들을 참조하므로 폴더 안에 폴더를 넣을 수 있고, Bytes는 자식들의 값을 합한다.
역사
GoF는 1994년 Design Patterns에서 Composite를 구조 패턴으로 정리했다. 부분과 전체의 계층을 같은 인터페이스로 다루는 구조다.1
왜 사용하는가?
호출부는 Entry.Bytes만 읽으면 된다. 여러 단계로 중첩된 폴더를 처리하는 반복은 Folder 내부의 재귀 호출로 이어진다.
모든 항목에 자식 추가 메서드를 강요할 필요는 없다. 이 예제는 잎에는 읽기만 허용하고 Folder를 만들 때 자식들을 전달한다.
예제
파일 두 개를 폴더 하나에 넣어 합계 크기를 읽는다. 실제 파일 시스템을 읽지 않는 메모리 예제다.
using System;
using System.Collections.Generic;
using System.Linq;
Entry folder = new Folder(new FileEntry(10), new FileEntry(20));
Console.WriteLine(folder.Bytes);
// 호출부가 잎과 묶음을 같은 방식으로 읽을 수 있는 약속이다.
abstract record Entry
{
public abstract long Bytes { get; }
}
sealed record FileEntry(long Length) : Entry
{
public override long Bytes => Length;
}
// 자식 종류를 검사하는 대신 공통 속성을 읽어 재귀 합계를 낸다.
sealed record Folder : Entry
{
private readonly Entry[] children;
public Folder(params Entry[] children) => this.children = children.ToArray();
public override long Bytes => children.Sum(entry => entry.Bytes);
}
30Folder는 자식이 어떤 종류인지 검사하지 않고 공통 Bytes를 읽는다.
범위 넓힌 예제
폴더를 다른 폴더에 넣고 파일 하나와 묶음을 같은 Entry 목록으로 읽는다.
using System;
using System.Collections.Generic;
using System.Linq;
Entry[] entries =
{
new FileEntry(7),
new Folder(new FileEntry(5), new Folder(new FileEntry(10), new FileEntry(20)))
};
foreach (Entry entry in entries) Console.WriteLine(entry.Bytes);
// 호출부가 잎과 묶음을 같은 방식으로 읽을 수 있는 약속이다.
abstract record Entry
{
public abstract long Bytes { get; }
}
sealed record FileEntry(long Length) : Entry
{
public override long Bytes => Length;
}
// 자식 종류를 검사하는 대신 공통 속성을 읽어 재귀 합계를 낸다.
sealed record Folder : Entry
{
private readonly Entry[] children;
public Folder(params Entry[] children) => this.children = children.ToArray();
public override long Bytes => children.Sum(entry => entry.Bytes);
}
7
35Folder는 생성 시 자식 배열을 복사하고 이후 추가 API를 제공하지 않는다. 구조를 변경하는 트리로 확장한다면 순환 연결을 막고, 합계의 범위와 갱신 시점을 정해야 한다.
TypeScript 예제
호출부가 파일과 폴더를 구별하지 않도록 공통 항목 Entry를 둔다. 파일은 자기 크기를, 폴더는 자식 크기의 합을 돌려주므로 중첩 구조를 계산하는 책임이 Folder 안에 모인다.
// 호출부가 잎과 묶음을 같은 방식으로 읽을 수 있는 약속이다.
abstract class Entry { abstract get bytes(): bigint; }
class FileEntry extends Entry {
constructor(private readonly length: bigint) { super(); }
override get bytes(): bigint { return this.length; }
}
// 자식 종류를 검사하는 대신 공통 속성을 읽어 재귀 합계를 낸다.
class Folder extends Entry {
private readonly children: readonly Entry[];
constructor(children: readonly Entry[]) { super(); this.children = [...children]; }
override get bytes(): bigint {
return this.children.reduce((total, child) => total + child.bytes, 0n);
}
}
const entries: Entry[] = [
new FileEntry(7n),
new Folder([new FileEntry(5n), new Folder([new FileEntry(10n), new FileEntry(20n)])]),
];
for (const entry of entries) console.log(entry.bytes.toString());7
35파일 크기는 정수이므로 TypeScript에서는 bigint를 사용하고 출력할 때 문자열로 바꿨다. 생성 시 자식 배열을 복사하며 실제 파일 시스템에는 접근하지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.