CodeGym /행동 /Kotlin SELF /`enum class`: 값, 파라미터, 메서드, 그리고 선언 규칙

`enum class`: 값, 파라미터, 메서드, 그리고 선언 규칙

Kotlin SELF
레벨 34, 레슨 0
사용 가능

1. 왜 enum class가 필요한가

예전에 if (category == "food") 같은 코드를 써 본 적이 있다면, 이미 “매직 문자열”이라는 고대의 악에 발을 들인 겁니다. 매직인 이유는, 오타 하나로 로직이 호박으로 변하기 때문이죠: "Food", "FOOD", "food ", "fod"는 사람 눈에는 거의 같아 보이지만, 프로그램에는 서로 다른 값입니다. 결국 컴파일러가 프로그램을 망가뜨리는 게 아니라, 실사용이 망가뜨립니다.

enum class는 컴파일러에게 이렇게 말하는 방법입니다. “허용되는 경우는 이 목록이 전부야. 다른 값은 있을 수 없어.” 그러면 Kotlin이 도와주기 시작합니다: 타입을 검사하고, IDE에서 가능한 값을 제안하고, 목록에 없는 값을 넣으려 하면 오류를 냅니다.

차이를 체감하기 위해 작은 표를 하나 만들어 봅시다:

접근 방식 무엇을 저장하나 무엇이 잘못될 수 있나
문자열
"FOOD", "food", "food (ko)"
오타, 대소문자 차이, 언어 혼용, 공백
enum class
Category.FOOD
거의 항상 사용자 입력 파싱 실수뿐(그리고 이는 해결 가능)

또 하나의 현실적인 예: 지출 카테고리. 카테고리를 문자열로 저장하면 언젠가 "Transport"와 "TRANSPORT"가 동시에 생기고, “카테고리별” 보고서가 갑자기 거의 같은 카테고리를 두 개로 보여 줄 겁니다. 버그가 아니라 “사용자 입력의 특성”이지만, 고생하는 건 여러분이죠.

최소한의 enum

“enum”이라고 하면 뭔가 무섭고 엔터프라이즈 같은 것을 떠올리곤 합니다. 하지만 기본 형태는 아주 단순합니다: 값을 쉼표로 나열하면 됩니다. 각 값(또는 enum entry, 즉 “열거형 항목”)은 enum 타입의 완성된 값입니다.

가장 최소 예시는 다음과 같습니다:

enum class Status { NEW, IN_PROGRESS, DONE }

fun main() {
    val s: Status = Status.DONE
    println(s) // DONE
}

여기서 Status.DONE은 문자열도, 숫자도, “그냥 텍스트”도 아닙니다. Status 타입의 값입니다. 그래서 Kotlin은 실수로 다른 것을 넣는 걸 허용하지 않습니다:

enum class Status { NEW, IN_PROGRESS, DONE }

fun main() {
    val s: Status = Status.DONE
    // s = "DONE" // 안 됨: String 타입이 맞지 않음
}

중요한 포인트: enum은 “값의 집합이 고정된 타입”입니다. 그래서 enum은 “가능한 값이 미리 정해져 있는” 곳에서 문자열을 훌륭하게 대체합니다.

name과 ordinal

enum을 println(s)로 출력하면 Kotlin은 보통 항목의 이름(DONE)을 출력합니다. 우연이 아닙니다: 각 enum 값에는 표준 프로퍼티 name과 ordinal이 있습니다. name은 상수의 문자열 이름이고, ordinal은 0부터 시작하는 선언 순서 인덱스입니다. 이는 Kotlin에서 enum이 가지는 표준 동작입니다.

확인해 봅시다:

enum class Status { NEW, IN_PROGRESS, DONE }

fun main() {
    println(Status.NEW.name)       // NEW
    println(Status.NEW.ordinal)    // 0
    println(Status.DONE.ordinal)   // 2
}

name은 보통 디버깅과 단순 출력에 유용합니다. 반면 ordinal은 매력적으로 들리지만(“오, 공짜 인덱스!”) 함정입니다.

ordinal을 파일에 “상태 코드”로 저장해 두었다가, 나중에 항목 순서를 바꾸면 어떻게 될까요?

enum class Status { NEW, DONE, IN_PROGRESS } // 순서 변경

