|

코틀린 디자인 패턴 (8) – Composite 패턴으로 트리 구조 다루기

코틀린 디자인 패턴 (8) – Composite 패턴으로 트리 구조 다루기
파일과 폴더처럼 하나와 묶음이 반복되는 구조를 같은 인터페이스로 다룬다

Composite 패턴은 트리 구조를 다룰 때 특히 힘을 씁니다. 파일 하나와 폴더 하나를 처리하는 코드가 따로 놀기 시작하면, 호출하는 쪽은 금방 타입 분기와 재귀 순회로 지저분해집니다.

이번 글에서는 왜 이런 불편이 생기는지 감각부터 먼저 잡고, Kotlin 예제로 Composite 패턴을 구현해보겠습니다. 그리고 어떤 트리 구조에서 특히 잘 맞는지, 반대로 언제는 과한 추상화가 되는지도 함께 정리하겠습니다.


Composite 패턴을 직관적으로 이해하기

Composite를 어렵게 보면 용어부터 막힙니다. 더 단순하게 보면 이렇습니다. 파일은 하나의 항목이고, 폴더도 하나의 항목입니다. 그런데 폴더 안에는 다시 파일과 폴더가 들어갑니다. 즉, 하나와 묶음이 같은 종류의 이름으로 반복되는 구조입니다.

문제는 이 구조를 코드로 다룰 때 시작됩니다. 파일은 바로 크기를 반환하면 되지만, 폴더는 안쪽 파일과 하위 폴더를 전부 돌아야 전체 크기를 구할 수 있습니다. 처음에는 파일용 코드와 폴더용 코드를 따로 쓰게 되지만, 트리가 깊어질수록 호출하는 쪽은 파일인지 폴더인지 확인하고, 폴더면 다시 내부를 돌고, 하위 폴더가 나오면 또 같은 분기를 반복하게 됩니다.

이때 Composite 패턴은 개별 객체와 묶음 객체를 같은 인터페이스로 다루자고 제안합니다. 파일도 FileSystemNode, 폴더도 FileSystemNode로 바라보면, 호출하는 쪽은 “이 노드의 크기를 구해줘”, “이 노드를 출력해줘”처럼 같은 요청만 보내면 됩니다.


Composite 패턴이 필요한 순간

트리 구조에서 자주 보이는 냄새는 비슷합니다. leaf와 composite를 처리하는 분기문이 여기저기 퍼지고, 재귀 순회 코드가 서비스 곳곳에 복사되며, 새 노드 종류를 추가할 때마다 호출부가 함께 수정됩니다.

예를 들어 파일 탐색 화면에서 전체 용량을 계산한다고 해보겠습니다. 아래처럼 sealed interfacewhen만으로도 충분히 시작할 수 있습니다.

sealed interface Entry

data class FileItem(
    val name: String,
    val size: Long,
) : Entry

data class FolderItem(
    val name: String,
    val children: List<Entry>,
) : Entry

fun totalSize(entry: Entry): Long {
    return when (entry) {
        is FileItem -> entry.size
        is FolderItem -> entry.children.sumOf(::totalSize)
    }
}

이 코드는 나쁘지 않습니다. 오히려 Kotlin의 sealed 계층을 잘 쓴 예에 가깝습니다. 다만 전체 크기 계산뿐 아니라 트리 출력, 검색, 선택 상태 집계, 권한 검사까지 붙기 시작하면 when 분기가 여러 곳으로 퍼질 수 있습니다. 바로 그 시점이 Composite를 고민할 만한 순간입니다.

Composite의 핵심은 트리 순회 규칙을 호출부에서 떼어내고, 각 노드 객체 안으로 밀어 넣는 데 있습니다.


Kotlin 예제로 Composite 구현하기

이번에는 챕터 방향에 맞춰 파일 시스템 형태로 구현해보겠습니다. FileSystemNode가 공통 인터페이스이고, FileNode는 leaf, DirectoryNode는 composite입니다.

interface FileSystemNode {
    val name: String

    fun totalSize(): Long

    fun render(depth: Int = 0): String
}

공통 인터페이스에는 모든 노드가 가져야 할 동작만 둡니다. 여기서는 이름, 전체 크기 계산, 트리 출력 세 가지를 선택하겠습니다.

leaf는 자기 일만 한다

class FileNode(
    override val name: String,
    private val size: Long,
) : FileSystemNode {

    override fun totalSize(): Long = size

    override fun render(depth: Int): String {
        val indent = "  ".repeat(depth)
        return "$indent- $name (${size}KB)"
    }
}

