봉인된 클래스와 인터페이스

봉인된 클래스와 인터페이스 (Sealed classes and interfaces)

봉인된(sealed) 클래스와 인터페이스는 클래스 계층의 상속을 통제할 수 있게 해 줘요. 봉인된 클래스의 모든 직접 하위 클래스는 컴파일 시점에 알 수 있어요. 봉인된 클래스가 정의된 모듈과 패키지 바깥에는 다른 하위 클래스가 나타날 수 없죠. 봉인된 인터페이스와 그 구현에도 같은 논리가 적용돼요. 봉인된 인터페이스를 담은 모듈이 한번 컴파일되면 새로운 구현을 만들 수 없어요.

직접 하위 클래스는 바로 슈퍼클래스에서 상속받는 클래스이고, 간접 하위 클래스는 슈퍼클래스로부터 두 단계 이상 아래에서 상속받는 클래스예요.

봉인된 클래스와 인터페이스를 when 표현식과 함께 쓰면 가능한 모든 하위 클래스의 동작을 빠짐없이 다룰 수 있고, 새 하위 클래스가 생겨서 코드에 나쁜 영향을 주는 일도 막을 수 있어요.

출처: Kotlin 공식 문서

본문

봉인된 클래스는 다음과 같은 상황에서 가장 잘 쓰여요.

  • 제한된 클래스 상속이 필요할 때: 클래스를 확장하는 하위 클래스가 사전에 정의된 유한한 집합이고, 그 모두가 컴파일 시점에 알려질 때.
  • 타입 안전한 설계가 필요할 때: 프로젝트에서 안전성과 패턴 매칭이 중요한 경우. 특히 상태 관리나 복잡한 조건 로직을 다룰 때 유용해요. 예시는 봉인된 클래스를 when 표현식과 함께 쓰기를 확인하세요.
  • 닫힌 API를 작업할 때: 라이브러리를 위한 견고하고 유지 보수 쉬운 공개 API를 원하고, 서드파티 클라이언트가 의도한 대로 API를 쓰도록 보장하고 싶을 때.

더 구체적인 실용 사례는 사용 사례 시나리오를 보세요.

참고로 Java 15에도 비슷한 개념이 도입됐는데, Java의 봉인된 클래스는 sealed 키워드를 permits 절과 함께 써서 제한된 계층을 정의해요.

봉인된 클래스나 인터페이스 선언하기

봉인된 클래스나 인터페이스를 선언하려면 sealed 수정자를 사용해요.

// 봉인된 인터페이스 만들기
sealed interface Error

// 봉인된 인터페이스 Error를 구현하는 봉인된 클래스 만들기
sealed class IOError(): Error

// 봉인된 클래스 'IOError'를 확장하는 하위 클래스 정의하기
class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

// 'Error' 봉인된 인터페이스를 구현하는 싱글턴 객체 만들기 
object RuntimeError : Error

이 예시는 라이브러리가 던질 수 있는 오류를 사용자가 처리할 수 있도록 오류 클래스를 담고 있는 라이브러리의 API를 나타낼 수 있어요. 이런 오류 클래스의 계층에 공개 API에 보이는 인터페이스나 추상 클래스가 포함되어 있다면, 다른 개발자가 클라이언트 코드에서 그것들을 구현하거나 확장하는 것을 막을 수 없어요. 라이브러리는 자기 바깥에서 선언된 오류를 모르기 때문에, 그 오류들을 자기 클래스들과 일관되게 다룰 수 없죠. 하지만 봉인된 오류 클래스 계층을 쓰면 라이브러리 작성자는 가능한 모든 오류 타입을 알고 있으며 다른 오류 타입이 나중에 나타날 수 없다고 확신할 수 있어요.

이 예시의 계층 구조는 정리하면 다음과 같은 모양이에요.

생성자

봉인된 클래스 자체는 항상 추상 클래스이고, 그래서 직접 인스턴스화할 수 없어요. 다만 생성자를 포함하거나 상속받을 수 있어요. 이런 생성자들은 봉인된 클래스 자신의 인스턴스를 만드는 게 아니라 그 하위 클래스들을 위한 거예요. Error라는 봉인된 클래스와 그 여러 하위 클래스를 실제로 인스턴스화하는 다음 예시를 볼게요.

