데이터 클래스와 Sealed Class — 타입 안전한 모델링
백엔드 개발을 하다 보면 데이터를 담는 클래스를 수도 없이 만든다. 자바에서는 getter, setter, equals, hashCode, toString을 일일이 만들어야 해서 보일러플레이트 지옥이었는데, 코틀린의 data class는 이걸 한 줄로 해결한다. 거기에 sealed class까지 조합하면 타입 안전한 모델링이 가능해진다. 면접에서 이 둘은 세트로 나오는 경우가 많다.
data class — 한 줄로 끝나는 데이터 클래스
data class는 데이터를 담기 위한 클래스다. data 키워드 하나만 붙이면 equals, hashCode, toString, copy, componentN 함수가 자동 생성된다.
data class User(
val name: String,
val age: Int,
val email: String
)
이게 끝이다. 자바로 같은 걸 만들려면 수십 줄이 필요했을 텐데.
자동 생성되는 함수들
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에 포함되나요?"
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의 제약 사항
// 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는 하위 클래스의 종류를 컴파일 타임에 제한하는 클래스다. "이 클래스를 상속할 수 있는 건 여기 정의된 것들뿐이야"라고 선언하는 것이다.
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 표현식과 만날 때 발휘된다.
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 표현식에서 컴파일 에러가 발생한다는 것이다.
// 나중에 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 class | sealed class |
|---|---|---|
| 인스턴스 | 각 상수가 싱글턴 | 각 하위 클래스가 여러 인스턴스 가능 |
| 프로퍼티 | 모든 상수가 동일한 구조 | 하위 클래스마다 다른 구조 가능 |
| 상태 | 고정된 값 | 각기 다른 데이터를 보유 가능 |
| when 완전성 | 지원 | 지원 |
// 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도 가능하다. 다중 상속이 필요할 때 유용하다.
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 응답을 모델링할 때 특히 빛난다.
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 Record
public record UserRecord(String name, int age) {}
// Kotlin data class
data class User(val name: String, val age: Int)
상세 비교
| 특성 | Java Record | Kotlin data class |
|---|---|---|
| equals/hashCode/toString | 자동 생성 | 자동 생성 |
| copy() | 없음 | 있음 |
| componentN() | 없음 | 있음 (구조 분해) |
| 가변 프로퍼티 | 불가 (전부 final) | var 사용 가능 |
| 상속 | java.lang.Record 상속 | 상속 불가 (final) |
| 커스텀 접근자 | compact constructor | init 블록 |
| 도입 시기 | Java 16 (2021) | Kotlin 1.0 (2016) |
data class에서 var를 쓸 수 있다는 건 장점이자 단점이다. 불변성을 지키고 싶다면 val만 사용하는 게 좋다.
copy()의 위력
Java Record에 없는 copy()가 실무에서 얼마나 유용한지 보여주는 예시다.
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를 왜 쓰느냐"가 핵심이다. 주 생성자 프로퍼티만 비교한다는 점, 그리고 새 타입 추가 시 컴파일 에러로 누락을 방지한다는 점을 기억하자.