끝입니다. 이제 DONE의 ordinal이 달라집니다. 즉, 예전 데이터가 잘못 해석됩니다. 그래서 선언 순서가 바뀔 수 있다면(대부분은 바뀔 수 있습니다. “더 예쁘게” 정리하고 싶어지니까요) ordinal을 안정적인 식별자로 보면 안 됩니다.

안정적인 식별자가 필요하면 enum의 파라미터로 명시적으로 두세요.

enum의 모든 항목: entries, values(), valueOf

곧바로 실용적인 질문이 나옵니다: “사용자에게 모든 카테고리 목록을 어떻게 보여 주지?” 또는 “모든 항목을 어떻게 순회하지?”. enum에는 이를 위해 (컴파일러가 자동 생성하는) “모든 값 얻기” 수단이 있습니다.

현대 Kotlin은 entries 프로퍼티를 제공하며, 이는 모든 항목의 특수한 리스트를 반환합니다.

예시:

enum class Category { FOOD, TRANSPORT, BOOKS }

fun main() {
    for (c in Category.entries) {
        println(c.name)
    }
    // FOOD
    // TRANSPORT
    // BOOKS
}

예전에는(그리고 인터넷의 오래된 예제에서는) values()를 보게 될 텐데, 이는 배열을 반환합니다. 하지만 지금은 entries가 더 낫다고 여겨집니다: 호출할 때마다 새 배열을 만들지 않고, 컬렉션처럼 보이기 때문입니다. Kotlin도 entries가 생기기 전에는 values()를 사용했다고 공식적으로 언급합니다.

또 하나 유용한 것은 valueOf("NAME")인데, 문자열 이름으로 enum을 반환합니다. 다만 성격이 있습니다: 알 수 없는 이름을 주면 IllegalArgumentException을 던집니다. 즉, valueOf는 “엄격한 파싱”이며, 데이터가 확실할 때 잘 맞습니다.

파라미터가 있는 Enum

여기까지의 enum은 단어 목록처럼 보였습니다. 하지만 실제 프로젝트에서는 각 항목에 “설명”, “코드”, “사람이 읽을 이름”, “한도” 등이 필요해지는 경우가 많습니다. 여기서 좋은 부분이 시작됩니다: enum은 생성자 파라미터를 가질 수 있으며, 거의 일반 클래스처럼 동작합니다.

지출 카테고리를 “사람 친화적으로” 만들어 봅시다: 한국어 이름과, 예를 들어 안정적인 코드를 부여합니다.

enum class Category(val code: Int, val title: String) {
    FOOD(10, "식비"),
    TRANSPORT(20, "교통"),
    BOOKS(30, "도서")
}

fun main() {
    println(Category.FOOD.title)   // 식비
    println(Category.FOOD.code)    // 10
}

이제 ordinal 대신 code가 있고, 이는 안정적입니다. 우리가 직접 지정했기 때문입니다. 항목 순서를 바꾸거나 새 항목을 추가해도 code는 저절로 바뀌지 않습니다. 카테고리를 외부로 저장(파일, DB, 네트워크 전송)할 계획이라면 아주 좋은 접근입니다.

그리고 title은 UI나 보고서에서 사용자에게 보여 줄 값입니다. TRANSPORT 같은 대문자 기술 이름을 그대로 출력하지 않아도 되죠(물론 “고함치는 회계사” 스타일이 좋다면 말리진 않겠습니다).

enum 내부의 메서드

데이터만으로 충분한 경우도 있지만, 때로는 각 enum 항목이 유용한 동작을 하길 원합니다. 이는 자연스러운 요구입니다: enum은 클래스이고, 각 항목은 그 클래스의 객체입니다. 따라서 enum에는 파라미터(code, title 등)를 사용하는 메서드를 둘 수 있습니다.

출력을 위한 라벨을 만드는 메서드를 추가해 봅시다:

enum class Category(val code: Int, val title: String) {
    FOOD(10, "식비"),
    TRANSPORT(20, "교통"),
    BOOKS(30, "도서");

    fun label(): String = "$title (#$code)"
}

fun main() {
    println(Category.TRANSPORT.label()) // 교통 (#20)
}

값 목록 뒤에 ;가 있는 것을 주목하세요. “그럴듯해 보이려고” 찍는 장식용 세미콜론이 아니라 문법 규칙입니다: enum에 본문(메서드, 프로퍼티 등)이 있으면 마지막 enum 항목 뒤에 구분자 ;가 필요합니다.

