(Kotlin/코틀린) 행동 패턴 — 템플릿 메서드 패턴 (Template Method)


1. 템플릿 메서드 패턴이란?

알고리즘의 뼈대(순서)를 부모 클래스에 정의하고, 세부 단계를 서브클래스가 구현하는 패턴입니다. “무엇을 하는지(순서)”는 부모가 결정하고, “어떻게 하는지(구현)”는 자식이 결정합니다.

AbstractClass
  templateMethod() {
    step1()  ← 공통 (부모가 직접 구현)
    step2()  ← 추상 (서브클래스가 반드시 구현)
    step3()  ← hook (서브클래스가 선택적으로 오버라이드)
  }

2. 기본 구현

데이터 처리 파이프라인을 예로 들겠습니다. 읽기→처리→저장 순서는 모든 프로세서가 공통으로 따르지만, 각 단계의 구현은 다릅니다.

abstract class DataProcessor {
    // 템플릿 메서드: 알고리즘의 뼈대를 정의
    // 서브클래스가 이 순서를 바꿀 수 없음 (Kotlin에서 기본적으로 open이 아님)
    fun process() {
        readData()       // 1단계: 데이터 읽기 (서브클래스 구현)
        processData()    // 2단계: 데이터 처리 (서브클래스 구현)
        writeData()      // 3단계: 결과 저장 (서브클래스 구현)
        if (shouldLog()) log()  // 4단계: 로그 (hook - 선택적)
    }

    // abstract: 서브클래스에서 반드시 구현해야 함
    protected abstract fun readData()
    protected abstract fun processData()
    protected abstract fun writeData()

    // Hook 메서드: 기본값(false)이 있어 오버라이드하지 않아도 동작
    protected open fun shouldLog() = false
    protected open fun log() = println("처리 완료")
}

// CSV 처리: 세 단계를 CSV에 맞게 구현
class CsvProcessor : DataProcessor() {
    override fun readData()    = println("CSV 파일 읽기")
    override fun processData() = println("CSV 파싱 및 변환")
    override fun writeData()   = println("데이터베이스에 저장")
    // hook 오버라이드: 이 프로세서는 로그를 남김
    override fun shouldLog()   = true
}

// JSON 처리: 세 단계를 JSON에 맞게 구현
class JsonProcessor : DataProcessor() {
    override fun readData()    = println("JSON API 읽기")
    override fun processData() = println("JSON 파싱 및 매핑")
    override fun writeData()   = println("캐시에 저장")
    // shouldLog()를 오버라이드하지 않음 → 기본값 false → 로그 없음
}

CsvProcessor().process()
// CSV 파일 읽기 → CSV 파싱 및 변환 → 데이터베이스에 저장 → 처리 완료

JsonProcessor().process()
// JSON API 읽기 → JSON 파싱 및 매핑 → 캐시에 저장 (로그 없음)

포인트: 템플릿 메서드(process())가 알고리즘의 실행 순서를 통제합니다. 서브클래스는 단계의 “내용”만 바꿀 수 있고 “순서”는 바꿀 수 없습니다.


3. 실전 예제 — 음료 제조

고전적인 패턴 교과서 예제입니다. 음료 제조 순서는 동일하지만 각 단계의 구현이 음료마다 다릅니다.

abstract class Beverage {
    // 최종 메서드: 서브클래스가 순서를 바꿀 수 없음
    fun prepare() {
        boilWater()        // 공통 1단계: 물 끓이기
        brew()             // 다른 2단계: 차/커피 우리기
        pourInCup()        // 공통 3단계: 컵에 따르기
        // hook: customerWantsCondiments()가 true인 경우만 첨가물 추가
        if (customerWantsCondiments()) addCondiments()
    }

    // private: 서브클래스도 오버라이드 불가 (완전히 고정된 단계)
    private fun boilWater() = println("물 끓이기")
    private fun pourInCup() = println("컵에 따르기")

    // 서브클래스가 반드시 구현
    protected abstract fun brew()
    protected abstract fun addCondiments()

