|

코틀린 디자인 패턴 시리즈 (24) – 코틀린에서는 어떤 패턴이 더 단순해질까

코틀린 디자인 패턴 시리즈 (24) – 코틀린에서는 어떤 패턴이 더 단순해질까
코틀린은 패턴을 없애기보다 더 작은 문법과 타입으로 표현하게 해준다

코틀린 디자인 패턴을 끝까지 따라오면 한 가지 질문이 남습니다. 코틀린에서는 전통적인 디자인 패턴이 정말 줄어들까요? 줄어든다면 어떤 패턴이 줄어들고, 어떤 패턴은 여전히 배워야 할까요?

이 24편은 코틀린 디자인 패턴 시리즈의 회수편입니다. Singleton부터 Visitor까지 봤던 내용을 다시 묶어, 코틀린의 object, data class, sealed class, delegation, higher-order function, Flow가 어떤 패턴의 보일러플레이트를 줄여주는지 정리합니다.

코틀린은 디자인 패턴을 없애는 언어가 아닙니다. 다만 어떤 패턴은 언어 기능 덕분에 더 작고 직접적인 코드로 표현됩니다.

코틀린 디자인 패턴 단순화 기준 요약 카드
패턴이 단순해지는지 보려면 언어 기능이 어떤 책임을 대신하는지 확인해야 한다

코틀린 디자인 패턴의 결론: 패턴이 사라지는 것이 아니라 표현 비용이 줄어든다

디자인 패턴은 특정 언어 문법이 부족해서 생긴 꼼수가 아닙니다. 반복되는 설계 문제에 붙인 이름입니다. 그래서 코틀린을 쓴다고 패턴이 전부 사라지지는 않습니다.

다만 패턴을 구현하는 데 필요한 코드 양은 줄어들 수 있습니다. Java에서는 클래스, 인터페이스, 구현체, boilerplate가 필요했던 구조가 Kotlin에서는 object, data class, sealed class, 함수 타입 하나로 표현되기도 합니다.

중요한 것은 패턴 이름을 버리는 것이 아닙니다. 패턴이 해결하려던 책임을 코틀린 문법이 이미 대신하고 있는지 확인하는 것입니다.


object는 Singleton을 작게 만든다

Singleton 패턴은 인스턴스를 하나만 유지하고 전역 접근 지점을 제공하는 구조입니다. Java에서는 생성자 숨기기, static instance, synchronized 같은 구현 세부가 따라붙기 쉽습니다.

Kotlin에서는 `object` 선언으로 이 많은 부분이 줄어듭니다. 그래서 단순한 Singleton은 패턴 구현보다 object의 책임을 어떻게 제한할지가 더 중요해집니다.

object AppLogger {
    fun log(message: String) {
        println("[app] $message")
    }
}

하지만 object가 쉽다고 전역 상태를 마음대로 늘리면 문제가 생깁니다. Singleton 구현은 단순해졌지만, 전역 접근이 만드는 결합도와 테스트 어려움은 그대로 남습니다.


data class copy는 Prototype의 흔한 형태를 줄인다

Prototype 패턴은 기존 객체를 복제해 비슷한 객체를 만드는 구조입니다. Kotlin의 data class는 `copy`를 제공하기 때문에 불변 값 객체의 얕은 복사는 매우 단순해집니다.

data class NotificationConfig(
    val channel: String,
    val sound: Boolean,
    val retryCount: Int,
)

val defaultConfig = NotificationConfig(
    channel = "push",
    sound = true,
    retryCount = 3,
)

val silentConfig = defaultConfig.copy(sound = false)

이 경우에는 굳이 Prototype 클래스를 따로 만들 필요가 없습니다. 다만 내부에 mutable list나 참조 객체가 들어가면 얕은 복사와 깊은 복사를 구분해야 합니다. data class copy는 Prototype의 많은 경우를 단순화하지만, 복사 의미까지 자동으로 해결하지는 않습니다.


named argument와 default parameter는 Builder 일부를 줄인다

Builder 패턴은 생성 인자가 많고 조합이 복잡할 때 읽기 쉬운 생성 과정을 제공하려고 씁니다. Kotlin에서는 named argument와 default parameter 덕분에 단순한 Builder는 필요성이 줄어듭니다.