왜 그럴까요? 그렇지 않으면 파서가 상수 목록이 끝난 지점과 클래스 멤버가 시작되는 지점을 구분하기 어렵기 때문입니다. Kotlin은 컴파일 오류가 “우주 어딘가에서 뭔가 잘못됨” 같은 형태가 아니라 이해하기 쉽길 원합니다.

enum의 쉼표와 ;

실무에서 enum에서 가장 자주 아픈 부분은 아이디어나 로직이 아니라 “왜 컴파일이 안 되지? 메서드만 추가했는데”입니다. 보통 답은 “;를 빼먹어서”입니다.

규칙은 이렇게 외울 수 있습니다: 항목만 나열할 때는 쉼표로 구분된 “목록”입니다. 뭔가 더 추가하고 싶어지는 순간(메서드/프로퍼티) ;를 찍고, 그 다음은 “일반적인 클래스 본문”이 옵니다.

올바른 템플릿은 다음과 같습니다:

enum class Category(val title: String) {
    FOOD("식비"),
    TRANSPORT("교통"),
    BOOKS("도서");

    fun upperTitle(): String = title.uppercase()
}

그리고 같은 맥락의 작은 디테일 하나: enum 항목 사이의 쉼표는(마지막을 제외하고) 필수입니다. ;는 아래에 클래스 멤버가 있을 때 마지막 항목 뒤에 둡니다.

IntelliJ가 빨간색으로 표시하면서 “Expecting member declaration” 또는 “There must be a semicolon after the last enum entry” 같은 메시지를 보여 준다면, 거의 확실히 여기입니다.

2. 예시: 콘솔 지출 기록에서의 enum

이제 가장 유용한 일을 해 봅시다: enum을 “진공 상태”에서가 아니라 학습용 콘솔 애플리케이션에 적용해 봅니다. 이미 간단한 지출 모델(이전에는 data class로 만들었습니다)이 있고, 프로그램 어딘가에 지출 목록을 저장한다고 합시다. 예전에는 카테고리를 문자열로 뒀지만, 이제는 타입 안전성을 원합니다.

Category를 enum으로 도입

기본부터 시작합니다: 카테고리를 나열합니다. 사용자에게 편하도록 각 카테고리에 “사람이 읽을 이름”을 부여합니다.

enum class Category(val title: String) {
    FOOD("식비"),
    TRANSPORT("교통"),
    BOOKS("도서")
}

지출 모델 업데이트

이제 Expense는 category로 String이 아니라 Category를 저장합니다.

data class Expense(
    val amount: Int,
    val category: Category,
    val note: String
)

입력에서 카테고리를 안전하게 파싱

사용자는 food 또는 FOOD 같은 것을 입력할 수 있습니다. 하지만 Category.valueOf(...)는 정확한 이름을 요구하고, 예외를 던질 수도 있습니다.

그래서 “사용자 입력”에는 부드러운 파싱이 더 낫습니다: 문자열을 정규화하고 entries에서 찾아봅니다.

깔끔한 함수는 다음과 같습니다:

fun parseCategoryOrNull(raw: String): Category? {
    val key = raw.trim().uppercase()
    return Category.entries.firstOrNull { it.name == key }
}

여기서 entries는 모든 enum 항목 목록을 제공합니다. 사용자가 알 수 없는 값을 입력하면 null이 반환되고, 이후 입력 로직에서(크래시 없이) 처리할 수 있습니다.

이 과정을 작은 흐름도로 보면 다음과 같습니다:

flowchart TD
    A["사용자가 문자열을 입력함"] --> B["trim()"]
    B --> C["uppercase()"]
    C --> D["Category.entries에서 name으로 검색"]
    D -->|찾음| E["Category 반환"]
    D -->|못 찾음| F["null 반환"]

지출 추가: 읽기 → 파싱 → 객체 생성

지출을 추가하는 작은 함수를 만들어 봅시다. 포맷은 단순하게: 금액, 카테고리, 메모(메모는 나머지 문자열)로 합니다.

fun addExpense(rawAmount: String, rawCategory: String, note: String): Expense? {
    val amount = rawAmount.trim().toIntOrNull() ?: return null
    val category = parseCategoryOrNull(rawCategory) ?: return null
    return Expense(amount = amount, category = category, note = note.trim())
}

