Composite 패턴: 트리 구조를 하나의 인터페이스로 다루는 법
Composite 패턴이 Leaf와 Composite를 같은 인터페이스로 묶어 부분-전체 계층의 조건문·캐스팅을 없애는 원리를 정리한다.
2026-08-13 · 최초 발행 2025-10-14
조건문으로 트리를 순회하던 코드가 무너지는 지점
파일시스템이든 메뉴든 조직도든, 개별 항목과 그 항목들을 묶은 그룹을 같은 화면에서 다뤄야 하는 순간이 온다. 이때 "이게 폴더인지 파일인지"를 매번 instanceof로 분기하는 코드는 계층이 깊어질수록 조건문이 늘어난다. Composite 패턴은 부분-전체(Part-Whole) 계층을 표현하는 구조적 패턴으로, Leaf(단일 객체)와 Composite(복합 객체)를 같은 Component 인터페이스로 묶어 클라이언트가 타입 분기 없이 동일한 연산을 호출하게 만든다.
핵심은 재귀적 합성이다. 상위 객체가 하위 Component 집합을 들고 있다가 동일한 연산을 재귀적으로 위임하면, 트리 전체에 일관된 처리가 퍼진다.
Component, Leaf, Composite가 나누는 역할
Component는 operation 같은 공통 인터페이스를 정의해 클라이언트가 타입 분기 없이 동일 연산을 호출하도록 한다. Leaf는 하위 요소가 없는 단일 객체로, 실제 작업을 수행하는 데만 집중한다. Composite는 자식 Component 컬렉션을 들고 순회·위임으로 트리 전체 연산을 수행하며, 자식 추가·삭제·검색을 제공한다. 불변·가변 여부나 락 전략 같은 운영 정책은 내부 자료구조 선택에 달려 있다.
구현 방식은 두 갈래로 갈린다. 투명성 변형은 add/remove/getChildren 같은 복합 연산도 Component 인터페이스에 선언해두는 방식으로, 사용은 일관되지만 Leaf 쪽에서 이 메서드들을 호출하면 예외 처리가 필요하다. 안전성 변형은 add/remove를 Composite에만 두어 컴파일 타임 안전성을 얻는 대신, 일부 상황에서 타입 캐스팅이나 별도의 조립 경로가 필요해진다.
모델링부터 자료구조 선택까지
먼저 도메인 트리 구조를 식별한다 — 메뉴, 파일시스템, UI 위젯 등에서 Leaf와 Composite의 경계를 정하고, operation 같은 연산 계약과 add/remove/getChildren 같은 구조 관리 범위를 결정한다. 그다음 투명성·안전성 변형 중 하나를 고르고, List냐 Set이냐, 정렬이 필요한지, 중복·사이클을 어떻게 막을지, 부모 참조가 필요한지를 정한다. 동시성이 요구되면 CopyOnWriteArrayList나 ReadWriteLock을 검토한다.
처리 흐름은 단순하다. 루트 Component와 클라이언트의 연산 요청(operation), 필요하면 조건·필터가 입력으로 들어오고, Composite가 자식을 순회하며 각 자식의 operation을 위임한다. 필요하면 단축 평가나 에러 집계를 하고, 최종적으로 트리 전체의 결과(누적 값, 렌더링, 집계 등)를 반환한다. 실패가 나면 부분 성공을 허용할지(continue-on-error), 전체를 롤백할지는 정책으로 정해야 한다.
성능 측면에서는 전체 연산이 O(N)(N=노드 수), add/remove는 자료구조에 따라 평균 O(1)~O(logN) 수준이다. 공간 복잡도는 노드 수와 깊이에 비례하므로 깊은 트리에서는 재귀 호출 스택 대신 반복문이나 명시적 스택을 고려할 만하다. 동시성은 읽기 다중·쓰기 단일 패턴을 기본으로 하되, 쓰기 동시성이 많이 필요하면 락을 분할하거나 버전이 있는 불변 트리(스냅샷)를 쓰는 편이 낫다.
Java로 확인하는 투명성 변형
JDK 17 이상에서 javac Main.java && java Main으로 실행된다.
// File: Main.java
import java.util.*;
interface Component {
void operation();
default void add(Component c) { throw new UnsupportedOperationException("Leaf does not support add"); }
default void remove(Component c) { throw new UnsupportedOperationException("Leaf does not support remove"); }
default List<Component> getChildren() { return List.of(); }
}
final class Leaf implements Component {
private final String name;
Leaf(String name) { this.name = name; }
@Override public void operation() {
System.out.println("Leaf.operation: " + name);
}
}
final class Composite implements Component {
private final String name;
private final List<Component> children = new ArrayList<>();
Composite(String name) { this.name = name; }
@Override public void operation() {
System.out.println("Composite.operation: " + name + " -> begin");
for (Component c : children) c.operation();
System.out.println("Composite.operation: " + name + " -> end");
}
@Override public void add(Component c) { children.add(Objects.requireNonNull(c)); }
@Override public void remove(Component c) { children.remove(c); }
@Override public List<Component> getChildren() { return Collections.unmodifiableList(children); }
}
public class Main {
public static void main(String[] args) {
Composite root = new Composite("root");
Composite groupA = new Composite("groupA");
Composite groupB = new Composite("groupB");
root.add(groupA);
root.add(groupB);
groupA.add(new Leaf("A1"));
groupA.add(new Leaf("A2"));
groupB.add(new Leaf("B1"));
root.operation();
}
}
안전성 변형으로 바꾸려면 add/remove를 Component에서 빼고 Composite에만 선언하면 된다. 대신 트리 조립 책임은 빌더나 팩토리로 분리하는 편이 컴파일 타임 안전성을 살리는 길이다.
트리 구조가 자연스러운 도메인들
UI 위젯 트리에서는 컨테이너(Composite)와 컨트롤(Leaf)의 렌더·레이아웃을 일관되게 처리할 수 있고, 파일시스템·메뉴·조직도에서는 조회·권한·집계 연산을 공통 인터페이스로 통합할 수 있다. AST나 쿼리 플랜에서는 구문 트리 순회·변환·평가를 재귀 위임으로 구현할 수 있다.
도입 전과 후, 무엇이 달라지는가
조건문 중심으로 짜인 코드는 얕은 계층에서는 빠르지만 깊어질수록 분기 비용이 늘고, 타입을 추가할 때마다 여러 곳을 고쳐야 해 확장성이 낮다. Composite를 투명성 변형으로 도입하면 예외 분기가 약간 남지만 일관된 인터페이스 덕에 재사용성과 사용 편의가 높아지고, 안전성 변형으로 도입하면 불필요한 호출이 줄고 컴파일 타임에 오용을 막을 수 있는 대신 조립을 위한 유틸리티가 따로 필요해진다.
| 구분 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| 도입 전(조건문 중심) | 얕은 계층에서 빠름, 깊어지면 분기 비용 증가 | 낮음, 타입 추가 시 다수 수정 | 낮음, 구현 불균질 | 중간, 런타임 오류 위험 | 낮음, 코드 파편화 |
| 도입 후-투명성 변형 | 중간, 예외 분기 약간 존재 | 높음, 일관 인터페이스 | 높음, 재사용 용이 | 중간, Leaf 예외 처리 필요 | 높음, 사용 API 단순 |
| 도입 후-안전성 변형 | 높음, 불필요 호출 제거 | 높음, 컴파일 타임 안전 | 높음 | 높음, 오용 방지 | 중간, 조립 유틸 필요 |
타입 분기와 캐스팅을 걷어내면 분기 수는 3060% 줄어드는 편이고, 신규 노드 타입을 추가할 때 클라이언트 코드를 고쳐야 하는 범위도 01곳 정도로 유지된다(개방-폐쇄 원칙이 강화되는 지점이다). 중복 제거로 결함 밀도가 10~25% 줄어든다는 관측도 있지만 이는 팀·도메인에 따라 편차가 있다. 트리 연산이 O(N)이라는 비용 모델이 단순해지는 것도 캐싱이나 프루닝 최적화를 붙이기 쉬워지는 이유다.
안전하게 쓰기 위한 조건들
사이클은 반드시 막아야 한다 — 자기 자신이나 조상을 자식으로 삽입하는 것을 허용하면 무한 순회로 이어진다. 부모 참조를 관리한다면 메모리 누수에도 주의해야 하고, getChildren은 불변 뷰로 반환해 외부에서 컬렉션을 직접 건드리지 못하게 막는 편이 안전하다.
동시성이 필요하다면 읽기가 많은 워크로드에서는 CopyOnWriteArrayList나 스냅샷 불변 트리를, 읽기·쓰기가 섞인 워크로드에서는 ReadWriteLock으로 읽기를 병렬화하고 쓰기만 단일화하는 편이 낫다. 확장이 필요해지면 연산 자체는 Visitor로 분리하고 순회는 Iterator로 안정화하는 조합이 흔하다. Leaf가 많아 메모리가 부담이면 Flyweight를, 부가 기능이 필요하면 Decorator를 얹어 조합할 수 있다.
계층형 도메인에서는 이렇게 얻는 코드 일관성과 확장성, 테스트 용이성이 초기 설계 비용을 상쇄한다. 변형 선택은 도메인 복잡성과 팀 숙련도에 맞추되, 불변 뷰·사이클 방지·락 전략만큼은 처음부터 표준으로 정해두는 편이 나중에 덜 고생한다.