|

코틀린 디자인 패턴 시리즈 (22) – Template Method 패턴과 함수형 대안

Template Method 패턴과 함수형 대안 대표 이미지
상속으로 흐름을 고정할지, 함수 인자로 변하는 단계를 주입할지 먼저 구분한다

Template Method 패턴과 함수형 대안은 코틀린에서 디자인 패턴을 다시 볼 때 꼭 짚어야 하는 지점입니다. 전통적인 Template Method는 상속으로 공통 실행 흐름을 고정하고, 일부 단계만 하위 클래스가 바꾸게 합니다. 그런데 코틀린에서는 함수를 값처럼 넘길 수 있기 때문에 같은 문제를 더 가볍게 풀 수도 있습니다.

중요한 질문은 “상속이 낡았으니 버리자”가 아닙니다. 공통 흐름을 얼마나 강하게 통제해야 하는가, 그리고 변하는 단계가 타입으로 이름을 가질 만큼 중요한가를 판단하는 것입니다. 바로 앞 글인 Strategy 패턴과 함수형 대안 비교가 정책 교체를 다뤘다면, 이번 글은 실행 흐름 고정을 다룹니다.

Template Method 패턴과 higher-order function 선택 기준 요약 카드
공통 흐름은 같고 일부 단계만 바뀔 때 두 선택지가 갈린다

먼저 문제 상황부터 보기

예를 들어 리포트를 내보내는 기능이 있다고 해보겠습니다. CSV, Markdown, JSON 형식은 다르지만 전체 순서는 거의 같습니다. 데이터를 불러오고, 검증하고, 포맷을 만들고, 저장합니다. 여기서 포맷만 다르다고 모든 코드를 복사하면 금방 중복이 생깁니다.

class CsvExporter {
    fun export() {
        val data = loadData()
        require(data.isNotEmpty())
        val content = data.joinToString(",")
        println("저장: $content")
    }

    private fun loadData(): List<String> = listOf("A", "B", "C")
}

class MarkdownExporter {
    fun export() {
        val data = loadData()
        require(data.isNotEmpty())
        val content = data.joinToString("\n") { "- $it" }
        println("저장: $content")
    }

    private fun loadData(): List<String> = listOf("A", "B", "C")
}

겉으로는 간단합니다. 하지만 검증 규칙이 바뀌거나 저장 방식이 바뀌면 두 클래스 이상을 같이 고쳐야 합니다. 이런 중복은 처음에는 눈에 잘 안 띄지만, 형식이 3개, 4개로 늘어나는 순간 유지보수 비용으로 바뀝니다.

Template Method 실행 흐름 도식
부모가 전체 순서를 고정하고 일부 단계만 하위 타입이 채우는 흐름

Template Method의 핵심

Template Method 패턴은 이런 상황에서 전체 흐름을 부모 클래스의 메서드 하나에 모읍니다. 이 메서드가 바로 template method입니다. 하위 클래스는 흐름 자체를 바꾸는 것이 아니라, 정해진 자리의 일부 단계만 구현합니다. 전통적인 패턴 설명은 Refactoring.Guru의 Template Method 설명을 참고하면 구조를 빠르게 확인할 수 있습니다.

abstract class ReportExporter {
    fun export() {
        val data = loadData()
        validate(data)
        val content = format(data)
        save(content)
    }

    protected abstract fun loadData(): List<String>
    protected abstract fun format(data: List<String>): String

    protected open fun validate(data: List<String>) {
        require(data.isNotEmpty()) { "내보낼 데이터가 없습니다." }
    }

    private fun save(content: String) {
        println("저장: $content")
    }
}

class CsvReportExporter : ReportExporter() {
    override fun loadData(): List<String> = listOf("A", "B", "C")
    override fun format(data: List<String>): String = data.joinToString(",")
}

class MarkdownReportExporter : ReportExporter() {
    override fun loadData(): List<String> = listOf("A", "B", "C")
    override fun format(data: List<String>): String {
        return data.joinToString("\n") { "- $it" }
    }
}

여기서 export는 하위 클래스가 바꾸지 못하는 전체 흐름입니다. loadData와 format은 하위 클래스가 반드시 채워야 하는 단계이고, validate는 필요하면 바꿀 수 있는 hook입니다. 이 구조 덕분에 저장 순서, 검증 위치, 공통 후처리 규칙을 부모 쪽에서 통제할 수 있습니다.


hook 메서드의 장단점

Template Method 설명에서 hook이라는 말이 자주 나옵니다. hook은 하위 클래스가 선택적으로 바꿀 수 있는 단계입니다. 위 예제의 validate처럼 기본 구현은 있지만 필요하면 override할 수 있는 메서드가 여기에 가깝습니다.

