Kotlin 클래스와 객체 | 클래스, 상속, 인터페이스

이 글의 핵심

Kotlin 클래스는 기본이 final이라 Java 습관대로 상속하려 하면 컴파일 에러부터 만납니다. open을 명시하게 한 설계 의도를 짚고, DTO에는 data class, 상태 분기에는 sealed class, 정적 멤버 대신 companion object를 쓰는 식으로 용도별 클래스 선택 기준을 정리해 Java보다 짧은 코드로 같은 구조를 만들게 합니다.

들어가며

클래스는 객체의 설계도에 해당하며, data class·sealed class 등으로 용도에 맞는 뼈대를 짧게 쓸 수 있습니다. 생성자·프로퍼티 문법이 Java보다 단순한 편입니다.


클래스 정의와 주·부 생성자

클래스 정의

class Person {
    var name: String = ""
    var age: Int = 0
    
    fun introduce() {
        println("안녕하세요, $name입니다. ${age}세입니다.")
    }
}
val person = Person()
person.name = "홍길동"
person.age = 25
person.introduce()

Kotlin에는 new 키워드가 없어서 Person()처럼 함수 호출과 같은 모양으로 객체를 만듭니다. var name: String = ""은 Java의 필드가 아니라 프로퍼티로, 컴파일러가 private 필드와 getName()/setName()을 자동으로 만들어 줍니다. person.name = "홍길동"은 실제로 setter 호출입니다. 그래서 Java처럼 getter/setter를 직접 쓸 필요가 없고, 나중에 검증 로직이 필요해지면 아래 getter/setter 절처럼 프로퍼티에 접근자를 붙이면 되어 호출하는 쪽 코드는 바뀌지 않습니다.

이 첫 예제는 기본값으로 빈 문자열과 0을 넣어 두었는데, 이런 “일단 만들고 나중에 채우는” 방식은 이름 없는 Person이 존재할 수 있게 만듭니다. Kotlin에서는 다음 절처럼 생성 시점에 필요한 값을 모두 받아 불완전한 객체가 생기지 않게 하는 편이 일반적입니다.

주 생성자

class Person(val name: String, var age: Int) {
    fun introduce() {
        println("안녕하세요, $name입니다. ${age}세입니다.")
    }
}
val person = Person("홍길동", 25)

클래스 이름 옆 괄호가 주 생성자이고, 매개변수에 val/var를 붙이면 매개변수 선언과 프로퍼티 선언, 대입이 한 번에 끝납니다. Java로 쓰면 필드 두 개, 생성자 하나, getter 두 개, setter 하나가 필요한 코드입니다. val은 읽기 전용(getter만), var는 읽기·쓰기 프로퍼티가 됩니다. val/var 없이 class Person(name: String)으로 쓰면 name은 생성자 매개변수일 뿐이라 init 블록이나 프로퍼티 초기화식에서만 쓸 수 있고, 메서드 안에서 name을 쓰면 “Unresolved reference” 오류가 납니다. 초보자가 자주 만나는 오류입니다.

바꿀 필요가 없는 값은 val로 두는 것이 Kotlin의 기본 습관입니다. name을 val로 둔 이 예제처럼 불변 프로퍼티가 많을수록 객체 상태를 추적하기 쉽고, 여러 스레드에서 공유해도 안전합니다.

init 블록

class Person(val name: String, var age: Int) {
    init {
        println("Person 객체 생성: $name")
        require(age >= 0) { "나이는 0 이상이어야 합니다" }
    }
}

주 생성자에는 코드 본문을 쓸 수 없으므로, 생성 시점에 실행할 로직은 init 블록에 둡니다. require(조건) { 메시지 }는 조건이 거짓이면 IllegalArgumentException을 던지는 표준 함수로, 잘못된 인자로 객체가 만들어지는 것 자체를 막습니다. 객체 상태 검사에는 IllegalStateException을 던지는 check()가 짝을 이룹니다.

init 블록과 프로퍼티 초기화식은 클래스 본문에 적힌 순서대로 실행됩니다. 그래서 init 블록보다 아래에 선언한 프로퍼티를 init 안에서 읽으면 아직 초기화 전이라 컴파일 오류가 나거나, 우회하는 경로로 읽으면 null이나 0이 보입니다. 또 init에서 open 메서드를 호출하면 자식 클래스가 재정의한 메서드가 자식의 프로퍼티가 초기화되기 전에 실행되어, non-null로 선언한 프로퍼티가 null인 기묘한 상황이 생길 수 있습니다. IDE가 “Calling non-final function in constructor” 경고를 띄우는 이유입니다.

