본문 바로가기

enum으로 정해진 값만 갖는 타입 만들기

BASIC, GOLD, DIAMOND 세 값만 가질 수 있는 등급 타입을 클래스로 직접 만들어 본 뒤, 같은 일을 언어가 맡아 주는 enum의 선언, 필드와 메서드, values·valueOf·ordinal, == 비교와 switch 사용법을 익힙니다.

앞 글에서는 등급을 문자열로 다루면 생기는 문제를 확인했습니다. 상수를 만들고 입력을 검사해도 매개변수 타입이 String인 한 컴파일러는 아무 문자열이나 통과시켰고, 그래서 등급을 쓰는 메서드마다 잘못된 값을 막는 default 분기가 필요했습니다. 정말로 필요한 것은 BASIC, GOLD, DIAMOND 세 값만 가질 수 있는 타입입니다. 매개변수가 그런 타입이라면 "SILVER"나 "DIAMONDD"는 아예 넘길 수 없습니다.

이 글에서는 먼저 그런 타입을 평범한 클래스로 직접 만들어 보면서 어떤 장치가 필요한지 확인합니다. 그다음 자바가 같은 일을 언어 차원에서 해 주는 열거형(enum type)을 배우고, enum에 데이터와 계산을 넣는 방법, 자동으로 생기는 메서드들, 비교와 switch에서의 사용법을 차례로 살펴봅니다.

클래스로 등급 타입 직접 만들기

세 값만 갖는 타입을 만들려면 두 가지가 필요합니다. 하나는 허용하는 세 객체를 미리 만들어 두는 것이고, 다른 하나는 그 밖의 객체를 아무도 만들지 못하게 막는 것입니다. 다음 ClassGrade는 세 등급 객체를 static final 필드로 만들어 두고, 생성자를 private으로 감췄습니다. 각 객체는 자기 이름과 할인율을 필드로 가집니다.

src/TypeSafeGradePattern.java
public final class TypeSafeGradePattern {
    public static void main(String[] args) {
        System.out.println(discount(ClassGrade.BASIC, 10_000));
        System.out.println(discount(ClassGrade.GOLD, 10_000));
        System.out.println(discount(ClassGrade.DIAMOND, 10_000));
    }

    private static int discount(ClassGrade grade, int price) {
        return price * grade.percent() / 100;
    }

    private static final class ClassGrade {
        static final ClassGrade BASIC = new ClassGrade("BASIC", 10);
        static final ClassGrade GOLD = new ClassGrade("GOLD", 20);
        static final ClassGrade DIAMOND = new ClassGrade("DIAMOND", 30);

        private final String name;
        private final int percent;

        private ClassGrade(String name, int percent) {
            this.name = name;
            this.percent = percent;
        }

        int percent() { return percent; }
        @Override public String toString() { return name; }
    }
}
1000
2000
3000

discount의 매개변수 타입이 이제 ClassGrade입니다. 그래서 discount("GOLD", 10_000)처럼 문자열을 넘기면 타입이 맞지 않아 컴파일 오류가 납니다. 넘길 수 있는 것은 ClassGrade 객체뿐이고, 생성자가 private이라 바깥에서는 새 객체를 만들 수 없으니, 쓸 수 있는 값은 미리 만들어 둔 ClassGrade.BASIC, GOLD, DIAMOND 셋뿐입니다. 할인율도 각 객체가 가지고 있으므로 discount 안에는 문자열을 비교하는 switch도, 잘못된 값을 막는 default도 필요 없습니다. 출력은 10,000원에 각 객체의 할인율 10%, 20%, 30%를 적용한 할인액입니다.

이렇게 private 생성자와 미리 만든 static final 객체로 값의 종류를 고정하는 방법을 타입 안전 열거 패턴(typesafe enum pattern)이라고 부릅니다. 자바 5에 enum이 생기기 전에는 이 패턴을 직접 작성했습니다. 이 패턴의 핵심은 private 생성자입니다. 생성자가 private이 아니라면 누구든 new ClassGrade("SILVER", 15)로 네 번째 등급을 만들 수 있고, 세 값으로 닫아 둔 의미가 사라집니다.

