iOS & Swift

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

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

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

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

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

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

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

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

Swift 컴포지트 패턴, 트리 전체를 객체 하나처럼 다루는 그림입니다
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에 붙여 돌려보시면 감이 확 올 거예요.

함께 보면 좋은 글