부 생성자

class Person(val name: String) {
    var age: Int = 0
    
    constructor(name: String, age: Int) : this(name) {
        this.age = age
    }
}
val person1 = Person("홍길동")
val person2 = Person("김철수", 30)

부 생성자(constructor)는 주 생성자가 있으면 : this(...)로 반드시 주 생성자에 위임해야 합니다. 모든 생성 경로가 주 생성자와 init 블록을 거치게 만들어 검증이 빠지지 않게 하려는 규칙입니다.

실무 Kotlin 코드에서 부 생성자는 생각보다 드물게 씁니다. 위 예제는 class Person(val name: String, var age: Int = 0)처럼 기본값 하나로 대체할 수 있고, 그쪽이 더 짧고 Person(name = "김철수", age = 30)처럼 이름 있는 인자와도 잘 어울립니다. 부 생성자가 꼭 필요한 경우는 Android의 커스텀 View처럼 Java 프레임워크가 서로 다른 시그니처의 생성자 여러 개를 요구할 때 정도이며, 이때도 @JvmOverloads constructor(...)로 대신하기도 합니다.


프로퍼티 getter/setter와 지연 초기화

getter/setter

class Person(val name: String) {
    var age: Int = 0
        get() = field
        set(value) {
            if (value >= 0) {
                field = value
            }
        }
    
    val isAdult: Boolean
        get() = age >= 18
}

접근자 안의 field는 프로퍼티 값이 실제로 저장되는 backing field를 가리킵니다. setter 안에서 field = value 대신 age = value라고 쓰면 setter가 자기 자신을 다시 호출해 무한 재귀에 빠지고 StackOverflowError로 끝납니다. Kotlin을 처음 쓸 때 거의 한 번은 겪는 실수입니다. get() = field는 기본 getter와 같아서 생략해도 되며, 여기서는 구조를 보여 주려고 적었습니다.

isAdult처럼 getter만 있고 field를 쓰지 않는 프로퍼티는 저장 공간 없이 읽을 때마다 계산됩니다. age가 바뀌면 isAdult도 자동으로 따라 바뀌므로 두 값이 어긋날 일이 없습니다. 계산 비용이 큰 값이라면 매번 계산하지 않도록 by lazy를 쓰거나 일반 메서드로 두어 비용이 있다는 사실을 드러내는 편이 좋습니다. 또 이 setter는 음수를 조용히 무시하는데, 호출한 쪽이 실패를 모르므로 require(value >= 0)로 예외를 던지는 방식도 고려할 만합니다.

지연 초기화

class MyClass {
    lateinit var name: String
    
    fun init() {
        name = "홍길동"
    }
    
    fun isInitialized() = ::name.isInitialized
}

Kotlin은 non-null 프로퍼티를 선언 시점이나 생성자에서 초기화하도록 강제합니다. 그런데 의존성 주입, Android의 onCreate, 테스트의 @BeforeEach처럼 객체 생성 이후에 값이 정해지는 경우가 있습니다. lateinit은 “초기화를 나중에 하겠다”고 컴파일러에게 약속하는 방법으로, String?로 선언하고 매번 !!를 붙이는 불편을 없애 줍니다.

약속을 어기고 초기화 전에 읽으면 UninitializedPropertyAccessException: lateinit property name has not been initialized가 납니다. null 체크를 건너뛴 대신 런타임 예외로 바뀐 셈이라, 초기화 시점이 분명한 곳에만 써야 합니다. lateinit은 var에만, 그리고 Int·Boolean 같은 기본 타입이 아닌 타입에만 쓸 수 있습니다. 값이 처음 필요할 때 계산하면 되는 경우라면 val name: String by lazy { ... }가 더 안전한 선택입니다.


open 상속과 abstract 클래스

open 클래스

Kotlin 클래스는 기본적으로 final이므로 상속하려면 open 키워드가 필요합니다:

// open: 상속 가능한 클래스
// Kotlin은 기본적으로 모든 클래스가 final (상속 불가)
// 상속을 허용하려면 명시적으로 open 선언
open class Animal(val name: String) {
    // open: 오버라이딩 가능한 메서드
    // 메서드도 기본적으로 final
    // 자식 클래스에서 재정의하려면 open 필요
    open fun makeSound() {
        println("동물 소리")
    }
    
