Theme:

백엔드 개발을 하다 보면 데이터를 담는 클래스를 수도 없이 만든다. 자바에서는 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를 실무에서 어디에 쓰냐"는 질문에 이 API 응답 패턴을 설명하면 좋다.

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를 왜 쓰느냐"가 핵심이다. 주 생성자 프로퍼티만 비교한다는 점, 그리고 새 타입 추가 시 컴파일 에러로 누락을 방지한다는 점을 기억하자.

댓글 로딩 중...