Software Design

Swift 컴포지트 패턴(Composite Pattern), 트리 구조를 객체 하나처럼 다루기

폴더 안에 폴더, 그 안에 또 파일. 이런 트리 구조를 코드로 다뤄본 분이라면 한 번쯤 막막했을 거예요.

4분 읽기
Swift 컴포지트 패턴(Composite Pattern), 트리 구조를 객체 하나처럼 다루기 대표 이미지

폴더 안에 폴더, 그 안에 또 파일. 이런 트리 구조를 코드로 다뤄본 분이라면 한 번쯤 막막했을 거예요.

“이게 폴더야 파일이야?“를 매번 if로 갈라 치다 보면 코드가 금세 지저분해지거든요.

파일 탐색기 비슷한 걸 만들다 보면 흔히 겪는 지옥인데요. 이럴 때 꺼내 들 도구가 바로 컴포지트 패턴(Composite Pattern)입니다.

컴포지트 패턴은 개별 객체(파일)와 그 묶음(폴더)을 똑같은 타입으로 취급해서, 트리 전체를 객체 하나처럼 다루게 해주는 구조 패턴입니다.

오늘은 이 패턴을 Swift로 어떻게 풀어내는지, 제가 직접 짜본 코드와 함께 정리해볼게요.

핵심 요약 먼저

  1. 컴포지트 패턴 = 잎(Leaf)과 가지(Composite)를 같은 프로토콜로 묶는 것
  2. 클라이언트는 파일인지 폴더인지 구분하지 않고 똑같이 호출
  3. 폴더의 계산은 자식들에게 다시 물어보는 재귀 구조
  4. Swift에서는 protocol 하나로 깔끔하게 구현 가능

컴포지트 패턴이 뭔가요?

말 그대로 ’합성’이에요. 작은 것들을 모아 하나의 큰 덩어리로 만드는 거죠.

가장 흔한 비유가 파일 시스템이에요.

파일 하나는 그 자체로 끝(잎)이에요. 반면 폴더는 안에 파일이나 또 다른 폴더를 품을 수 있는 가지고요.

폴더 안에 폴더, 이 구조가 딱 컴포지트예요
폴더 안에 폴더, 이 구조가 딱 컴포지트예요

그런데 사용자 입장은 좀 달라요. 폴더를 열든 파일을 열든, 우리는 그냥 “열어”라고 명령할 뿐이에요.

이렇게 개별 요소와 묶음을 같은 방식으로 다루게 하는 게 컴포지트 패턴의 목표예요.

구성 요소는 세 가지로 나뉩니다.

그림으로 보면 이렇게 단순해요
그림으로 보면 이렇게 단순해요
  • Component: 공통 인터페이스 (프로토콜)
  • Leaf: 자식이 없는 말단 객체 (파일)
  • Composite: 자식을 담는 객체 (폴더)

Swift로 어떻게 구현하나요?

먼저 공통 프로토콜부터 정의해요. 이름과 크기를 물어볼 수 있게요.

아래는 파일과 폴더가 함께 따를 규칙(Component)입니다.

protocol FileComponent {
    var name: String { get }
    func size() -> Int   // 바이트 크기 반환
}

이제 잎에 해당하는 파일이에요. 자기 크기만 딱 알려주면 끝이라 단순합니다.

struct File: FileComponent {
    let name: String
    let bytes: Int
    func size() -> Int { bytes }  // 자기 크기 그대로 반환
}

핵심은 폴더(Composite)예요. 자식들을 배열로 품고 크기를 물으면 자식들에게 다시 물어봐요.

struct Folder: FileComponent {
    let name: String
    var children: [FileComponent] = []
    func size() -> Int {
        children.reduce(0) { $0 + $1.size() }  // 자식 합산(재귀)
    }
}

여기서 size()가 스스로를 다시 부르는 재귀 구조라는 점이 포인트예요.

폴더 안의 폴더가 몇 겹이든, 이 한 줄이 알아서 바닥까지 훑어 내려갑니다.


실제로 쓰면 이런 느낌

이제 파일과 폴더를 섞어서 트리를 만들어볼게요.

let root = Folder(name: "문서", children: [
    File(name: "메모.txt", bytes: 100),
    Folder(name: "사진", children: [
        File(name: "여행.jpg", bytes: 2000)
    ])
])
print(root.size())  // 2100 출력

보이시나요? root.size() 한 번이면 폴더 안 모든 파일 크기가 합산돼요.

제가 직접 돌려보고 제일 감탄한 부분이 이거였어요.

클라이언트 코드에서 “이게 폴더냐 파일이냐”를 단 한 번도 묻지 않는다는 것.

그냥 size()만 부르면 돼요. 나머지는 각 객체가 알아서 처리하니까요.

예제 그대로 Playground에 붙여 돌려봤어요
예제 그대로 Playground에 붙여 돌려봤어요

분기문으로 처리하는 것과 뭐가 다를까?

“그냥 if 문으로 타입 나눠서 처리하면 안 되나요?” 싶으실 거예요.

됩니다. 다만 트리가 깊어질수록 차이가 확 벌어져요.

구분 분기문(if/switch) 방식 컴포지트 패턴
타입 판별 매번 직접 확인 필요 없음
새 타입 추가 분기문 곳곳 수정 프로토콜만 채택
재귀 처리 직접 순회 코드 작성 객체가 알아서
코드 가독성 깊어질수록 복잡 일정하게 유지

분기 방식은 새로운 종류(예: 바로가기 링크)가 생기면 여기저기 if를 또 추가해야 해요.

반면 컴포지트는 FileComponent만 채택한 새 타입을 하나 만들면 끝이에요. 기존 코드는 건드릴 필요가 없죠.


자주 묻는 질문

Q. 잎과 가지의 인터페이스가 완전히 같아야 하나요?

이상적으로는 그래요. 다만 파일에는 “자식 추가” 같은 기능이 어색하죠. 이럴 땐 공통 동작(size 등)만 프로토콜에 두고 자식 관리는 폴더에만 두는 게 실용적이에요.

Q. class가 아니라 struct로 써도 되나요?

네, 위 예제처럼 값 타입으로도 충분히 동작해요. 다만 트리를 자주 변경하고 참조를 공유해야 한다면 class가 더 편할 수 있어요.


트리 구조 앞에서 if 지옥에 빠졌다면, 컴포지트 패턴을 한번 꺼내보세요.

파일 시스템뿐 아니라 뷰 계층, 메뉴, 조직도처럼 ’안에 또 안’이 있는 구조라면 어디든 잘 맞아요.

오늘 예제를 그대로 Playground에 붙여 돌려보시면 감이 확 올 거예요.

함께 보면 좋은 글