Skip to main content
Summary

지난 장에서는 클래스가 데이터와 행동을 묶는 기본 단위라는 점을 배웠습니다. 이번 장에서는 그 기본 위에 코틀린이 제공하는 더 선명한 타입 도구를 얹어 보겠습니다.

코틀린에는 단순히 클래스를 더 짧게 쓰기 위한 문법만 있는 것이 아닙니다. data class, object, enum class, sealed class와 sealed interface는 각각 다른 의도를 표현합니다. 데이터를 전달하려는지, 하나뿐인 객체가 필요한지, 정해진 값 목록을 다루려는지, 제한된 상태 집합을 안전하게 표현하려는지에 따라 선택하는 도구가 달라집니다.

핵심은 하나입니다. 반복 코드는 줄이고, 타입 자체가 의도를 말하게 만드는 것입니다.

코틀린다운 객체 모델 이해하기

data class: 데이터를 담는 클래스의 표준 형태

일반 클래스로 사용자 정보를 표현하면 다음처럼 쓸 수 있습니다.

class User(val name: String, val age: Int)

겉보기에는 충분해 보입니다. 하지만 두 User 객체의 값이 같은지 비교하거나, 로그에 보기 좋은 문자열로 출력하거나, 일부 값만 바꾼 새 객체를 만들려면 직접 코드를 작성해야 합니다.

코틀린에서는 이런 용도의 클래스를 data class로 선언합니다.

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

data 키워드를 붙이면 컴파일러가 주 생성자에 선언된 프로퍼티를 기준으로 equals(), hashCode(), toString(), componentN(), copy()를 자동으로 만들어 줍니다.1

fun main() {
    val user1 = User("Mina", 28)
    val user2 = User("Mina", 28)

    println(user1 == user2) // true
    println(user1)          // User(name=Mina, age=28)
}

여기서 ==는 참조가 같은지 보는 것이 아니라 equals()를 호출합니다. data class의 equals()는 주 생성자에 있는 값들을 비교하도록 자동 생성됩니다. 그래서 서로 다른 인스턴스라도 name과 age가 같으면 같은 값으로 판단합니다.

이런 특징 때문에 data class는 DTO, API 응답 모델, 화면 상태, 값 전달 객체에 잘 어울립니다. DTO는 Data Transfer Object의 줄임말로, 계층 사이에서 데이터를 옮기기 위한 객체를 뜻합니다.

copy(): 원본은 유지하고 일부 값만 바꾸기

data class의 가장 실용적인 기능 중 하나는 copy()입니다.

data class Profile(
    val nickname: String,
    val email: String,
    val age: Int
)

fun main() {
    val profile = Profile(
        nickname = "kotlinFan",
        email = "[email protected]",
        age = 20
    )

    val updated = profile.copy(email = "[email protected]")

    println(profile)
    // Profile(nickname=kotlinFan, [email protected], age=20)

    println(updated)
    // Profile(nickname=kotlinFan, [email protected], age=20)
}

copy()는 지정한 프로퍼티만 바꾸고 나머지는 원본 값을 그대로 사용해 새 인스턴스를 만듭니다. 그래서 val 프로퍼티와 잘 어울립니다. 객체 내부 값을 직접 바꾸는 대신 새 객체를 만들면, 값이 언제 어디서 바뀌었는지 추적하기 쉬워집니다.

특히 화면 상태를 다룰 때 이런 방식이 유용합니다.

data class UiState(
    val isLoading: Boolean = false,
    val username: String = "",
    val errorMessage: String? = null
)

val state = UiState()
val loadingState = state.copy(isLoading = true)
val errorState = loadingState.copy(
    isLoading = false,
    errorMessage = "로그인에 실패했습니다."
)

상태를 직접 수정하지 않고, 이전 상태를 바탕으로 다음 상태를 만듭니다. 이 방식은 코드 흐름을 읽기 쉽게 만듭니다.

copy()는 얕은 복사다

copy()는 편리하지만 깊은 복사를 해 주지는 않습니다. 즉, 프로퍼티가 다른 객체를 참조하고 있다면 그 참조는 공유됩니다.

data class Team(
    val name: String,
    val members: MutableList<String>
)

fun main() {
    val original = Team("Backend", mutableListOf("Mina"))
    val copied = original.copy()

    copied.members.add("Joon")

    println(original.members) // [Mina, Joon]
    println(copied.members)   // [Mina, Joon]
}

original과 copied는 서로 다른 Team 인스턴스입니다. 하지만 members는 같은 MutableList를 가리킵니다. 그래서 복사본에서 리스트를 수정하면 원본에서도 보입니다.

