본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 래퍼·열거형·시간 API

enum 행동과 입력 변환

잘못된 valueOf 입력 실패를 재현하고 등급별 할인 행동·인증 레벨·안전한 파서로 조건 분기와 중복을 제거합니다.

enum을 매개변수 타입으로 바꾸는 것만으로 오타 입력은 줄지만, 외부 서비스가 상수마다 if를 나열하면 새 값 추가 때 기존 조건문을 계속 수정합니다.

상수가 가진 데이터와 계산을 enum 내부로 옮기면 선택지와 행동이 함께 진화합니다.

외부 문자열은 안전한 파싱 경계에서 enum으로 바꿉니다.


valueOf와 입력 대소문자

valueOf는 선언 이름과 대소문자까지 정확히 일치해야 합니다.

사용자 입력을 그대로 넘기면 정상 의도의 문자열도 예외가 됩니다.

lab/EnumValueOfFailure.java
public final class EnumValueOfFailure {
    public static void main(String[] args) {
        AuthGrade grade = AuthGrade.valueOf("gold");
        System.out.println(grade);
    }

    private enum AuthGrade { GUEST, LOGIN, ADMIN }
}
실패 관찰
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant

valueOf는 내부의 신뢰된 이름 복원에는 유용하지만 사용자 친화 파서는 아닙니다.

양끝 공백과 대소문자를 정규화하고, 실패를 사용 가능한 값 안내로 변환하는 별도 메서드를 둡니다.


Grade의 할인 계산 책임

초기 구조에서는 DiscountService가 등급별 퍼센트를 알고 switch로 선택합니다.

percent를 enum에 옮긴 뒤에도 서비스가 getter로 꺼내 계산하면 지식이 둘로 나뉩니다.

grade.discount(price)까지 이동하면 Grade가 자기 정책을 완결합니다.