    // open이 없는 메서드는 final (오버라이딩 불가)
    fun sleep() {
        println("$name이(가) 잠을 잡니다.")
    }
}
// Dog 클래스: Animal을 상속
class Dog(name: String) : Animal(name) {
    // : Animal(name) : 부모 생성자 호출
    // name을 부모 클래스에 전달
    
    // override: 부모 메서드 재정의
    override fun makeSound() {
        println("멍멍!")
    }
    
    // Dog만의 메서드
    fun fetch() {
        println("$name이(가) 공을 가져옵니다.")
    }
}
// Cat 클래스: Animal을 상속
class Cat(name: String) : Animal(name) {
    override fun makeSound() {
        println("야옹!")
    }
    
    fun scratch() {
        println("$name이(가) 할퀴기를 합니다.")
    }
}
// 사용 예제
fun main() {
    val dog = Dog("바둑이")
    dog.makeSound()  // 멍멍! (오버라이딩)
    dog.sleep()      // 바둑이이(가) 잠을 잡니다. (상속)
    dog.fetch()      // 바둑이이(가) 공을 가져옵니다. (Dog 고유)
    
    val cat = Cat("나비")
    cat.makeSound()  // 야옹! (오버라이딩)
    cat.sleep()      // 나비이(가) 잠을 잡니다. (상속)
    cat.scratch()    // 나비이(가) 할퀴기를 합니다. (Cat 고유)
    
    // 다형성
    val animals: List<Animal> = listOf(
        Dog("멍멍이"),
        Cat("야옹이")
    )
    
    for (animal in animals) {
        animal.makeSound()  // 각 객체의 실제 타입에 따라 호출
        // 멍멍!
        // 야옹!
    }
}

open vs final 비교:

// ❌ final 클래스 (기본)
class FinalClass {
    fun method() {}
}
// class SubClass : FinalClass()  // 컴파일 에러!
// This type is final, so it cannot be inherited from
// ✅ open 클래스
open class OpenClass {
    open fun method() {}
}
class SubClass : OpenClass() {
    override fun method() {}  // OK
}

왜 기본이 final인가:

  • 안전성: 의도하지 않은 상속 방지
  • 성능: final 메서드는 최적화 가능
  • 명확성: 상속 가능 여부가 명시적

이 결정은 “상속을 위해 설계하고 문서화하라, 그렇지 않으면 상속을 금지하라”는 Effective Java의 권고를 언어 기본값으로 만든 것입니다. 상속을 고려하지 않은 클래스를 누군가 상속하면, 부모 클래스의 내부 구현(어떤 메서드가 어떤 메서드를 호출하는지)에 자식이 의존하게 되어 부모를 조금만 고쳐도 자식이 깨집니다. 기본이 final이면 상속 가능한 지점을 설계자가 open으로 직접 골라야 합니다.

현실적인 불편도 있습니다. Spring은 @Transactional 같은 기능을 위해 클래스를 상속한 프록시를 만드는데, Kotlin 클래스가 final이면 이 프록시를 만들 수 없습니다. 그래서 Spring 프로젝트에서는 kotlin-spring(all-open) 컴파일러 플러그인으로 특정 어노테이션이 붙은 클래스를 자동으로 open으로 만들고, JPA 엔티티에는 kotlin-jpa 플러그인을 씁니다. Mockito로 final 클래스를 목킹하려다 “Cannot mock/spy class … final class”를 만나는 것도 같은 이유이며, mockito-inline이나 MockK로 해결합니다. 자식 클래스에서 override한 메서드는 기본적으로 다시 open 상태이므로, 손자 클래스의 재정의를 막으려면 final override로 적습니다.

abstract 클래스

abstract class Shape {
    abstract fun area(): Double
    abstract fun perimeter(): Double
    
    fun describe() {
        println("넓이: ${area()}, 둘레: ${perimeter()}")
    }
}
class Circle(val radius: Double) : Shape() {
    override fun area() = Math.PI * radius * radius
    override fun perimeter() = 2 * Math.PI * radius
}
class Rectangle(val width: Double, val height: Double) : Shape() {
    override fun area() = width * height
    override fun perimeter() = 2 * (width + height)
}