sealed class Error(val message: String) {
    class NetworkError : Error("Network failure")
    class DatabaseError : Error("Database cannot be reached")
    class UnknownError : Error("An unknown error has occurred")
}

fun main() {
    val errors = listOf(Error.NetworkError(), Error.DatabaseError(), Error.UnknownError())
    errors.forEach { println(it.message) }
}
// Network failure 
// Database cannot be reached 
// An unknown error has occurred

봉인된 클래스 안에서 enum 클래스를 써서 enum 상수로 상태를 나타내고 추가 정보를 제공할 수도 있어요. enum 상수는 각각 단일 인스턴스로만 존재하지만, 봉인된 클래스의 하위 클래스는 여러 인스턴스를 가질 수 있어요. 아래 예시에서 sealed class Error와 그 여러 하위 클래스는 오류 심각도를 나타내기 위해 enum을 사용해요. 각 하위 클래스 생성자는 severity를 초기화하고 상태를 바꿀 수 있어요.

enum class ErrorSeverity { MINOR, MAJOR, CRITICAL }

sealed class Error(val severity: ErrorSeverity) {
    class FileReadError(val file: File): Error(ErrorSeverity.MAJOR)
    class DatabaseError(val source: DataSource): Error(ErrorSeverity.CRITICAL)
    object RuntimeError : Error(ErrorSeverity.CRITICAL)
    // 여기에 추가 오류 타입을 넣을 수 있음
}

봉인된 클래스의 생성자는 가시성이 두 종류 중 하나일 수 있어요: protected(기본값) 또는 private.

sealed class IOError {
    // 봉인된 클래스 생성자는 기본적으로 protected 가시성을 가짐. 이 클래스와 하위 클래스 안에서 보임 
    constructor() { /*...*/ }

    // private 생성자. 이 클래스 안에서만 보임. 
    // 봉인된 클래스에서 private 생성자를 쓰면 인스턴스화를 더 엄격히 통제할 수 있어, 클래스 안에서 특정 초기화 절차를 가능하게 함
    private constructor(description: String): this() { /*...*/ }

    // 이 코드는 public과 internal 생성자가 봉인된 클래스에서 허용되지 않아 오류를 일으킴
    // public constructor(code: Int): this() {} 
}

상속

봉인된 클래스와 인터페이스의 직접 하위 클래스는 같은 패키지에 선언되어야 해요. 그것들은 최상위 레벨이거나 여러 개의 다른 이름 있는 클래스, 이름 있는 인터페이스, 이름 있는 객체 안에 얼마든지 중첩될 수 있어요. 하위 클래스는 Kotlin의 일반 상속 규칙과 호환되는 한 어떤 가시성이라도 가질 수 있어요. 여기에는 프로퍼티 오버라이딩에 대한 규칙도 포함돼요.

봉인된 클래스의 하위 클래스는 제대로 한정된 이름을 가져야 해요. 지역 객체나 익명 객체일 수는 없죠.

enum 클래스는 봉인된 클래스나 다른 어떤 클래스도 확장할 수 없어요. 다만 봉인된 인터페이스는 구현할 수 있어요.

sealed interface Error

// 봉인된 인터페이스 Error를 구현하는 enum class
enum class ErrorType : Error {
    FILE_ERROR, DATABASE_ERROR
}

이런 제한은 간접 하위 클래스에는 적용되지 않아요. 봉인된 클래스의 직접 하위 클래스가 봉인되지 않았다면, 그 수정자가 허용하는 어떤 방식으로든 확장될 수 있어요.

// 봉인된 인터페이스 'Error'는 같은 패키지와 모듈 안에서만 구현을 가짐
sealed interface Error

// 봉인된 클래스 'IOError'는 'Error'를 확장하며 같은 패키지 안에서만 확장 가능
sealed class IOError(): Error

// open 클래스 'CustomError'는 'Error'를 확장하며 보이는 곳 어디서든 확장 가능
open class CustomError(): Error

멀티플랫폼 프로젝트에서의 상속

멀티플랫폼 프로젝트에는 상속 제한이 하나 더 있어요. 봉인된 클래스의 직접 하위 클래스는 같은 소스 세트에 있어야 해요. 이것은 expected/actual 수정자가 없는 봉인된 클래스에 적용돼요.