data class HttpClientConfig(
    val timeoutMillis: Long = 5000,
    val retryCount: Int = 3,
    val followRedirects: Boolean = true,
)

val config = HttpClientConfig(
    timeoutMillis = 10_000,
    followRedirects = false,
)

하지만 생성 과정에 검증, 단계별 제약, 중첩 DSL, 복잡한 조립이 들어가면 Builder나 DSL 스타일이 여전히 의미가 있습니다. Kotlin은 Builder를 없애는 것이 아니라 단순 생성용 Builder를 줄여줍니다.


delegation은 Decorator, Adapter, Proxy 감각을 가볍게 만든다

Kotlin의 delegation은 인터페이스 구현을 다른 객체에 위임할 수 있게 해줍니다. 덕분에 Decorator처럼 기능을 덧붙이거나, Proxy처럼 접근을 감싸는 구조가 더 짧아질 수 있습니다.

interface Repository {
    fun find(id: Long): String
}

class RealRepository : Repository {
    override fun find(id: Long): String = "item-$id"
}

class LoggingRepository(
    private val delegate: Repository,
) : Repository by delegate {
    override fun find(id: Long): String {
        println("find: $id")
        return delegate.find(id)
    }
}

이 코드는 모든 메서드를 손으로 다시 위임하지 않아도 됩니다. 하지만 delegation이 Decorator와 Proxy의 설계 판단을 없애지는 않습니다. 무엇을 감쌀지, 어떤 책임을 추가할지, 원래 인터페이스를 지켜도 되는지는 여전히 설계 문제입니다.


sealed class와 when은 State와 Visitor 일부를 단순화한다

State 패턴과 Visitor 패턴은 여러 타입과 분기 처리 문제를 다룹니다. Kotlin의 sealed class와 when은 닫힌 타입 계층을 다룰 때 매우 강합니다.

sealed interface PaymentState {
    data object Ready : PaymentState
    data object Processing : PaymentState
    data class Failed(val reason: String) : PaymentState
    data class Completed(val receiptId: String) : PaymentState
}

fun labelOf(state: PaymentState): String {
    return when (state) {
        PaymentState.Ready -> "결제 대기"
        PaymentState.Processing -> "처리 중"
        is PaymentState.Failed -> "실패: ${state.reason}"
        is PaymentState.Completed -> "완료: ${state.receiptId}"
    }
}

작은 상태 모델에서는 이 방식이 State 클래스를 여러 개 만드는 것보다 단순합니다. 또 타입 집합이 닫혀 있고 연산이 많지 않다면 Visitor보다 when 함수가 읽기 쉽습니다.

반대로 타입 구조는 안정적인데 분석, 출력, 검증, 변환 같은 연산이 계속 늘어난다면 Visitor가 다시 의미를 가질 수 있습니다. sealed class는 선택지를 늘려줄 뿐, 모든 Visitor를 대체하지는 않습니다.


함수 타입은 Strategy와 Template Method를 작게 만든다

Strategy 패턴은 알고리즘이나 정책을 바꿔 끼우는 구조입니다. Kotlin에서는 함수 타입과 람다 덕분에 작은 전략을 별도 클래스로 만들지 않아도 됩니다.

fun calculateDiscount(
    price: Int,
    policy: (Int) -> Int,
): Int {
    return policy(price)
}

val vipDiscount: (Int) -> Int = { price -> (price * 0.8).toInt() }
val eventDiscount: (Int) -> Int = { price -> price - 3000 }

Template Method도 마찬가지입니다. 전체 흐름은 함수 안에 두고 변하는 단계만 람다로 받으면, 상속 구조 없이도 공통 흐름을 재사용할 수 있습니다.

fun exportReport(
    load: () -> List<String>,
    format: (List<String>) -> String,
) {
    val rows = load()
    val output = format(rows)
    println(output)
}

다만 정책이 도메인 이름을 가져야 하고, 상태와 여러 메서드를 함께 가져야 한다면 함수 타입만으로는 부족할 수 있습니다. 그때는 Strategy 인터페이스나 클래스로 분리하는 편이 낫습니다.


Flow는 Observer를 현대적으로 다시 보게 한다

Observer 패턴은 어떤 상태 변화가 생겼을 때 여러 대상에게 알리는 구조입니다. Android와 Kotlin에서는 Flow, StateFlow, SharedFlow를 통해 변화 알림과 비동기 stream을 더 명시적으로 다루는 경우가 많습니다.

Flow가 Observer 패턴을 완전히 대체한다고 말하면 과합니다. 하지만 많은 앱 코드에서는 직접 observer 목록을 관리하고 subscribe/unsubscribe를 구현하는 대신, Flow의 수집과 lifecycle-aware collection을 사용하는 쪽이 더 안전하고 읽기 쉽습니다.


여전히 패턴 이름이 필요한 순간

코틀린 문법이 강해져도 패턴 이름은 여전히 쓸모가 있습니다. 이유는 팀 대화 때문입니다. “이건 단순한 람다 주입이 아니라 Strategy로 이름을 붙일 만큼 정책이 커졌다”라고 말할 수 있어야 합니다.

  • 도메인 정책이 커져서 함수 하나보다 이름 있는 타입이 필요할 때
  • 생성 책임이 복잡해서 Factory나 Builder가 설계 의도를 더 잘 드러낼 때
  • 접근 제어, 캐싱, 원격 호출처럼 Proxy의 의도가 분명할 때
  • 타입 계층은 안정적이고 새 연산이 계속 늘어나 Visitor가 유지보수 단위를 만들어 줄 때
  • 여러 객체의 상호작용을 Mediator나 Command처럼 이름 붙여야 대화가 쉬울 때

코틀린에서 더 단순해지는 패턴 정리

  • Singleton: object로 구현 비용이 크게 줄어든다
  • Prototype: data class copy로 값 객체 복제가 쉬워진다
  • Builder: named argument와 default parameter가 단순 Builder를 줄인다
  • Decorator/Proxy 일부: delegation으로 반복 위임 코드가 줄어든다
  • Strategy 일부: 함수 타입과 람다로 작은 정책을 표현할 수 있다
  • Template Method 일부: higher-order function으로 변하는 단계를 주입할 수 있다
  • State/Visitor 일부: sealed class와 when으로 작은 닫힌 타입 계층을 단순하게 다룰 수 있다
  • Observer 일부: Flow 계열 API가 직접 observer 관리 코드를 줄여준다

하지만 단순화가 항상 좋은 것은 아니다

코틀린다운 코드는 짧은 코드가 아니라 의도가 잘 드러나는 코드입니다. 람다로 줄였더니 정책 이름이 사라지고, sealed class로 줄였더니 연산이 여러 파일에 흩어진다면 단순화가 오히려 해가 될 수 있습니다.

그래서 단순화의 기준은 줄 수가 아닙니다. 변경이 생겼을 때 어디를 고쳐야 하는지, 팀원이 의도를 읽을 수 있는지, 테스트 단위가 자연스러운지입니다.


이 시리즈를 마무리하며

코틀린 디자인 패턴 시리즈의 목표는 GoF 패턴을 그대로 외우는 것이 아니었습니다. 각 패턴이 어떤 문제를 풀려고 나왔고, 코틀린에서는 그 문제를 어떤 문법과 설계 감각으로 더 단순하게 표현할 수 있는지 보는 것이었습니다.

패턴 이름은 여전히 유효합니다. 다만 구현부터 떠올리기보다 문제를 먼저 봐야 합니다. 생성 책임인가, 객체 조합인가, 상태 변화인가, 실행 흐름인가, 변화 방향인가를 먼저 보면 코틀린에서도 패턴은 더 실용적인 도구가 됩니다.

다음 단계에서는 패턴 이름을 하나씩 나열하는 방식보다, 실제 문제 상황에서 어떤 설계 선택을 해야 하는지 다루는 문제해결형 설계 글로 이어가면 좋습니다.

함께 보면 좋은 글: Strategy 패턴과 함수형 대안 비교, Template Method 패턴과 함수형 대안, Visitor 패턴은 sealed class와 어떻게 다를까

출처: Kotlin object declarations, Kotlin delegation, Kotlin sealed classes, Kotlin higher-order functions and lambdas


함께보면 좋은 글