abstract 클래스와 멤버는 자동으로 open이라 따로 붙이지 않아도 상속·재정의할 수 있습니다. describe()처럼 추상 메서드를 이용하는 공통 로직을 부모에 두고, 도형마다 다른 계산만 자식이 채우는 구조입니다(템플릿 메서드 패턴). 자식이 추상 메서드 하나라도 구현하지 않으면 “Class ‘Circle’ is not abstract and does not implement abstract base class member” 오류가 납니다. 부모 생성자 호출 Shape()의 괄호는 인자가 없어도 생략할 수 없는데, 인터페이스 구현(괄호 없음)과 클래스 상속(괄호 있음)을 문법으로 구분하기 때문입니다.


인터페이스와 다중 구현

기본 인터페이스

interface Drawable {
    fun draw()
    fun erase() {
        println("지우기")  // 기본 구현
    }
}
class Circle : Drawable {
    override fun draw() {
        println("원 그리기")
    }
}

다중 인터페이스

interface Clickable {
    fun click()
}
interface Focusable {
    fun focus()
}
class Button : Clickable, Focusable {
    override fun click() {
        println("버튼 클릭")
    }
    
    override fun focus() {
        println("버튼 포커스")
    }
}

Kotlin 인터페이스는 Java 8 이후 인터페이스처럼 기본 구현(erase())을 가질 수 있고, 상태를 저장하지 않는 추상 프로퍼티(val label: String)도 선언할 수 있습니다. 추상 클래스와의 차이는 상태(backing field)와 생성자를 가질 수 없다는 것이고, 대신 여러 개를 동시에 구현할 수 있습니다. 앞 절의 Circle과 이 절의 Circle은 이름이 같으니 한 파일에 함께 두면 “Redeclaration” 오류가 난다는 점도 참고하세요.

두 인터페이스가 같은 이름의 기본 구현을 가지면(Clickable과 Focusable에 모두 showOff()가 있다면) 구현 클래스는 반드시 그 메서드를 재정의해야 하고, 안에서 super<Clickable>.showOff()처럼 어느 부모의 구현을 쓸지 꺾쇠로 지정합니다. 컴파일러가 모호함을 조용히 해결하지 않고 선택을 강제하는 것이 Kotlin의 일관된 태도입니다.


data class의 copy와 구조 분해

기본 사용

data class User(
    val name: String,
    val age: Int,
    val email: String
)
val user1 = User("홍길동", 25, "[email protected]")
val user2 = User("홍길동", 25, "[email protected]")
println(user1 == user2)  // true (equals 자동 생성)
println(user1)  // User(name=홍길동, age=25, [email protected])

Kotlin의 ==는 equals() 호출이고, 참조가 같은지 비교하려면 ===를 씁니다. 일반 클래스였다면 user1 == user2는 Object.equals의 기본 동작(참조 비교)이라 false지만, data class는 주 생성자의 프로퍼티 값을 비교하는 equals와 그에 맞는 hashCode를 만들어 주므로 true가 됩니다. 값이 같으면 같은 객체로 취급해야 하는 DTO, API 응답, Set이나 Map의 키로 쓰는 객체에 적합합니다.

자동 생성 대상은 주 생성자에 선언한 프로퍼티뿐입니다. 클래스 본문에 var lastLogin: Long = 0을 추가하면 equals, toString, copy 어디에도 포함되지 않습니다. 또 toString이 모든 프로퍼티를 출력하므로 비밀번호나 토큰을 담은 data class를 로그에 찍으면 그대로 노출됩니다. 민감한 필드가 있다면 toString을 재정의하세요. data class는 open이 될 수 없어 상속 계층의 부모로는 쓸 수 없습니다.

copy 메서드

val user1 = User("홍길동", 25, "[email protected]")
val user2 = user1.copy(age = 26)
println(user1)  // age=25
println(user2)  // age=26

copy는 원본을 바꾸지 않고 일부 값만 바꾼 새 객체를 만듭니다. 프로퍼티를 모두 val로 두고 변경이 필요할 때 copy로 새 객체를 만드는 방식은 상태 관리(Android의 UI State, Redux 스타일 상태)에서 표준처럼 쓰입니다. 이전 상태와 새 상태를 ==로 비교해 변경 여부를 바로 알 수 있기 때문입니다.

