안동민 개발노트

본문 시작

타입 안전 열거 패턴과 enum

private 생성자로 선택 집합을 닫는 패턴을 구현하고 언어 enum의 생성 제한·동등성·values·valueOf 계약을 검증합니다.

문자열 대신 등급 전용 타입을 매개변수로 받으면 임의 문자열이 서비스 안으로 들어오지 못합니다.

클래스로도 미리 만든 인스턴스만 공개하는 타입 안전 열거 패턴을 구현할 수 있고, 자바 enum은 이 구조를 언어 차원에서 간결하고 안전하게 제공합니다.


enum 인스턴스 생성 제한

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
    }
}
컴파일 실패 관찰
error: enum types may not be instantiated

서비스 매개변수가 Grade라면 BASIC, GOLD, DIAMOND 또는 null만 들어올 수 있습니다.

오타 문자열과 대소문자 변형은 Grade 인스턴스가 될 수 없고, switch도 허용 상수를 기준으로 완전성을 검사할 수 있습니다.


타입 안전 열거 패턴

호출자가 접근할 수 없는 private 생성자와 미리 만든 static final 인스턴스로 생성 경로를 제한할 수 있습니다. 다만 이 예제의 ClassGrade는 중첩 타입이므로 같은 최상위 클래스 안의 코드는 private 생성자에 접근할 수 있습니다. enum처럼 모든 명시적 new를 언어가 금지하는 것은 아닙니다.

각 인스턴스는 자기 이름과 할인율을 보관합니다.

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

private 생성자가 빠지면 호출자가 같은 필드를 가진 임의 등급을 만들 수 있어 닫힌 집합이 깨집니다.

직접 패턴은 values, valueOf, 직렬화 동일성 같은 부가 기능을 스스로 구현해야 합니다.

언어 enum은 이 반복을 제거합니다.


필드와 메서드를 가진 enum

enum은 단순 정수 상수가 아니라 지정된 타입의 유일한 인스턴스들입니다.

생성자 인수로 데이터가 들어가고 일반 메서드가 그 데이터를 사용합니다.

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

할인 계산이 Grade 안에 있으므로 서비스의 종류별 if가 사라집니다.

새 등급의 데이터와 행동이 한 선언에 모입니다.

모든 등급이 같은 공식이라면 필드 기반 메서드가 적합하고 상수마다 완전히 다른 행동이 필요하면 상수별 메서드 구현도 가능하지만 복잡도가 커질 수 있습니다.

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

클래스 패턴은 private 생성자와 미리 만든 인스턴스를 사용하지만 언어 enum은 new를 금지하고 values와 valueOf를 제공한다. 둘 모두 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 기본 메서드의 용도

values()는 선언 순서의 모든 상수를 새 배열로 반환합니다.

valueOf("GOLD")는 정확한 이름을 찾아 상수를 반환하고 일치하지 않으면 IllegalArgumentException을 던집니다.

name()은 선언 이름이고 toString()은 재정의될 수 있습니다.

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

ordinal은 선언 순서가 바뀌면 달라집니다.

데이터베이스 저장, 네트워크 코드, 업무 우선순위에 사용하지 않고 별도 안정 코드나 level 필드를 둡니다.

화면 표시명도 name에 의존하지 않고 description 필드나 번역 계층에서 제공합니다.


enum 비교와 switch 완전성

같은 enum 타입의 각 상수는 하나의 인스턴스이므로 == 비교가 안전하고 null도 equals 호출 없이 비교할 수 있습니다.

switch 식에서 모든 상수를 나열하고 default를 생략하면 새 상수 추가 시 컴파일러가 빠진 분기를 알려 줄 수 있습니다.

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

이 switch 식에 null Priority를 전달하면 일치하는 case를 고르기 전에 NullPointerException이 발생합니다.

null이 업무 상태인지 입력 오류인지 정하고 별도 상수 UNKNOWN을 추가할지 경계에서 거부할지 선택합니다.

UNKNOWN은 실제 미정 상태일 때만 사용하고 오류를 숨기는 기본값으로 남용하지 않습니다.

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

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

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

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으로 고정하지 않습니다.


연습 문제

DRAFT, REVIEW, PUBLISHED 상태와 사용자 설명을 가진 PostStatus를 작성하세요.

values를 순회해 이름=설명을 출력하고 valueOf로 REVIEW를 조회합니다.

해설 보기
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

description은 화면 문구를 선언 이름과 분리합니다.

다국어가 필요하면 enum에 번역문을 직접 넣기보다 메시지 키를 보관합니다.