이 문제를 줄이려면 가변 컬렉션인 MutableList보다 읽기 전용 List를 우선 사용하거나, 필요할 때 리스트 자체도 새로 만들어야 합니다.

val saferCopied = original.copy(
    members = original.members.toMutableList()
)

정리하면 copy()는 객체 껍데기는 새로 만들지만, 내부 참조 객체까지 자동으로 새로 만들지는 않습니다.

주 생성자 밖의 프로퍼티는 자동 생성 함수에서 제외된다

data class에서 자동 생성되는 함수들은 주 생성자에 선언된 프로퍼티만 사용합니다.1

data class Person(val name: String) {
    var age: Int = 0
}

fun main() {
    val person1 = Person("Mina").apply { age = 20 }
    val person2 = Person("Mina").apply { age = 30 }

    println(person1 == person2) // true
    println(person1)            // Person(name=Mina)

    val copied = person1.copy()
    println(copied.age)         // 0
}

age는 클래스 본문에 선언되어 있습니다. 따라서 equals(), hashCode(), toString(), copy(), componentN()의 기준에 포함되지 않습니다.

이 규칙은 실수로 이어지기 쉽습니다. 어떤 값이 객체의 정체성을 구성한다면 주 생성자에 넣어야 합니다.

// 권장
data class Person(val name: String, val age: Int)

반대로 비교나 복사 대상에서 제외하고 싶은 값이라면 클래스 본문에 둘 수 있습니다. 예를 들어 임시 캐시, 계산 결과, 화면에서만 쓰는 보조 값이 여기에 해당할 수 있습니다.

구조 분해 선언과 componentN()

data class는 componentN() 함수도 자동으로 생성합니다. 이 함수 덕분에 객체를 여러 변수로 나누어 받을 수 있습니다.

data class Point(val x: Int, val y: Int)

fun main() {
    val point = Point(10, 20)
    val (x, y) = point

    println(x) // 10
    println(y) // 20
}

이 문법을 구조 분해 선언이라고 합니다.

주의할 점은 이름이 아니라 선언 순서로 분해된다는 것입니다.

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

val user = User("Mina", 28)
val (first, second) = user

println(first)  // Mina
println(second) // 28

first라는 변수 이름 때문에 첫 번째 값이 들어가는 것이 아닙니다. name이 첫 번째 프로퍼티이기 때문에 들어갑니다. 프로퍼티가 많거나 타입이 비슷하면 구조 분해보다 프로퍼티 이름으로 접근하는 편이 더 명확할 수 있습니다.

object: 클래스 선언과 인스턴스 생성을 한 번에

지난 장에서 말한 객체는 보통 클래스에서 만들어진 인스턴스를 뜻했습니다.

val user = User("Mina", 28)

그런데 코틀린의 object 키워드는 조금 다릅니다. object 선언은 클래스를 정의하면서 동시에 단 하나의 인스턴스를 만듭니다. 즉, 싱글턴 객체를 만드는 문법입니다.2

object AppLogger {
    fun info(message: String) {
        println("[INFO] $message")
    }
}

fun main() {
    AppLogger.info("프로그램 시작")
}

AppLogger()처럼 생성자를 호출하지 않습니다. 객체 이름인 AppLogger로 바로 접근합니다.

object는 다음과 같은 경우에 어울립니다.

  • 애플리케이션 전체에서 하나만 있으면 되는 설정 객체

  • 공통 로거

  • 상태가 거의 없거나 공유 상태를 명확히 관리하는 유틸리티 객체

  • sealed 계층에서 값이 없는 단일 상태를 표현하는 객체

다만 전역 상태를 많이 담는 object는 조심해야 합니다. 어디서든 바꿀 수 있는 공유 상태가 많아지면 테스트와 디버깅이 어려워집니다. object는 "편해서 쓰는 전역 변수 창고"가 아니라 "정말 하나만 있어야 하는 대상"에 사용해야 합니다.

companion object: 클래스에 연결된 객체

코틀린에는 자바의 static 키워드가 없습니다. 대신 클래스 내부에 companion object를 둘 수 있습니다. companion object는 클래스 이름으로 접근할 수 있는 함수나 값을 제공할 때 사용합니다.2

class User private constructor(val name: String) {
    companion object {
        const val DEFAULT_NAME = "guest"

        fun create(name: String): User {
            val trimmed = name.trim()
            return if (trimmed.isEmpty()) {
                User(DEFAULT_NAME)
            } else {
                User(trimmed)
            }
        }
    }
}