copy는 얕은 복사라는 점을 주의해야 합니다. 프로퍼티가 MutableList 같은 가변 객체라면 원본과 복사본이 같은 리스트를 공유해서, 한쪽에서 요소를 추가하면 다른 쪽에도 보입니다. 불변 상태를 의도했다면 프로퍼티 타입을 List처럼 읽기 전용으로 두고 변경할 때 새 리스트를 만들어 넘기세요.

구조 분해

val user = User("홍길동", 25, "[email protected]")
val (name, age, email) = user
println("이름: $name, 나이: $age")

구조 분해는 이름이 아니라 선언 순서로 값을 꺼냅니다. data class가 만든 component1(), component2(), component3()가 순서대로 호출되기 때문입니다. 그래서 나중에 User의 프로퍼티 순서를 바꾸거나 중간에 새 프로퍼티를 끼워 넣으면, 기존 구조 분해 코드가 오류 없이 엉뚱한 값을 받게 됩니다. 두 프로퍼티의 타입이 같다면(name과 email이 둘 다 String) 컴파일러도 잡아 주지 못합니다. 프로퍼티가 서너 개 이상이거나 순서가 바뀔 수 있는 클래스라면 user.name처럼 이름으로 접근하는 편이 안전하고, 쓰지 않는 값은 val (name, _, email)처럼 _로 건너뜁니다.


sealed class

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
    object Loading : Result()
}
fun handleResult(result: Result) {
    when (result) {
        is Result.Success -> println("성공: ${result.data}")
        is Result.Error -> println("에러: ${result.message}")
        is Result.Loading -> println("로딩 중...")
    }
}

sealed class는 하위 클래스를 같은 모듈의 같은 패키지 안에서만 정의할 수 있게 제한합니다. 컴파일러가 하위 타입 목록을 전부 알고 있으므로 when에서 세 경우를 모두 다루면 else가 필요 없고, 새 하위 타입 Result.Empty를 추가하면 처리하지 않은 모든 when이 컴파일 오류가 됩니다. 상태를 하나 추가했을 때 처리할 곳을 빠뜨리는 버그를 컴파일 단계에서 막아 주는 것이 핵심 가치입니다. Kotlin 1.7부터는 when을 값으로 쓰지 않는 문장 형태에서도 sealed 타입의 모든 경우를 다루지 않으면 오류가 됩니다.

is Result.Success 분기 안에서 result.data를 캐스팅 없이 쓸 수 있는 것은 스마트 캐스트 덕분입니다. Loading처럼 데이터가 없는 경우는 인스턴스가 하나면 충분하므로 object로 선언했고, is 없이 Result.Loading ->으로 비교해도 됩니다. Kotlin 1.9부터는 data object Loading으로 선언하면 toString()이 Loading처럼 읽기 좋게 출력됩니다.


object 선언과 companion object

싱글톤

object Database {
    private var connection: String? = null
    
    fun connect() {
        connection = "Connected"
        println("데이터베이스 연결됨")
    }
    
    fun disconnect() {
        connection = null
        println("데이터베이스 연결 해제됨")
    }
}
Database.connect()

object 선언은 클래스 정의와 유일한 인스턴스 생성을 한 번에 합니다. 인스턴스는 처음 접근할 때 JVM의 클래스 초기화 규칙에 따라 스레드 안전하게 한 번만 만들어지므로, Java에서 쓰던 이중 검사 잠금(double-checked locking) 싱글톤 코드가 필요 없습니다. 생성자는 가질 수 없고 init 블록은 쓸 수 있습니다.

싱글톤의 일반적인 단점은 그대로 남습니다. Database를 직접 호출하는 코드는 테스트에서 가짜 DB로 바꾸기 어렵고, 테스트 사이에 connection 상태가 남습니다. 교체 가능성이 필요하다면 인터페이스를 정의하고 object가 그것을 구현하게 한 뒤, 사용하는 쪽은 인터페이스 타입을 생성자로 주입받는 구조가 테스트하기 쉽습니다.

Companion Object

class User(val name: String) {
    companion object {
        const val MAX_AGE = 150
        
        fun create(name: String): User {
            return User(name)
        }
    }
}
val user = User.create("홍길동")
println(User.MAX_AGE)