봉인된 클래스가 공통 소스 세트에서 expect로 선언되고 플랫폼 소스 세트에 actual 구현이 있다면, expectactual 버전 모두 자기 소스 세트에 하위 클래스를 가질 수 있어요. 게다가 계층 구조를 사용한다면 expectactual 선언 사이에 있는 아무 소스 세트에서든 하위 클래스를 만들 수 있어요. 멀티플랫폼 프로젝트의 계층 구조에 대해 더 알아보기.

봉인된 클래스를 when 표현식과 함께 쓰기

봉인된 클래스의 핵심 이점은 when 표현식에서 사용할 때 나타나요. 봉인된 클래스와 함께 쓰는 when 표현식은 Kotlin 컴파일러가 가능한 모든 경우가 빠짐없이 다뤄졌는지 철저히 검사하게 해 줘요. 이런 경우에는 else 절을 추가할 필요가 없어요.

// 봉인된 클래스와 그 하위 클래스들
sealed class Error {
    class FileReadError(val file: String): Error()
    class DatabaseError(val source: String): Error()
    object RuntimeError : Error()
}

//sampleStart
// 오류를 기록하는 함수
fun log(e: Error) = when(e) {
    is Error.FileReadError -> println("Error while reading file ${e.file}")
    is Error.DatabaseError -> println("Error while reading from database ${e.source}")
    Error.RuntimeError -> println("Runtime error")
    // 모든 경우가 다뤄졌기 때문에 `else` 절이 필요 없음
}
//sampleEnd

// 모든 오류 나열하기
fun main() {
    val errors = listOf(
        Error.FileReadError("example.txt"),
        Error.DatabaseError("usersDatabase"),
        Error.RuntimeError
    )

    errors.forEach { log(it) }
}

when 표현식에서 반복을 줄이려면 문맥에 민감한 해석(context-sensitive resolution, 현재 미리 보기 상태)을 시도해 볼 수 있어요. 이 기능은 기대 타입이 알려져 있으면 봉인된 클래스 멤버를 매칭할 때 타입 이름을 생략하게 해 줘요. 더 자세한 내용은 문맥에 민감한 해석 미리 보기 또는 관련 KEEP 제안을 참고하세요.

when 표현식에서 봉인된 클래스를 쓸 때, 단일 분기에 추가 확인을 포함하도록 guard 조건을 붙일 수도 있어요. 자세한 내용은 when 표현식의 guard 조건을 참고하세요.

멀티플랫폼 프로젝트에서 공통 코드에 when 표현식이 있는 봉인된 클래스를 expected 선언으로 가진다면 여전히 else 분기가 필요해요. 그 이유는 actual 플랫폼 구현의 하위 클래스가 공통 코드에서 알려지지 않은 봉인된 클래스를 확장할 수 있기 때문이에요.

사용 사례 시나리오

봉인된 클래스와 인터페이스가 특히 유용한 실용적인 시나리오를 몇 가지 살펴볼게요.

UI 애플리케이션에서의 상태 관리

봉인된 클래스를 써서 애플리케이션의 여러 UI 상태를 나타낼 수 있어요. 이 접근 방식은 UI 변경을 구조적이고 안전하게 처리하게 해 줘요. 다음 예시는 다양한 UI 상태를 관리하는 방법을 보여줘요.

sealed class UIState { 
    data object Loading : UIState()
    data class Success(val data: String) : UIState()
    data class Error(val exception: Exception) : UIState()
}

fun updateUI(state: UIState) { 
    when (state) {
        is UIState.Loading -> showLoadingIndicator()
        is UIState.Success -> showData(state.data)
        is UIState.Error -> showError(state.exception) 
    }
}

결제 방법 처리

실무 비즈니스 애플리케이션에서는 여러 결제 방법을 효율적으로 처리하는 것이 흔한 요구사항이에요. 봉인된 클래스를 when 표현식과 함께 써서 그런 비즈니스 로직을 구현할 수 있어요. 서로 다른 결제 방법을 봉인된 클래스의 하위 클래스로 표현하면, 거래를 처리하는 명확하고 관리하기 쉬운 구조가 만들어져요.

sealed class Payment {
    data class CreditCard(val number: String, val expiryDate: String) : Payment()
    data class PayPal(val email: String) : Payment()
    data object Cash : Payment()
}

