백엔드 개발을 하다 보면 데이터를 담는 클래스를 수도 없이 만든다. 자바에서는 getter, setter, equals, hashCode, toString을 일일이 만들어야 해서 보일러플레이트 지옥이었는데, 코틀린의 data class는 이걸 한 줄로 해결한다. 거기에 sealed class까지 조합하면 타입 안전한 모델링이 가능해진다.

data class — 한 줄로 끝나는 데이터 클래스

data class는 데이터를 담기 위한 클래스예요. data 키워드 하나만 붙이면 equals, hashCode, toString, copy, componentN 함수가 자동 생성됩니다.

KOTLIN
data class User(
    val name: String,
    val age: Int,
    val email: String
)

이게 끝이에요. 자바로 같은 걸 만들려면 수십 줄이 필요했을 텐데요.

자동 생성되는 함수들

KOTLIN
val user1 = User("심정훈", 28, "test@email.com")
val user2 = User("심정훈", 28, "test@email.com")

// equals() — 주 생성자 프로퍼티 기반 비교
println(user1 == user2)         // true (값 비교)
println(user1 === user2)        // false (참조 비교)

// hashCode() — equals와 일관된 해시코드
println(user1.hashCode() == user2.hashCode())   // true

// toString() — 읽기 좋은 문자열 표현
println(user1)                  // User(name=심정훈, age=28, email=test@email.com)

// copy() — 일부 프로퍼티만 변경한 복사본
val user3 = user1.copy(age = 29)
println(user3)                  // User(name=심정훈, age=29, email=test@email.com)

// componentN() — 구조 분해 선언
val (name, age, email) = user1
println("$name, $age살")       // 심정훈, 28살

흔한 함정

"body에 선언된 프로퍼티는 equals/hashCode에 포함되나요?"

KOTLIN
data class Product(val name: String, val price: Int) {
    var stock: Int = 0    // body에 선언 — equals/hashCode에 포함 안 됨!
}

val p1 = Product("키보드", 50000).apply { stock = 100 }
val p2 = Product("키보드", 50000).apply { stock = 0 }

println(p1 == p2)    // true! — stock은 비교 대상이 아님

주 생성자에 선언된 프로퍼티만 자동 생성 함수에 포함돼요. 이걸 모르고 body에 중요한 프로퍼티를 넣으면 버그의 원인이 됩니다.

data class의 제약 사항

KOTLIN
// 1. 주 생성자에 최소 하나 이상의 파라미터 필요
// data class Empty()              // 컴파일 에러

// 2. abstract, open, sealed, inner 불가
// open data class Base(val x: Int)    // 컴파일 에러

// 3. data class 간 상속 불가
// data class Child(val y: Int) : Base(1)  // 불가

data class가 상속을 허용하지 않는 이유는 equals/hashCode의 대칭성(symmetry) 계약을 보장하기 어렵기 때문이에요. 이 이유를 알면 설계 의도를 깊이 이해할 수 있습니다.

sealed class — 제한된 상속 계층

sealed class는 ** 하위 클래스의 종류를 컴파일 타임에 제한 **하는 클래스예요. "이 클래스를 상속할 수 있는 건 여기 정의된 것들뿐이야"라고 선언하는 거죠.

KOTLIN
sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String, val code: Int) : Result()
    data object Loading : Result()
}

when과의 조합 — 완전성 보장

sealed class의 진짜 위력은 when 표현식과 만날 때 발휘됩니다.

KOTLIN
fun handleResult(result: Result): String {
    return when (result) {
        is Result.Success -> "성공: ${result.data}"
        is Result.Error -> "에러(${result.code}): ${result.message}"
        is Result.Loading -> "로딩 중..."
        // else 불필요! 모든 케이스를 커버했으므로
    }
}

모든 하위 타입을 처리하면 else가 필요 없어요. 여기서 진짜 중요한 건, ** 나중에 새 하위 타입을 추가하면 처리하지 않은 when 표현식에서 컴파일 에러가 발생 **한다는 점입니다.

KOTLIN
// 나중에 Timeout을 추가하면
sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String, val code: Int) : Result()
    data object Loading : Result()
    data class Timeout(val duration: Long) : Result()   // 추가!
}

// 기존 when에서 컴파일 에러 발생 — Timeout 처리 누락!

이게 enum에는 없는 강력한 장점이에요. 코드의 안전성을 컴파일러가 보장해줍니다.

sealed class vs enum class