다만 private이 완벽한 자물쇠는 아닙니다. private 멤버는 같은 최상위 클래스 안의 코드라면 접근할 수 있으므로, 이 예제처럼 ClassGrade를 다른 클래스 안에 중첩해서 선언하면 바깥 클래스 TypeSafeGradePattern의 코드는 new ClassGrade("SILVER", 15)를 호출할 수 있습니다. 실수만 하지 않으면 문제가 없겠지만, 그것을 막아 주는 것은 결국 사람의 주의입니다.

직접 만든 패턴에는 손이 가는 일도 많습니다. 모든 등급을 차례로 훑고 싶다면 등급 목록을 담은 배열을 직접 관리해야 하고, "GOLD"라는 이름으로 ClassGrade.GOLD를 찾는 메서드도 직접 짜야 합니다. 객체를 파일에 저장했다가 다시 읽는 직렬화(serialization)를 쓰면, 읽어 들일 때 GOLD와 내용만 같은 새 객체가 만들어져서 '등급마다 객체는 하나'라는 전제가 깨지기도 합니다. enum은 이런 일을 모두 언어가 대신 처리합니다.

enum 선언하고 사용하기

같은 등급 타입을 enum으로 선언하면 다음 한 줄이면 됩니다. 앞의 ClassGrade에서 직접 적었던 static final 필드 세 개와 private 생성자를 이 한 줄이 대신합니다. 할인율 같은 값은 아직 없는데, 상수마다 값을 붙이는 방법은 뒤에서 봅니다.

enum Grade { BASIC, GOLD, DIAMOND }

class 대신 enum이라고 쓰고, 중괄호 안에 허용할 값의 이름을 쉼표로 나열합니다. 이 이름들을 열거 상수(enum constant)라고 하며, 상수이므로 관례대로 대문자로 씁니다. 이것만으로 Grade 타입의 객체는 Grade.BASIC, Grade.GOLD, Grade.DIAMOND 세 개로 정해지고, 다른 객체는 만들 수 없게 됩니다. enum도 클래스의 한 종류라서, 이 세 상수는 각각 Grade 타입의 객체입니다.

정말로 네 번째 객체를 만들 수 없는지 확인해 보겠습니다.

lab/EnumInstantiationFailure.java
public final class EnumInstantiationFailure {
    public static void main(String[] args) {
        Grade unknown = new Grade();
        System.out.println(unknown);
    }

    private enum Grade {
        BASIC, GOLD, DIAMOND
    }
}
컴파일 오류(JDK 21)
EnumInstantiationFailure.java:3: error: enum classes may not be instantiated
        Grade unknown = new Grade();
                        ^
1 error

new Grade()는 컴파일 단계에서 거부됩니다. 오류 메시지는 '열거형은 인스턴스를 만들 수 없다'는 뜻입니다. JDK 버전에 따라 enum types may not be instantiated처럼 문구가 조금 다를 수 있지만 뜻은 같습니다. 앞의 ClassGrade와 달리, 이 Grade는 EnumInstantiationFailure 안에 중첩되어 있는데도 바깥 클래스에서 new를 쓸 수 없습니다. enum 자기 자신의 코드 안에서도 마찬가지입니다. enum의 객체는 선언에 나열한 상수뿐이라는 것을 컴파일러가 직접 지켜 줍니다.

따라서 매개변수 타입이 Grade인 메서드에 들어올 수 있는 값은 Grade.BASIC, Grade.GOLD, Grade.DIAMOND, 그리고 null뿐입니다. Grade는 참조 타입이므로 null까지는 막지 못한다는 점에 주의하세요. 이 문제는 뒤에서 switch와 함께 다시 봅니다.

상수마다 데이터와 계산 넣기

enum은 상수 이름만 나열하는 데서 그치지 않습니다. 클래스처럼 필드, 생성자, 메서드를 가질 수 있어서, 앞의 ClassGrade처럼 등급마다 할인율을 넣을 수 있습니다.

src/EnumGradeDiscount.java
public final class EnumGradeDiscount {
    public static void main(String[] args) {
        int price = 10_000;
        for (Grade grade : Grade.values()) {
            System.out.println(grade.name() + "=" + grade.discount(price));
        }
    }