fun main() {
    val user = User.create("  Mina  ")
    println(user.name) // Mina
}

이 예제에서 생성자는 private입니다. 외부에서는 User(...)로 직접 만들 수 없고, User.create(...)를 통해서만 만들 수 있습니다. 이렇게 하면 객체 생성 규칙을 한곳에 모을 수 있습니다.

companion object는 다음 용도에 잘 맞습니다.

  • 팩토리 함수

  • 클래스와 강하게 관련된 상수

  • 생성 규칙을 감춘 API

  • 클래스 단위의 보조 함수

겉보기에는 정적 멤버처럼 보이지만, 코틀린에서 companion object는 실제로 객체입니다. 그래서 인터페이스를 구현할 수도 있습니다. 자바와 함께 사용할 때의 세부 동작은 뒤의 "자바와 함께 사용하는 코틀린" 장에서 다시 다룹니다.

data object: 값 없는 상태를 더 읽기 좋게 표현하기

object는 싱글턴입니다. 여기에 data를 붙이면 data object가 됩니다.

data object Loading

data object는 copy()나 componentN()을 만들지 않습니다. 하나뿐인 객체를 복사하거나 구조 분해할 이유가 없기 때문입니다. 대신 toString(), equals(), hashCode()가 데이터 객체에 맞게 생성됩니다.2

특히 sealed 계층에서 유용합니다.

sealed interface ScreenState

data object Loading : ScreenState
data class Content(val text: String) : ScreenState
data class Error(val message: String) : ScreenState

Loading은 추가 데이터가 없습니다. 이런 상태를 일반 object로 표현해도 되지만, data object를 쓰면 출력 결과가 더 읽기 좋고 data class들과 나란히 놓았을 때 형태도 자연스럽습니다.

enum class: 제한된 상수 집합을 타입으로 표현하기

값의 종류가 정해져 있다면 문자열이나 숫자로 표현하지 말고 enum class를 사용할 수 있습니다.

enum class OrderStatus {
    PENDING,
    PAID,
    SHIPPED,
    CANCELED
}

OrderStatus는 네 가지 값만 가질 수 있습니다. 따라서 다음처럼 잘못된 상태를 넣을 수 없습니다.

fun printStatus(status: OrderStatus) {
    println(status)
}

printStatus(OrderStatus.PAID)
// printStatus("PAID") // 타입이 맞지 않음

enum class의 각 상수는 객체입니다. 프로퍼티와 메서드를 가질 수도 있습니다.3

enum class OrderStatus(val label: String) {
    PENDING("주문 접수"),
    PAID("결제 완료"),
    SHIPPED("배송 중"),
    CANCELED("취소됨");

    fun canCancel(): Boolean {
        return this == PENDING || this == PAID
    }
}

fun main() {
    val status = OrderStatus.PAID

    println(status.label)       // 결제 완료
    println(status.canCancel()) // true
}

상수 목록 뒤에 메서드나 프로퍼티를 정의하려면 세미콜론 ;으로 상수 영역과 멤버 영역을 구분합니다.

상수마다 다른 동작을 줄 수도 있습니다.

enum class Calculator {
    PLUS {
        override fun apply(a: Int, b: Int): Int = a + b
    },
    MINUS {
        override fun apply(a: Int, b: Int): Int = a - b
    };

    abstract fun apply(a: Int, b: Int): Int
}

fun main() {
    println(Calculator.PLUS.apply(10, 3))  // 13
    println(Calculator.MINUS.apply(10, 3)) // 7
}

단순한 값 목록으로 시작했지만, 필요한 경우 상태와 동작을 함께 묶을 수 있습니다.

enum에는 기본적으로 name과 ordinal이 있습니다.

println(OrderStatus.PAID.name)    // PAID
println(OrderStatus.PAID.ordinal) // 1

ordinal은 선언 순서입니다. 중간에 상수를 추가하거나 순서를 바꾸면 값이 달라질 수 있으므로, 데이터베이스 저장 값이나 외부 API 값으로 직접 쓰는 것은 피하는 편이 안전합니다. 외부와 주고받을 값이 필요하면 별도 프로퍼티를 두는 방식이 낫습니다.

enum class OrderStatus(val code: String) {
    PENDING("P"),
    PAID("D"),
    SHIPPED("S"),
    CANCELED("C")
}

sealed class와 sealed interface: 닫힌 계층 구조 만들기

enum class는 정해진 상수 집합을 표현하기 좋습니다. 하지만 각 경우가 서로 다른 데이터를 가져야 한다면 enum만으로는 부족합니다.

