스물한 번째, 봉인된 클래스와 인터페이스입니다.
※ sealed를 번역해서 봉인된 클래스라고도 하고 그냥 실드 클래스라고도 부르기도 합니다. 여기서는 "봉인된"으로 칭하도록 하겠습니다.
봉인된(sealed) 클래스와 인터페이스 클래스 계층 구조의 제어된 상속을 제공합니다. 봉인된 클래스의 모든 직접적인 하위 클래스(direct subclass)는 컴파일 시점에 알 수 있습니다. 어떤 하위 클래스도 봉인된 클래스가 정의된 모듈과 패키지 바깥에 나타날 수 없습니다. 같은 로직이 봉인된 인터페이스와 그 구현에도 적용됩니다. 일단 한 번 봉인된 인터페이스를 포함하는 모듈이 컴파일되고 나면 더 이상 새로운 구현은 생성할 수 없습니다.
직접적인 하위 클래스(direct subclasses)는 수퍼클래스로 부터 바로 상속 받은 클래스를 말합니다.
간접적인 하위 클래스(indirect subclasses)는 수퍼 클래스로 부터 한 단계 이상 아래에서 상속 받는 클래스를 말합니다.
봉인된 클래스와 인터페이스를 when과 결합해서 사용하면, 모든 가능한 하위 클래스들의 행위를 다룰 수 있고, 코드에 안 좋은 영향을 미치는 새로운 하위 클래스가 생성되지 않는 다는 것을 보장할 수 있습니다.
봉인된 클래스는 다음과 같은 시나리오에 가장 적합합니다.
- 제한된 클래스 상속이 필요할 때 : 특정 클래스의 미리 정의되고 유한하여 컴파일 타임에 모두 알 수 있는 하위 클래스 집합을 가져야 할 때.
- 타입에 안전한(type-safe) 설계가 요구될 때 : 프로젝트에서 안전성과 패턴 일치는 중요합니다. 특별히, 상태(state) 관리나 복잡한 조건의 로직을 다룰때 그렇습니다. 관련된 예로 하위에서 설명하는 'when 표현식과 봉인된 클래스 사용' 부분에 확인할 수있습니다.
- 폐쇄된(closed) API와 작업할 떄 : 써드 파티 클라이언트가 의도된 대로 사용하는 것을 보장하는 견고하고 유지 보수 가능한 public API를 원하는 때
보다 상세한 실용적인 애플리케이션은 아래에서 얘기하는 사용 사례 시나리오를 보시기 바랍니다.
Java 15에서 비슷한 개념이 소개 됐습니다. 여기서 봉인된 클래스는 제한된 계층 구조를 정의하기 위해서 sealed 키워드를 permit 과 함께 사용합니다.
봉인된 클래스와 인터페이스 선언
봉인된 클래스와 인터페이스를 선언하기 위해서 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를 나타낼 수 있습니다. 이런 오류 클래스 (상속) 계층에는 public 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 상수는 하나의 인스턴스만 존재합니다. 반면에 봉인된 클래스의 하위 클래스는 다중 인스턴스 일 수 있습니다. 다음의 예에서 봉인된 클래스 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)
// Additional error types can be added here
}
봉인된 클래스의 생성자는 protected와 private 둘 중에 하나의 가시성을 갖습니다. 기본값은 protected입니다.
sealed class IOError {
// 봉인된 클래스의 생성자는 기본적으로 protected 가시성을 갖습니다.
// 그러므로, 자신과 하위 클래스들에 보입니다.
constructor() { /*...*/ }
// private 생성자는 해당 클래스 내부에서만 보입니다.
// 봉인된 클래스에서 private 생성자를 사용하면 특별한 초기화 작업을 해당 클래스 내에서만 수행하게 하여,
// 인스턴스화를 보다 엄격하게 제어할 수 있습니다.
private constructor(description: String): this() { /*...*/ }
// 봉인된 클래스에는 public이나 internal 생성자는 허용되지 않기 때문에 이렇게 하면 오류가 발생합니다.
// public constructor(code: Int): this() {}
}
상속
봉인된 클래스와 인터페이스의 직접적인(direct) 하위 클래스는 같은 패키지 안에 선언돼야 합니다. 해당 선언은 최상위 수준이나 이름이 있는 클래스/객체/인터페이스 내부에 위치할 수 있습니다. 하위 클래스는 Kotlin에서의 정상적인 상속 규칙에 부합하는한 어떠한 가시성도 가질 수 있습니다.
봉인된 클래스의 하위 클래스는 반드시 적절하게 정규화된(qualified) 이름을 가져야합니다(즉, Kotlin에서 허용하는 일반적인 클래스 이름 규칙에 맞게 지어진 이름을 가져야 합니다). 지역(local)이나 익명 객체는 불가합니다.
enum 클래스는 봉인된 클래스나 여타 다른 클래스를 확장할 수 없습니다. 하지만, 봉인된 인터페이스를 구현할 수는 있습니다.
sealed interface Error
// 봉인된 인터페이스 Error를 확장하는 enum 클래스
enum class ErrorType : Error {
FILE_ERROR, DATABASE_ERROR
}
이러한 제약은 간접적인 하위 클래스에는 적용되지 않습니다. 봉인된 클래스의 직접적인 하위 클래스가 sealed로 지정되지 않았다면, 해당 수정자가 허용하는 범위 내에서 어떤 방법으로도 확장(상속)될 수 있습니다.
// 봉인된 인터페이스는 오로지 같은 패키지와 모듈 내에서 구현됩니다.
sealed interface Error
// 봉인된 클래스 IOError는 Error를 확장하고 오로지 같은 패키지에서만 확장(상속)될 수 있습니다.
sealed class IOError(): Error
// open 클래스인 'CustomError'는 'Error'를 확장(상속)하고 이 클래스를 볼 수 있는 어는 곳에서든 확장(상속)될 수 있습니다.
open class CustomError(): Error
멀티플랫폼에서의 상속
멀티플랫폼 프로젝트에서는 제약이 하나 더 있습니다. 봉인된 클래스의 직접적인 하위 클래스는 반드시 같은 소스 세트 안에 있어야 합니다. 이는 expect와 actual 수정자가 없는 봉인된 클래스들에 적용됩니다.
봉인된 클래스가 공통 소스 세트에서 expect로 선언되고 플랫폼 소스 세트에서 actual 구현을 가지고 있다면, expect와 actual 버전 둘 다 각각의 소스 세트에서 하위 클래스를 가질 수 있습니다. 게다가, 계층적 구조를 사용하면, expect와 actual 선언 사이에서 어떤 소스 세트에도 하위 클래스를 만들 수 있습니다.
보다 상세한 내용은 여기에서 확인할 수 있습니다.
when 표현식과 같이 사용
봉인된 클래스 사용할 때의 주요 이점은 when 표현식과 같이 사용할 때 나타납니다. 봉인된 클래스와 함께 사용된 when 표현식은 코틀린 컴파일러에게 모든 가능한 경우가 다뤄졌는지 철저하게 검사하는 것을 가능하게 합니다. 이런 경우에는 else 절은 필요 없습니다.
// 봉인된 클래스와 그 하위 클래스들
sealed class Error {
class FileReadError(val file: String): Error()
class DatabaseError(val source: String): Error()
object RuntimeError : Error()
}
// 에러는 기록하는 함수
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 절은 불필요합니다.
}
// 모든 오류 열거
fun main() {
val errors = listOf(
Error.FileReadError("example.txt"),
Error.DatabaseError("usersDatabase"),
Error.RuntimeError
)
errors.forEach { log(it) }
}
/** 결과
Error while reading file example.txt
Error while reading from database usersDatabase
Runtime error
*/
멀티플랫폼 프로젝트의 경우, 공통 코드에서 when 표현식과 사용하는 봉인된 클래스가 expect 선언이면, 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의 경우 number, expiryDate, PayPal의 경우 email 처럼, 각각의 하위 클래스는 그에 맞는 특정한 프로퍼티를 가질 수 있습니다.
processPayment() 함수는 서로 다른 결제 방법들을 어떻게 다뤄야 하는지 보여줍니다. 이러한 접근 방법은 모든 가능한 결제 방법이 고려되도록 보장해 줍니다. 그리고, 시스템은 미래에 추가될 새로운 결제 수단에 대해서도 유연성을 유지합니다.
API 요청-응답 처리
봉인된 클래스와 인터페이스를 API 요청과 응답을 다루는 시스템의 사용자 인증을 구현하는 데 사용할 수 있습니다. 사용자 인증 시스템은 로그인과 로그아웃 기능을 갖습니다. 봉인된 인터페이스 ApiRequest 는 로그인을 위한 LoginRequest, 로그아웃을 위한 LogoutRequst 같은 특정한 요청 유형을 정의합니다. 봉인된 클래스 ApiResponse 서로 다른 응답 시나리오를 캡슐화합니다(사용자 데이터를 가진 UserSuccess, 없는 사용자를 위한 UserNotFound, 실패를 위한 Error). handleRequest 함수는 when 표현식을 사용하여 이러한 요청을 타입에 안전한(type-safe) 방법으로 처리합니다. getUserById는 사용자를 조회하는 것을 시뮬레이션합니다.
// 필요한 모듈 임포트
import io.ktor.server.application.*
import io.ktor.server.resources.*
import kotlinx.serialization.*
// Ktor(https://ktor.io) 자원을 사용하여 API 요청을 위한 봉인된 인터페이스 정의
// ktor를 처음 들어봤다면 무시해도 됩니다. 봉인된 클래스 이해와는 무관하고, 실제 같은 예를 위해 Ktor 프레임워크의 한 부분이 사용된 것입니다.
@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")) // For demonstration
}
}
}
// getUserById 호출을 시뮬레이션하는 함수
fun getUserById(userId: String): ApiResponse {
return if (userId == "validUserId") {
ApiResponse.UserSuccess(UserData("validUserId", "John Doe", "john@example.com"))
} else {
ApiResponse.UserNotFound
}
}
// 사용법을 보여주는 main 함수
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)
}
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 23. Nested and inner classes (0) | 2024.03.11 |
---|---|
공식 문서로 배우는 코틀린 - 22. Generics: in, out, where (0) | 2024.03.11 |
공식 문서로 배우는 코틀린 - 20. Data classes (0) | 2024.03.09 |
공식 문서로 배우는 코틀린 - 19. Extensions (1) | 2024.03.09 |
공식 문서로 배우는 코틀린 - 18. Visibility modifiers (2) | 2024.03.08 |