KOTLIN · 심층 가이드
Kotlin 객체지향 완전 정리
sealed 계층·data class·by 위임을 조합해 상속을 최소화하면서 타입으로 도메인을 표현하는 Kotlin 객체지향 설계를 22개 주제로 다룹니다.
Kotlin 클래스는 기본이 final입니다. 상속을 허용하려면 open을 직접 붙여야 하고, 이 결정 하나가 설계 습관을 바꿉니다. 무심코 부모 클래스를 만들어 확장 포인트를 열어 두는 대신, 계층을 sealed로 닫아 두고 when이 모든 가지를 처리했는지 컴파일러에게 검사시키는 쪽이 Kotlin다운 방식입니다. 상속이 필요해 보이는 자리 대부분은 by 위임이나 인터페이스 기본 구현으로 대체됩니다.
class & 주 생성자 & init에서 프로퍼티 초기화 순서를 잡고, data class — equals·hashCode·copy·toString로 값 객체를 만든 다음, sealed class & sealed interface로 그 값 객체들을 하나의 닫힌 타입 아래 묶는 순서를 권합니다. 여기까지가 도메인 모델링의 뼈대입니다. 위임 패턴 (Delegation by)과 고급 제네릭 — in/out variance는 중복이 눈에 보인 뒤에 읽어야 머리에 남습니다.
data class에서 자주 걸리는 함정이 두 가지 있습니다. 첫째, 자동 생성되는 equals와 hashCode는 주 생성자에 선언된 프로퍼티만 봅니다. 클래스 본문에 선언한 val은 비교에서 빠지므로, 값이 달라도 두 객체가 같다고 판정됩니다. 둘째, copy()는 얕은 복사입니다. 안에 가변 리스트가 들어 있으면 복사본과 원본이 같은 리스트를 공유합니다. 불변을 의도했다면 프로퍼티 타입을 읽기 전용 인터페이스로 두는 것만으로는 부족합니다.
01class & 주 생성자 & init
Kotlin 클래스의 주 생성자(primary constructor)와 초기화 블록.
Kotlin code
// 주 생성자 — 클래스 헤더에 직접
class Person(
val name: String, // val/var → 프로퍼티 자동 생성
var age: Int,
private val email: String // private 프로퍼티
) {
// init 블록 — 생성 시 실행
init {
require(age >= 0) { "나이는 0 이상이어야 합니다" }
println("$name 생성됨")
}
// 보조 생성자
constructor(name: String) : this(name, 0, "")
// 프로퍼티 커스텀 getter
val isAdult: Boolean
get() = age >= 18
// 프로퍼티 커스텀 setter
var nickname: String = name
set(value) {
field = value.trim() // field = backing field
}
override fun toString() = "Person($name, $age)"
}
fun main() {
val p = Person("Alice", 25, "alice@test.com")
println(p.isAdult) // true
p.nickname = " Dev "
println(p.nickname) // "Dev"
}init 블록은 주 생성자 실행 직후 호출됩니다. 여러 init 블록을 작성하면 선언 순서대로 실행됩니다.
Setter 내에서 프로퍼티에 직접 대입하면(nickname = value) 무한 재귀가 됩니다. 반드시 field 키워드를 사용하세요.
02data class — equals·hashCode·copy·toString
컴파일러가 equals(), hashCode(), toString(), copy(), componentN()을 자동 생성합니다.
Kotlin code
data class User(
val id: Int,
val name: String,
val email: String,
val role: String = "user" // 기본값 OK
)
fun main() {
val u1 = User(1, "Alice", "a@test.com")
val u2 = User(1, "Alice", "a@test.com")
// 구조적 동등성 (값 비교)
println(u1 == u2) // true
println(u1 === u2) // false (다른 객체)
// 자동 생성 toString
println(u1)
// User(id=1, name=Alice, email=a@test.com, role=user)
// copy — 일부 값만 변경한 복사본
val admin = u1.copy(role = "admin")
val renamed = u1.copy(id = 2, name = "Bob")
// 구조 분해
val (id, name) = u1
println("$id: $name")
// HashMap 키로 안전하게 사용 (hashCode 보장)
val users = mapOf(u1 to "active", u2 to "active")
println(users.size) // 1 (u1 == u2이므로)
}copy()는 얕은 복사(shallow copy)입니다. 중첩 객체는 참조가 공유됩니다. 깊은 복사가 필요하면 직접 구현하거나 serialization을 활용하세요.
equals/hashCode는 주 생성자에 선언된 프로퍼티만 포함합니다. 클래스 바디에 선언한 프로퍼티는 비교에서 제외됩니다.
03sealed class & sealed interface
제한된 클래스 계층. when과 함께 써서 컴파일러가 모든 케이스를 확인합니다.
Kotlin code
// 네트워크 응답 모델링
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(
val code: Int,
val message: String
) : NetworkResult<Nothing>()
object Loading : NetworkResult<Nothing>()
}
// UI 상태 모델링 (Android/Compose 패턴)
sealed interface UiState<out T> {
object Idle : UiState<Nothing>
object Loading : UiState<Nothing>
data class Content<T>(val data: T) : UiState<T>
data class Error(val throwable: Throwable) : UiState<Nothing>
}
fun <T> handleResult(result: NetworkResult<T>) {
when (result) {
is NetworkResult.Success -> println("✅ ${result.data}")
is NetworkResult.Error -> println("❌ ${result.code}: ${result.message}")
NetworkResult.Loading -> println("⏳ 로딩 중...")
// else 불필요 — 컴파일러가 완전성 검사
}
}
fun main() {
handleResult(NetworkResult.Success("Hello Kotlin"))
handleResult(NetworkResult.Error(404, "Not Found"))
handleResult(NetworkResult.Loading)
}Kotlin 1.5+에서 sealed interface가 추가됐습니다. 하나의 클래스가 여러 sealed interface를 구현할 수 있어 더 유연합니다.
sealed class의 하위 클래스는 같은 파일에 있어야 합니다(Kotlin 1.5 이전). 1.5+에서는 같은 패키지 내 어디든 가능합니다.
04object & companion object
싱글턴 패턴과 정적 멤버를 Kotlin다운 방식으로 구현합니다.
Kotlin code
// object — 싱글턴
object AppConfig {
val version = "1.0.0"
var debugMode = false
fun log(msg: String) {
if (debugMode) println("[DEBUG] $msg")
}
}
// companion object — 클래스의 정적 영역
class User private constructor(
val id: Int,
val name: String
) {
companion object {
private var nextId = 1
// 팩토리 함수 (생성자 대신)
fun create(name: String) = User(nextId++, name)
// @JvmStatic — Java에서 정적 메서드로 보임
@JvmStatic
fun fromId(id: Int): User? = null // DB 조회 가정
}
}
// object expression — 익명 객체
val comparator = object : Comparator<String> {
override fun compare(a: String, b: String) =
a.length.compareTo(b.length)
}
fun main() {
AppConfig.debugMode = true
AppConfig.log("앱 시작") // [DEBUG] 앱 시작
val u1 = User.create("Alice")
val u2 = User.create("Bob")
println("${u1.id}: ${u1.name}") // 1: Alice
println("${u2.id}: ${u2.name}") // 2: Bob
}companion object는 인터페이스를 구현하거나 확장 함수를 받을 수 있습니다. 팩토리 패턴 구현에 자주 사용됩니다.
object 싱글턴은 처음 접근 시 초기화됩니다. 초기화 순서에 의존하는 로직은 주의가 필요합니다.
05인터페이스 & 추상 클래스
인터페이스의 기본 구현(default method)과 추상 클래스의 차이를 이해합니다.
Kotlin code
// 인터페이스 — 기본 구현 가능
interface Drawable {
val color: String // 추상 프로퍼티
fun draw() // 추상 메서드
fun describe() = "나는 $color 색 도형" // 기본 구현
}
interface Resizable {
fun resize(factor: Double)
}
// 다중 인터페이스 구현
class Circle(
override val color: String,
var radius: Double
) : Drawable, Resizable {
override fun draw() = println("⭕ 반지름 $radius 원")
override fun resize(factor: Double) { radius *= factor }
}
// 추상 클래스 — 상태(필드)를 가질 수 있음
abstract class Shape(val name: String) {
var x = 0.0; var y = 0.0
abstract fun area(): Double
fun move(dx: Double, dy: Double) {
x += dx; y += dy
}
}
class Rectangle(val w: Double, val h: Double)
: Shape("사각형") {
override fun area() = w * h
}
fun main() {
val c = Circle("red", 5.0)
c.draw()
println(c.describe())
c.resize(2.0)
c.draw()
val r = Rectangle(3.0, 4.0)
r.move(1.0, 2.0)
println("넓이: ${r.area()}, 위치: (${r.x}, ${r.y})")
}인터페이스는 상태(backing field)를 가질 수 없습니다. 상태가 필요하면 추상 클래스를 사용하세요. 인터페이스는 다중 구현이 가능합니다.
두 인터페이스에 같은 이름의 기본 구현이 있으면 반드시 오버라이드해야 합니다. 그렇지 않으면 컴파일 에러입니다.
06Type-safe Builder DSL
invoke operator와 람다 수신자로 DSL 만들기
Kotlin code
<span class="cm">// Type-safe Builder DSL 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Type-safe Builder DSL") }KOTLIN 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
07고급 제네릭 — in/out variance
Kotlin의 out(공변)과 in(반공변) 키워드는 제네릭 타입의 변성(variance)을 선언합니다. Java의 ? extends T와 ? super T를 대체하며, 타입 안전한 제네릭 API 설계에 필수적입니다.
Kotlin code
// out = 공변 (Producer): T를 반환만 함
interface Producer<out T> {
fun produce(): T
// fun consume(item: T) // 컴파일 오류! out 타입은 입력 불가
}
// in = 반공변 (Consumer): T를 소비만 함
interface Consumer<in T> {
fun consume(item: T)
// fun produce(): T // 컴파일 오류! in 타입은 반환 불가
}
// 무공변 (Invariant): T를 입출력 모두 사용
interface Transformer<T> {
fun transform(item: T): T
}
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
class Cat(name: String) : Animal(name)
class DogProducer : Producer<Dog> {
override fun produce() = Dog("멍멍이")
}
class AnimalConsumer : Consumer<Animal> {
override fun consume(item: Animal) {
println("동물 처리: ${item.name}")
}
}
fun feedAnimals(producer: Producer<Animal>, consumer: Consumer<Dog>) {
val animal = producer.produce()
consumer.consume(Dog("강아지"))
}
fun main() {
// out: Dog는 Animal의 하위 → Producer<Dog>는 Producer<Animal>의 하위
val dogProducer: Producer<Animal> = DogProducer() // OK!
println(dogProducer.produce().name)
// in: Animal은 Dog의 상위 → Consumer<Animal>은 Consumer<Dog>의 하위
val animalConsumer: Consumer<Dog> = AnimalConsumer() // OK!
animalConsumer.consume(Dog("바둑이"))
feedAnimals(DogProducer(), AnimalConsumer())
}기억법: out은 "나가기만"(produce), in은 "들어오기만"(consume). List<out T>는 읽기 전용이라 List<Dog>를 List<Animal>로 안전하게 사용할 수 있습니다.
타입 파라미터에 out을 선언하면 해당 타입을 함수 매개변수(입력 위치)에 사용할 수 없습니다. 입출력 모두 필요하면 변성을 선언하지 말고 사용처 변성(use-site variance)을 활용하세요.
08위임 패턴 (Delegation by)
by 키워드로 인터페이스 구현을 다른 객체에 위임합니다. 상속 없이 기능을 재사용하는 강력한 패턴입니다.
Kotlin code
interface Logger {
fun log(message: String)
fun error(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) = println("[LOG] $message")
override fun error(message: String) = println("[ERR] $message")
}
// by로 Logger 구현을 위임
class UserService(logger: Logger) : Logger by logger {
fun createUser(name: String) {
log("사용자 생성: $name")
// 비즈니스 로직...
log("사용자 생성 완료: $name")
}
// 필요 시 특정 메서드만 오버라이드
override fun error(message: String) {
println("[USER-ERR] $message") // 커스텀 에러 처리
}
}
fun main() {
val service = UserService(ConsoleLogger())
service.createUser("김철수")
service.error("중복 사용자")
}위임은 데코레이터 패턴의 보일러플레이트를 크게 줄여줍니다. 필요한 메서드만 오버라이드하면 됩니다.
위임된 객체의 메서드에서 this는 위임자가 아닌 원래 객체를 가리킵니다. 오버라이드한 메서드가 위임 객체 내부에서 호출되지 않습니다.
09봉인 인터페이스 (Sealed Interface)
sealed interface는 구현 클래스를 같은 패키지 내로 제한합니다. sealed class보다 유연한 다중 상속이 가능합니다.
Kotlin code
sealed interface UiState {
data object Loading : UiState
data class Success<T>(val data: T) : UiState
data class Error(val message: String, val code: Int) : UiState
}
sealed interface Refreshable {
fun refresh()
}
// 여러 sealed interface 구현 가능
class UserListState(
val users: List<String>
) : UiState.Success<List<String>>(users), Refreshable {
override fun refresh() = println("새로고침!")
}
fun render(state: UiState) = when (state) {
is UiState.Loading -> println("로딩 중...")
is UiState.Success<*> -> println("데이터: ${state.data}")
is UiState.Error -> println("오류(${state.code}): ${state.message}")
}
fun main() {
render(UiState.Loading)
render(UiState.Success(listOf("김철수", "이영희")))
render(UiState.Error("서버 오류", 500))
}sealed interface는 when에서 모든 분기를 강제하므로 새 상태를 추가하면 컴파일러가 누락된 처리를 알려줍니다.
sealed class와 달리 sealed interface는 상태를 가질 수 없습니다. 공통 상태가 필요하면 sealed class를 사용하세요.
10중첩 클래스 (Nested Class)
중첩 클래스는 외부 클래스의 인스턴스에 접근하지 않는 정적 내부 클래스입니다. Java의 static class에 해당합니다.
Kotlin code
class Outer(val name: String) {
// 중첩 클래스: 외부 인스턴스에 접근 불가
class Nested {
fun greet() = "중첩 클래스에서 인사!"
// println(name) // 컴파일 오류!
}
// 중첩 enum
enum class Color { RED, GREEN, BLUE }
// 중첩 data class
data class Config(val key: String, val value: String)
fun createConfig() = Config("host", "localhost")
}
fun main() {
// 외부 인스턴스 없이 생성 가능
val nested = Outer.Nested()
println(nested.greet())
val color = Outer.Color.RED
println(color)
val config = Outer.Config("port", "8080")
println(config)
}중첩 클래스는 외부 클래스의 참조를 갖지 않아 메모리 누수 위험이 없습니다. 관련성 있는 타입을 논리적으로 그룹화할 때 사용하세요.
Kotlin의 중첩 클래스는 기본적으로 Java의 static class입니다. 외부 인스턴스 접근이 필요하면 inner class를 사용하세요.
11내부 클래스 (Inner Class)
inner 키워드를 붙이면 외부 클래스의 인스턴스에 접근할 수 있는 내부 클래스가 됩니다.
Kotlin code
class RecyclerView(val items: List<String>) {
inner class ViewHolder(val position: Int) {
// 외부 클래스의 items에 접근 가능
fun bind() {
val item = items[position]
println("바인딩[$position]: $item")
}
// this@Outer로 외부 인스턴스 참조
fun getParentInfo() =
"RecyclerView(총 ${this@RecyclerView.items.size}개)"
}
fun createHolders(): List<ViewHolder> =
items.indices.map { ViewHolder(it) }
}
fun main() {
val rv = RecyclerView(listOf("사과", "바나나", "체리"))
val holders = rv.createHolders()
holders.forEach { it.bind() }
println(holders.first().getParentInfo())
}this@OuterClass 문법으로 외부 클래스의 this에 명시적으로 접근할 수 있습니다.
inner class는 외부 인스턴스를 참조하므로 외부 객체가 GC되지 않을 수 있습니다. Android에서 Activity 내부의 inner class는 메모리 누수 원인이 됩니다.
12제네릭 클래스 (Generic Classes)
타입 파라미터를 사용하여 다양한 타입을 처리하는 범용 클래스를 설계합니다. 타입 제약으로 안전성을 확보합니다.
Kotlin code
// 타입 제약이 있는 제네릭 클래스
class SortedList<T : Comparable<T>> {
private val items = mutableListOf<T>()
fun add(item: T) {
items.add(item)
items.sort()
}
fun getAll(): List<T> = items.toList()
fun min(): T? = items.firstOrNull()
fun max(): T? = items.lastOrNull()
}
// 여러 타입 파라미터
class Result<out S, out E>(
val value: S? = null,
val error: E? = null
) {
val isSuccess get() = value != null
fun <R> map(transform: (S) -> R): Result<R, E> =
if (isSuccess) Result(value = transform(value!!))
else Result(error = error)
}
fun main() {
val list = SortedList<Int>()
listOf(5, 2, 8, 1).forEach { list.add(it) }
println(list.getAll()) // [1, 2, 5, 8]
val ok: Result<String, Exception> = Result(value = "성공")
val mapped = ok.map { it.uppercase() }
println(mapped.value) // 성공
}T : Comparable처럼 상한 바운드를 설정하면 타입 파라미터의 기능을 보장하면서 안전하게 사용할 수 있습니다.
제네릭 타입은 런타임에 소거됩니다. list is List 같은 검사는 불가능합니다. reified를 활용하세요.
13공변성/반공변성 (Variance)
out(공변성)과 in(반공변성)으로 제네릭 타입의 상하위 관계를 제어합니다.
Kotlin code
// out: 생산만 (공변성) - 읽기 전용
interface Producer<out T> {
fun produce(): T
}
// in: 소비만 (반공변성) - 쓰기 전용
interface Consumer<in T> {
fun consume(item: T)
}
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
class Cat(name: String) : Animal(name)
class DogProducer : Producer<Dog> {
override fun produce() = Dog("멍이")
}
class AnimalConsumer : Consumer<Animal> {
override fun consume(item: Animal) {
println("처리: ${item.name}")
}
}
fun feedAnimal(producer: Producer<Animal>, consumer: Consumer<Dog>) {
val animal = producer.produce()
// consumer.consume(Dog("바둑이"))
println("동물: ${animal.name}")
}
fun main() {
val dogProducer: Producer<Dog> = DogProducer()
// Producer<Dog>을 Producer<Animal>로 사용 가능 (out)
val animalProducer: Producer<Animal> = dogProducer
println(animalProducer.produce().name)
// Consumer<Animal>을 Consumer<Dog>로 사용 가능 (in)
val animalConsumer: Consumer<Animal> = AnimalConsumer()
val dogConsumer: Consumer<Dog> = animalConsumer
dogConsumer.consume(Dog("바둑이"))
}out T는 "T를 생산만 한다"(반환만), in T는 "T를 소비만 한다"(파라미터로만)는 의미입니다.
out으로 선언한 타입 파라미터를 함수 파라미터에 사용하면 컴파일 오류가 발생합니다. 생산과 소비의 방향을 혼동하지 마세요.
14프로퍼티 위임 (Custom Delegation)
커스텀 프로퍼티 위임을 구현하여 프로퍼티 접근 로직을 재사용합니다. getValue/setValue 연산자를 정의합니다.
Kotlin code
import kotlin.reflect.KProperty
class Trimmed {
private var value = ""
operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue.trim()
println("${property.name} 설정: '$value'")
}
}
class Audited<T>(private var value: T) {
private val history = mutableListOf<T>()
operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
history.add(value)
value = newValue
}
fun getHistory(): List<T> = history
}
class UserForm {
var name: String by Trimmed()
var email: String by Trimmed()
var status: String by Audited("DRAFT")
}
fun main() {
val form = UserForm()
form.name = " 김철수 "
form.email = " kim@test.com "
form.status = "REVIEW"
form.status = "APPROVED"
println("이름: ${form.name}, 이메일: ${form.email}")
}커스텀 위임으로 검증, 로깅, 캐싱, 포맷팅 등의 횡단 관심사를 프로퍼티 수준에서 깔끔하게 처리할 수 있습니다.
var 프로퍼티 위임은 getValue와 setValue 모두 필요합니다. val이면 getValue만 있으면 됩니다.
15지연 초기화 패턴 (Late Init)
lateinit으로 non-null 프로퍼티의 초기화를 지연합니다. DI 프레임워크나 테스트에서 자주 사용됩니다.
Kotlin code
class DatabaseConnection {
lateinit var connectionString: String
lateinit var driver: String
fun isInitialized() = ::connectionString.isInitialized
fun connect() {
if (!::connectionString.isInitialized) {
throw IllegalStateException("연결 문자열이 설정되지 않았습니다")
}
println("연결: $connectionString (드라이버: $driver)")
}
}
// 테스트에서의 활용
class UserServiceTest {
lateinit var service: String // @BeforeEach에서 초기화
fun setUp() {
service = "UserService-Mock"
}
fun testCreate() {
setUp()
println("테스트: $service")
}
}
fun main() {
val db = DatabaseConnection()
println("초기화됨? ${db.isInitialized()}") // false
db.connectionString = "jdbc:mysql://localhost/test"
db.driver = "mysql"
println("초기화됨? ${db.isInitialized()}") // true
db.connect()
}::property.isInitialized로 초기화 여부를 안전하게 확인한 후 접근하면 UninitializedPropertyAccessException을 방지합니다.
lateinit은 var에만, 기본형이 아닌 타입에만 사용 가능합니다. Int, Boolean 등 기본형에는 사용할 수 없습니다.
16빌더 패턴 (Builder Pattern)
Kotlin의 명명된 인수와 기본값으로 빌더 패턴을 간결하게 구현합니다. apply 스코프 함수와 결합하면 더욱 깔끔합니다.
Kotlin code
class HttpRequest private constructor(
val url: String,
val method: String,
val headers: Map<String, String>,
val body: String?
) {
class Builder(private val url: String) {
private var method = "GET"
private val headers = mutableMapOf<String, String>()
private var body: String? = null
fun method(m: String) = apply { method = m }
fun header(key: String, value: String) = apply { headers[key] = value }
fun body(b: String) = apply { body = b }
fun build() = HttpRequest(url, method, headers, body)
}
override fun toString() = "$method $url
Headers: $headers
Body: $body"
}
fun main() {
val request = HttpRequest.Builder("https://api.example.com/users")
.method("POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer token123")
.body("""{"name": "김철수"}""")
.build()
println(request)
}Kotlin에서는 data class + named arguments가 빌더 패턴을 대체할 수 있습니다. 복잡한 생성 로직이 있을 때만 빌더를 사용하세요.
빌더의 apply 메서드가 this(Builder)를 반환하는지 확인하세요. 반환하지 않으면 메서드 체이닝이 불가능합니다.
17팩토리 패턴 (Factory Pattern)
companion object의 팩토리 메서드로 객체 생성 로직을 캡슐화합니다. 인터페이스 기반 팩토리도 구현 가능합니다.
Kotlin code
sealed class Notification(val message: String) {
class Email(message: String, val to: String) : Notification(message)
class Sms(message: String, val phone: String) : Notification(message)
class Push(message: String, val token: String) : Notification(message)
companion object {
fun create(type: String, message: String, target: String): Notification =
when (type.lowercase()) {
"email" -> Email(message, target)
"sms" -> Sms(message, target)
"push" -> Push(message, target)
else -> throw IllegalArgumentException("알 수 없는 타입: $type")
}
}
fun send() = when (this) {
is Email -> println("이메일 → $to: $message")
is Sms -> println("SMS → $phone: $message")
is Push -> println("푸시 → $token: $message")
}
}
fun main() {
val noti = Notification.create("email", "환영합니다!", "user@test.com")
noti.send()
val sms = Notification.create("sms", "인증번호: 1234", "010-1234-5678")
sms.send()
}companion object 팩토리는 생성자와 달리 서브타입을 반환하거나 캐싱을 적용할 수 있어 유연합니다.
팩토리에 새 타입을 추가할 때 when 분기를 잊으면 else로 빠져 런타임 오류가 됩니다. sealed class를 활용하면 컴파일 타임에 잡을 수 있습니다.
18옵저버 패턴 (Observer Pattern)
이벤트 발생 시 등록된 관찰자들에게 알림을 전달하는 패턴입니다. Kotlin의 고차 함수와 위임을 활용하여 간결하게 구현합니다.
Kotlin code
class EventEmitter<T> {
private val listeners = mutableListOf<(T) -> Unit>()
fun on(listener: (T) -> Unit) { listeners.add(listener) }
fun off(listener: (T) -> Unit) { listeners.remove(listener) }
fun emit(event: T) { listeners.forEach { it(event) } }
fun clear() { listeners.clear() }
}
data class UserEvent(val type: String, val userId: String)
class UserService {
val events = EventEmitter<UserEvent>()
fun createUser(id: String) {
println("사용자 생성: $id")
events.emit(UserEvent("CREATED", id))
}
fun deleteUser(id: String) {
println("사용자 삭제: $id")
events.emit(UserEvent("DELETED", id))
}
}
fun main() {
val service = UserService()
service.events.on { e -> println("로그: ${e.type} - ${e.userId}") }
service.events.on { e ->
if (e.type == "CREATED") println("환영 이메일 발송: ${e.userId}")
}
service.createUser("user-001")
service.deleteUser("user-001")
}리스너를 제거하려면 람다를 변수에 저장해야 합니다. 익명 람다는 참조가 달라 off로 제거할 수 없습니다.
이벤트 핸들러에서 예외가 발생하면 나머지 핸들러가 호출되지 않습니다. 각 핸들러를 try-catch로 감싸는 것을 고려하세요.
19상태 패턴 (State Pattern)
객체의 상태에 따라 동작을 변경하는 패턴입니다. sealed class와 when으로 안전하게 상태 전이를 관리합니다.
Kotlin code
sealed class OrderState {
data object Created : OrderState()
data object Paid : OrderState()
data object Shipped : OrderState()
data object Delivered : OrderState()
data class Cancelled(val reason: String) : OrderState()
}
class Order(val id: String) {
var state: OrderState = OrderState.Created
private set
fun pay() { transition(OrderState.Created, OrderState.Paid) }
fun ship() { transition(OrderState.Paid, OrderState.Shipped) }
fun deliver() { transition(OrderState.Shipped, OrderState.Delivered) }
fun cancel(reason: String) {
require(state !is OrderState.Delivered) { "배송 완료 후 취소 불가" }
state = OrderState.Cancelled(reason)
}
private fun transition(from: OrderState, to: OrderState) {
require(state == from) { "현재 상태($state)에서 전이 불가" }
state = to
println("주문[$id]: $from → $to")
}
}
fun main() {
val order = Order("ORD-001")
order.pay()
order.ship()
order.deliver()
println("최종 상태: ${order.state}")
}sealed class로 상태를 정의하면 when에서 모든 상태를 빠짐없이 처리할 수 있어 상태 누락 버그를 방지합니다.
상태 전이 규칙을 검증하지 않으면 유효하지 않은 상태 변경이 발생합니다. require로 전이 사전 조건을 반드시 검증하세요.
20데코레이터 패턴 (Decorator Pattern)
기존 객체에 동적으로 기능을 추가하는 패턴입니다. Kotlin의 by 위임과 결합하면 보일러플레이트를 크게 줄입니다.
Kotlin code
interface TextProcessor {
fun process(text: String): String
}
class PlainProcessor : TextProcessor {
override fun process(text: String) = text
}
class UpperCaseDecorator(
private val inner: TextProcessor
) : TextProcessor by inner {
override fun process(text: String) =
inner.process(text).uppercase()
}
class TrimDecorator(
private val inner: TextProcessor
) : TextProcessor by inner {
override fun process(text: String) =
inner.process(text).trim()
}
class PrefixDecorator(
private val inner: TextProcessor,
private val prefix: String
) : TextProcessor by inner {
override fun process(text: String) =
"$prefix${inner.process(text)}"
}
fun main() {
val processor = PrefixDecorator(
UpperCaseDecorator(TrimDecorator(PlainProcessor())),
"[결과] "
)
println(processor.process(" hello kotlin "))
// [결과] HELLO KOTLIN
}데코레이터를 함수형으로 구현할 수도 있습니다: val process = ::trim andThen ::uppercase 형태의 함수 합성이 더 간결할 수 있습니다.
데코레이터 순서가 결과에 영향을 미칩니다. TrimDecorator를 UpperCaseDecorator 뒤에 적용하면 의도와 다른 결과가 나올 수 있습니다.
21어댑터 패턴 (Adapter Pattern)
호환되지 않는 인터페이스를 연결하는 패턴입니다. 확장 함수로도 간결한 어댑터를 구현할 수 있습니다.
Kotlin code
// 레거시 시스템
class LegacyPrinter {
fun printDocument(text: String, copies: Int) {
repeat(copies) { println("[레거시] $text") }
}
}
// 새 인터페이스
interface ModernPrinter {
fun print(document: Document)
}
data class Document(val content: String, val copies: Int = 1)
// 클래스 어댑터
class PrinterAdapter(private val legacy: LegacyPrinter) : ModernPrinter {
override fun print(document: Document) {
legacy.printDocument(document.content, document.copies)
}
}
// 확장 함수 어댑터
fun LegacyPrinter.toModern(): ModernPrinter = object : ModernPrinter {
override fun print(document: Document) {
printDocument(document.content, document.copies)
}
}
fun main() {
val legacy = LegacyPrinter()
val modern: ModernPrinter = legacy.toModern()
modern.print(Document("보고서 출력", 2))
}확장 함수를 사용한 어댑터는 원본 클래스 수정 없이 변환 메서드를 추가할 수 있어 깔끔합니다.
어댑터가 너무 많아지면 간접 레이어가 복잡해집니다. 가능하면 인터페이스를 통일하는 리팩토링을 우선 고려하세요.
22SOLID 원칙 적용
Kotlin에서 SOLID 원칙을 적용하는 실용적인 예제입니다. 인터페이스 분리와 의존성 역전을 중심으로 설계합니다.
Kotlin code
// ISP: 인터페이스 분리
interface Readable { fun read(): String }
interface Writable { fun write(data: String) }
interface Closeable { fun close() }
// DIP: 의존성 역전 - 추상화에 의존
class FileStorage : Readable, Writable, Closeable {
override fun read() = "파일 데이터"
override fun write(data: String) { println("파일 쓰기: $data") }
override fun close() { println("파일 닫기") }
}
// SRP: 단일 책임
class DataProcessor(private val source: Readable) {
fun process(): String = source.read().uppercase()
}
// OCP: 개방-폐쇄 (확장에 열림, 수정에 닫힘)
class DataSaver(private val dest: Writable) {
fun save(data: String) = dest.write(data)
}
fun main() {
val storage = FileStorage()
val processor = DataProcessor(storage) // Readable만 사용
val saver = DataSaver(storage) // Writable만 사용
val result = processor.process()
saver.save(result)
storage.close()
}인터페이스를 작게 분리하면 클라이언트가 필요한 기능만 의존하게 되어 테스트와 목(mock) 생성이 쉬워집니다.
모든 기능을 하나의 큰 인터페이스에 넣으면 구현 클래스가 불필요한 메서드까지 구현해야 합니다. ISP를 적용하여 분리하세요.
정리하며
- 클래스는 기본 final이므로, 확장 지점은 open으로 의도를 밝힌 곳에만 만듭니다
- sealed 계층 + when으로 분기하면 케이스 추가 시 컴파일러가 누락을 잡아 줍니다
- data class의 equals는 주 생성자 프로퍼티만 포함하고 copy()는 얕은 복사입니다
- 상속 대신 by 위임으로 구성을 표현하면 부모 변경에 끌려다니지 않습니다
더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.