예를 들어 로그인 결과를 생각해 보겠습니다.

  • 성공하면 사용자 정보가 필요합니다.

  • 비밀번호가 틀리면 남은 시도 횟수가 필요할 수 있습니다.

  • 네트워크 오류라면 오류 메시지가 필요합니다.

  • 로딩 중이면 추가 데이터가 필요 없습니다.

이런 경우 sealed 계층이 잘 맞습니다.

sealed interface LoginResult

data class Success(val userId: Long, val nickname: String) : LoginResult
data class WrongPassword(val remainingAttempts: Int) : LoginResult
data class NetworkError(val message: String) : LoginResult
data object Loading : LoginResult

sealed class와 sealed interface는 허용되는 직접 하위 타입을 제한합니다. 직접 하위 타입은 같은 모듈과 같은 패키지 안에서 선언되어야 하며, 컴파일러는 가능한 하위 타입을 알 수 있습니다.4

이 특징 덕분에 sealed는 다음 상황에 잘 어울립니다.

  • 성공/실패 결과

  • 화면 상태

  • 사용자 이벤트

  • 네트워크 응답

  • 결제 수단

  • 도메인 규칙상 가능한 경우가 정해진 값들

sealed class를 써도 됩니다.

sealed class PaymentResult {
    data class Success(val receiptId: String) : PaymentResult()
    data class Failure(val reason: String) : PaymentResult()
    data object Canceled : PaymentResult()
}

sealed interface는 조금 더 유연합니다. 코틀린 클래스는 하나의 클래스만 상속할 수 있지만, 인터페이스는 여러 개 구현할 수 있습니다. 그래서 이미 다른 클래스를 상속해야 하는 타입까지 같은 닫힌 계층에 넣고 싶다면 sealed interface가 편할 수 있습니다.

처음에는 이렇게 구분하면 충분합니다.

  • 공통 상태나 생성자 값이 필요하면 sealed class

  • 타입의 역할만 제한하고 싶으면 sealed interface

when과 함께 쓰면 누락을 줄일 수 있다

enum과 sealed 계층의 힘은 when과 함께 사용할 때 더 분명해집니다.

먼저 enum입니다.

fun statusMessage(status: OrderStatus): String = when (status) {
    OrderStatus.PENDING -> "주문을 확인하고 있습니다."
    OrderStatus.PAID -> "결제가 완료되었습니다."
    OrderStatus.SHIPPED -> "상품이 배송 중입니다."
    OrderStatus.CANCELED -> "주문이 취소되었습니다."
}

모든 OrderStatus 값을 처리했기 때문에 else가 필요 없습니다. 만약 나중에 REFUNDED라는 상태를 추가하면, 이 when 식에서 새 상태를 처리하지 않았다는 사실을 컴파일러가 알려 줄 수 있습니다.

sealed 계층도 마찬가지입니다.

fun render(result: LoginResult): String = when (result) {
    is Success -> "${result.nickname}님, 환영합니다."
    is WrongPassword -> "비밀번호가 틀렸습니다. 남은 횟수: ${result.remainingAttempts}"
    is NetworkError -> "네트워크 오류: ${result.message}"
    Loading -> "로그인 중입니다."
}

여기서 Success, WrongPassword, NetworkError는 데이터를 가진 클래스이므로 is로 타입을 검사합니다. Loading은 하나뿐인 객체이므로 값 자체를 분기합니다.

이 방식의 장점은 큽니다.

sealed interface LoginResult

data class Success(val userId: Long, val nickname: String) : LoginResult
data class WrongPassword(val remainingAttempts: Int) : LoginResult
data class NetworkError(val message: String) : LoginResult
data object Loading : LoginResult
data object TwoFactorRequired : LoginResult

새로운 TwoFactorRequired 상태를 추가하면 기존 when 식은 더 이상 모든 경우를 처리하지 못합니다. 그러면 컴파일러가 빠진 분기를 알려 줍니다. else로 대충 덮어 두지 않았다면, 새로운 상태를 추가할 때 고쳐야 할 위치를 쉽게 찾을 수 있습니다.

그래서 sealed 계층을 다룰 때는 가능하면 else를 습관적으로 쓰지 않는 편이 좋습니다. 모든 경우를 명시하면 코드가 길어질 수는 있지만, 상태 변화에 더 안전합니다.

enum과 sealed는 어떻게 다를까

둘 다 "가능한 경우를 제한한다"는 점에서 비슷합니다. 하지만 쓰임은 다릅니다.