hook은 유용하지만 많아지면 문제가 됩니다. 하위 클래스 작성자는 어떤 hook을 언제 override해야 하는지 문서를 계속 읽어야 합니다. 부모 클래스의 암묵적 규칙이 늘어나면, 재사용보다 숨은 결합이 커집니다.

  • 필수 단계는 abstract로 명확하게 드러낸다
  • 선택 단계는 open으로 열되 개수를 최소화한다
  • hook이 많아지면 상속보다 조합이나 Strategy를 다시 검토한다

함수형 대안으로 가볍게 풀기

코틀린에서는 함수를 인자로 받을 수 있습니다. 공식 문서의 higher-order functions and lambdas 설명처럼, 함수는 변수에 담고 다른 함수에 전달할 수 있는 값입니다. 그래서 변하는 단계가 작다면 상속 계층 없이도 공통 흐름을 만들 수 있습니다.

fun exportReport(
    loadData: () -> List<String>,
    format: (List<String>) -> String,
    validate: (List<String>) -> Unit = { data ->
        require(data.isNotEmpty()) { "내보낼 데이터가 없습니다." }
    },
) {
    val data = loadData()
    validate(data)
    val content = format(data)
    println("저장: $content")
}

fun main() {
    exportReport(
        loadData = { listOf("A", "B", "C") },
        format = { data -> data.joinToString(",") }
    )

    exportReport(
        loadData = { listOf("A", "B", "C") },
        format = { data -> data.joinToString("\n") { "- $it" } }
    )
}

이 방식은 클래스가 필요 없습니다. exportReport가 공통 흐름을 갖고, loadData, format, validate가 변하는 지점입니다. 변형이 작고 호출부에서 바로 보이는 편이 더 이해하기 쉽다면 이쪽이 더 코틀린답게 읽힙니다.

Template Method와 higher-order function 비교 카드
상속 기반 Template Method와 함수 인자 기반 대안의 선택 기준 비교

진짜 차이는 규약의 위치다

higher-order function 예제가 짧다고 해서 항상 더 좋은 것은 아닙니다. 차이는 코드 길이가 아니라 규약을 어디에 둘지입니다. Template Method는 타입 계층에 규약을 둡니다. higher-order function은 함수 시그니처와 호출부에 규약을 둡니다.

상속 계층에 규약을 두는 방식

ReportExporter를 상속한다는 사실만으로 이 객체가 export 흐름에 참여한다는 의미가 생깁니다. 하위 타입 이름도 문서 역할을 합니다. CsvReportExporter, MarkdownReportExporter처럼 오래 재사용되는 개념이라면 이 이름이 도움이 됩니다.

호출부에 규약을 두는 방식

함수형 방식에서는 호출하는 곳에서 loadData와 format을 바로 넘깁니다. 이 방식은 읽는 사람이 “이번 호출에서는 어떤 단계가 들어갔는지”를 즉시 볼 수 있습니다. 대신 이 동작이 도메인에서 오래 살아남는 이름 있는 개념이라면 함수 인자만으로는 의미가 약할 수 있습니다.


Strategy와의 차이

Strategy는 여러 알고리즘이나 정책 중 하나를 교체하는 패턴입니다. Template Method는 전체 실행 흐름을 고정하고 일부 단계만 바꾸는 패턴입니다. 둘 다 변하는 부분을 분리하지만, 분리하는 단위가 다릅니다.

  • Strategy: 전체 정책 또는 알고리즘을 바꿔 끼운다
  • Template Method: 고정된 흐름 안의 일부 단계를 바꾼다
  • higher-order function 대안: 작은 전략이나 작은 단계 변경을 함수 인자로 표현한다

예를 들어 할인 정책 자체를 바꾸는 문제라면 Strategy가 더 자연스럽습니다. 반면 리포트 내보내기처럼 준비, 검증, 포맷, 저장이라는 순서가 고정되어 있고 포맷 단계만 달라지는 문제라면 Template Method나 higher-order function 흐름이 더 자연스럽습니다.


Template Method가 더 나은 경우

  1. 실행 순서 자체가 도메인 규칙이거나 프레임워크 규칙이다
  2. 하위 타입마다 이름 있는 역할이 있고 재사용된다
  3. 공통 상태나 protected helper가 실제로 필요하다
  4. 사용자가 바꿀 수 있는 확장 지점을 제한해야 한다
  5. 흐름을 깨면 버그가 되는 구조라서 부모가 순서를 강하게 통제해야 한다

예를 들어 배치 처리 프레임워크, 테스트 실행 흐름, 문서 생성 파이프라인처럼 순서가 중요한 구조에서는 Template Method가 여전히 힘을 가집니다. 하위 구현자는 일부 단계만 채우고, 전체 안정성은 부모가 잡습니다.


함수형 대안이 더 나은 경우

  1. 변하는 단계가 작고 일회성이다
  2. 상속 계층을 만들면 파일과 타입이 너무 많아진다
  3. 호출부에서 동작이 바로 보이는 편이 더 읽기 쉽다
  4. 공통 상태 공유보다 동작 주입이 핵심이다
  5. 테스트에서도 람다 하나로 대체하면 충분하다