    private enum Grade {
        BASIC(10), GOLD(20), DIAMOND(30);

        private final int percent;

        Grade(int percent) {
            this.percent = percent;
        }

        int discount(int price) {
            if (price < 0) throw new IllegalArgumentException("price");
            return price * percent / 100;
        }
    }
}
BASIC=1000
GOLD=2000
DIAMOND=3000

BASIC(10)처럼 상수 이름 뒤에 괄호로 값을 쓰면, 그 값이 생성자 Grade(int percent)에 인수로 전달됩니다. 즉 클래스가 처음 쓰일 때 자바가 BASIC은 할인율 10으로, GOLD는 20으로, DIAMOND는 30으로 생성자를 한 번씩 호출해 세 객체를 만들어 둡니다. 상수 목록이 끝나는 곳에는 세미콜론을 찍고 그 뒤에 필드와 메서드를 씁니다. enum의 생성자에는 public을 붙일 수 없습니다. 접근 제한자를 생략해도 자동으로 private이 되기 때문에, 이 생성자는 상수를 만들 때 자바만 호출할 수 있습니다.

main은 Grade.values()로 모든 상수를 선언 순서대로 받아 반복하면서, 상수마다 discount를 호출합니다. 할인 계산이 Grade 안에 들어 있으므로 할인을 계산하는 쪽에서는 등급별로 if나 switch를 쓸 필요가 없습니다. 새 등급을 추가할 때도 상수 한 줄에 할인율을 함께 적으면 되므로, 등급의 데이터와 동작이 한곳에 모입니다. 출력의 BASIC, GOLD 같은 이름은 name() 메서드가 돌려준 선언 이름입니다. 이 메서드는 다음 절에서 자세히 봅니다.

이 예제의 세 등급은 할인율만 다를 뿐 계산 공식은 같습니다. 이렇게 공식이 같다면 값의 차이를 필드에 담고 메서드 하나를 함께 쓰는 지금 방식이 가장 단순합니다. 상수마다 계산 방식 자체가 완전히 다르다면 상수별로 메서드를 따로 구현하는 방법도 있지만, 선언이 길고 복잡해지므로 필드만으로 표현할 수 없을 때 고려합니다.

더 깊이: 선택지를 닫아도 계산의 범위는 따로 확인해야 합니다

등급을 세 개로 닫았다고 해서 계산까지 안전해지는 것은 아닙니다. discount는 음수 가격을 거부하지만, 아주 큰 가격은 그대로 통과시킵니다. price가 2000000000(20억)이면 DIAMOND의 할인액은 6억이어야 하는데, 실제로 계산하면 -1295421이 나옵니다. price * percent의 결과 600억이 int의 최댓값 약 21억을 넘어 오버플로가 일어나고, 그렇게 망가진 값을 100으로 나눴기 때문입니다. GOLD도 4억이어야 할 할인액이 13452943으로 나옵니다.

최종 할인액은 int에 넉넉히 들어가지만, 계산 도중의 곱셈 결과가 들어가지 않는 것이 문제입니다. 이 가격 범위까지 지원하려면 Grade 안에 다음과 같은 메서드를 두고 곱셈을 long으로 하면 됩니다.

Grade 내부의 큰 가격 계산 보완
int discountSafe(int price) {
    if (price < 0) throw new IllegalArgumentException("price");
    return (int) ((long) price * percent / 100);
}

(long)을 곱셈의 결과가 아니라 첫 피연산자 price에 붙였다는 점이 중요합니다. 그래야 곱셈 자체가 long으로 계산됩니다. (long) (price * percent)처럼 쓰면 이미 int로 계산되어 넘쳐 버린 값을 long으로 바꾸는 것이라 아무 소용이 없습니다. 마지막의 (int) 변환은 안전합니다. 할인율이 100 이하이므로 할인액은 가격보다 클 수 없고, 가격은 int였기 때문입니다. 이렇게 고치면 20억 원의 할인액은 GOLD가 400000000, DIAMOND가 600000000으로 바르게 나오고, 10000원처럼 작은 가격에서는 두 메서드의 결과가 같습니다.

enum이 자동으로 갖는 메서드

모든 enum은 자동으로 java.lang.Enum 클래스를 상속하고, 컴파일러가 몇 가지 메서드를 더 만들어 넣습니다. 그중 자주 쓰는 것을 확인해 보겠습니다.

src/EnumCoreMethods.java
import java.util.Arrays;

public final class EnumCoreMethods {
    public static void main(String[] args) {
        Grade[] grades = Grade.values();
        System.out.println("values=" + Arrays.toString(grades));
        System.out.println("parsed=" + Grade.valueOf("GOLD"));
        for (Grade grade : grades) {
            System.out.println(grade.name() + ":" + grade.ordinal());
        }
    }

    private enum Grade {
        BASIC, GOLD, DIAMOND
    }
}
values=[BASIC, GOLD, DIAMOND]
parsed=GOLD
BASIC:0
GOLD:1
DIAMOND:2

values()는 모든 상수를 선언한 순서대로 담은 배열을 돌려줍니다. 호출할 때마다 새 배열을 만들어 주므로 받은 배열을 고쳐도 enum에는 영향이 없지만, 배열에 담긴 것은 새 객체가 아니라 원래의 세 상수입니다. valueOf("GOLD")는 이름이 정확히 "GOLD"인 상수, 즉 Grade.GOLD를 찾아 돌려줍니다. 이름이 대소문자까지 정확히 일치해야 해서 Grade.valueOf("gold")는 IllegalArgumentException을 던지고, 메시지는 No enum constant EnumCoreMethods.Grade.gold입니다. 인수가 null이면 NullPointerException이 납니다.

name()은 상수를 선언할 때 쓴 이름을 문자열로 돌려줍니다. 반복문 출력의 BASIC, GOLD, DIAMOND가 그것입니다. toString()도 기본적으로 같은 이름을 돌려주므로 첫 줄과 둘째 줄처럼 상수를 그대로 출력하면 이름이 나옵니다. 둘의 차이는 name()은 재정의할 수 없고 toString()은 재정의할 수 있다는 데 있습니다. 그래서 언제나 선언 이름이 필요하면 name()을 쓰고, 출력 형식을 바꾸고 싶으면 toString()을 재정의합니다.

ordinal()은 상수가 선언된 위치를 0부터 센 번호입니다. BASIC이 0, GOLD가 1, DIAMOND가 2입니다. 그런데 이 번호는 선언 순서에서 나온 것이라서, 누군가 BASIC과 GOLD 사이에 SILVER를 끼워 넣으면 GOLD는 2가 되고 DIAMOND는 3이 됩니다. 만약 데이터베이스에 등급을 ordinal() 값으로 저장해 두었다면, 이 수정 하나로 기존 GOLD 회원이 모두 SILVER로 읽히게 됩니다. 그래서 ordinal()을 저장하거나, 네트워크로 주고받거나, 업무 규칙의 우선순위로 쓰면 안 됩니다. 바뀌지 않는 번호나 코드가 필요하다면 앞의 할인율처럼 필드로 직접 넣어 두는 편이 안전합니다.

같은 이유로 화면에 보여 줄 문구도 name()에 기대지 않는 것이 좋습니다. 선언 이름은 코드를 위한 식별자라서 DIAMOND를 '다이아몬드 회원'으로 보여 주고 싶다면 설명을 담은 필드를 따로 두어야 합니다. 이 방법은 연습 문제에서 직접 해 봅니다.

클래스 패턴과 enum의 생성·열거 계약

클래스 패턴은 private 생성자와 미리 만든 인스턴스를 사용하지만 언어 enum은 new를 금지하고 values와 valueOf를 제공한다. 둘 모두 null 참조는 별도 확인이 필요하다.

enum은 상수 생성과 열거를 언어 차원에서 보장합니다

같은 등급 집합을 표현해도, 생성 통제와 기본 연산을 누가 보장하는지가 다릅니다.