fun processPayment(payment: Payment) { 
    when (payment) {
        is Payment.CreditCard -> processCreditCardPayment(payment.number, payment.expiryDate)
        is Payment.PayPal -> processPayPalPayment(payment.email)
        is Payment.Cash -> processCashPayment() 
    }
}

Payment는 전자상거래 시스템의 서로 다른 결제 방법(CreditCard, PayPal, Cash)을 나타내는 봉인된 클래스예요. 각 하위 클래스는 고유한 프로퍼티를 가질 수 있는데, CreditCard에는 numberexpiryDate가, PayPal에는 email이 그 예시죠.

processPayment() 함수는 서로 다른 결제 방법을 어떻게 처리하는지 보여줘요. 이 접근 방식은 가능한 모든 결제 타입이 고려되도록 보장하고, 시스템이 미래에 새 결제 방법을 추가해도 유연하게 대처할 수 있게 해 줘요.

API 요청-응답 처리

봉인된 클래스와 봉인된 인터페이스를 써서 API 요청과 응답을 처리하는 사용자 인증 시스템을 구현할 수 있어요. 이 사용자 인증 시스템에는 로그인과 로그아웃 기능이 있어요. ApiRequest 봉인된 인터페이스는 특정 요청 타입을 정의하는데, LoginRequest는 로그인, LogoutRequest는 로그아웃 작업을 위한 것이에요. ApiResponse 봉인된 클래스는 다양한 응답 시나리오를 담아요. UserSuccess는 사용자 데이터를, UserNotFound는 없는 사용자를, Error는 어떤 실패든 나타내죠. handleRequest 함수는 when 표현식으로 이런 요청을 타입 안전하게 처리하고, getUserById는 사용자 조회를 흉내 냅니다.

// 필요한 모듈 가져오기
import io.ktor.server.application.*
import io.ktor.server.resources.*

import kotlinx.serialization.*

// Ktor resources를 사용해 API 요청을 위한 봉인된 인터페이스 정의
@Resource("api")
sealed interface ApiRequest

@Serializable
@Resource("login")
data class LoginRequest(val username: String, val password: String) : ApiRequest

@Serializable
@Resource("logout")
object LogoutRequest : ApiRequest

// 상세한 응답 타입을 가진 ApiResponse 봉인된 클래스 정의
sealed class ApiResponse {
    data class UserSuccess(val user: UserData) : ApiResponse()
    data object UserNotFound : ApiResponse()
    data class Error(val message: String) : ApiResponse()
}

// 성공 응답에 사용할 사용자 데이터 클래스
data class UserData(val userId: String, val name: String, val email: String)

// 사용자 자격 증명 검증 함수 (설명용)
fun isValidUser(username: String, password: String): Boolean {
    // 몇 가지 검증 로직 (이것은 자리표시자일 뿐임)
    return username == "validUser" && password == "validPass"
}

// 상세한 응답과 함께 API 요청을 처리하는 함수
fun handleRequest(request: ApiRequest): ApiResponse {
    return when (request) {
        is LoginRequest -> {
            if (isValidUser(request.username, request.password)) {
                ApiResponse.UserSuccess(UserData("userId", "userName", "userEmail"))
            } else {
                ApiResponse.Error("Invalid username or password")
            }
        }
        is LogoutRequest -> {
            // 이 예시에서는 로그아웃 작업이 항상 성공한다고 가정
            ApiResponse.UserSuccess(UserData("userId", "userName", "userEmail")) // 설명용
        }
    }
}

// getUserById 호출을 흉내 내는 함수
fun getUserById(userId: String): ApiResponse {
    return if (userId == "validUserId") {
        ApiResponse.UserSuccess(UserData("validUserId", "John Doe", "[email protected]"))
    } else {
        ApiResponse.UserNotFound
    }
    // 오류 처리는 Error 응답을 만들기도 함.
}

// 사용법을 보여주는 메인 함수
fun main() {
    val loginResponse = handleRequest(LoginRequest("user", "pass"))
    println(loginResponse)

    val logoutResponse = handleRequest(LogoutRequest)
    println(logoutResponse)

    val userResponse = getUserById("validUserId")
    println(userResponse)

    val userNotFoundResponse = getUserById("invalidId")
    println(userNotFoundResponse)
}

더 알아보기 (Learn more)