이런 경우에는 abstract class를 만드는 순간 설계가 무거워집니다. 코틀린의 장점은 전통 패턴을 더 짧게 번역하는 데 있지 않고, 패턴이 필요했던 지점 중 일부를 언어 기능으로 더 단순하게 표현할 수 있다는 데 있습니다.


남용하면 생기는 문제

Template Method를 남용하면 하위 클래스가 계속 늘어납니다. 실제 차이는 format 한 줄뿐인데 CsvExporter, MarkdownExporter, JsonExporter, HtmlExporter가 모두 클래스로 생기면 구조가 과해질 수 있습니다.

또 부모 클래스가 커질수록 하위 클래스는 부모의 호출 순서와 protected 메서드 규칙에 묶입니다. 부모 쪽 변경 하나가 여러 하위 클래스에 영향을 주고, 어떤 hook을 override해도 되는지 판단 비용이 늘어납니다.

Template Method 남용 신호 카드
하위 클래스와 hook이 늘어날수록 Template Method의 유지보수 비용도 커진다

실무 판단 질문

  1. 이 흐름의 순서를 반드시 한 곳에서 통제해야 하는가
  2. 변하는 단계가 도메인에서 이름을 가질 만큼 중요한가
  3. 하위 타입이 오래 재사용되는가, 아니면 호출 한 번으로 끝나는가
  4. 공통 상태나 protected helper가 정말 필요한가
  5. 함수 인자로 넘기면 의미가 더 선명한가, 오히려 숨는가
  6. 나중에 단계가 늘어날 때 상속 계층이 버틸 수 있는가

이 질문에 답하면 Template Method와 higher-order function 중 어느 쪽이 더 자연스러운지 빠르게 보입니다. 흐름 통제와 명명된 확장 지점이 중요하면 Template Method, 작고 지역적인 동작 주입이 중요하면 higher-order function 쪽이 낫습니다.


마무리

Template Method 패턴은 공통 흐름을 유지하면서 일부 단계만 바꾸는 구조입니다. 코틀린에서는 이 문제를 상속으로만 풀 필요는 없고, higher-order function으로 더 단순하게 표현할 수도 있습니다.

정답은 한쪽을 고정하는 것이 아닙니다. 흐름 순서가 설계의 중심이면 Template Method가 좋고, 변하는 단계가 작고 호출부에서 바로 보이는 편이 좋다면 higher-order function이 더 자연스럽습니다. 전통 패턴을 외우는 것보다, 패턴이 해결하려던 문제를 코틀린 문법으로 다시 보는 것이 더 중요합니다.


코틀린 디자인 패턴 시리즈 전체 링크

코틀린 디자인 패턴 시리즈(0) – 왜 아직도 디자인 패턴을 배워야 할까

코틀린 디자인 패턴(1) – Singleton 패턴은 언제 쓰고 object는 어떻게 다를까

코틀린 디자인 패턴(2) – Factory Method 패턴으로 생성 책임 나누기

코틀린 디자인 패턴(3) – Abstract Factory 패턴은 언제 필요할까

코틀린 디자인 패턴(4) – Builder 패턴과 named argument는 어떻게 다를까

코틀린 디자인 패턴(5) – Prototype 패턴과 data class copy 정리

코틀린 디자인 패턴(6) – Adapter 패턴으로 기존 코드를 새 인터페이스에 맞추기

코틀린 디자인 패턴(7) – Bridge 패턴은 상속 폭발을 어떻게 줄일까

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

코틀린 디자인 패턴(9) – Decorator 패턴은 상속 대신 어떻게 확장할까

코틀린 디자인 패턴(10) – Facade 패턴으로 복잡한 시스템 감추기

코틀린 디자인 패턴(11) – Flyweight 패턴은 메모리를 어떻게 아낄까

코틀린 디자인 패턴(12) – Proxy 패턴은 Decorator와 무엇이 다를까

코틀린 디자인 패턴(13) – Chain of Responsibility 패턴으로 조건 분기 줄이기

코틀린 디자인 패턴(14) – Command 패턴으로 실행과 취소를 다루는 법

코틀린 디자인 패턴(15) – Interpreter 패턴은 어디까지 써야 할까

코틀린 디자인 패턴(16) – Iterator 패턴과 sequence는 어떻게 연결될까

코틀린 디자인 패턴(17) – Mediator 패턴으로 객체 간 얽힘 줄이기

코틀린 디자인 패턴(18) – Memento 패턴으로 상태 저장과 복원하기

코틀린 디자인 패턴(19) – Observer 패턴과 Flow 감각 함께 이해하기

코틀린 디자인 패턴(20) – State 패턴은 조건문 폭발을 어떻게 막을까

코틀린 디자인 패턴(21) – Strategy 패턴과 함수형 대안 비교

함께보면 좋은 글