상황적합한 도구
단순히 정해진 상수 목록이 필요하다enum class
각 값이 같은 종류의 프로퍼티와 메서드를 가진다enum class
각 경우가 서로 다른 데이터를 가져야 한다sealed class / sealed interface
성공, 실패, 로딩처럼 상태 모양이 다르다sealed class / sealed interface
값 하나하나가 모두 싱글턴이면 충분하다enum class 또는 object
어떤 경우는 데이터가 있고 어떤 경우는 데이터가 없다sealed + data class + data object

예를 들어 주문 상태는 enum으로 충분할 수 있습니다.

enum class DeliveryStatus {
    READY,
    MOVING,
    DONE
}

하지만 배송 결과가 다음처럼 서로 다른 정보를 가져야 한다면 sealed가 더 낫습니다.

sealed interface DeliveryResult

data class Delivered(val completedAt: String) : DeliveryResult
data class Failed(val reason: String) : DeliveryResult
data object InProgress : DeliveryResult

enum은 "값 목록"에 강하고, sealed는 "닫힌 타입 계층"에 강합니다.

함께 보면 더 자연스러운 모델이 된다

실제 코드는 이 기능들을 따로따로만 쓰지 않습니다. 함께 쓰면 더 읽기 좋은 모델을 만들 수 있습니다.

enum class Role(val label: String) {
    ADMIN("관리자"),
    MEMBER("회원"),
    GUEST("손님")
}

data class Account(
    val id: Long,
    val name: String,
    val role: Role
)

sealed interface AccountState

data object Loading : AccountState
data class Loaded(val account: Account) : AccountState
data class Failed(val message: String) : AccountState

object AccountLogger {
    fun log(state: AccountState) {
        println("AccountState = $state")
    }
}

class AccountRepository private constructor() {
    companion object {
        fun create(): AccountRepository = AccountRepository()
    }
}

fun render(state: AccountState): String = when (state) {
    Loading -> "계정 정보를 불러오는 중입니다."
    is Loaded -> "${state.account.name} (${state.account.role.label})"
    is Failed -> "오류: ${state.message}"
}

이 예제에서 각 도구의 역할은 분명합니다.

  • Role은 정해진 역할 목록입니다.

  • Account는 데이터를 담는 값 객체입니다.

  • AccountState는 화면에 가능한 상태를 제한합니다.

  • Loading은 데이터가 없는 단일 상태입니다.

  • AccountLogger는 하나만 있으면 되는 객체입니다.

  • companion object는 저장소 생성 방법을 클래스에 붙여 제공합니다.

  • when은 가능한 상태를 빠짐없이 처리합니다.

이처럼 코틀린다운 객체 모델은 문법을 줄이는 데서 끝나지 않습니다. 타입을 잘 나누면 코드가 스스로 설명하기 시작합니다.

선택 기준 정리

마지막으로 어떤 도구를 선택할지 빠르게 정리해 보겠습니다.

도구핵심 의도대표 사용처
data class데이터를 값처럼 다룬다DTO, API 응답, 화면 상태, 값 객체
object애플리케이션 안에 하나만 존재한다로거, 설정 객체, 싱글턴 서비스
companion object클래스에 연결된 함수와 값을 제공한다팩토리 함수, 상수, 생성 규칙
data object데이터 없는 단일 상태를 읽기 좋게 표현한다sealed 계층의 로딩, 완료, 없음 상태
enum class제한된 상수 집합을 타입으로 표현한다주문 상태, 권한, 방향, 모드
sealed class제한된 하위 클래스 집합을 만든다결과 타입, 화면 상태, 이벤트
sealed interface제한된 구현 타입 집합을 만든다여러 타입이 공유하는 닫힌 역할

처음부터 복잡한 설계를 하려고 할 필요는 없습니다. 문자열이나 숫자로 상태를 표현하고 있다면 enum을 떠올리세요. 성공과 실패처럼 경우마다 필요한 데이터가 다르다면 sealed를 떠올리세요. 데이터를 담는 클래스에서 반복 코드가 많다면 data class를 사용하세요. 하나만 있어야 하는 대상이라면 object를 검토하세요.

코틀린의 객체 모델은 "더 짧게 쓰기"보다 "더 정확하게 표현하기"에 가깝습니다. 타입이 의도를 담으면, 컴파일러가 더 많은 실수를 대신 잡아 줍니다.

참고

1Data classes | Kotlin Documentation

2Object declarations and expressions | Kotlin Documentation

3Enum classes | Kotlin Documentation

4Sealed classes and interfaces | Kotlin Documentation

책 목록

처음 배우는 코틀린: 문법에서 실전 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기