Kotlin에는 static 키워드가 없고, 클래스에 속한 멤버는 companion object 안에 둡니다. User.create()처럼 클래스 이름으로 호출할 수 있어 Java의 정적 메서드처럼 보이지만, 실제로는 클래스마다 하나씩 있는 객체의 멤버입니다. 그래서 companion object는 인터페이스를 구현하거나 확장 함수를 가질 수 있습니다. create 같은 팩토리 함수를 두고 생성자를 private constructor로 막으면, 캐시된 인스턴스를 돌려주거나 입력에 따라 하위 타입을 고르는 등 생성 로직을 한 곳에서 통제할 수 있습니다.

Java 코드에서 호출할 때는 User.Companion.create("홍길동")처럼 Companion을 거쳐야 합니다. Java 쪽에서도 User.create()로 부르게 하려면 함수에 @JvmStatic을 붙이고, 상수는 const val(기본 타입과 String만 가능)이나 @JvmField로 선언합니다. const val은 컴파일 시점에 값이 호출 지점에 복사되는 상수라, 라이브러리에서 값을 바꾸면 사용하는 쪽도 다시 컴파일해야 반영된다는 점도 알아 두세요.


쇼핑 시스템을 클래스로 설계하기

data class Product(
    val id: String,
    val name: String,
    val price: Int
)
data class CartItem(
    val product: Product,
    var quantity: Int
)
class ShoppingCart {
    private val items = mutableListOf<CartItem>()
    
    fun addItem(product: Product, quantity: Int = 1) {
        val existing = items.find { it.product.id == product.id }
        if (existing != null) {
            existing.quantity += quantity
        } else {
            items.add(CartItem(product, quantity))
        }
    }
    
    fun removeItem(productId: String) {
        items.removeIf { it.product.id == productId }
    }
    
    fun getTotalPrice(): Int {
        return items.sumOf { it.product.price * it.quantity }
    }
    
    fun printCart() {
        println("=== 장바구니 ===")
        items.forEach {
            println("${it.product.name} x${it.quantity} = ${it.product.price * it.quantity}원")
        }
        println("총액: ${getTotalPrice()}원")
    }
}
fun main() {
    val cart = ShoppingCart()
    
    cart.addItem(Product("P001", "노트북", 1000000))
    cart.addItem(Product("P002", "마우스", 30000), 2)
    
    cart.printCart()
}

클래스 종류를 용도에 맞게 나눈 예제입니다. Product는 값 자체가 의미인 데이터라 data class, ShoppingCart는 목록을 관리하는 동작이 중심이라 일반 클래스입니다. items를 private으로 두고 addItem/removeItem으로만 바꾸게 했으므로, “같은 상품은 수량을 합친다”는 규칙이 우회될 수 없습니다. 외부에 목록을 보여 줘야 한다면 val items: List<CartItem> get() = _items처럼 읽기 전용 타입으로 노출합니다(읽기 전용 뷰일 뿐 복사본은 아니라는 점은 알아 두세요).

주의할 설계는 CartItem의 var quantity입니다. data class의 hashCode는 프로퍼티 값으로 계산되므로, CartItem을 HashSet에 넣은 뒤 quantity를 바꾸면 해시 값이 달라져 contains가 false를 돌려주고 remove도 실패합니다. 이 예제는 리스트만 쓰기 때문에 문제가 드러나지 않지만, data class는 val로만 구성하고 수량 변경은 copy(quantity = ...)로 새 객체를 만들어 교체하는 방식이 더 안전합니다. 금액을 Int로 계산하는 것도 약 21억을 넘으면 오버플로가 나므로 실제 쇼핑몰이라면 Long이나 BigDecimal을 씁니다.


클래스 문법 요약

  1. 클래스: 주 생성자, init 블록
  2. 상속: open, override
  3. 인터페이스: 다중 구현 가능
  4. 데이터 클래스: equals, hashCode, copy 자동
  5. Sealed 클래스: 제한된 상속
  6. Object: 싱글톤, Companion Object

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. enum 대신 sealed class를 쓰는 이유는 무엇인가요?

A. enum의 각 상수는 모두 같은 구조를 가진 단일 인스턴스지만, sealed class의 하위 타입은 Success(data), Error(message), Loading처럼 서로 다른 데이터를 가질 수 있습니다. 하위 타입이 같은 모듈·같은 패키지 안으로 제한되므로 when으로 분기할 때 모든 경우를 다뤘는지 컴파일러가 검사해 주고, else 없이도 빠진 경우를 잡아 줍니다. 결과·상태처럼 경우마다 담는 값이 다를 때는 sealed class, 단순한 고정 값 목록이라면 enum이 맞습니다.