미니 예제로 테스트해 봅시다:

fun main() {
    val e = addExpense("120", "food", "점심") ?: return
    println(e) // Expense(amount=120, category=FOOD, note=점심)
}

현재 category 출력은 FOOD일 텐데, 이는 name이기 때문입니다. 하지만 사용자에게는 title을 보여 줄 겁니다:

fun main() {
    val e = addExpense("120", "food", "점심") ?: return
    println("${e.amount} ₣  — ${e.category.title} (${e.note})")
    // 120 ₣  — 식비 (점심)
}

사용자 안내: 어떤 카테고리가 있는지

enum이 있으니 카테고리 목록을 쉽게 출력할 수 있습니다:

fun printCategories() {
    val text = Category.entries.joinToString { it.name.lowercase() }
    println("카테고리: $text")
    // 카테고리: food, transport, books
}

여기서도 entries는 모든 항목에 대한 “단일 진실 공급원(source of truth)” 역할을 합니다.

이 접근의 장점은, 예를 들어 HEALTH("건강") 카테고리를 추가하면 안내 문구가 자동으로 업데이트된다는 점입니다(어딘가에 문자열 목록을 손으로 적어 둔 곳을 찾아서 수정할 필요가 없습니다).

3. enum class 사용 시 흔한 실수

오류 1: “그게 더 빠르니까”라는 이유로 계속 문자열로 저장하기.
String이 더 쉬워 보일 때가 있습니다: 읽고, 비교하고, 끝. 하지만 보고서/필터링/통계가 생기고, 공백이 붙은 food 를 입력하는 사용자라도 한 명 등장하는 순간, 여러 곳에서 “문자열 정규화”에 시간을 쓰게 됩니다. enum은 이 문제를 한 곳(입력 파싱)으로 옮깁니다. 그리고 프로그램 내부의 값들은 엄격하고 일관되게 유지됩니다.

오류 2: ordinal을 안정적인 식별자로 사용하기.
ordinal은 선언 순서에 의존합니다. 오늘 FOOD는 ordinal = 0인데, 내일 항목을 재배치하면 조용히(경고 없이) 망가집니다. ordinal은 아주 로컬한 용도(예: “지금 이 순간 리스트에서의 위치 출력”)에는 쓸 수 있지만, 저장이나 프로토콜에는 적합하지 않습니다. 안정적인 코드가 필요하면 code 같은 필드를 추가하세요.

오류 3: enum의 메서드/프로퍼티 앞에서 ;를 빼먹기.
전형적인 상황입니다: 항목을 선언한 다음 메서드를 추가했더니 컴파일러가 갑자기 엉뚱한 곳에서 불평합니다. 이유는 간단합니다: enum에 클래스 멤버가 있으면, 값 목록은 반드시 ;로 분리되어야 합니다. 이는 Kotlin의 enum 규칙이며 실제로 필수입니다.

오류 4: 보호 없이 사용자 입력을 valueOf()로 파싱하기.
valueOf("FOOD")는 편하지만 문자열이 일치하지 않으면 IllegalArgumentException을 던집니다. “외부 데이터”(사용자 입력)에는 보통 안전한 파싱이 필요합니다: entries.firstOrNull { ... }처럼 사용해 null을 얻고, 예외로 죽는 대신 이해 가능한 메시지를 보여 주세요.

오류 5: “사람이 읽을 이름”과 enum 항목의 name을 섞어 쓰기.
name은 상수의 기술적 이름(FOOD)입니다. 코드와 디버깅에는 좋지만, 사용자에게는 친절하지 않은 경우가 많습니다. 보기 좋은 출력을 원하면 title: String 같은 필드를 추가하고 그것을 표시하세요. 그러면 UI를 기술 이름에 묶지 않게 되고, 표시만 바꾸고 로직은 유지할 수 있습니다.

1
과제
Kotlin SELF, 레벨 34, 레슨 0
잠금
진행 현황판
진행 현황판
1
과제
Kotlin SELF, 레벨 34, 레슨 0
잠금
봇의 기분
봇의 기분
1
과제
Kotlin SELF, 레벨 34, 레슨 0
잠금
음료 자판기
음료 자판기
1
과제
Kotlin SELF, 레벨 34, 레슨 0
잠금
실행 모드
실행 모드
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION