그래픽 편집기에서 도형을 그린다고 하자. 선 하나, 사각형 하나, 텍스트 하나가 있고, 이들을 여러 개 묶어 그룹으로 만들 수 있다. 그룹을 통째로 드래그하면 안의 도형이 함께 움직이고, 그룹 안에 또 그룹을 넣을 수도 있다. 이제 화면 전체를 다시 그리는 코드를 짜 보면 곧 이상한 지점에 부딪힌다. 낱개 도형은 shape.draw()를 부르면 되는데, 그룹은 안에 든 것을 하나하나 꺼내 다시 그려 줘야 한다. 그리려는 대상이 낱개냐 그룹이냐를 매번 물어봐야 하는 것이다.
이 물음은 draw에서 끝나지 않는다. 그룹의 넓이를 재려면, 옮기려면, 삭제하려면 — 모든 연산마다 “이게 잎이냐 가지냐”를 따져야 한다. 컴포지트(Composite) 패턴은 이 물음 자체를 없앤다. 낱개 도형과 그룹에게 같은 얼굴을 씌워, 클라이언트가 둘을 구분하지 않고 하나처럼 다루게 한다. 그룹을 그리라고 하면 그룹이 알아서 자식들을 그린다 — 재귀가 구분을 대신한다.
무엇이 문제인가 — 동기
부분-전체(part-whole) 트리는 소프트웨어 도처에 있다. 파일과 디렉터리, 메뉴와 하위 메뉴, GUI의 패널과 위젯, 조직의 팀과 사원. 공통점은 하나다 — 개별 원소(잎)와 그것을 담는 복합체(가지)가 섞여 트리를 이루고, 가지 안에 또 가지가 들어간다. 이 구조를 잎과 가지를 다른 타입으로 두고 짜면, 클라이언트 코드가 병에 걸린다.
파일시스템으로 옮겨 보자. File은 크기가 자기 바이트 수고, Directory는 크기가 안에 든 모든 것의 합이다. 전체 크기를 구하는 코드는 이렇게 시작한다.
long size(Object node) {
if (node instanceof File) {
return ((File) node).getBytes();
} else if (node instanceof Directory) {
long total = 0;
for (Object child : ((Directory) node).getChildren()) {
total += size(child); // 또 타입을 물어본다
}
return total;
}
throw new IllegalArgumentException();
}
첫째, 타입 분기가 연산마다 번진다. 크기뿐 아니라 이름 출력, 검색, 삭제, 권한 확인 — 트리를 도는 모든 연산이 이 if (instanceof File) ... else ... 골격을 복제한다. 종류가 하나 늘면(심볼릭 링크가 추가되면) 이 모든 자리를 찾아 else if를 끼워 넣어야 한다.
둘째, 재귀가 클라이언트의 짐이 된다. 디렉터리가 자식을 순회하며 자신을 재귀 호출하는 로직이 클라이언트 코드에 노출돼 있다. 트리 구조를 다루는 방법을 클라이언트가 속속들이 알아야 한다 — 자식 목록을 어떻게 꺼내는지, 어떻게 내려가는지.
셋째, 분기를 빠뜨리면 조용히 깨진다. 새 연산을 짜면서 디렉터리 케이스를 잊으면, 컴파일은 통과하되 런타임에 크기가 0으로 나온다. 타입을 손으로 갈라내는 코드는 갈래 하나를 빠뜨리기 쉽고, 그 실수를 컴파일러가 잡아 주지 않는다.
판단 기준: 데이터가 “원소와 그것을 담는 그릇이 재귀적으로 중첩되는 트리”고, 그 트리에 여러 연산을 거는데 연산마다 잎/가지 분기가 반복된다면 컴포지트를 의심한다. 함정: 계층이 한 겹뿐이고(그룹 안에 그룹이 없고) 앞으로도 깊어지지 않는다면, 굳이 재귀 구조를 세울 것 없이 리스트 순회 하나가 정답이다 — 트리가 아닌 걸 트리로 만들면 대가만 치른다.
패턴의 구조 — 누가 무엇을 하나
컴포지트는 세 배역으로 이뤄진다. 핵심은 잎과 가지가 같은 인터페이스를 구현한다는 것, 그리고 가지가 그 인터페이스 타입으로 자식들을 품는다는 것이다.
Component(공통 인터페이스). 잎과 가지가 공유하는 하나의 얼굴이다. Node라면 long size(), void print(String indent) 같은 연산을 선언한다. 클라이언트는 오직 이 타입만 안다 — 손에 쥔 것이 파일인지 디렉터리인지 모른 채 node.size()를 부른다. 구분이 사라지는 지점이 바로 여기다.
Leaf(잎). 트리의 끝, 더 이상 자식이 없는 개별 객체다. File이 자기 바이트 수를 반환하듯, 연산을 자기 자신에 대해 곧바로 처리한다. 자식이 없으니 재귀도 없다.
Composite(복합체, 가지). 자식들을 품는 그릇이다. 결정적인 점은 자식을 Component 타입으로 담는다는 것 — List<Node>이지 List<File>이 아니다. 그래서 가지의 자식으로 잎이 오든 또 다른 가지가 오든 상관하지 않는다. Directory.size()는 자식 목록을 돌며 각 자식의 size()를 부르고 합한다. 이 자식이 파일이면 바로 바이트를 돌려주고, 디렉터리면 그 안에서 또 재귀가 일어난다 — 가지 스스로가 재귀의 주체이고, 클라이언트는 첫 호출만 하면 트리 끝까지 저절로 내려간다.
협력의 결정적 지점은 자식 관리 연산(add/remove/getChild)을 어디에 두느냐다. 이 연산은 가지에만 의미가 있다 — 파일에 자식을 추가하는 건 말이 안 된다. 두 갈래가 있다. 이 연산들을 Component 인터페이스에 끌어올리면(투명성 우선), 클라이언트가 잎과 가지를 진짜로 똑같이 다룰 수 있지만 잎에도 add가 생겨 호출하면 예외를 던지는 무의미한 메서드가 된다. 반대로 Composite에만 두면(안전성 우선), 잎에 add를 부르는 실수를 컴파일러가 막아 주지만 클라이언트가 자식을 추가하려면 그 대상이 가지임을 알고 형변환해야 한다 — 구분을 없애려던 목적이 반쯤 되살아난다. GoF는 투명성을 기본으로 권했지만, 이건 컴포지트에서 가장 오래된 딜레마이고 정답이 맥락에 달렸다.
판단 기준: 클라이언트가 자식 추가·삭제를 자주 하고 트리를 균일하게 다루는 게 최우선이면 자식 관리를 Component로 올린다(투명성). 잎에 자식을 다는 실수를 타입으로 막는 게 더 중요하면 Composite에만 둔다(안전성). 함정: 투명성을 택하고 잎의 add가 조용히 아무것도 안 하게 만들면, 버그가 예외 대신 침묵으로 나타나 더 찾기 어렵다 — 무의미한 연산은 침묵이 아니라 예외로 막아라.
코드로 — 패턴이 자라나는 과정
타입을 손으로 갈라내는 크기 계산에서 출발해, 공통 인터페이스를 뽑고 잎과 가지가 재귀로 협력하게 만드는 과정을 단계로 따라간다.
// File과 Directory가 공통 조상 없이 따로 논다. 트리를 다루는 코드는// 매번 instanceof로 갈래를 갈라야 한다.public class File { private String name; private long bytes; public long getBytes() { return bytes; }}public class Directory { private String name; private List<Object> children = new ArrayList<>(); // File 또는 Directory public List<Object> getChildren() { return children; }}public class SizeCalculator { public long size(Object node) { if (node instanceof File) { return ((File) node).getBytes(); } else if (node instanceof Directory) { long total = 0; for (Object child : ((Directory) node).getChildren()) { total += size(child); // 자식마다 또 instanceof } return total; } throw new IllegalArgumentException("unknown node"); }}// 이름 출력, 검색, 삭제… 트리를 도는 모든 연산이 이 if/else 골격을 복제한다.// 종류가 하나 늘면 이 분기들을 전부 찾아 고쳐야 하고, 갈래 하나만 빠뜨려도// 컴파일은 통과한 채 런타임에 조용히 틀린 값이 나온다.
첫 스텝의 instanceof 분기가 사라진 것이 핵심이다. 정확히는 사라진 게 아니라, “잎이냐 가지냐”의 구분이 if에서 다형성으로 옮겨 갔다. child.size()를 부르는 순간 자바가 실제 타입에 맞는 메서드로 보내 주므로, 클라이언트는 갈래를 고를 필요가 없다. 그리고 재귀는 클라이언트가 아니라 가지 자신이 짊어진다 — Directory.size()가 자식의 size()를 부르고, 그 자식이 또 디렉터리면 같은 일이 반복된다. 클라이언트는 뿌리에 한 번만 요청하면 트리 깊이만큼의 순회가 저절로 굴러간다.
판단 기준: 리팩터링 후 “새 종류 추가 = Node를 구현한 클래스 하나, 기존 연산 0줄 수정”이고 클라이언트에서 instanceof가 완전히 사라졌다면 컴포지트가 제대로 앉은 것이다. 함정: 공통 인터페이스를 뽑았는데 클라이언트가 여전히 if (node instanceof Directory)로 자식을 순회하고 있다면, 구조만 세우고 재귀를 가지에게 안 넘긴 것이다 — 순회 책임이 클라이언트에 남아 있으면 반쪽짜리다.
결과 — 무엇을 얻고 무엇을 내주나
컴포지트가 사는 값은 균일성과 재귀의 캡슐화다. 클라이언트가 잎과 가지를 구분하지 않으니, 트리를 다루는 코드에서 타입 분기가 통째로 사라진다. 연산을 새로 짤 때 “디렉터리 케이스를 잊었나” 걱정할 일이 없다. 새 종류의 노드(심볼릭 링크, 압축 파일)를 추가해도 Node를 구현하기만 하면 기존 연산·클라이언트는 그를 자동으로 받아들인다 — 개방-폐쇄 원칙이 트리 구조에서 성립한다. 재귀 순회의 복잡성은 가지 안에 갇혀, 클라이언트는 뿌리에 한 번 요청하는 단순함만 누린다.
대가는 세 가지다. 첫째, 타입 안전성이 약해진다. 잎과 가지를 하나로 다루는 대가로, 컴파일 시점의 구분을 포기한다. 자식 관리를 Component에 올려 투명성을 택하면 잎에도 add가 생겨, 파일에 자식을 다는 무의미한 호출을 컴파일러가 못 막는다. 균일성을 얻는 만큼 “이 연산이 이 노드에 정말 유효한가”의 검증을 런타임으로 미루게 된다. 둘째, 과하게 일반적인 설계가 되기 쉽다. 모두를 Node로 통일하려다 보면 인터페이스가 최소 공통분모로 수렴하고, 특정 노드에만 있어야 할 제약(가지만 자식을 가진다)을 타입으로 표현하지 못한다. 셋째, 자식 관리 위치의 딜레마가 남는다. 투명성이냐 안전성이냐는 둘 다 무언가를 내주는 선택이라, 어느 쪽을 골라도 반대편의 불편을 떠안는다 — 이 결정은 사라지지 않고 클라이언트가 트리를 어떻게 쓰느냐에 달려 계속 따라다닌다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. 트리가 깊고, 여러 연산을 걸며, 노드 종류가 늘어나는 구조라면, 약해진 타입 안전성의 대가보다 “모든 연산에서 분기가 사라지고 새 종류가 공짜로 끼는” 이득이 크다. 반대로 계층이 얕고 종류가 굳어 있으면, 공통 인터페이스는 표현력만 깎고 이득은 쓰이지 않는다.
판단 기준: 얻는 것(균일 처리·재귀 캡슐화·새 종류 확장)과 내주는 것(타입 안전성 약화·인터페이스 일반화·자식 관리 딜레마)을 세어 순이득이 양수일 때만 컴포지트를 앉힌다. 함정: 트리도 아닌 것에 “확장성”을 위해 미리 Component 추상을 깔면, 재귀도 안 일어나는 자리에 다형성 배선과 약해진 타입만 떠안는다 — 대가만 크고 균일성은 놀고 있다.
언제 쓰고, 언제 과용인가
컴포지트가 정확히 맞는 자리는 이렇다. 부분-전체 계층이 재귀적으로 중첩되고(그룹 안의 그룹, 디렉터리 안의 디렉터리), 클라이언트가 개별 원소와 복합체를 똑같이 다루기를 원할 때. GUI 위젯 트리(패널이 버튼과 다른 패널을 품는다), 그래픽 그룹, 파일시스템, 중첩 메뉴, 수식·불린식의 파싱 트리가 교과서적 사례다. 자바 Swing의 Component/Container 관계, AWT의 컨테이너가 위젯을 담는 구조가 바로 이 패턴이다.
과용의 냄새는 반대편에서 난다. 계층이 한 겹뿐이라 재귀가 아예 일어나지 않는데 “언젠가 중첩될지 모르니” 컴포지트를 세운다면, 그건 오지 않을 깊이에 거는 보험이다. 잎과 가지의 행동이 실은 크게 달라서 공통 인터페이스가 억지로 짜맞춘 최소 공통분모가 됐다면(가지에만 의미 있는 메서드가 절반이면), 구분을 없앤 게 아니라 억지로 덮은 것이다. 자식 관리를 Component에 올려 놓고 잎의 add가 조용히 실패하게 두면, 균일성을 위해 안전성을 팔았는데 그 안전성 상실이 침묵하는 버그로 돌아온다.
여기서 이웃 패턴과의 경계를 그어 둘 필요가 있다. [데코레이터]는 컴포지트와 구조가 닮았다 — 둘 다 객체가 같은 인터페이스의 다른 객체를 참조하는 재귀적 합성이다. 갈라지는 것은 의도와 자식 수다. 컴포지트는 여럿의 자식을 모아 하나로 다루려는 것(“합치기”)이고, 데코레이터는 하나의 대상을 감싸 책임을 덧붙이려는 것(“꾸미기”)이다. 자식이 여럿이면 컴포지트, 감싸는 대상이 하나면 데코레이터다 — 그래서 둘은 자주 함께 쓰인다. [반복자]는 컴포지트가 만든 트리를 순회하는 방법을 별도 객체로 뽑아, 트리 구조와 순회 방식을 분리한다. [방문자]는 컴포지트 트리에 연산을 추가하는 다른 길이다 — 노드마다 메서드를 넣는 대신, 순회하며 노드 종류에 맞는 연산을 방문자에 모은다. 트리를 파일 크기·검색·출력처럼 여러 방식으로 훑어야 하고 그 연산이 자꾸 는다면, 컴포지트 위에 방문자를 얹는 조합이 흔하다.
판단 기준: 같은 인터페이스로 재귀 합성을 하는데 자식이 여럿을 모으는가(컴포지트) 아니면 **하나를 감싸 기능을 더하는가(데코레이터)**로 둘을 가른다. 함정: 트리 순회 로직을 노드 클래스마다 흩뿌리면 연산이 늘 때마다 모든 노드를 고쳐야 한다 — 순회는 반복자로, 늘어나는 연산은 방문자로 밀어내야 컴포지트가 구조에만 집중한다.
다음으로 데코레이터