KB 24 sealed 와 enum — 경우를 타입으로 닫는다
고친 사람 github-actions[bot]
0. else 를 지웠는데 컴파일이 된다
결제 수단이 카드와 현금 둘뿐인 가게가 있다고 합시다. 결제 하나를 받아 설명 문자열을 돌려주는 함수를 코틀린으로 썼습니다. 수단이 둘이니 분기도 둘이고, else 는 쓰지 않았습니다.
interface Payment
class Card(val number: String) : Payment
class Cash(val amount: Int) : Payment
fun describe(p: Payment): String = when (p) {
is Card -> "card ${p.number}"
is Cash -> "cash ${p.amount}"
}
interface Payment— 자바와 같은 인터페이스입니다. 몸체가 비면 중괄호도 뺍니다class Card(val number: String) : Payment— 괄호 안이 생성자이자 필드입니다. 콜론 뒤가 구현하는 인터페이스로, 자바의implements에 해당합니다= when (p) { ... }— 함수 몸체를=뒤에 식 하나로 적었습니다.when은 자바switch에 해당하고, 여기서는 값을 돌려주는 식으로 썼습니다is Card ->— 자바의instanceof입니다. 이 분기 안에서는p를 따로 캐스트하지 않아도Card로 다룹니다(스마트 캐스트). 그래서p.number가 됩니다
코틀린 컴파일러 kotlinc 에 넣었습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다.
$ kotlinc Open.kt -d out
Open.kt:5:36: error: 'when' expression must be exhaustive. Add an 'else' branch.
fun describe(p: Payment): String = when (p) {
^^^^
when 식은 빠짐없이(exhaustive) 모든 경우를 다뤄야 하니 else 를 더하라는 에러입니다. 컴파일러 눈에는 Payment 를 구현한 클래스가 둘뿐이라는 보장이 없습니다. 내일 누가 다른 파일에서 class Coupon : Payment 를 만들 수 있으니까요.
이제 interface 앞에 낱말 하나만 붙입니다. 나머지는 한 글자도 바꾸지 않았고, 부르는 main 만 더했습니다.
sealed interface Payment
class Card(val number: String) : Payment
class Cash(val amount: Int) : Payment
fun describe(p: Payment): String = when (p) {
is Card -> "card ${p.number}"
is Cash -> "cash ${p.amount}"
}
fun main() {
// ↓ card 1234
println(describe(Card("1234")))
// ↓ cash 5000
println(describe(Cash(5000)))
}
$ kotlinc Pay.kt -d out
$ kotlin -cp out PayKt
에러 없이 컴파일됐고 두 줄을 찍었습니다. kotlin 은 java 처럼 클래스를 실행하는 명령이고, PayKt 는 컴파일러가 지은 클래스 이름입니다. 클래스 밖, 파일 바로 아래에 쓴 함수(톱레벨 함수)는 파일 이름 + Kt 클래스의 static 메서드로 들어갑니다.
sealed 는 「봉인된」이라는 뜻입니다. 이 인터페이스를 구현하는 타입의 목록이 컴파일할 때 닫힌다는 표시입니다. 목록이 닫혔으니 컴파일러가 Card 와 Cash 둘이 전부라는 것을 알고, else 없이도 빠진 경우가 없다고 판정했습니다.
이 편은 경우의 수를 타입으로 닫는 두 도구, enum 과 sealed 를 봅니다. 1절이 enum, 2절이 sealed 의 규칙입니다. 3절은 경우를 하나 늘렸을 때 어디가 깨지는지로, 이 편에서 가장 중요한 대목입니다. 4절은 둘 중 무엇을 고르나, 5절은 자바 17 과의 대조, 6절은 성능입니다. 출력은 전부 Kotlin 2.4.10 · JDK 21.0.12.1 에서 직접 돌린 결과입니다.
1. enum — 자바 enum 과 거의 같다
enum 은 경우의 수를 닫는 가장 오래된 도구입니다. 자바 개발자에게는 익숙하니, 이 절은 문법이 어디서 갈리는지만 봅니다. 재료는 우선순위 셋을 담은 Level 입니다.
enum class Level(val code: Int) {
LOW(1), MID(5), HIGH(9);
fun isUrgent() = code >= 9
}
fun main() {
println(Level.MID) // MID
println(Level.MID.code) // 5
println(Level.MID.name) // MID
println(Level.MID.ordinal) // 1
// ↓ true
println(Level.valueOf("HIGH").isUrgent())
// ↓ [LOW, MID, HIGH]
println(Level.entries)
println(Level.LOW < Level.HIGH) // true
}
enum class Level(val code: Int)— 코틀린은enum이 아니라enum class두 낱말로 씁니다. 상수마다 가질 필드code를 클래스 머리의 괄호에 적습니다LOW(1), MID(5), HIGH(9);— 상수 목록입니다. 괄호 안 값이 생성자로 들어갑니다fun isUrgent() = code >= 9— 상수마다 부를 수 있는 메서드입니다. 반환 타입은 값에서Boolean으로 정해집니다Level.entries— 상수 전부를 선언 순서대로 담은 읽기 전용 리스트입니다. 6절에서 자바의values()와 견줍니다
$ kotlinc Level.kt -d out
$ kotlin -cp out LevelKt
name·ordinal·valueOf·< 비교까지 자바 enum 에 있던 것이 이름 그대로 다 됩니다. 코틀린 enum 도 JVM 에서는 자바 java.lang.Enum 을 물려받는 클래스라서입니다.
세미콜론은 딱 한 군데에서 필요하다
코틀린은 줄 끝 세미콜론을 쓰지 않는 언어입니다. 그런데 위 코드의 HIGH(9); 에는 세미콜론이 있습니다. 이것을 지운 NoSemi.kt 를 컴파일했습니다.
$ kotlinc NoSemi.kt -d out
NoSemi.kt:2:28: error: syntax error: Expecting ';' after the last enum entry or '}' to close enum class body.
LOW(1), MID(5), HIGH(9)
^
상수 뒤에 메서드나 프로퍼티가 이어지면 마지막 상수 뒤에 ; 를 둬야 합니다. 상수만 있는 enum class Level { LOW, MID, HIGH } 는 세미콜론이 필요 없습니다(공식 문서). 코틀린 코드에서 세미콜론을 반드시 써야 하는 드문 경우입니다.
없는 이름을 valueOf 에 넣으면
문자열로 상수를 찾는 valueOf 는 없는 이름에 어떻게 반응할까요. Level.valueOf("URGENT") 한 줄짜리 Bad.kt 를 앞의 Level 과 함께 돌렸습니다.
$ kotlinc Bad.kt -cp out -d bad
$ kotlin -cp out:bad BadKt
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant Level.URGENT
at java.base/java.lang.Enum.valueOf(Enum.java:293)
at Level.valueOf(Level.kt)
at BadKt.main(Bad.kt:2)
at BadKt.main(Bad.kt)
예외를 던진 곳이 자바의 java.lang.Enum.valueOf 입니다. 자바와 같은 IllegalArgumentException 이니, 요청 파라미터처럼 밖에서 온 문자열을 넣을 때는 자바에서처럼 잡거나 먼저 걸러야 합니다.
자바와 갈리는 곳
| 자바 | 코틀린 | |
|---|---|---|
| 선언 | enum Level |
enum class Level |
| 상수의 필드 | 몸체에 필드와 생성자를 따로 씀 | 머리에 (val code: Int) 한 줄 |
상수 목록 끝 ; |
뒤에 멤버가 있으면 필요 | 같다. 코틀린에서 드물게 필수인 세미콜론 |
| 상수 전부 | values() 배열 |
entries 리스트 (values() 도 있음) |
switch·when 식 |
상수를 전부 짚으면 default 생략 |
상수를 전부 짚으면 else 생략 |
마지막 줄은 KB 09 when — break 없는 switch 에서 실측한 내용입니다. 한 줄로 다시 풀면, enum 의 상수는 컴파일러가 전부 알고 있으니 전부 짚은 when 에는 else 가 필요 없고, 하나를 빠뜨리면 Add the 'BLUE' branch or an 'else' branch. 처럼 빠진 상수를 이름으로 짚은 에러가 납니다.
정리하면, 코틀린 enum 은 자바 enum 과 같은 물건에 문법만 짧아진 것입니다. 경우를 닫는다는 점에서는 0절의 sealed 와 같은 일을 합니다. 그럼 sealed 는 무엇이 다를까요.
2. sealed 클래스와 sealed 인터페이스 — 하위 타입 목록이 컴파일 때 닫힌다
0절에서 sealed 를 붙이자 구현 목록이 닫혔습니다. 그러면 누가 어디서 그 목록에 들어갈 수 있을까요. 이 절에서는 주문 결과를 뜻하는 sealed class Result 를 shop 패키지에 두고, 여러 파일에서 이 클래스를 물려받아 봅니다.
package shop
sealed class Result {
class Ok(val value: String) : Result()
class Fail(val reason: String) : Result()
}
sealed class Result— 이번에는 인터페이스가 아니라 클래스에sealed를 붙였습니다. 하위 클래스 둘을 몸체 안에 넣었는데, 바깥에 따로 둬도 됩니다: Result()— 클래스를 물려받을 때는 부모 이름 뒤에 생성자 호출 괄호를 붙입니다. 인터페이스를 구현할 때는 괄호가 없습니다- 코틀린 클래스는 기본이
final이라 보통은open을 붙여야 물려받을 수 있습니다(KB 14 상속 — 클래스는 기본이 final 이다).sealed클래스는open없이도 물려받을 수 있습니다
이 Result.kt 와 함께, 같은 shop 패키지의 다른 파일 Timeout.kt 에서 class Timeout(val millis: Long) : Result() 를 선언했습니다. 다른 패키지 other 의 Other.kt 에서는 class Retry : Result() 를 선언했습니다.
$ kotlinc Result.kt Timeout.kt -d out
$ kotlinc Result.kt Other.kt -d out
Other.kt:5:15: error: a class can only extend a sealed class or interface declared in the same package.
class Retry : Result()
^^^^^^
같은 패키지의 다른 파일은 아무 말 없이 통과했고, 다른 패키지는 막혔습니다. 하위 타입은 부모와 같은 패키지에 있어야 합니다. 파일이 같을 필요는 없습니다.
모듈이 달라도 막힌다
규칙이 하나 더 있습니다. 코틀린에서 모듈(module)은 한 번에 함께 컴파일되는 파일 묶음입니다. Gradle 로 치면 하위 프로젝트 하나, 여기서는 kotlinc 한 번이 그것입니다.
그래서 Result 를 컴파일해 둔 out 을 클래스 경로(-cp)로만 주고, 같은 shop 패키지에 class Later : Result() 를 적은 Lib2.kt 를 따로 컴파일했습니다. 라이브러리 jar 에 든 sealed 클래스를 내 코드에서 물려받으려는 상황과 같습니다.
$ kotlinc Lib2.kt -cp out -d lib2
Lib2.kt:3:15: error: extending sealed classes or interfaces from a different module is prohibited.
class Later : Result()
^^^^^^
패키지 이름을 맞춰도 모듈이 다르면 막힙니다. 같은 패키지, 같은 모듈 두 조건을 모두 채워야 하위 타입이 될 수 있습니다. 그러니 하위 타입 목록은 그 모듈을 컴파일하는 순간 정해지고, 밖에서는 늘릴 방법이 없습니다.
sealed 클래스 자신은 추상 클래스라서 Result() 로 직접 만들 수도 없습니다. 해 보면 cannot access 'constructor(): Result': it is protected in 'shop.Result'. 에러가 납니다. 생성자가 기본으로 protected 라 하위 클래스만 부를 수 있습니다.
클래스로 닫을까, 인터페이스로 닫을까
0절은 sealed interface, 이 절은 sealed class 를 썼습니다. 닫는 규칙은 같고, 고르는 기준은 자바에서 추상 클래스와 인터페이스를 고를 때와 같습니다.
sealed class |
sealed interface |
|
|---|---|---|
| 모든 하위 타입이 공유할 필드·생성자 | 둘 수 있다 | 없다 |
| 하위 타입이 다른 클래스도 물려받나 | 못 한다. 클래스는 부모가 하나 | 된다. 인터페이스는 여럿 구현 |
| 하위 타입 쪽 문법 | : Result() 괄호 있음 |
: Payment 괄호 없음 |
공유할 상태가 없다면 인터페이스 쪽이 하위 타입을 덜 묶습니다. 정리하면, sealed 는 「같은 패키지·같은 모듈 안에서 선언한 것만 하위 타입」이라는 규칙이고, 그래서 컴파일러가 목록 전체를 압니다.
3. 하위 타입을 하나 늘리면 어디가 깨지나
목록이 닫혀 있다는 것은 언제 값을 할까요. 목록을 바꿀 때입니다. 가게가 포인트 결제를 새로 받기로 했다고 합시다. 0절의 Payment 에 한 줄을 더합니다.
sealed interface Payment
class Card(val number: String) : Payment
class Cash(val amount: Int) : Payment
class Point(val amount: Int) : Payment
Payment 를 쓰는 코드는 이미 여러 파일에 흩어져 있습니다. 수수료를 계산하는 Fee.kt, 로그를 남기는 Log.kt, 화면 문구를 만드는 Label.kt 셋입니다. 셋 다 Point 가 생기기 전에 쓴 코드입니다.
// Fee.kt — when 을 식으로
fun fee(p: Payment): Int = when (p) {
is Card -> 300
is Cash -> 0
}
// Log.kt — when 을 문으로
fun log(p: Payment) {
when (p) {
is Card -> println("card")
is Cash -> println("cash")
}
}
// Label.kt — else 를 둔 when
fun label(p: Payment): String = when (p) {
is Card -> "card"
else -> "cash"
}
- 식(expression)은 값을 돌려주는 코드, 문(statement)은 값을 돌려주지 않고 일만 하는 코드입니다.
Log.kt의when은 결과를 아무 데도 쓰지 않으니 문입니다 Label.kt는 「카드가 아니면 현금」이라고 보고else로 줄였습니다. 결제 수단이 둘일 때는 틀린 코드가 아니었습니다
여기서 볼 것은 세 파일 중 어느 것이 에러를 내느냐입니다. 넷을 한 번에 컴파일했습니다.
$ kotlinc Payment.kt Fee.kt Log.kt Label.kt -d out
Fee.kt:1:28: error: 'when' expression must be exhaustive. Add the 'is Point' branch or an 'else' branch.
fun fee(p: Payment): Int = when (p) {
^^^^
Log.kt:2:5: error: 'when' expression must be exhaustive. Add the 'is Point' branch or an 'else' branch.
when (p) {
^^^^
에러가 둘입니다. Fee.kt 와 Log.kt 가 빠진 경우를 is Point 라는 이름으로 짚었습니다. 문으로 쓴 Log.kt 도 걸렸다는 점을 볼 것입니다. 괄호 안 값이 sealed 타입이면 코틀린은 문에서도 빠짐없이 짚기를 요구합니다.
Label.kt 는 아무 말도 듣지 못했습니다. else 가 「나머지는 전부 여기」라고 이미 답해 두었기 때문입니다. 그럼 포인트 결제가 이 함수에 들어오면 어떻게 될까요. 같은 Payment 와 label 에 main 을 붙인 Trap.kt 로 돌렸습니다.
fun main() {
println(label(Point(100))) // cash
}
$ kotlinc Trap.kt -d out
$ kotlin -cp out TrapKt
포인트 결제가 화면에 「현금」으로 찍혔습니다. 예외도 경고도 없습니다. 컴파일은 통과했고 실행도 멀쩡하니, 누가 화면을 보고 이상하다고 말하기 전까지 아무도 모릅니다.
flowchart TD
A["Payment 에 Point 를 더함"] --> B["kotlinc 로 다시 컴파일"]
B --> C["Fee.kt: else 없는 when 식"]
B --> D["Log.kt: else 없는 when 문"]
B --> E["Label.kt: else 있는 when"]
C --> F["에러: Add the 'is Point' branch"]
D --> F
E --> G["통과. 실행하면 cash"]
이것이 sealed 를 쓰는 까닭의 전부입니다. 하위 타입을 하나 늘리면, 손봐야 할 when 이 컴파일 에러 목록으로 나옵니다. 코드 검색으로 is Card 를 찾아다닐 필요가 없고, 빠뜨린 곳이 있으면 빌드가 멈춥니다.
그 목록에서 빠지는 것은 else 를 쓴 곳뿐입니다. 그래서 sealed 타입을 가르는 when 에는 else 를 쓰지 않는 편이 안전합니다. 경우가 많아 몇 개를 한데 묶고 싶다면 is Cash, is Point -> "cash" 처럼 이름을 늘어놓습니다. 그러면 새 타입이 생길 때 다시 에러로 불려 옵니다.
정리하면, sealed 의 값은 선언할 때가 아니라 경우를 늘릴 때 나옵니다. 그리고 else 한 줄이 그 검사를 조용히 끕니다.
4. enum 과 갈리는 기준 — 경우마다 다른 값을 드나
enum 도 sealed 도 경우를 닫고 else 없는 when 을 허락합니다. 그럼 결제 수단을 enum class Method { CARD, CASH } 로 두면 안 될까요. 이 절에서는 같은 결제를 두 방식으로 적어 무엇이 담기는지 견줍니다.
enum 의 상수는 상수마다 객체가 딱 하나입니다. Method.CARD 는 프로그램 전체에서 하나뿐이고, 1절의 code 처럼 필드를 줘도 선언할 때 정한 값으로 굳습니다. 그래서 「카드 결제 한 건의 카드 번호」처럼 건마다 다른 값은 담을 곳이 없습니다.
sealed 의 하위 타입은 보통 클래스라서 건마다 새 객체를 만들고, 타입마다 필드 모양도 다를 수 있습니다. 카드는 번호를, 현금은 금액을 듭니다.
enum class Method { CARD, CASH }
sealed interface Payment
data class Card(val number: String) : Payment
data class Cash(val amount: Int) : Payment
data object Unpaid : Payment
fun describe(p: Payment): String = when (p) {
is Card -> "card ${p.number.takeLast(4)}"
is Cash -> "cash ${p.amount}"
Unpaid -> "not paid"
}
fun main() {
println(Method.CARD) // CARD
// ↓ Card(number=1111-2222)
println(Card("1111-2222"))
// ↓ card 2222
println(describe(Card("1111-2222")))
// ↓ cash 5000
println(describe(Cash(5000)))
println(describe(Unpaid)) // not paid
}
data class—equals·hashCode·toString을 필드로 만들어 주는 클래스입니다.println하면Card(number=1111-2222)처럼 필드가 찍힙니다. 자세한 것은 뒤에 나올 data class 편에서 봅니다object— 클래스 선언과 동시에 객체를 딱 하나 만드는 선언입니다. 자바의 싱글턴을 한 줄로 쓴 것입니다. 앞에data를 붙인data object는 5절에서 봅니다Unpaid ->—is가 없습니다. 객체가 하나뿐이라 타입 검사 대신 그 객체와 같은지(==)로 가릅니다takeLast(4)— 문자열의 끝 네 글자를 돌려주는 코틀린 표준 라이브러리 함수입니다
$ kotlinc Method.kt -d out
$ kotlin -cp out MethodKt
Method.CARD 는 그저 CARD 입니다. 반면 Card 는 건마다 번호를 들고 다니고, describe 의 is Card 분기 안에서 스마트 캐스트로 p.number 를 바로 꺼냅니다. enum 으로 같은 일을 하려면 결제 한 건을 담을 클래스를 따로 만들어 method 와 number·amount 를 전부 널 가능한 필드로 늘어놓아야 합니다. 현금 결제에 카드 번호 칸이 비어 있는 모양이 됩니다.
반대로 sealed 가 못 하는 일도 있습니다. enum 은 entries 로 경우 전부를 돌 수 있고, valueOf("CARD") 로 문자열에서 찾을 수 있고, ordinal 로 순서가 있습니다. 설정 값이나 DB 칼럼처럼 이름 하나로 저장하고 다시 읽는 값에는 enum 이 맞습니다.
| 물을 것 | enum | sealed |
|---|---|---|
| 경우마다 다른 필드를 드나 | 못 든다. 상수마다 객체 하나 | 든다. 타입마다 모양이 달라도 됨 |
| 한 경우의 객체가 여럿인가 | 하나뿐 | 클래스면 여럿, object 면 하나 |
| 경우 전부를 돌 수 있나 | entries |
기본 문법으로는 없음 |
| 문자열 이름에서 찾기 | valueOf |
직접 짜야 함 |
else 없는 when |
된다 | 된다 |
경우마다 다른 값을 드는가가 가르는 질문입니다. 안 든다면 enum, 든다면 sealed 입니다. 요청 처리 결과(성공이면 데이터, 실패면 사유)나 상태 전이(대기 · 진행 중이면 진행률 · 끝남)처럼 경우마다 딸린 값이 다른 것이 sealed 의 전형입니다.
5. 자바 17 sealed 와 대조, 그리고 data object
자바도 17 에서 sealed 클래스를 정식 기능으로 들였습니다(JEP 409). 이 절에서는 0절의 결제를 자바 21 로 옮겨 두 언어가 목록을 닫는 방식을 견주고, 4절에서 넘긴 data object 를 봅니다.
// Payment.java
sealed interface Payment permits Card, Cash {}
// Card.java
record Card(String number) implements Payment {}
// Cash.java
final class Cash implements Payment {
final int amount;
Cash(int amount) { this.amount = amount; }
}
// Main.java
public class Main {
static String describe(Payment p) {
return switch (p) {
case Card c -> "card " + c.number();
case Cash c -> "cash " + c.amount;
};
}
public static void main(String[] args) {
System.out.println(describe(new Card("1234")));
System.out.println(describe(new Cash(5000)));
}
}
permits Card, Cash— 하위 타입 목록을 부모 쪽에 이름으로 적습니다record— 필드와 생성자·접근자·equals를 한 줄로 만드는 자바 16 의 레코드 클래스입니다. 레코드는 언제나final입니다case Card c ->— 타입이 맞으면c에 담아 쓰는 패턴 매칭switch입니다. 자바 21 에서 정식이 됐습니다(JEP 441).default없이 컴파일됐습니다
$ javac -d jout *.java
$ java -cp jout Main
card 1234
cash 5000
코틀린 0절과 같은 두 줄입니다. 자바도 목록이 닫혀 있으면 switch 식에 default 없이 빠짐없는지 검사합니다. 여기까지는 같습니다.
자바는 하위 타입 쪽에도 수식어를 요구한다
갈리는 곳을 보려고 두 가지를 바꿨습니다. Cash 에서 final 을 지우고, permits 에 없는 final class Point implements Payment {} 를 더했습니다.
$ javac -d jout *.java
Cash.java:1: error: sealed, non-sealed or final modifiers expected
class Cash implements Payment {
^
Point.java:1: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)
final class Point implements Payment {}
^
2 errors
자바의 하위 타입은 final(더 못 물려받음) · sealed(다시 닫음) · non-sealed(다시 엶) 중 하나를 반드시 골라야 합니다. 그리고 목록은 permits 에 적힌 이름이 전부입니다.
코틀린에서는 두 에러가 모두 나지 않습니다. 코틀린 클래스는 수식어가 없으면 이미 final 이고, 목록은 permits 없이 「같은 패키지·같은 모듈」로 정해집니다. 3절에서 Point 를 더할 때 Payment 쪽 목록을 고치지 않은 것도 그래서입니다.
| 자바 17+ | 코틀린 | |
|---|---|---|
| 목록을 정하는 법 | permits 에 이름을 적음. 생략하면 같은 파일 안의 하위 타입 |
같은 패키지 · 같은 모듈에 선언한 것 |
| 하위 타입의 수식어 | final·sealed·non-sealed 필수 |
없으면 final. 따로 안 씀 |
| 빠짐없는 분기 | 패턴 매칭 switch (자바 21) |
when |
| 새 하위 타입을 더하면 | permits 도 고침 |
선언 한 줄 |
data object — println 에 이름이 찍히는 object
4절의 Unpaid 는 object 앞에 data 를 붙였습니다. 붙이면 무엇이 달라지는지 둘을 나란히 찍었습니다.
object Plain
data object Tagged
fun main() {
println(Plain) // Plain@7591083d
println(Tagged) // Tagged
}
$ kotlinc Obj.kt -d out
$ kotlin -cp out ObjKt
그냥 object 는 자바 Object.toString() 을 물려받아 클래스이름@해시 를 찍습니다. @ 뒤 숫자는 실행할 때마다 달라질 수 있습니다. data object 는 이름만 찍습니다. 코틀린 1.9 에서 정식 기능이 됐습니다(What's new in 1.9.0).
sealed 계층에서 이 차이가 드러나는 곳은 로그입니다. Card(number=1111-2222) 옆에 Unpaid@7591083d 가 찍히는 것보다 Unpaid 가 찍히는 편이 읽기 좋습니다. 그래서 sealed 계층에 들어가는 object 에는 data 를 붙이는 것이 흔한 모양입니다.
정리하면, 자바와 코틀린의 sealed 는 「목록을 닫고 빠짐없는 분기를 검사한다」는 뼈대가 같습니다. 코틀린은 permits 와 하위 타입 수식어를 규칙으로 대신해 적을 것이 적습니다.
6. 성능 — 한 번 만들어 두기와 매번 새로 만들기
sealed 와 enum 자체에 따로 드는 비용은 없습니다. 알아 둘 것은 둘입니다. 상태 없는 경우를 무엇으로 선언하나, 그리고 enum 상수 전부를 무엇으로 꺼내나. 둘 다 객체가 새로 생기느냐의 문제라, 숫자 대신 ===(같은 객체인가를 보는 참조 비교, 자바의 ==)로 확인합니다.
enum class Level { LOW, MID, HIGH }
sealed interface State
object Idle : State
class Busy : State
fun main() {
println(Idle === Idle) // true
println(Busy() === Busy()) // false
// ↓ false
println(Level.values() === Level.values())
// ↓ true
println(Level.entries === Level.entries)
}
$ kotlinc Perf.kt -d out
$ kotlin -cp out PerfKt
상태가 없는 경우는 object 로
Idle 은 object 라 객체가 하나뿐이고, 몇 번을 꺼내도 같은 객체입니다. 이 객체는 처음 쓸 때 한 번 만들어지고, 그 초기화는 스레드 안전합니다(공식 문서). Busy 는 필드가 하나도 없는데 class 라서 Busy() 를 부를 때마다 새 객체가 생겼습니다.
필드가 없는 Busy 객체 둘은 담은 것이 똑같으니, 여러 개 만들 까닭이 없습니다. 만들어진 객체는 곧 버려지고 가비지 컬렉션이 치웁니다. 상태 전이가 요청마다, 반복문마다 일어나는 경로라면 이 쓰레기가 쌓입니다. 한 번 만들고 마는 경로라면 신경 쓸 필요가 없습니다.
성능보다 먼저 체감되는 것은 비교입니다. object 는 4절의 Unpaid -> 처럼 객체 자체로 가를 수 있고, == 로 견주면 언제나 같다고 나옵니다. 필드가 없는 경우는 object(로그를 생각하면 data object)로 둡니다.
values() 는 부를 때마다 배열을 복사한다
Level.values() === Level.values() 가 false 입니다. 부를 때마다 새 배열이 나왔다는 뜻입니다. 왜 매번 새로 만드는지는 배열의 성질에서 나옵니다. 받은 배열을 바꿔 봤습니다.
enum class Level { LOW, MID, HIGH }
fun main() {
val arr = Level.values()
arr[0] = Level.HIGH
// ↓ [HIGH, MID, HIGH]
println(arr.toList())
// ↓ [LOW, MID, HIGH]
println(Level.values().toList())
}
$ kotlinc Copy.kt -d c
$ kotlin -cp c CopyKt
받은 배열의 첫 칸을 HIGH 로 덮었는데, 다음 values() 는 멀쩡히 LOW 부터 돌려줬습니다. 배열은 칸을 바꿀 수 있는 자료라서, 원본을 그대로 내주면 받은 쪽이 enum 의 상수 목록을 망칠 수 있습니다. 그래서 values() 는 부를 때마다 원본을 복사해 새 배열을 줍니다. 자바의 values() 도 같은 이유로 매번 복사본을 돌려줍니다.
entries 는 읽기 전용 List 라 받은 쪽이 바꿀 방법이 없습니다. 그래서 복사할 까닭이 없고, 앞의 Perf.kt 에서처럼 부를 때마다 같은 객체가 나왔습니다. 코틀린 문서도 values() 는 부를 때마다 새 배열을 만들고 entries 는 같은 리스트를 돌려준다며 entries 를 권합니다(공식 문서). entries 는 코틀린 1.9 에서 정식이 됐습니다.
복사가 문제가 되는 조건은 자주 부르는 경로입니다. 요청마다 Level.values().first { it.code == code } 로 코드값에서 상수를 찾는 식이면 요청마다 배열이 하나씩 생깁니다. 시작할 때 한 번 부르는 코드라면 차이가 없습니다. 새로 쓰는 코드는 비용을 따질 것 없이 entries 로 쓰면 됩니다.
정리하면, 상태가 없는 경우는 object 로 한 번만 만들고, enum 상수 전부가 필요하면 values() 대신 entries 를 씁니다. 둘 다 코드를 거의 바꾸지 않고 얻는 것입니다.
7. 한 장 요약
enum과sealed는 경우의 수를 타입으로 닫아else없는when을 가능하게 한다. 경우마다 다른 값을 들면sealed, 아니면enum. sealed 에 하위 타입을 더하면 손볼when이 컴파일 에러로 나오는데,else를 쓴 곳만 그 목록에서 빠진다.
| 하는 일 | 자바 | 코틀린 | 확인한 방법 |
|---|---|---|---|
| 열거형 선언 | enum Level · 필드는 몸체에 |
enum class Level(val code: Int) · 멤버가 뒤따르면 ; |
실행 · kotlinc 에러 |
| 없는 이름 찾기 | IllegalArgumentException |
같음. java.lang.Enum.valueOf 가 던짐 |
실행 |
| 하위 타입 목록 닫기 | sealed ... permits A, B |
sealed class · sealed interface |
컴파일 |
| 하위 타입이 될 수 있는 곳 | permits 에 적은 것 |
같은 패키지 · 같은 모듈 | kotlinc 에러 둘 |
| 하위 타입 수식어 | final·sealed·non-sealed 필수 |
기본 final, 안 씀 |
javac 에러 |
| 하위 타입을 더했을 때 | 빠진 switch 가 에러 |
Add the 'is Point' branch 에러. 문도 걸림. else 쓴 곳은 통과 |
kotlinc 에러 · 실행 |
| 상태 없는 경우 | 싱글턴 클래스 | object · 로그용 이름은 data object |
실행 (===, toString) |
| 상수 전부 | values() 매번 복사 |
entries 같은 리스트 |
실행 (===, 배열 덮어쓰기) |
관련 항목
경우의 수를 타입으로 닫는 코틀린 선언
sealed class · sealed interface · enum class · 열거형 · object 선언 · data object · data class
sealed 계층을 가르는 분기 문법
when · 완전성 검사 · else 분기 · is 연산자 · 스마트 캐스트 · NoWhenBranchMatchedException
자바 쪽에서 같은 일을 하는 기능
자바 sealed 클래스 · permits · non-sealed · 패턴 매칭 switch · 레코드 · JEP 409 · JEP 441 · java.lang.Enum
enum 상수를 꺼내는 API
entries · values · valueOf · ordinal · IllegalArgumentException
sealed 의 하위 타입 범위를 정하는 단위
패키지 · 모듈 · 가시성 변경자 · protected · 추상 클래스 · 인터페이스
객체를 한 번만 만드는 것과 매번 만드는 것
싱글턴 패턴 · 참조 비교 · 방어적 복사 · 배열 · 읽기 전용 리스트 · 가비지 컬렉션