FileNode는 단순합니다. 더 내려갈 자식이 없기 때문에 자기 크기만 반환하면 됩니다. 실제 일은 leaf가 많이 합니다. Composite는 큰 구조처럼 보이지만, 가장 구체적인 데이터는 leaf가 들고 있는 경우가 많습니다.

composite는 자식에게 일을 나눠 맡긴다

class DirectoryNode(
    override val name: String,
    private val children: List<FileSystemNode>,
) : FileSystemNode {

    override fun totalSize(): Long {
        return children.sumOf { it.totalSize() }
    }

    override fun render(depth: Int): String {
        val indent = "  ".repeat(depth)
        val current = "$indent+ $name"
        val childLines = children.joinToString(separator = "\n") {
            it.render(depth + 1)
        }
        return if (childLines.isBlank()) current else "$current\n$childLines"
    }
}

DirectoryNode는 자기가 직접 파일 크기를 갖고 있지 않습니다. 대신 자식 노드들에게 같은 메시지를 다시 보냅니다. 클라이언트는 폴더 안에 파일이 몇 단계로 중첩되어 있는지 몰라도 됩니다. DirectoryNode가 아래로 요청을 흘려보내기 때문입니다.

클라이언트 코드는 훨씬 단순해진다

fun main() {
    val root = DirectoryNode(
        name = "project",
        children = listOf(
            FileNode("README.md", 8),
            DirectoryNode(
                name = "src",
                children = listOf(
                    FileNode("Main.kt", 24),
                    FileNode("FileService.kt", 36),
                    DirectoryNode(
                        name = "resources",
                        children = listOf(
                            FileNode("logo.png", 120),
                            FileNode("banner.webp", 220),
                        ),
                    ),
                ),
            ),
        ),
    )

    println(root.render())
    println("total size = ${root.totalSize()}KB")
}

호출하는 쪽은 root가 파일인지 폴더인지 매번 따지지 않습니다. 그냥 render()totalSize()를 호출하면 끝입니다. 이 차이가 실전에서 꽤 큽니다. 클라이언트 코드가 트리 순회 전문가가 될 필요가 없어집니다.


이 예제에서 꼭 읽어야 할 포인트

공통 인터페이스가 호출부를 단순하게 만든다

fun printSummary(node: FileSystemNode) {
    println(node.render())
    println("size = ${node.totalSize()}KB")
}

이 함수는 파일 하나를 받아도 되고, 깊은 폴더 트리를 받아도 됩니다. 타입 분기가 없습니다. 바로 이 점이 Composite의 가장 큰 장점입니다.

composite는 자식 목록을 들고 재귀를 감싼다

DirectoryNode는 자식 목록을 가지고 있고, 같은 인터페이스를 다시 호출합니다. 그래서 깊이가 깊어져도 클라이언트는 몰라도 됩니다. 트리 구조를 다룰 때 Composite가 편한 이유는 깊이와 중첩을 객체 구조가 대신 감당하기 때문입니다.

새 노드 동작이 늘어날 때 trade-off가 생긴다

예제에서는 totalSize()render()를 인터페이스에 직접 넣었습니다. 이 방식은 호출부를 단순하게 만들지만, 나중에 동작 종류가 너무 많아지면 인터페이스가 무거워질 수 있습니다. 즉 Composite는 트리를 다루는 좋은 패턴이지만, 모든 변화에 만능은 아닙니다. 연산 종류가 계속 늘어나는 쪽이 더 큰 문제라면 Visitor 같은 다른 선택지가 더 맞을 수 있습니다.


트리 구조에서 Composite 패턴이 특히 잘 맞는 경우

  1. 파일 시스템처럼 폴더 안에 파일과 폴더가 함께 들어가는 구조
  2. 메뉴 트리처럼 상위 메뉴 아래 하위 메뉴가 반복되는 구조
  3. 카테고리 트리처럼 부모 카테고리 아래 자식 카테고리가 이어지는 구조
  4. UI 컴포넌트 트리처럼 컨테이너 안에 버튼, 텍스트, 또 다른 컨테이너가 들어가는 구조

공통점은 하나입니다. 개별 항목과 묶음 항목이 같은 맥락에서 취급되어야 한다는 점입니다. 예를 들어 화면 렌더링 엔진에서는 버튼 하나도 그려야 하고 패널 전체도 그려야 합니다. 카테고리 집계에서는 상품 하나도 계산 대상이고, 하위 카테고리를 묶은 상위 카테고리도 계산 대상입니다. 이런 장면에서는 하나와 묶음을 같은 인터페이스로 보는 편이 구조를 훨씬 단순하게 만듭니다.


Kotlin에서는 sealed 계층만으로 끝내도 되는 경우

여기서 한 번은 꼭 멈춰야 합니다. 트리 구조라고 해서 항상 GoF식 Composite 클래스를 만들어야 하는 것은 아닙니다. 앞에서 본 sealed interface Entrywhen 방식이 더 자연스러운 경우도 많습니다.

  • 노드 종류가 닫혀 있고 적다
  • 연산이 몇 개 안 된다
  • 트리 순회가 한두 군데에만 있다
  • 객체마다 공통 인터페이스 메서드를 굳이 넣고 싶지 않다

이럴 때는 Kotlin의 sealed 계층이 더 담백할 수 있습니다. 억지로 클래스를 쪼개고 인터페이스 메서드를 늘리면, 얻는 것보다 읽기 부담이 더 커질 수 있습니다.

또 Kotlin 공식 문서가 설명하듯 delegation은 구현 전달 보일러플레이트를 줄이는 데 도움이 됩니다. 다만 delegation 문법이 곧 Composite 자체를 대신하는 것은 아닙니다. 핵심은 문법이 아니라, 트리 구조의 공통 행위를 어디에 둘지에 대한 설계 판단입니다.


언제 과한 추상화가 될까

트리가 아닌 구조인데 이름만 Composite일 때

실제로는 평평한 리스트인데, 패턴 이름을 맞추려고 composite와 leaf를 따로 만들면 코드만 무거워집니다. 트리인지부터 먼저 봐야 합니다.

leaf와 composite를 같은 방식으로 다루지 않아도 될 때

파일과 폴더를 결국 완전히 다른 화면, 다른 규칙, 다른 흐름으로 처리한다면 공통 인터페이스의 이득이 줄어듭니다. 겉으로만 통일되고 실제 분기는 여전히 바깥에 남을 수 있습니다.

연산이 계속 늘어나는데 인터페이스에 전부 얹을 때

render(), totalSize(), search(), export(), validate(), sync()가 계속 붙으면 공통 인터페이스가 비대해집니다. 이건 Composite가 틀렸다기보다, 변화 방향을 잘못 읽은 경우에 가깝습니다.

자식 관리 책임이 애매할 때

Composite 예제에서는 보통 composite가 자식 목록을 관리합니다. 그런데 실제 도메인에서는 자식 추가와 제거를 아무 데나 열어두면 안 되는 경우가 많습니다. 일부 노드는 읽기 전용일 수 있고, 트리 수정은 별도 서비스에서만 허용할 수도 있습니다. 이런 제약을 무시한 채 패턴 모양만 따라가면 설계가 느슨해집니다.

Composite는 트리 구조를 예쁘게 보이게 하는 패턴이 아니라, 호출부의 분기와 재귀를 줄일 만큼 공통 행위가 분명할 때 쓰는 패턴입니다.


실무에서 기억하면 좋은 판단 기준

  1. 내 모델이 정말 트리 구조인가
  2. leaf와 composite를 호출하는 쪽에서 같은 방식으로 다루고 싶은가
  3. 분기와 재귀가 여러 곳으로 퍼지고 있는가

세 질문에 모두 그렇다에 가깝다면 Composite가 꽤 잘 맞습니다. 반대로 첫 번째 질문부터 애매하면, 패턴보다 단순한 데이터 구조와 함수 조합이 더 나을 수 있습니다.


정리

Composite 패턴을 한 문장으로 줄이면 이렇습니다. 트리 구조에서 개별 객체와 묶음 객체를 같은 인터페이스로 다뤄서, 호출부의 분기와 재귀를 줄이는 패턴입니다.

파일과 폴더, 메뉴와 하위 메뉴, 컨테이너와 자식 컴포넌트처럼 계층이 반복되는 구조에서는 매우 유용합니다. 하지만 트리가 아닌데도 패턴부터 들이밀거나, 실제 공통 행위가 약한데 인터페이스만 맞추면 금방 과해집니다.

이전 글인 코틀린 디자인 패턴 (7) – Bridge 패턴은 상속 폭발을 어떻게 줄일까코틀린 디자인 패턴 (6) – Adapter 패턴으로 기존 코드를 새 인터페이스에 맞추기를 함께 보면, 구조 패턴 안에서도 문제의 결이 어떻게 다른지 더 자연스럽게 비교할 수 있습니다. 외부 참고 자료로는 Refactoring.Guru의 Composite 설명Kotlin Delegation 문서를 연결해서 보면 좋습니다.

함께보면 좋은 글