enum은 상수 생성과 열거를 언어 차원에서 보장합니다 직접 클래스의 private생성자는 같은 바깥에서 접근 가능,언어enum은 명시new금지. 세 상수·생성 주체·null계약을 비교하며 영역 간 실행 연결 없음. 직접 클래스 패턴 private 생성자 같은 바깥 클래스는 접근 가능 공개한 상수 BASIC / GOLD / DIAMOND 언어 enum 세 상수 선언 언어가 생성 명시적 new 금지 enum의 컴파일 규칙 타입만으로 null 참조를 금지하지 않음
enum은 상수 생성과 열거를 언어 차원에서 보장합니다 직접 클래스의 private생성자는 같은 바깥에서 접근 가능,언어enum은 명시new금지. 세 상수·생성 주체·null계약을 비교하며 영역 간 실행 연결 없음. 클래스 패턴 private 생성자 같은 바깥 클래스 내부에서는 접근 가능 공개한 상수 BASIC / GOLD DIAMOND 언어 enum 세 상수의 선언 언어가 객체 생성 명시적 new 금지 바깥 클래스 안에서도 불가 비교 영역은 독립 null은 별도로 확인
클래스 패턴과 enum의 생성·열거 계약
계약직접 클래스 패턴언어 enum
인스턴스 생성private 생성자와 미리 만든 static final 인스턴스상수 선언으로 생성하며 new Grade()는 컴파일 실패
전체 순회·이름 조회필요하면 직접 구현values() · valueOf(name) 제공
할인 계산 위치이 예제는 외부 discount가 percent() 조회이 예제는 Grade.discount(price)가 필드와 공식 사용
없는 참조null을 타입만으로 막지 않음null을 타입만으로 막지 않음
인스턴스 생성

직접 클래스 패턴: private 생성자와 미리 만든 static final 인스턴스

언어 enum: 상수 선언으로 생성하며 new Grade()는 컴파일 실패

전체 순회·이름 조회

직접 클래스 패턴: 필요하면 직접 구현

언어 enum: values() · valueOf(name) 제공

할인 계산 위치

직접 클래스 패턴: 이 예제는 외부 discount가 percent() 조회

언어 enum: 이 예제는 Grade.discount(price)가 필드와 공식 사용

없는 참조

직접 클래스 패턴: null을 타입만으로 막지 않음

언어 enum: null을 타입만으로 막지 않음

접근 제한과 상수 집합의 언어 보장은 다릅니다. 이 예제의 중첩 ClassGrade 생성자는 같은 최상위 클래스 안에서 접근할 수 있지만, enum은 그 안에서도 new로 만들 수 없습니다.

enum은 ==로 비교하고 switch로 나눕니다

래퍼 클래스나 String은 ==로 비교하면 안 되었습니다. 값이 같아도 서로 다른 객체일 수 있기 때문입니다. enum은 다릅니다. 상수마다 객체가 정확히 하나씩만 존재하므로, 같은 상수를 가리키는 참조는 언제나 같은 객체를 가리킵니다. 그래서 enum은 ==로 비교해도 됩니다. ==는 한쪽이 null이어도 예외 없이 false를 돌려준다는 장점도 있습니다. 반면 grade.equals(Grade.GOLD)는 grade가 null이면 NullPointerException이 납니다.

enum은 switch와도 잘 어울립니다. 다음 코드는 게시글의 우선순위에 따라 처리 시점을 정합니다.

src/EnumBoardPriority.java
public final class EnumBoardPriority {
    public static void main(String[] args) {
        System.out.println(label(Priority.LOW));
        System.out.println(label(Priority.NORMAL));
        System.out.println(label(Priority.HIGH));
    }

    private static String label(Priority priority) {
        return switch (priority) {
            case LOW -> "later";
            case NORMAL -> "today";
            case HIGH -> "now";
        };
    }

    private enum Priority { LOW, NORMAL, HIGH }
}
later
today
now

case 뒤에는 Priority.LOW처럼 타입 이름을 붙이지 않고 LOW처럼 상수 이름만 써도 됩니다. switch의 대상이 Priority라는 것을 컴파일러가 알고 있기 때문입니다. Java 21부터는 case Priority.LOW처럼 타입 이름을 붙여 써도 되지만, 그 전 버전에서는 컴파일 오류였기 때문에 지금도 상수 이름만 쓰는 코드가 대부분입니다. 이 switch 식에는 default가 없다는 점을 눈여겨보세요. 앞 글의 문자열 switch 식은 모든 문자열을 다룰 수 없으니 default가 반드시 있어야 했습니다. 하지만 Priority의 상수는 세 개뿐이고 세 case가 그것을 모두 다루므로, 컴파일러는 이 switch 식이 빠짐없이 값을 낸다고 판단합니다.

