KOTLIN · 심층 가이드
Kotlin 디자인패턴 완전 정리
GoF 패턴 대부분이 by 위임·object·람다 수신자 같은 언어 기능으로 줄어드는 지점을 확인하고, MVVM·MVI까지 20개 주제로 이어갑니다.
디자인 패턴 상당수는 언어가 못 해 주는 일을 코드로 메우던 관습이었습니다. Kotlin에서는 싱글턴이 object 한 줄이고, 데코레이터와 위임은 by 키워드가 대신 써 주며, 전략 패턴은 함수 타입 파라미터로 끝납니다. 그러니 패턴 이름을 외우기보다 "이 패턴이 해결하려던 문제가 Kotlin에도 남아 있는가"를 먼저 묻는 편이 낫습니다. 남아 있지 않은 것을 구현하면 클래스만 늘어납니다.
Builder 패턴 & DSL은 람다 수신자가 생성자 인자 나열을 어떻게 대체하는지 보여 주는 출발점이고, Delegation 패턴 — by, by lazy, observable은 클래스 위임과 프로퍼티 위임을 한 번에 정리해 줍니다. 구조 쪽은 MVVM 패턴과 MVI 패턴을 나란히 읽어 상태를 여러 StateFlow로 쪼갤 때와 단일 상태로 묶을 때를 비교하고, DI - 의존성 주입 (Koin)으로 경계를 정리하는 순서를 권합니다.
클래스 위임에는 잘 알려지지 않은 함정이 있습니다. class A(b: B) : B by b에서 A가 B의 메서드 하나를 오버라이드해도, 위임 대상 객체 b가 내부에서 자기 자신의 그 메서드를 호출하면 A의 구현이 아니라 b의 원래 구현이 실행됩니다. 상속에서라면 오버라이드가 먹었을 자리가 위임에서는 먹지 않는 것입니다. 또 by lazy는 기본이 동기화 모드라 락 비용이 있으니, 단일 스레드에서만 접근한다면 LazyThreadSafetyMode.NONE을 고려할 만합니다.
01Builder 패턴 & DSL
Kotlin의 람다 수신자(lambda with receiver)로 읽기 쉬운 DSL을 만듭니다.
Kotlin code
// HTML DSL 스타일 빌더
data class HtmlTag(val name: String) {
val children = mutableListOf<String>()
var text: String = ""
fun render(indent: Int = 0): String {
val pad = " ".repeat(indent)
return "$pad<$name>$text</$name>"
}
}
class HtmlBuilder {
private val tags = mutableListOf<HtmlTag>()
fun div(init: HtmlTag.() -> Unit) {
tags.add(HtmlTag("div").also(init))
}
fun p(text: String) {
tags.add(HtmlTag("p").also { it.text = text })
}
fun build() = tags.joinToString("
") { it.render() }
}
fun html(init: HtmlBuilder.() -> Unit) =
HtmlBuilder().also(init).build()
// 설정 DSL 패턴
data class ServerConfig(
val host: String = "localhost",
val port: Int = 8080,
val timeout: Int = 30
)
fun server(block: ServerConfig.() -> ServerConfig) =
ServerConfig().block()
fun main() {
val page = html {
div { text = "헤더" }
p("본문 내용")
p("두 번째 단락")
}
println(page)
}Kotlin 표준 라이브러리의 buildList { }, buildMap { }, buildString { }도 동일한 DSL 패턴입니다.
DSL 블록 안에서 외부 DSL 메서드가 우연히 호출되면 혼란스럽습니다. @DslMarker 어노테이션으로 스코프를 제한하세요.
02Delegation 패턴 — by
by 키워드로 보일러플레이트 없이 위임 패턴을 구현합니다.
Kotlin code
import kotlin.properties.Delegates
// 인터페이스 위임
interface Logger {
fun log(msg: String)
}
class ConsoleLogger : Logger {
override fun log(msg: String) = println("[LOG] $msg")
}
// by로 모든 Logger 메서드를 ConsoleLogger에 위임
class UserService(logger: Logger) : Logger by logger {
fun createUser(name: String) {
log("사용자 생성: $name") // 위임된 메서드
}
}
// 프로퍼티 위임
class UserPrefs {
// observable — 값 변경 감지
var theme: String by Delegates.observable("light") { _, old, new ->
println("테마 변경: $old → $new")
}
// vetoable — 조건 불만족 시 변경 거부
var fontSize: Int by Delegates.vetoable(14) { _, _, new ->
new in 8..32 // 8~32 범위만 허용
}
// Map 위임 (JSON 파싱 패턴에 유용)
val data = mapOf("name" to "Alice", "age" to "30")
val name: String by data
val age: String by data
}
fun main() {
val svc = UserService(ConsoleLogger())
svc.createUser("Bob")
val prefs = UserPrefs()
prefs.theme = "dark" // 테마 변경: light → dark
prefs.fontSize = 50 // 거부됨 (범위 초과)
println(prefs.fontSize) // 여전히 14
println("${prefs.name}, ${prefs.age}")
}커스텀 프로퍼티 위임을 만들려면 ReadOnlyProperty 또는 ReadWriteProperty 인터페이스를 구현하세요.
Delegates.observable은 변경 후 콜백, Delegates.vetoable은 변경 전 콜백입니다. 순서를 헷갈리지 마세요.
03Serialization 고급 설정
polymorphic, contextual 직렬화 심화
Kotlin code
<span class="cm">// Serialization 고급 설정 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Serialization 고급 설정") }KOTLIN 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
04Delegation 패턴 — by, by lazy, observable
Kotlin의 by 키워드는 위임 패턴을 언어 차원에서 지원합니다. 클래스 위임으로 상속 대신 구성을 쉽게 구현하고, 프로퍼티 위임으로 lazy, observable 등 반복 로직을 재사용할 수 있습니다.
Kotlin code
import kotlin.properties.Delegates
// 1. 클래스 위임: 인터페이스 구현을 다른 객체에 위임
interface Printer {
fun print(message: String)
}
class ConsolePrinter : Printer {
override fun print(message: String) = println("[콘솔] $message")
}
// by 키워드로 Printer 구현을 ConsolePrinter에 위임
class LoggingPrinter(printer: Printer) : Printer by printer
// 2. 프로퍼티 위임
class UserSettings {
// lazy: 최초 접근 시 한 번만 초기화
val config: Map<String, String> by lazy {
println("설정 로딩 중...")
mapOf("theme" to "dark", "lang" to "ko")
}
// observable: 값 변경 시 콜백 실행
var fontSize: Int by Delegates.observable(14) { _, old, new ->
println("폰트 크기 변경: $old → $new")
}
// vetoable: 조건에 맞지 않으면 변경 거부
var volume: Int by Delegates.vetoable(50) { _, _, new ->
new in 0..100 // 0~100 범위만 허용
}
}
fun main() {
val printer = LoggingPrinter(ConsolePrinter())
printer.print("위임 패턴 테스트")
val settings = UserSettings()
println(settings.config) // lazy 초기화 발생
println(settings.config) // 캐시된 값 반환
settings.fontSize = 18 // observable 콜백 실행
settings.volume = 200 // vetoable: 거부됨
println("볼륨: ${settings.volume}") // 여전히 50
}by lazy는 기본적으로 thread-safe(LazyThreadSafetyMode.SYNCHRONIZED)입니다. 단일 스레드 환경이면 lazy(LazyThreadSafetyMode.NONE)으로 성능을 높일 수 있습니다.
클래스 위임 시 위임 객체의 메서드를 오버라이드하면 위임 객체 내부에서 호출하는 다른 메서드는 여전히 원래 구현을 사용합니다. 위임은 상속과 다르게 다형성이 적용되지 않습니다.
05KMP (Kotlin Multiplatform) 소개
Kotlin Multiplatform(KMP)은 비즈니스 로직을 commonMain에 한 번 작성하고 Android, iOS, Web, Desktop에서 공유합니다. expect/actual 메커니즘으로 플랫폼별 구현을 분리하여 최대한의 코드 재사용을 달성합니다.
Kotlin code
// commonMain/kotlin/Platform.kt
// expect 선언: 공통 코드에서 인터페이스 정의
expect fun getPlatformName(): String
expect class HttpClient() {
suspend fun get(url: String): String
}
// 공통 비즈니스 로직
class Greeting {
fun greet(): String {
return "Hello from ${getPlatformName()}!"
}
}
// androidMain/kotlin/Platform.android.kt
// actual 구현: Android 플랫폼용
actual fun getPlatformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
actual class HttpClient actual constructor() {
actual suspend fun get(url: String): String {
// OkHttp 사용
return okhttp3.OkHttpClient().newCall(
okhttp3.Request.Builder().url(url).build()
).execute().body?.string() ?: ""
}
}
// iosMain/kotlin/Platform.ios.kt
// actual 구현: iOS 플랫폼용
actual fun getPlatformName(): String = "iOS ${UIDevice.currentDevice.systemVersion}"
actual class HttpClient actual constructor() {
actual suspend fun get(url: String): String {
// NSURLSession 사용
return "" // iOS 네이티브 네트워크 호출
}
}
// 공유 테스트
// commonTest/kotlin/GreetingTest.kt
fun testGreeting() {
val greeting = Greeting()
assert(greeting.greet().startsWith("Hello from"))
}KMP 프로젝트 시작 시 KMP Wizard를 사용하면 프로젝트 구조를 자동 생성할 수 있습니다. Ktor, kotlinx.serialization 등 KMP 호환 라이브러리를 우선 선택하세요.
expect/actual을 남용하면 공통 코드의 이점이 줄어듭니다. 인터페이스와 DI(의존성 주입)를 활용해 플랫폼 의존성을 최소화하세요.
06MVVM 패턴
Model-View-ViewModel 패턴을 Kotlin으로 구현합니다. StateFlow로 상태를 관리하고 UI와 분리합니다.
Kotlin code
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Model
data class Todo(val id: Int, val text: String, val done: Boolean = false)
// ViewModel
class TodoViewModel {
private val _todos = MutableStateFlow<List<Todo>>(emptyList())
val todos: StateFlow<List<Todo>> = _todos.asStateFlow()
private var nextId = 1
fun addTodo(text: String) {
_todos.value += Todo(nextId++, text)
}
fun toggleTodo(id: Int) {
_todos.value = _todos.value.map {
if (it.id == id) it.copy(done = !it.done) else it
}
}
fun removeDone() {
_todos.value = _todos.value.filter { !it.done }
}
val stats: Flow<String> = _todos.map { list ->
val done = list.count { it.done }
"완료: $done / 전체: ${list.size}"
}
}
// View (콘솔 시뮬레이션)
fun main() = runBlocking {
val vm = TodoViewModel()
val job = launch { vm.todos.collect { println("목록: $it") } }
vm.addTodo("Kotlin 공부")
vm.addTodo("프로젝트 완성")
delay(50)
vm.toggleTodo(1)
delay(50)
vm.removeDone()
delay(50)
job.cancel()
}ViewModel은 Android의 ViewModel 클래스를 상속하면 구성 변경(화면 회전)에도 상태가 유지됩니다.
ViewModel에서 View를 직접 참조하면 메모리 누수가 발생합니다. 반드시 StateFlow/SharedFlow를 통해 간접 통신하세요.
07MVI 패턴
Model-View-Intent 패턴으로 단방향 데이터 흐름을 구현합니다. 상태 관리가 예측 가능하고 디버깅이 쉽습니다.
Kotlin code
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// State
data class CounterState(val count: Int = 0, val loading: Boolean = false)
// Intent (사용자 액션)
sealed class CounterIntent {
data object Increment : CounterIntent()
data object Decrement : CounterIntent()
data object Reset : CounterIntent()
}
// Store (Reducer)
class CounterStore {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()
private val _intents = MutableSharedFlow<CounterIntent>()
suspend fun processIntents() {
_intents.collect { intent ->
val current = _state.value
_state.value = reduce(current, intent)
}
}
private fun reduce(state: CounterState, intent: CounterIntent): CounterState =
when (intent) {
CounterIntent.Increment -> state.copy(count = state.count + 1)
CounterIntent.Decrement -> state.copy(count = state.count - 1)
CounterIntent.Reset -> CounterState()
}
suspend fun dispatch(intent: CounterIntent) {
_intents.emit(intent)
}
}
fun main() = runBlocking {
val store = CounterStore()
launch { store.processIntents() }
val job = launch { store.state.collect { println("상태: $it") } }
store.dispatch(CounterIntent.Increment)
store.dispatch(CounterIntent.Increment)
store.dispatch(CounterIntent.Decrement)
delay(50)
store.dispatch(CounterIntent.Reset)
delay(50)
job.cancel()
}MVI의 reducer는 순수 함수이므로 단위 테스트가 매우 쉽습니다. 이전 상태 + Intent → 새 상태를 검증하면 됩니다.
reducer 내에서 부수 효과(API 호출 등)를 수행하지 마세요. 부수 효과는 미들웨어나 별도 계층에서 처리해야 합니다.
08리포지토리 패턴 (Repository)
데이터 소스를 추상화하여 비즈니스 로직과 데이터 접근을 분리합니다. 캐싱과 데이터 소스 전환이 투명합니다.
Kotlin code
data class User(val id: String, val name: String, val email: String)
interface UserRepository {
suspend fun getById(id: String): User?
suspend fun getAll(): List<User>
suspend fun save(user: User)
suspend fun delete(id: String)
}
class UserRepositoryImpl(
private val remoteSource: RemoteDataSource,
private val localCache: LocalCache
) : UserRepository {
override suspend fun getById(id: String): User? =
localCache.get(id) ?: remoteSource.fetch(id)?.also { localCache.put(it) }
override suspend fun getAll(): List<User> = remoteSource.fetchAll()
override suspend fun save(user: User) {
remoteSource.save(user)
localCache.put(user)
}
override suspend fun delete(id: String) {
remoteSource.delete(id)
localCache.remove(id)
}
}
// 간단한 구현체
class RemoteDataSource {
private val db = mutableMapOf<String, User>()
suspend fun fetch(id: String): User? = db[id]
suspend fun fetchAll(): List<User> = db.values.toList()
suspend fun save(user: User) { db[user.id] = user }
suspend fun delete(id: String) { db.remove(id) }
}
class LocalCache {
private val cache = mutableMapOf<String, User>()
fun get(id: String): User? = cache[id]
fun put(user: User) { cache[user.id] = user }
fun remove(id: String) { cache.remove(id) }
}
fun main() = kotlinx.coroutines.runBlocking {
val repo: UserRepository = UserRepositoryImpl(RemoteDataSource(), LocalCache())
repo.save(User("1", "김철수", "kim@test.com"))
println(repo.getById("1"))
}리포지토리 인터페이스에 의존하면 테스트에서 인메모리 구현으로 교체하여 빠른 단위 테스트가 가능합니다.
리포지토리에 UI 로직이나 프레젠테이션 로직을 넣지 마세요. 데이터 접근과 캐싱만 담당해야 합니다.
09유즈케이스 패턴 (Use Case)
하나의 비즈니스 동작을 캡슐화하는 유즈케이스(인터랙터) 패턴입니다. 클린 아키텍처의 핵심 구성요소입니다.
Kotlin code
// 기본 유즈케이스 인터페이스
fun interface UseCase<in P, out R> {
suspend operator fun invoke(params: P): R
}
data class User(val id: String, val name: String, val active: Boolean)
// 리포지토리
interface UserRepo {
suspend fun findById(id: String): User?
suspend fun save(user: User)
}
// 구체적 유즈케이스
class GetActiveUsersUseCase(
private val repo: UserRepo
) : UseCase<Unit, List<User>> {
override suspend fun invoke(params: Unit): List<User> =
listOf(User("1", "김철수", true), User("2", "이영희", false))
.filter { it.active }
}
class DeactivateUserUseCase(
private val repo: UserRepo
) : UseCase<String, Result<Unit>> {
override suspend fun invoke(params: String): Result<Unit> = runCatching {
val user = repo.findById(params)
?: throw NoSuchElementException("사용자 없음: $params")
repo.save(user.copy(active = false))
}
}
fun main() = kotlinx.coroutines.runBlocking {
val mockRepo = object : UserRepo {
val store = mutableMapOf<String, User>()
override suspend fun findById(id: String) = store[id]
override suspend fun save(user: User) { store[user.id] = user }
}
val getActive = GetActiveUsersUseCase(mockRepo)
println("활성 사용자: ${getActive(Unit)}")
}operator fun invoke를 사용하면 유즈케이스를 함수처럼 호출할 수 있어 코드가 간결해집니다: getUsers()
유즈케이스에 여러 책임을 넣으면 SRP 위반입니다. 하나의 유즈케이스는 하나의 비즈니스 동작만 수행해야 합니다.
10DI - 의존성 주입 (Koin)
Koin을 사용한 경량 의존성 주입 패턴입니다. DSL로 모듈을 정의하고 런타임에 의존성을 주입합니다.
Kotlin code
// Koin 개념 시뮬레이션 (실제: org.koin:koin-core)
class Container {
val factories = mutableMapOf<String, () -> Any>()
val singletons = mutableMapOf<String, Any>()
inline fun <reified T : Any> single(noinline factory: () -> T) {
factories[T::class.simpleName!!] = factory
}
inline fun <reified T : Any> get(): T {
val key = T::class.simpleName!!
return singletons.getOrPut(key) { factories[key]!!() } as T
}
}
interface Logger { fun log(msg: String) }
interface UserRepo { fun find(id: String): String }
class ConsoleLogger : Logger {
override fun log(msg: String) = println("[LOG] $msg")
}
class UserRepoImpl(private val logger: Logger) : UserRepo {
override fun find(id: String): String {
logger.log("사용자 조회: $id")
return "User-$id"
}
}
class UserService(private val repo: UserRepo, private val logger: Logger) {
fun getUser(id: String): String {
logger.log("서비스 호출")
return repo.find(id)
}
}
fun main() {
val di = Container()
di.single<Logger> { ConsoleLogger() }
di.single<UserRepo> { UserRepoImpl(di.get()) }
di.single { UserService(di.get(), di.get()) }
val service = di.get<UserService>()
println(service.getUser("001"))
}Koin의 single은 싱글톤, factory는 매번 새 인스턴스를 생성합니다. 상태 없는 서비스에는 single을 사용하세요.
순환 의존성(A→B→A)이 있으면 스택 오버플로가 발생합니다. 인터페이스로 의존성을 역전시켜 순환을 끊으세요.
11이벤트 버스 (Event Bus)
컴포넌트 간 느슨한 결합을 위한 이벤트 버스를 SharedFlow로 구현합니다.
Kotlin code
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
object EventBus {
private val _events = MutableSharedFlow<Any>(extraBufferCapacity = 100)
suspend fun publish(event: Any) { _events.emit(event) }
inline fun <reified T> subscribe(): Flow<T> =
_events.filterIsInstance<T>()
}
// 이벤트 정의
sealed class AppEvent {
data class UserLoggedIn(val userId: String) : AppEvent()
data class OrderPlaced(val orderId: String, val total: Int) : AppEvent()
data class ErrorOccurred(val message: String) : AppEvent()
}
fun main() = runBlocking {
// 구독자 등록
val job1 = launch {
EventBus.subscribe<AppEvent.UserLoggedIn>().collect {
println("인증 모듈: ${it.userId} 로그인")
}
}
val job2 = launch {
EventBus.subscribe<AppEvent.OrderPlaced>().collect {
println("알림 모듈: 주문 ${it.orderId} (${it.total}원)")
}
}
delay(50)
// 이벤트 발행
EventBus.publish(AppEvent.UserLoggedIn("user-001"))
EventBus.publish(AppEvent.OrderPlaced("ORD-123", 50000))
EventBus.publish(AppEvent.ErrorOccurred("네트워크 오류"))
delay(100)
job1.cancel(); job2.cancel()
}filterIsInstance()로 관심 있는 이벤트 타입만 구독하면 타입 안전한 이벤트 처리가 가능합니다.
이벤트 버스를 남용하면 이벤트 흐름 추적이 어려워집니다. 직접 의존성 주입이 가능한 경우에는 DI를 선호하세요.
12메디에이터 패턴 (Mediator)
객체 간 직접 통신 대신 중재자를 통해 간접 통신합니다. 복잡한 상호작용을 중앙에서 관리합니다.
Kotlin code
interface Mediator {
fun notify(sender: Component, event: String)
}
abstract class Component(val name: String) {
var mediator: Mediator? = null
}
class AuthComponent : Component("Auth") {
fun login(user: String) {
println("[$name] $user 로그인")
mediator?.notify(this, "LOGIN:$user")
}
fun showWelcome(user: String) = println("[$name] 환영합니다, $user!")
}
class LogComponent : Component("Log") {
fun log(msg: String) = println("[$name] 기록: $msg")
}
class NotifyComponent : Component("Notify") {
fun send(msg: String) = println("[$name] 알림: $msg")
}
class AppMediator : Mediator {
lateinit var auth: AuthComponent
lateinit var log: LogComponent
lateinit var notify: NotifyComponent
override fun notify(sender: Component, event: String) {
when {
event.startsWith("LOGIN:") -> {
val user = event.substringAfter("LOGIN:")
log.log("$user 로그인")
notify.send("$user 님이 접속했습니다")
auth.showWelcome(user)
}
}
}
}
fun main() {
val mediator = AppMediator()
val auth = AuthComponent().also { it.mediator = mediator }
val log = LogComponent().also { it.mediator = mediator }
val notify = NotifyComponent().also { it.mediator = mediator }
mediator.auth = auth; mediator.log = log; mediator.notify = notify
auth.login("김철수")
}메디에이터 패턴은 GUI 컴포넌트, 마이크로서비스 간 통신, 채팅 방 등 N:N 관계를 1:N으로 단순화할 때 유용합니다.
중재자에 너무 많은 로직이 집중되면 "신(God) 객체"가 됩니다. 복잡해지면 여러 중재자로 분리하세요.
13커맨드 패턴 (Command)
요청을 객체로 캡슐화하여 실행, 취소, 큐잉을 지원합니다. 편집기의 Undo/Redo에 적합합니다.
Kotlin code
interface Command {
fun execute()
fun undo()
val description: String
}
class TextEditor {
var content = ""
private set
fun insert(text: String) { content += text }
fun deleteLast(count: Int) {
content = content.dropLast(count)
}
override fun toString() = "[$content]"
}
class InsertCommand(private val editor: TextEditor, private val text: String) : Command {
override val description = "삽입: '$text'"
override fun execute() = editor.insert(text)
override fun undo() = editor.deleteLast(text.length)
}
class CommandHistory {
private val undoStack = ArrayDeque<Command>()
private val redoStack = ArrayDeque<Command>()
fun execute(cmd: Command) {
cmd.execute()
undoStack.addLast(cmd)
redoStack.clear()
println("실행: ${cmd.description} → $cmd")
}
fun undo() {
undoStack.removeLastOrNull()?.let {
it.undo(); redoStack.addLast(it)
println("취소: ${it.description}")
}
}
fun redo() {
redoStack.removeLastOrNull()?.let {
it.execute(); undoStack.addLast(it)
println("재실행: ${it.description}")
}
}
}
fun main() {
val editor = TextEditor()
val history = CommandHistory()
history.execute(InsertCommand(editor, "Hello"))
history.execute(InsertCommand(editor, " World"))
println("현재: $editor")
history.undo()
println("undo: $editor")
history.redo()
println("redo: $editor")
}커맨드 패턴은 매크로(여러 커맨드 묶음), 트랜잭션(모두 성공 또는 모두 롤백), 로깅에도 활용됩니다.
undo 구현이 불완전하면 상태가 일치하지 않습니다. 각 커맨드의 undo가 execute의 역연산인지 반드시 검증하세요.
14체인 오브 책임 (Chain of Responsibility)
요청을 처리할 수 있는 핸들러 체인을 구성합니다. 미들웨어, 필터, 검증 파이프라인에 활용됩니다.
Kotlin code
data class Request(val path: String, val headers: Map<String, String>, val body: String)
data class Response(val code: Int, val body: String)
fun interface Middleware {
fun handle(request: Request, next: (Request) -> Response): Response
}
val authMiddleware = Middleware { req, next ->
if (req.headers.containsKey("Authorization")) {
println("[Auth] 인증 통과")
next(req)
} else {
Response(401, "인증 필요")
}
}
val loggingMiddleware = Middleware { req, next ->
println("[Log] ${req.path} 요청")
val response = next(req)
println("[Log] 응답: ${response.code}")
response
}
val rateLimitMiddleware = Middleware { req, next ->
println("[RateLimit] 요청 허용")
next(req)
}
fun buildChain(middlewares: List<Middleware>, handler: (Request) -> Response): (Request) -> Response =
middlewares.foldRight(handler) { mw, next -> { req -> mw.handle(req, next) } }
fun main() {
val chain = buildChain(
listOf(loggingMiddleware, authMiddleware, rateLimitMiddleware)
) { req -> Response(200, "응답: ${req.path}") }
val req = Request("/api/data", mapOf("Authorization" to "Bearer xyz"), "")
val resp = chain(req)
println("최종: ${resp.code} - ${resp.body}")
}foldRight로 미들웨어 체인을 구성하면 등록 순서대로 실행됩니다. 순서를 바꾸기만 해도 동작이 달라집니다.
체인에서 next(req)를 호출하지 않으면 후속 핸들러가 실행되지 않습니다. 의도적인 차단이 아니라면 반드시 next를 호출하세요.
15비지터 패턴 (Visitor)
sealed class와 when을 활용하여 객체 구조를 변경하지 않고 새 연산을 추가하는 비지터 패턴입니다.
Kotlin code
sealed class Expr {
data class Num(val value: Double) : Expr()
data class Add(val left: Expr, val right: Expr) : Expr()
data class Mul(val left: Expr, val right: Expr) : Expr()
data class Neg(val expr: Expr) : Expr()
}
// 비지터: 새 연산 추가 (클래스 수정 불필요)
fun eval(expr: Expr): Double = when (expr) {
is Expr.Num -> expr.value
is Expr.Add -> eval(expr.left) + eval(expr.right)
is Expr.Mul -> eval(expr.left) * eval(expr.right)
is Expr.Neg -> -eval(expr.expr)
}
fun prettyPrint(expr: Expr): String = when (expr) {
is Expr.Num -> expr.value.toString()
is Expr.Add -> "(${prettyPrint(expr.left)} + ${prettyPrint(expr.right)})"
is Expr.Mul -> "(${prettyPrint(expr.left)} * ${prettyPrint(expr.right)})"
is Expr.Neg -> "-${prettyPrint(expr.expr)}"
}
fun depth(expr: Expr): Int = when (expr) {
is Expr.Num -> 0
is Expr.Add -> 1 + maxOf(depth(expr.left), depth(expr.right))
is Expr.Mul -> 1 + maxOf(depth(expr.left), depth(expr.right))
is Expr.Neg -> 1 + depth(expr.expr)
}
fun main() {
// (3 + 4) * -(2)
val expr = Expr.Mul(
Expr.Add(Expr.Num(3.0), Expr.Num(4.0)),
Expr.Neg(Expr.Num(2.0))
)
println("식: ${prettyPrint(expr)}")
println("값: ${eval(expr)}")
println("깊이: ${depth(expr)}")
}Kotlin에서는 sealed class + when 조합이 전통적인 Visitor 인터페이스보다 간결합니다. 새 연산은 함수를 추가하면 됩니다.
sealed class에 새 하위 타입을 추가하면 모든 when 분기를 수정해야 합니다. 타입이 자주 바뀌면 전통적인 OOP 방식이 나을 수 있습니다.
16프록시 패턴 (Proxy)
원본 객체 접근을 제어하는 프록시를 구현합니다. 캐싱, 접근 제어, 로깅 프록시를 by 위임으로 간결하게 작성합니다.
Kotlin code
interface ImageLoader {
fun load(url: String): String
fun getInfo(url: String): String
}
class RealImageLoader : ImageLoader {
override fun load(url: String): String {
println(" [원본] 이미지 다운로드: $url")
Thread.sleep(100) // 시뮬레이션
return "이미지 데이터($url)"
}
override fun getInfo(url: String) = "정보: $url"
}
// 캐싱 프록시
class CachingProxy(private val real: ImageLoader) : ImageLoader by real {
private val cache = mutableMapOf<String, String>()
override fun load(url: String): String {
return cache.getOrPut(url) {
println("[캐시] 캐시 미스, 원본 호출")
real.load(url)
}.also { println("[캐시] 반환: ${it.take(20)}...") }
}
}
// 로깅 프록시
class LoggingProxy(private val real: ImageLoader) : ImageLoader by real {
override fun load(url: String): String {
println("[로그] load 호출: $url")
val start = System.currentTimeMillis()
val result = real.load(url)
println("[로그] 소요: ${System.currentTimeMillis() - start}ms")
return result
}
}
fun main() {
val loader: ImageLoader = CachingProxy(LoggingProxy(RealImageLoader()))
loader.load("https://example.com/img.jpg")
println("---")
loader.load("https://example.com/img.jpg") // 캐시 히트
println(loader.getInfo("test.jpg")) // 위임된 메서드
}by 위임으로 프록시를 만들면 오버라이드하지 않은 메서드는 자동으로 원본에 위임됩니다.
프록시 계층이 깊어지면 디버깅이 어려워집니다. 3단계 이상 중첩되면 AOP 프레임워크를 고려하세요.
17플라이웨이트 패턴 (Flyweight)
공유 가능한 객체를 재사용하여 메모리를 절약하는 패턴입니다. 대량의 유사 객체 생성 시 유용합니다.
Kotlin code
data class CharStyle(val font: String, val size: Int, val color: String)
class StyleFactory {
private val cache = mutableMapOf<String, CharStyle>()
var cacheHits = 0; var cacheMisses = 0
fun getStyle(font: String, size: Int, color: String): CharStyle {
val key = "$font-$size-$color"
return cache.getOrPut(key) {
cacheMisses++
CharStyle(font, size, color)
}.also { if (cache.containsKey(key) && cacheMisses < cache.size) cacheHits++ }
}
fun stats() = "캐시 크기: ${cache.size}, 히트: $cacheHits, 미스: $cacheMisses"
}
data class Character(val char: Char, val x: Int, val y: Int, val style: CharStyle)
fun main() {
val factory = StyleFactory()
val document = mutableListOf<Character>()
// 대량 문자 생성 (스타일은 공유)
val text = "Hello Kotlin World! 안녕하세요 코틀린!"
text.forEachIndexed { i, c ->
val style = when {
c.isUpperCase() -> factory.getStyle("Bold", 14, "red")
c.isLetter() -> factory.getStyle("Normal", 12, "black")
else -> factory.getStyle("Normal", 12, "gray")
}
document.add(Character(c, i * 10, 0, style))
}
println("문자 수: ${document.size}")
println("스타일 ${factory.stats()}")
println("고유 스타일: ${document.map { it.style }.distinct().size}")
}Kotlin의 data class는 equals/hashCode를 자동 생성하므로 캐시 키로 사용하기에 적합합니다.
플라이웨이트 객체는 불변이어야 합니다. 공유 객체를 변경하면 모든 참조자에게 영향을 미칩니다.
18템플릿 메서드 패턴 (Template Method)
알고리즘의 골격을 정의하고 세부 단계를 하위 클래스에서 구현합니다. 훅 메서드로 선택적 확장도 지원합니다.
Kotlin code
abstract class DataExporter(val name: String) {
// 템플릿 메서드
fun export(data: List<Map<String, Any>>): String {
val filtered = filterData(data)
val header = formatHeader(filtered.first().keys.toList())
val body = filtered.map { formatRow(it) }
val result = combine(header, body)
onExportComplete(result) // 훅
return result
}
abstract fun formatHeader(columns: List<String>): String
abstract fun formatRow(row: Map<String, Any>): String
open fun filterData(data: List<Map<String, Any>>) = data // 훅: 기본은 필터 없음
open fun combine(header: String, rows: List<String>) = (listOf(header) + rows).joinToString("
")
open fun onExportComplete(result: String) {} // 훅
}
class CsvExporter : DataExporter("CSV") {
override fun formatHeader(columns: List<String>) = columns.joinToString(",")
override fun formatRow(row: Map<String, Any>) = row.values.joinToString(",")
}
class MarkdownExporter : DataExporter("Markdown") {
override fun formatHeader(columns: List<String>) =
"| ${columns.joinToString(" | ")} |
| ${columns.map { "---" }.joinToString(" | ")} |"
override fun formatRow(row: Map<String, Any>) = "| ${row.values.joinToString(" | ")} |"
override fun onExportComplete(result: String) { println("[MD] 내보내기 완료 (${result.length}자)") }
}
fun main() {
val data = listOf(
mapOf("이름" to "김철수", "점수" to 85),
mapOf("이름" to "이영희", "점수" to 92),
)
println(CsvExporter().export(data))
println("---")
println(MarkdownExporter().export(data))
}훅 메서드(open fun)는 기본 구현을 제공하여 하위 클래스가 선택적으로 오버라이드할 수 있습니다.
템플릿 메서드에서 추상 메서드가 너무 많으면 구현 부담이 커집니다. 핵심 단계만 추상화하고 나머지는 훅으로 제공하세요.
19인터프리터 패턴 (Interpreter)
DSL이나 간단한 언어의 문법을 해석하는 패턴입니다. sealed class로 AST를 정의하고 재귀적으로 평가합니다.
Kotlin code
sealed class Expr {
data class Num(val value: Int) : Expr()
data class Var(val name: String) : Expr()
data class BinOp(val op: String, val left: Expr, val right: Expr) : Expr()
data class Let(val name: String, val value: Expr, val body: Expr) : Expr()
}
typealias Env = Map<String, Int>
fun eval(expr: Expr, env: Env = emptyMap()): Int = when (expr) {
is Expr.Num -> expr.value
is Expr.Var -> env[expr.name] ?: throw RuntimeException("정의되지 않은 변수: ${expr.name}")
is Expr.BinOp -> {
val l = eval(expr.left, env)
val r = eval(expr.right, env)
when (expr.op) {
"+" -> l + r; "-" -> l - r
"*" -> l * r; "/" -> l / r
else -> throw RuntimeException("알 수 없는 연산: ${expr.op}")
}
}
is Expr.Let -> {
val v = eval(expr.value, env)
eval(expr.body, env + (expr.name to v))
}
}
fun main() {
// let x = 10 in (x + 5) * 2
val program = Expr.Let("x", Expr.Num(10),
Expr.BinOp("*",
Expr.BinOp("+", Expr.Var("x"), Expr.Num(5)),
Expr.Num(2)
)
)
println("결과: ${eval(program)}") // 30
// let a = 3 in let b = 4 in a * a + b * b
val pythagoras = Expr.Let("a", Expr.Num(3),
Expr.Let("b", Expr.Num(4),
Expr.BinOp("+",
Expr.BinOp("*", Expr.Var("a"), Expr.Var("a")),
Expr.BinOp("*", Expr.Var("b"), Expr.Var("b"))
)
)
)
println("피타고라스: ${eval(pythagoras)}") // 25
}sealed class로 AST를 정의하면 when에서 모든 노드 타입을 빠짐없이 처리할 수 있습니다.
인터프리터 패턴은 복잡한 문법에는 적합하지 않습니다. 문법이 복잡하면 ANTLR 등 파서 생성기를 사용하세요.
20스펙 패턴 (Specification)
비즈니스 규칙을 조합 가능한 객체로 캡슐화합니다. AND, OR, NOT 연산으로 복합 조건을 구성합니다.
Kotlin code
fun interface Spec<T> {
fun isSatisfiedBy(candidate: T): Boolean
infix fun and(other: Spec<T>): Spec<T> =
Spec { isSatisfiedBy(it) && other.isSatisfiedBy(it) }
infix fun or(other: Spec<T>): Spec<T> =
Spec { isSatisfiedBy(it) || other.isSatisfiedBy(it) }
operator fun not(): Spec<T> = Spec { !isSatisfiedBy(it) }
}
data class Product(val name: String, val price: Int, val category: String, val inStock: Boolean)
// 스펙 정의
val inStock = Spec<Product> { it.inStock }
val affordable = Spec<Product> { it.price < 50000 }
val isElectronics = Spec<Product> { it.category == "전자기기" }
fun priceRange(min: Int, max: Int) = Spec<Product> { it.price in min..max }
fun main() {
val products = listOf(
Product("마우스", 30000, "전자기기", true),
Product("키보드", 80000, "전자기기", true),
Product("노트", 3000, "문구", true),
Product("모니터", 350000, "전자기기", false),
)
// 스펙 조합
val buyable = inStock and affordable and isElectronics
val premium = isElectronics and priceRange(100000, 500000)
println("구매 가능: ${products.filter { buyable.isSatisfiedBy(it) }.map { it.name }}")
println("프리미엄: ${products.filter { premium.isSatisfiedBy(it) }.map { it.name }}")
println("재고없음: ${products.filter { (!inStock).isSatisfiedBy(it) }.map { it.name }}")
}스펙 패턴은 복잡한 쿼리 조건, 할인 규칙, 접근 제어 등 비즈니스 규칙이 자주 변하는 도메인에 유용합니다.
스펙을 너무 세분화하면 조합이 복잡해집니다. 자주 함께 사용하는 스펙은 하나로 합쳐서 명명하세요.
정리하며
- object·by·함수 타입으로 대체되는 패턴은 구현하지 말고 언어 기능을 그대로 씁니다
- 클래스 위임은 위임 대상의 자기 호출에 오버라이드가 반영되지 않습니다
- MVVM은 상태를 나눠 갱신하기 쉽고, MVI는 상태 조합의 일관성을 보장하기 쉽습니다
- by lazy는 기본이 동기화 모드이므로 접근 스레드 조건을 보고 모드를 정합니다
더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.