src/EncapsulatedGradePolicy.java
public final class EncapsulatedGradePolicy {
    public static void main(String[] args) {
        int price = 10_000;
        for (Grade grade : Grade.values()) {
            System.out.println(grade + "=" + 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

외부 서비스가 단순 위임만 한다면 제거할 수도 있습니다.

다만 주문 조회, 쿠폰 조합, 결과 기록처럼 여러 협력을 조정한다면 서비스는 남고 등급 자체 계산만 enum에 맡깁니다.

객체에 행동을 넣는 목적은 클래스를 줄이는 것이 아니라 데이터에 가장 가까운 책임을 배치하는 것입니다.


AuthGrade의 값과 설명

인증 등급은 선언 순서 ordinal이 아니라 명시적인 level을 사용합니다.

관리 권한 판단도 enum 메서드로 제공할 수 있습니다.

src/AuthGradePolicy.java
public final class AuthGradePolicy {
    public static void main(String[] args) {
        for (AuthGrade grade : AuthGrade.values()) {
            System.out.println(grade.name() + ":level=" + grade.level()
                    + ":" + grade.description() + ":admin=" + grade.canManage());
        }
    }

    private enum AuthGrade {
        GUEST(1, "손님"), LOGIN(10, "로그인 회원"), ADMIN(100, "관리자");

        private final int level;
        private final String description;

        AuthGrade(int level, String description) {
            this.level = level;
            this.description = description;
        }

        int level() { return level; }
        String description() { return description; }
        boolean canManage() { return level >= ADMIN.level; }
    }
}
GUEST:level=1:손님:admin=false
LOGIN:level=10:로그인 회원:admin=false
ADMIN:level=100:관리자:admin=true

level은 저장과 비교에 쓸 수 있는 명시적 업무 값이지만, 이후 변경 가능성이 있다면 영구 식별 코드와 권한 크기를 분리합니다.

“숫자가 크면 모든 권한을 포함한다”는 가정이 복잡해지면 역할 집합이나 권한 정책 객체가 더 적합합니다.


입력 파싱과 null 경계

콘솔에서 잘못된 등급을 입력한 것은 프로그램 결함보다 사용자 오류입니다.

예외 스택 대신 값이 없다는 결과와 안내를 반환할 수 있습니다.

여기서는 이미 배운 null을 메서드 안의 짧은 경계에만 사용하고, 값의 존재 여부를 타입으로 표현하는 방법은 ch33에서 배웁니다.

src/SafeAuthGradeParser.java
import java.util.Arrays;
import java.util.Locale;

public final class SafeAuthGradeParser {
    public static void main(String[] args) {
        print(" admin ");
        print("owner");
    }

    private static void print(String raw) {
        AuthGrade parsed = parseOrNull(raw);
        if (parsed != null) {
            System.out.println("grade=" + parsed);
        } else {
            System.out.println("invalid=" + raw.strip() + ", allowed=" + Arrays.toString(AuthGrade.values()));
        }
    }

    private static AuthGrade parseOrNull(String raw) {
        if (raw == null) return null;
        try {
            return AuthGrade.valueOf(raw.strip().toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException error) {
            return null;
        }
    }

    private enum AuthGrade { GUEST, LOGIN, ADMIN }
}
grade=ADMIN
invalid=owner, allowed=[GUEST, LOGIN, ADMIN]

모든 예외를 잡지 않고 valueOf가 약속한 IllegalArgumentException만 변환합니다.

parseOrNull이라는 이름은 실패 결과가 null일 수 있음을 드러내며 호출 직후 확인합니다.

이 값이 여러 계층을 지나야 한다면 ch33의 선택 결과 타입으로 계약을 더 분명히 만듭니다.

내부 설정 파일의 잘못된 값은 시작 실패로 다루는 편이 좋을 수 있으므로 모든 파서를 같은 방식으로 만들지는 않습니다.


열거 상수 추가와 변경 범위

상수에 데이터와 공통 행동을 넣으면 새 PLATINUM을 선언하는 한 줄로 할인 계산이 확장될 수 있습니다.

반면 외부 switch가 필요하면 default를 생략해 컴파일러가 새 상수 처리 누락을 알려 주게 합니다.

모든 분기를 enum 안으로 억지로 넣어 UI 색상, 데이터베이스 코드, 할인 정책을 한 타입에 몰지 않습니다.

선택 기준은 변경 이유입니다.

  • 모든 소비자가 공유하는 본질적 데이터와 행동은 enum에 둡니다.
  • 화면 표현과 번역은 프레젠테이션 계층에 둡니다.
  • 외부 시스템 코드 매핑은 어댑터에 둘 수 있습니다.
  • 독립적으로 배포·설정되는 정책은 전략 객체로 분리합니다.

연습 문제

AuthGrade 레벨 이하의 메뉴를 출력하세요.

Menu도 requiredLevel을 가진 enum으로 만들고 ADMIN 사용자는 세 메뉴를 모두 볼 수 있게 합니다.

해설 보기
src/AuthMenuExercise.java
public final class AuthMenuExercise {
    public static void main(String[] args) {
        printMenus(AuthGrade.LOGIN);
        printMenus(AuthGrade.ADMIN);
    }

    private static void printMenus(AuthGrade grade) {
        System.out.print(grade + "=");
        boolean first = true;
        for (Menu menu : Menu.values()) {
            if (grade.level < menu.requiredLevel) continue;
            if (!first) System.out.print(",");
            System.out.print(menu);
            first = false;
        }
        System.out.println();
    }

    private enum AuthGrade {
        GUEST(1), LOGIN(10), ADMIN(100);
        private final int level;
        AuthGrade(int level) { this.level = level; }
    }

    private enum Menu {
        HOME(1), BOARD(10), ADMIN_PANEL(100);
        private final int requiredLevel;
        Menu(int requiredLevel) { this.requiredLevel = requiredLevel; }
    }
}
LOGIN=HOME,BOARD
ADMIN=HOME,BOARD,ADMIN_PANEL

등급과 메뉴가 각각 자기 레벨을 보관하고 조합 로직이 비교합니다.

권한이 단순 순서가 아니게 되면 숫자 비교를 권한 집합으로 교체합니다.