이 성질은 enum에 상수를 추가할 때 진가를 발휘합니다. Priority에 URGENT를 추가하면, 위의 label 메서드는 the switch expression does not cover all possible input values라는 컴파일 오류를 냅니다. 새 상수를 처리하지 않은 switch 식을 컴파일러가 모두 찾아 주는 것입니다. 만약 습관적으로 default를 넣어 두었다면 컴파일러는 아무 말도 하지 않고, URGENT는 조용히 default의 처리를 받게 됩니다. 그래서 enum의 모든 상수를 다루는 switch 식에는 default를 넣지 않는 편이 좋습니다. 단, 이 검사는 값을 내는 switch 식에만 적용되고, 화살표를 쓰더라도 값을 내지 않는 switch 문은 빠진 상수가 있어도 컴파일됩니다.

그런데 이 switch 식에 null을 넘기면 어떻게 될까요? case를 고르기도 전에 NullPointerException이 납니다. JDK 21에서 실행하면 메시지는 Cannot invoke "EnumBoardPriority$Priority.ordinal()" because "<parameter1>" is null입니다. 컴파일러가 enum의 switch를 상수의 ordinal() 값으로 나누도록 바꿔 두었기 때문에, null에서 그 메서드를 부르다 실패한 것입니다. 세 상수를 모두 다뤘다는 컴파일러의 판단은 null까지 포함하지 않습니다.

그러니 null을 어떻게 볼지 먼저 정해야 합니다. null이 잘못된 입력이라면 값이 들어오는 곳에서 바로 거부합니다. '아직 정하지 않음'이 실제로 있는 상태라면 UNSET 같은 상수를 추가해 그 상태에 이름을 붙이는 방법이 있습니다. Java 21부터는 switch에 case null -> "unset";처럼 null을 다루는 case를 직접 쓸 수도 있습니다. 다만 상수를 추가하는 방법을 쓸 때, 원인을 알 수 없는 모든 값을 UNKNOWN 같은 상수 하나에 몰아넣지는 않도록 주의합니다. 그렇게 하면 앞 글에서 본 'default에서 0% 할인' 버그를 다른 모양으로 되풀이하게 됩니다.

enum 메서드의 결과와 안정성 경계

values와 valueOf는 Grade 상수를 다루며 name은 선언 이름, ordinal은 선언 위치다. ==는 같은 enum 타입의 상수를 비교할 수 있지만 null을 받는 switch의 안전성까지 보장하지 않는다.

enum의 이름과 선언 위치는 서로 다른 계약입니다

이 장의 Grade 선언은 BASIC, GOLD, DIAMOND 순서입니다.

enum의 이름과 선언 위치는 서로 다른 계약입니다 values의 새 배열 슬롯0/1/2는 기존 BASIC/GOLD/DIAMOND를 참조. valueOfGOLD도 기존 GOLD를 반환하며 ordinal0/1/2는 선언 위치. values()의 새 배열 [0] 참조 BASIC ordinal0 [1] 참조 GOLD ordinal1 [2] 참조 DIAMOND ordinal2 valueOf("GOLD") 기존 GOLD를 반환 새 배열과 기존 enum 구별 · ordinal은 선언 위치
enum의 이름과 선언 위치는 서로 다른 계약입니다 values의 새 배열 슬롯0/1/2는 기존 BASIC/GOLD/DIAMOND를 참조. valueOfGOLD도 기존 GOLD를 반환하며 ordinal0/1/2는 선언 위치. values()의 새 배열 [0] 참조 BASIC ordinal0 [1] 참조 GOLD ordinal1 [2] 참조 DIAMOND ordinal2 valueOf("GOLD") 기존 GOLD를 반환 화살표: 기존 상수 참조 ordinal은 선언 위치 영구 저장용 코드가 아님
enum 메서드의 결과와 안정성 경계
연산이 선언에서 얻는 결과변경·실패 경계
values()세 상수를 선언 순서로 담은 배열배열은 새로 반환하지만 상수는 새로 생성하지 않음
valueOf("GOLD")Grade.GOLD이름이 없으면 IllegalArgumentException; null 이름은 NullPointerException
name()

