트렌드브리프
컴포지트 패턴, 부분과 전체를 같은 코드로 다루기

컴포지트 패턴, 부분과 전체를 같은 코드로 다루기

· 게시 · 약 6분 분량

컴포지트 패턴은 파일·폴더처럼 부분과 전체가 섞인 트리 구조를 하나의 인터페이스로 묶어 재귀로 처리하는 GoF 구조 패턴이다. 세 가지 역할과 자바 예시, 도입·회피 기준, 투명성 대 안전성 트레이드오프까지 정리했다.

목차
  1. 컴포지트 패턴은 어떤 문제를 푸는가
  2. 세 가지 역할: 컴포넌트, 잎, 컴포지트
  3. 파일 트리로 구현해 보기
  4. 표준 라이브러리와 실무에서 만나는 컴포지트
  5. 언제 쓰고 무엇을 주의할까

컴포지트 패턴(composite pattern)은 개별 객체 하나와 여러 객체를 담은 묶음을 클라이언트 코드가 똑같은 방식으로 다루게 해 주는 객체지향 설계 패턴이다. 파일과 폴더가 대표적이다. 폴더 안에는 파일이 들어가고 다른 폴더도 들어가는데, 이 둘을 하나의 공통 타입으로 묶으면 '이게 파일인지 폴더인지' 매번 확인하지 않고도 크기 계산이나 삭제 같은 작업을 한 번에 처리한다. 컴포지트 패턴은 GoF(Gang of Four)가 정리한 고전 디자인 패턴 가운데 구조 패턴에 속하며, refactoring.guru는 이를 객체를 트리 구조로 조합해 마치 개별 객체처럼 다루는 패턴으로 설명한다.

이 글은 특정 프레임워크 튜토리얼이 아니라 패턴의 구조와 도입·회피 기준을 다룬다. 예시 코드는 자바로 들지만, 인터페이스와 상속을 지원하는 객체지향 언어라면 개념은 그대로 옮겨진다.

컴포지트 패턴은 어떤 문제를 푸는가

핵심은 '부분과 전체를 구분하는 조건문'을 없애는 것이다. 트리처럼 계층이 있는 구조를 다룰 때 컴포지트 패턴이 없으면, 코드 곳곳에 '이 노드가 잎(자식이 없는 말단)인가, 아니면 자식을 가진 묶음인가'를 확인하는 분기가 퍼진다.

폴더 용량을 구하는 경우를 보자. 컴포지트 패턴 없이 짜면 if (node instanceof Folder) { 자식 순회 } else { 파일 크기 반환 } 같은 타입 검사가 계층이 깊어질수록 반복된다. 컴포지트 패턴은 파일과 폴더가 같은 size() 메서드를 갖게 만들어, 클라이언트가 타입을 몰라도 node.size() 한 줄로 끝내게 한다.

트리를 재귀(자기 자신을 다시 호출하는 방식)로 훑어 결과를 합산하는 이 구조는 이진 트리를 순회하며 높이를 재는 원리나 문제를 작은 조각으로 쪼개 푸는 분할 정복과 뿌리가 같다. 차이는 자료구조가 아니라 '클라이언트가 노드 종류를 신경 쓰지 않게 한다'는 설계 의도에 있다.

세 가지 역할: 컴포넌트, 잎, 컴포지트

컴포지트 패턴은 세 가지 역할로 구성된다. GoF는 이를 Component, Leaf, Composite로 부른다.

역할자식 보유요청을 받으면
Component인터페이스공통 메서드만 선언
Leaf(파일)없음자기 작업을 직접 수행
Composite(폴더)있음자식에게 위임해 재귀 처리

핵심은 Composite가 자식을 처리할 때 그 자식이 Leaf인지 또 다른 Composite인지 구분하지 않는다는 점이다. 둘 다 같은 Component 타입이라 같은 메서드를 부르면 그만이다. 이 지점에서 재귀가 자연스럽게 성립한다.

폴더가 하위 폴더와 파일로 뻗은 트리에서, 같은 명령 화살표가 최상위 폴더와 낱개 파일에 똑같이 닿는 선화 일러스트
폴더든 파일이든 같은 명령이 통한다 — 부분과 전체를 한 코드로.

파일 트리로 구현해 보기

코드로 보면 구조가 분명해진다. 파일과 폴더가 공통 인터페이스 Node를 구현하고, 폴더의 size()가 자식의 size()를 다시 부르는 방식이다.

// 공통 인터페이스: 파일이든 폴더든 이 타입으로 다룬다 (Component)
interface Node {
 int size(); // 바이트 크기 반환
}

// 잎 노드: 자식이 없는 파일 (Leaf)
class TextFile implements Node {
 private final int bytes;
 TextFile(int bytes) { this.bytes = bytes; }
 public int size() { return bytes; }
}

// 복합 노드: 자식을 담는 폴더 (Composite)
class Folder implements Node {
 private final List<Node> children = new ArrayList<>();
 public void add(Node child) { children.add(child); }
 public int size() {
  int total = 0;
  for (Node child : children) {
   total += child.size(); // 파일·폴더 구분 없이 같은 호출
  }
  return total;
 }
}

이제 폴더를 겹겹이 쌓아도 클라이언트 코드는 최상위 노드의 size()만 부르면 된다.

Folder root = new Folder();
root.add(new TextFile(100));

Folder sub = new Folder();
sub.add(new TextFile(50));
root.add(sub);

System.out.println(root.size()); // 150 — 하위 폴더까지 재귀 집계

root.size()를 부르면 폴더는 자기 자식인 파일(100)과 하위 폴더의 size()를 호출하고, 하위 폴더는 다시 자기 자식(50)을 더해 150을 돌려준다. 클라이언트는 트리가 몇 단계인지, 각 노드가 파일인지 폴더인지 전혀 몰라도 된다.

표준 라이브러리와 실무에서 만나는 컴포지트

컴포지트 패턴은 교과서 예시가 아니라 실제 표준 라이브러리에 박혀 있다.

이처럼 'A가 A들의 묶음을 담는' 계층이 보이면, 컴포지트 패턴이 이미 적용됐거나 적용할 자리라고 보면 된다.

언제 쓰고 무엇을 주의할까

컴포지트 패턴은 데이터가 진짜 트리일 때만 값을 한다. refactoring.guru도 앱의 핵심 모델을 트리로 표현할 수 있을 때에만 이 패턴이 의미가 있다고 짚는다. 계층이 없는 평평한 목록에 억지로 씌우면 불필요한 추상화만 늘어난다.

도입 기준을 정리하면 이렇다.

한 가지 설계 결정이 남는다. 자식을 추가·삭제하는 add()·remove() 메서드를 어디에 둘 것인가다. GoF는 이 선택을 '투명성 대 안전성'의 문제로 정리한다. 이 메서드들을 공통 Component 인터페이스에 두면 잎과 묶음을 완전히 똑같이 다룰 수 있어 투명하지만, 자식을 가질 수 없는 잎(파일)에도 add()가 노출돼 잘못 호출될 위험이 생긴다. 반대로 Composite에만 두면 안전하지만, 클라이언트가 다시 타입을 구분해야 해 패턴의 이점이 줄어든다. 정답은 없다. 잘못된 호출을 컴파일 단계에서 막고 싶으면 안전성을, 코드를 최대한 단순하게 유지하고 싶으면 투명성을 택한다.

더 많은 글 보기 RSS 구독