    // hook: 기본은 true (첨가물 추가), 서브클래스가 false로 오버라이드 가능
    protected open fun customerWantsCondiments() = true
}

class Tea : Beverage() {
    override fun brew()           = println("차 우려내기")
    override fun addCondiments()  = println("레몬 추가")
    // customerWantsCondiments() → 기본 true → 레몬 추가
}

class Coffee : Beverage() {
    override fun brew()          = println("필터로 커피 추출")
    override fun addCondiments() = println("설탕과 우유 추가")
    // 블랙커피: hook을 false로 → 첨가물 건너뜀
    override fun customerWantsCondiments() = false
}

Tea().prepare()
// 물 끓이기 → 차 우려내기 → 컵에 따르기 → 레몬 추가

Coffee().prepare()
// 물 끓이기 → 필터로 커피 추출 → 컵에 따르기  (첨가물 건너뜀)

4. 함수 타입으로 대체 (Kotlin 스타일)

상속 대신 함수를 파라미터로 주입하면 클래스 계층 없이 동일한 효과를 낼 수 있습니다. 재사용성이 높고 테스트하기 쉽습니다.

// 보고서 생성 뼈대: 타이틀 출력 → 데이터 행 추가 → 푸터 출력
// 각 단계를 함수 타입으로 주입받아 유연하게 교체
fun buildReport(
    title: String,
    fetchData: () -> List<String>,         // 데이터 조회 방법 주입
    formatRow: (String) -> String,         // 행 포맷 방법 주입
    footer: (() -> String)? = null         // 선택적 푸터 (hook과 동일한 역할)
): String {
    val sb = StringBuilder()
    sb.appendLine("=== $title ===")
    // fetchData()로 데이터 조회 후 formatRow()로 각 행 포맷
    fetchData().forEach { sb.appendLine(formatRow(it)) }
    // footer가 주어진 경우에만 출력
    footer?.let { sb.appendLine(it()) }
    return sb.toString()
}

// 사용: 뼈대는 고정, 세부 구현만 람다로 주입
val report = buildReport(
    title = "판매 보고서",
    fetchData = { listOf("사과 100개", "바나나 50개", "오렌지 30개") },
    formatRow = { "  - $it" },   // 들여쓰기 포맷
    footer = { "총 3개 품목" }
)
println(report)
// === 판매 보고서 ===
//   - 사과 100개
//   - 바나나 50개
//   - 오렌지 30개
// 총 3개 품목

언제 상속 vs 함수 주입을 선택?: 단계가 많고 서브클래스가 상태를 가진다면 상속 방식이 적합합니다. 단계가 단순하고 재사용/테스트가 중요하다면 함수 주입 방식이 더 유연합니다.


5. 단계 조합 — 파이프라인 패턴

fold를 이용해 데이터를 단계별로 변환하는 파이프라인을 만들 수 있습니다.

// 파이프라인: 데이터에 여러 변환 단계를 순서대로 적용
class Pipeline<T>(private var data: T) {
    private val steps = mutableListOf<(T) -> T>()

    // 단계 추가 (메서드 체이닝 지원)
    fun addStep(step: (T) -> T): Pipeline<T> {
        steps.add(step)
        return this  // this 반환으로 체이닝 가능
    }

    // 실행: fold로 초기값(data)에 각 단계를 순서대로 적용
    // fold(초기값) { 누적값, 단계 → 단계(누적값) }
    fun execute(): T = steps.fold(data) { acc, step -> step(acc) }
}

// 문자열 정제 파이프라인: trim → lowercase → 쉼표 제거
val result = Pipeline("  Hello, World!  ")
    .addStep { it.trim() }            // "  Hello, World!  " → "Hello, World!"
    .addStep { it.lowercase() }       // "Hello, World!" → "hello, world!"
    .addStep { it.replace(",", "") }  // "hello, world!" → "hello world!"
    .execute()
println(result)  // "hello world!"

6. 정리



Related Posts