toString()

기본적으로 선언 이름name은 재정의 불가; toString은 재정의 가능
ordinal()BASIC:0

GOLD:1

DIAMOND:2

선언 순서가 바뀌면 달라져 영구 코드로 쓰지 않음
==같은 enum 타입의 상수 동일성 비교null == Grade.GOLD는 false
values()

이 선언에서 얻는 결과: 세 상수를 선언 순서로 담은 배열

변경·실패 경계: 배열은 새로 반환하지만 상수는 새로 생성하지 않음

valueOf("GOLD")

이 선언에서 얻는 결과: Grade.GOLD

변경·실패 경계: 이름이 없으면 IllegalArgumentException; null 이름은 NullPointerException

name() / toString()

이 선언에서 얻는 결과: 기본적으로 선언 이름

변경·실패 경계: name은 재정의 불가; toString은 재정의 가능

ordinal()
이 선언에서 얻는 결과:

BASIC:0

GOLD:1

DIAMOND:2

변경·실패 경계: 선언 순서가 바뀌면 달라져 영구 코드로 쓰지 않음

==

이 선언에서 얻는 결과: 같은 enum 타입의 상수 동일성 비교

변경·실패 경계: null == Grade.GOLD는 false

위 연산의 계약과 switch는 별개입니다. 본문의 Priority switch 식은 세 상수를 모두 처리하지만 case null이 없어 null 입력에는 NullPointerException이 발생합니다.

enum이 어울리는 값과 어울리지 않는 값

enum은 선택지가 코드와 함께 정해지고 바뀌는 값에 어울립니다. 회원 등급, 게시글 상태, 우선순위처럼 값의 목록이 프로그램을 작성할 때 정해져 있고, 새 값이 생기면 그에 맞는 코드도 함께 고쳐야 하는 경우입니다. 값마다 할인율이나 설명 같은 데이터, 할인 계산 같은 동작이 붙는다면 더욱 그렇습니다.

반대로 관리자가 운영 중에 새 값을 추가해야 하는 목록은 enum으로 고정하지 않습니다. 게시판 분류를 관리자 화면에서 마음대로 추가하는 서비스라면, 분류가 하나 늘 때마다 코드를 고쳐 다시 배포할 수는 없습니다. 이런 목록은 데이터베이스 같은 저장소에 데이터로 두고, 들어온 값이 목록에 있는지 실행 중에 확인하는 방식이 맞습니다.

정리

  • private 생성자와 미리 만든 static final 객체로 값의 종류를 고정하는 방법을 타입 안전 열거 패턴이라고 합니다. 직접 만들 수 있지만, 모든 값 나열, 이름으로 찾기, 직렬화 같은 부분까지 손수 처리해야 합니다.
  • enum은 같은 일을 언어가 맡습니다. 상수 목록 밖의 객체는 new로 만들 수 없고, 매개변수 타입이 enum이면 그 상수들과 null만 들어올 수 있습니다.
  • enum도 필드, 생성자, 메서드를 가질 수 있어서 상수마다 데이터와 동작을 넣을 수 있습니다. 생성자는 자동으로 private입니다.
  • values()는 모든 상수를 선언 순서대로, valueOf(name)은 이름이 정확히 같은 상수를 돌려줍니다. ordinal()은 선언 순서가 바뀌면 달라지므로 저장하거나 업무 규칙에 쓰지 않습니다.
  • enum은 ==로 비교해도 됩니다. 모든 상수를 다루는 switch 식에는 default를 넣지 않아야 새 상수를 추가할 때 컴파일러가 빠진 곳을 알려 줍니다. null은 switch 전에 따로 처리합니다.