특성enum classsealed class
인스턴스각 상수가 싱글턴각 하위 클래스가 여러 인스턴스 가능
프로퍼티모든 상수가 동일한 구조하위 클래스마다 다른 구조 가능
상태고정된 값각기 다른 데이터를 보유 가능
when 완전성지원지원
KOTLIN
// enum으로는 이런 표현이 어렵다
// 각 상태가 서로 다른 데이터를 갖기 때문
sealed class PaymentState {
    data object Idle : PaymentState()
    data class Processing(val transactionId: String) : PaymentState()
    data class Completed(val receipt: Receipt) : PaymentState()
    data class Failed(val error: Throwable, val retryCount: Int) : PaymentState()
}

enum 대신 sealed class를 쓰는 이유는 "각 상태가 서로 다른 데이터를 가져야 할 때"입니다.

sealed interface (Kotlin 1.5+)

sealed class뿐 아니라 sealed interface도 가능해요. 다중 상속이 필요할 때 유용합니다.

KOTLIN
sealed interface Error {
    data class NetworkError(val code: Int) : Error
    data class DatabaseError(val query: String) : Error
    data object UnknownError : Error
}

// 다중 구현도 가능
sealed interface Loggable
sealed interface Retryable

data class ApiError(val code: Int) : Error, Loggable, Retryable

실무 패턴 — API 응답 모델링

sealed class는 API 응답을 모델링할 때 특히 빛나요.

KOTLIN
sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
    data object Loading : ApiResult<Nothing>()
}

// 사용
fun fetchUsers(): ApiResult<List<User>> {
    return try {
        val users = api.getUsers()
        ApiResult.Success(users)
    } catch (e: HttpException) {
        ApiResult.Error(e.code(), e.message())
    }
}

// UI 레이어에서 처리
fun render(result: ApiResult<List<User>>) {
    when (result) {
        is ApiResult.Success -> showUsers(result.data)
        is ApiResult.Error -> showError(result.message)
        is ApiResult.Loading -> showLoading()
    }
}

이 패턴은 Android 개발에서 특히 많이 쓰여요. 실무에서 sealed class를 활용하는 대표적인 패턴입니다.

Java Record와의 비교

Java 16에서 도입된 Record와 코틀린의 data class는 비슷한 목적을 가지고 있어요.

JAVA
// Java Record
public record UserRecord(String name, int age) {}
KOTLIN
// Kotlin data class
data class User(val name: String, val age: Int)

상세 비교

특성Java RecordKotlin data class
equals/hashCode/toString자동 생성자동 생성
copy()없음있음
componentN()없음있음 (구조 분해)
가변 프로퍼티불가 (전부 final)var 사용 가능
상속java.lang.Record 상속상속 불가 (final)
커스텀 접근자compact constructorinit 블록
도입 시기Java 16 (2021)Kotlin 1.0 (2016)

data class에서 var를 쓸 수 있다는 건 장점이자 단점이에요. 불변성을 지키고 싶다면 val만 사용하는 게 좋습니다.

copy()의 위력

Java Record에 없는 copy()가 실무에서 얼마나 유용한지 보여주는 예시입니다.

KOTLIN
data class Config(
    val host: String = "localhost",
    val port: Int = 8080,
    val debug: Boolean = false,
    val maxRetry: Int = 3
)

val defaultConfig = Config()
val prodConfig = defaultConfig.copy(
    host = "api.example.com",
    debug = false
)
val testConfig = defaultConfig.copy(
    debug = true,
    maxRetry = 1
)

불변 객체를 유지하면서 일부 값만 변경한 새 객체를 만드는 패턴이에요. 함수형 프로그래밍에서 자주 쓰이는 방식이기도 합니다.

정리

data class와 sealed class는 코틀린의 타입 설계를 지탱하는 두 축이에요.

  • data class: equals/hashCode/toString/copy/componentN 자동 생성, body 프로퍼티는 포함 안 됨
  • sealed class: 하위 타입을 컴파일 타임에 제한, when과 조합하면 완전성 보장
  • sealed interface: 다중 구현이 필요할 때 사용 (Kotlin 1.5+)
  • vs Java Record: copy()와 componentN()이 있고, var도 허용된다는 차이
  • ** 실무 패턴 **: API 응답, 상태 관리 등에서 sealed class + data class 조합이 강력

핵심은 data class의 equals가 주 생성자 프로퍼티만 비교한다는 점, 그리고 sealed class에서 새 타입 추가 시 컴파일 에러로 누락을 방지한다는 점입니다.

댓글 로딩 중...