연습 문제

  1. 게시글 상태 DRAFT, REVIEW, PUBLISHED를 나타내는 PostStatus enum을 작성하세요. 각 상태에는 화면에 보여 줄 설명('초안', '검토', '공개')을 필드로 넣습니다. values()로 모든 상태를 이름=설명 형식으로 출력하고, valueOf로 "REVIEW"에 해당하는 상수를 찾아 출력하세요.
  2. 본문의 EnumCoreMethods에서 Grade의 선언을 enum Grade { BASIC, SILVER, GOLD, DIAMOND }로 바꾸면 반복문의 출력이 어떻게 달라지나요? 기존 회원 등급을 ordinal() 값으로 저장해 두었다면 어떤 일이 생길지도 설명하세요.
  3. EnumBoardPriority의 Priority에 URGENT를 추가하고 label은 그대로 두면 무슨 일이 생기나요? label의 switch 식에 default -> "today";가 들어 있었다면 결과가 어떻게 달라졌을지도 비교하세요.
해설 보기

1번. 설명을 생성자로 받아 필드에 저장하고, 설명을 돌려주는 메서드를 둡니다.

src/PostStatusExercise.java
public final class PostStatusExercise {
    public static void main(String[] args) {
        for (PostStatus status : PostStatus.values()) {
            System.out.println(status.name() + "=" + status.description());
        }
        System.out.println("parsed=" + PostStatus.valueOf("REVIEW"));
    }

    private enum PostStatus {
        DRAFT("초안"), REVIEW("검토"), PUBLISHED("공개");

        private final String description;

        PostStatus(String description) {
            this.description = description;
        }

        String description() { return description; }
    }
}
DRAFT=초안
REVIEW=검토
PUBLISHED=공개
parsed=REVIEW

values()가 선언 순서대로 세 상수를 돌려주므로 DRAFT, REVIEW, PUBLISHED 순서로 출력됩니다. 마지막 줄의 PostStatus.valueOf("REVIEW")는 PostStatus.REVIEW를 돌려주고, 이것을 출력하면 toString()이 선언 이름을 돌려주므로 REVIEW가 나옵니다. 화면에 보여 줄 문구를 description 필드로 따로 두었기 때문에, 문구를 '검토 중'으로 바꾸더라도 상수 이름과 그것을 쓰는 코드는 고칠 필요가 없습니다. 여러 언어를 지원해야 한다면 enum에 번역문을 직접 넣기보다 번역 파일에서 문구를 찾을 메시지 키를 넣어 두는 편이 낫습니다.

2번. 반복문의 출력은 다음과 같이 바뀝니다.

BASIC:0
SILVER:1
GOLD:2
DIAMOND:3

SILVER가 1번 자리를 차지하면서 GOLD는 1에서 2로, DIAMOND는 2에서 3으로 밀려납니다. 기존 회원 등급을 ordinal() 값으로 저장해 두었다면, GOLD 회원의 저장값 1은 이제 SILVER로 읽히고 DIAMOND 회원의 저장값 2는 GOLD로 읽힙니다. 회원들의 등급이 한꺼번에 한 단계씩 내려가는데, 예외나 컴파일 오류는 전혀 나지 않습니다. 저장할 값이 필요하면 선언 순서와 무관한 코드를 필드로 직접 넣어야 합니다.

3번. URGENT를 추가하면 label의 switch 식이 the switch expression does not cover all possible input values 오류를 내며 컴파일되지 않습니다. 세 case가 더는 모든 상수를 다루지 못하기 때문입니다. 귀찮아 보이지만, 새 상수를 어떻게 처리할지 정해야 하는 곳을 컴파일러가 정확히 짚어 주는 셈입니다. 반면 default -> "today";가 있었다면 아무 오류 없이 컴파일되고, URGENT 게시글은 조용히 today로 처리됩니다. 가장 급한 게시글이 보통 게시글처럼 다뤄지는 버그가 생기는데, 이 버그를 찾아 주는 것은 아무것도 없습니다.

다음 글

enum 메서드와 문자열 입력 변환에서는 enum을 실제 기능에 적용합니다. 등급별 할인 계산을 enum 안으로 옮겨 조건문을 없애고, 사용자가 입력한 문자열을 valueOf보다 너그럽고 안전하게 enum으로 바꾸는 방법을 살펴봅니다.