본문 바로가기

enum 메서드와 문자열 입력 변환

등급별 할인 계산을 enum 안으로 옮기고, 사용자가 입력한 문자열을 valueOf로 안전하게 enum 상수로 바꾸는 방법을 배웁니다.

앞 글에서는 등급을 Grade enum으로 바꾸고, 할인율을 필드로, 할인 계산을 메서드로 넣어 보았습니다. 매개변수 타입이 Grade이면 "DIAMONDD" 같은 오타는 아예 컴파일되지 않습니다. 하지만 enum을 실제 프로그램에 쓰기 시작하면 곧바로 두 가지 질문이 생깁니다. 등급마다 달라지는 계산은 enum 안과 그 enum을 쓰는 서비스 중 어디에 두어야 할까요? 그리고 사용자가 키보드로 입력한 " admin " 같은 문자열은 어떻게 enum 상수로 바꿀까요?

이 글은 두 질문을 차례로 다룹니다. 먼저 할인 계산이 서비스의 switch에서 enum의 메서드로 옮겨 가는 과정을 따라가며, enum에 넣을 것과 밖에 둘 것을 가르는 기준을 정합니다. 이어서 valueOf를 사용자 입력에 그대로 쓰기 어려운 이유를 확인하고, 공백과 대소문자를 정리한 뒤 실패하면 사용할 수 있는 값을 안내하는 작은 파서를 만듭니다.

등급별 할인 계산을 어디에 둘까

enum이 생기기 전의 할인 서비스는 등급을 보고 할인율을 고르는 switch를 직접 가지고 있었습니다. 매개변수를 Grade로 바꾸어도 이 구조를 그대로 두면 서비스는 다음과 같은 모양이 됩니다.

할인율과 공식을 모두 아는 서비스
final class DiscountService {
    int discount(Grade grade, int price) {
        int percent = switch (grade) {
            case BASIC -> 10;
            case GOLD -> 20;
            case DIAMOND -> 30;
        };
        return price * percent / 100;
    }
}

오타는 막았지만 등급에 관한 지식이 여전히 enum 밖에 있습니다. 새 등급 PLATINUM을 추가하면 enum 선언뿐 아니라 이 switch도 찾아서 고쳐야 하고, 할인율을 쓰는 서비스가 여러 개라면 고칠 곳도 그만큼 늘어납니다. 그래서 할인율을 enum의 필드로 옮기고, 서비스는 getter로 꺼내 쓰게 바꾸어 봅니다.

할인율만 enum으로 옮긴 서비스
final class DiscountService {
    int discount(Grade grade, int price) {
        return price * grade.percent() / 100;
    }
}

switch는 사라졌지만 이번에는 지식이 둘로 나뉘었습니다. 할인율은 Grade가 알고, 그 할인율로 금액을 계산하는 공식은 서비스가 압니다. 할인액이 필요한 곳이 서비스 하나뿐일 때는 문제가 없어 보이지만, 다른 코드에서도 할인액이 필요해지면 price * grade.percent() / 100이라는 식이 여기저기 복사됩니다. 그 뒤에 음수 가격을 거부하는 규칙이 생기면 복사본을 모두 찾아 고쳐야 합니다.

마지막 단계는 공식까지 Grade 안으로 옮겨서 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

Grade.values()는 선언 순서대로 세 상수를 돌려주고, 반복문은 상수마다 discount(10_000)을 호출합니다. 각 상수는 생성자에서 받은 percent를 자기 필드로 가지고 있으므로, 같은 메서드가 BASIC에서는 10%, GOLD에서는 20%, DIAMOND에서는 30%로 계산해 1000, 2000, 3000이 나옵니다. 각 줄 앞의 BASIC=은 문자열에 enum을 이어 붙일 때 호출되는 toString()의 결과인데, 재정의하지 않았으므로 선언 이름과 같습니다. 이제 할인율과 공식, 음수 가격 검사가 모두 Grade 한곳에 모였습니다.

아래 그림은 지금까지 본 세 구조를 나란히 놓고, 등급별 할인율과 할인 공식이 각각 어디에 있는지 비교합니다.

등급 데이터와 계산 책임을 옮기는 범위

서비스가 상수 분기와 공식을 갖는 단계, enum에 퍼센트만 두는 단계, enum이 discount를 수행하는 단계를 비교한다. 여러 협력을 조정하는 서비스의 책임까지 enum으로 옮기지는 않는다.

등급의 데이터와 할인 공식을 함께 옮깁니다

본문의 설계 변화는 percent의 위치와 할인 공식의 위치를 함께 비교해야 드러납니다.

등급의 데이터와 할인 공식을 함께 옮깁니다 독립된 세 설계를 비교한다. 서비스가 비율과 공식 보유, enum에 비율만 이동, enum에 비율과 discount 공식 이동. 다른 협력 책임을 모두 enum에 옮기는 뜻은 아니다. 서비스가 분기 서비스 호출을 조정 할인 계산 위치 서비스: 비율과 공식 비율만 enum에 이동 서비스 호출을 조정 할인 계산 위치 서비스: 공식 enum: 비율 공식도 enum에 이동 서비스 호출을 조정 할인 계산 위치 enum: 비율과 공식 독립된 세 설계 · 영역 사이 실행 연결 없음
등급의 데이터와 할인 공식을 함께 옮깁니다 독립된 세 설계를 비교한다. 서비스가 비율과 공식 보유, enum에 비율만 이동, enum에 비율과 discount 공식 이동. 다른 협력 책임을 모두 enum에 옮기는 뜻은 아니다. 서비스가 분기 서비스 호출을 조정 할인 계산 위치 서비스: 비율과 공식 비율만 enum에 이동 서비스 호출을 조정 할인 계산 위치 서비스: 공식 enum: 비율 공식도 enum에 이동 서비스 호출을 조정 할인 계산 위치 enum: 비율과 공식 독립된 세 설계의 비교 영역 간 실행 연결 없음
등급 데이터와 계산 책임을 옮기는 범위
구조등급별 퍼센트할인 공식을 아는 쪽
외부 분기서비스의 switch가 선택서비스
데이터만 이동enum 필드; 서비스가 getter로 조회서비스
행동까지 이동enum 필드enum의 discount(price)
외부 분기

등급별 퍼센트: 서비스의 switch가 선택

할인 공식을 아는 쪽: 서비스

데이터만 이동

등급별 퍼센트: enum 필드; 서비스가 getter로 조회

할인 공식을 아는 쪽: 서비스

행동까지 이동

등급별 퍼센트: enum 필드

할인 공식을 아는 쪽: enum의 discount(price)

실제 EncapsulatedGradePolicy는 마지막 구조입니다. 10,000원에서 BASIC/GOLD/DIAMOND의 할인액은 1,000/2,000/3,000원입니다. 주문 조회·쿠폰 조합·기록 같은 협력은 서비스에 남길 수 있습니다.

그렇다고 서비스 클래스가 언제나 사라지는 것은 아닙니다. 서비스가 등급에게 할인액을 물어서 그대로 돌려주기만 한다면 없애도 됩니다. 하지만 실제 주문 처리는 주문을 조회하고, 쿠폰과 등급 할인을 조합하고, 결과를 기록하는 식으로 여러 객체를 엮는 일입니다. 이런 조정은 서비스가 계속 맡고, 등급 하나만 보고 답할 수 있는 계산만 enum에 맡깁니다. 행동을 객체 안으로 옮기는 목적은 클래스 수를 줄이는 것이 아니라, 계산을 그 계산에 필요한 데이터 가까이에 두는 것입니다.

상수를 추가할 때 고쳐야 하는 곳

계산이 enum 안에 있으면 새 등급을 추가하는 일이 선언 한 줄로 끝납니다. EncapsulatedGradePolicy의 상수 목록을 BASIC(10), GOLD(20), DIAMOND(30), PLATINUM(40);으로 바꾸고 다시 실행하면, 다른 코드는 하나도 고치지 않았는데도 출력 끝에 PLATINUM=4000이 한 줄 늘어납니다. values()가 새 상수까지 돌려주고, discount는 새 상수의 percent로 계산하기 때문입니다.

하지만 모든 분기를 enum 안으로 옮길 수 있는 것은 아닙니다. 예를 들어 화면에 보여 줄 등급 배지의 색상은 화면을 만드는 코드가 정할 일이므로, 그쪽에 switch가 남는 것이 자연스럽습니다. 이렇게 enum 밖에 switch를 둘 때는 값을 돌려주는 switch 식으로 모든 상수를 나열하고 default를 쓰지 않는 것이 좋습니다. 그러면 PLATINUM을 추가한 순간 컴파일러가 빠진 분기를 찾아 줍니다. 다음 프로그램은 enum에 PLATINUM을 추가했지만 배지 색상의 switch는 고치지 않은 상태입니다.

lab/GradeBadgeMissingCase.java
public final class GradeBadgeMissingCase {
    public static void main(String[] args) {
        System.out.println(badgeColor(Grade.GOLD));
    }

    private static String badgeColor(Grade grade) {
        return switch (grade) {
            case BASIC -> "gray";
            case GOLD -> "yellow";
            case DIAMOND -> "blue";
        };
    }

    private enum Grade { BASIC, GOLD, DIAMOND, PLATINUM }
}
컴파일 결과
GradeBadgeMissingCase.java:7: error: the switch expression does not cover all possible input values
        return switch (grade) {
               ^
1 error

switch 식은 어떤 입력이 와도 반드시 값을 만들어야 하므로, 컴파일러는 enum의 모든 상수가 어느 case에든 걸리는지 검사합니다. PLATINUM에 해당하는 case가 없으니 '모든 입력값을 다루지 않는다'는 오류가 납니다. 만약 여기에 default -> "gray";를 넣어 두었다면 컴파일은 통과하고, PLATINUM 회원에게는 아무 경고 없이 회색 배지가 표시됩니다. 오류가 사라진 것이 아니라 발견이 늦어진 것입니다. 참고로 상수 이름만 나열한 switch 문, 즉 값을 돌려주지 않는 switch에서는 상수를 빠뜨려도 컴파일 오류가 나지 않으므로 이 검사를 받으려면 switch 식을 써야 합니다.

그러면 어떤 분기를 enum에 넣고 어떤 분기를 밖에 두어야 할까요? 기준은 '그 코드가 바뀌는 이유가 무엇인가'입니다. 할인 정책과 화면 색상, 데이터베이스 저장 코드를 모두 Grade 하나에 넣으면, 디자인을 바꿀 때도 데이터베이스를 옮길 때도 같은 enum을 고쳐야 합니다. 대략 다음처럼 나누면 됩니다.

  • 그 등급을 쓰는 모든 코드가 똑같이 필요로 하는 데이터와 계산(할인율, 할인 공식)은 enum에 둡니다.
  • 화면에 보여 줄 문구와 색상, 번역은 화면을 만드는 코드에 둡니다.
  • 외부 시스템이 쓰는 코드 값과의 변환은 그 시스템과 연결하는 코드에 둡니다.
  • 운영 중에 따로 바꾸거나 설정으로 고르는 정책은 enum과 분리된 별도 객체로 만듭니다.

더 깊이: 상수마다 다른 메서드 구현

모든 등급이 같은 공식을 쓰면 필드 하나로 충분하지만, 한 상수만 공식이 다를 수도 있습니다. 예를 들어 DIAMOND의 할인액에만 5,000원 상한이 있다고 해 봅시다. 상수 이름 뒤에 중괄호로 몸체를 붙이면 그 상수만 메서드를 재정의할 수 있는데, 이를 상수별 클래스 몸체(constant-specific class body)라고 합니다.

src/ConstantBodyGrade.java
public final class ConstantBodyGrade {
    public static void main(String[] args) {
        for (Grade grade : Grade.values()) {
            System.out.println(grade + ": 10000 -> " + grade.discount(10_000)
                    + ", 30000 -> " + grade.discount(30_000));
        }
    }

    private enum Grade {
        BASIC(10),
        GOLD(20),
        DIAMOND(30) {
            @Override
            int discount(int price) {
                return Math.min(super.discount(price), 5_000);
            }
        };

        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: 10000 -> 1000, 30000 -> 3000
GOLD: 10000 -> 2000, 30000 -> 6000
DIAMOND: 10000 -> 3000, 30000 -> 5000

DIAMOND만 3만 원에서 9,000원 대신 5,000원이 나왔습니다. 몸체가 붙은 상수는 컴파일러가 Grade의 이름 없는 하위 클래스로 만들고(컴파일하면 ConstantBodyGrade$Grade$1.class 파일이 하나 더 생깁니다), 그 객체의 discount가 Grade의 구현을 재정의합니다. 몸체 안에서 percent를 직접 쓰지 않고 super.discount(price)를 호출한 데에도 이유가 있습니다. percent는 private이라 하위 클래스인 몸체로 상속되지 않으므로, 직접 쓰면 non-static variable percent cannot be referenced from a static context라는 엉뚱해 보이는 컴파일 오류가 납니다. 상수마다 공식이 전부 다르다면 abstract 메서드를 선언하고 모든 상수가 몸체에서 구현하게 할 수도 있지만, 상수가 늘수록 선언이 길어집니다. 공식이 대부분 같다면 이 예제처럼 필드와 공통 메서드를 기본으로 두고 예외적인 상수만 재정의하는 편이 읽기 쉽습니다.

순서 번호 대신 명시적인 값 두기

이번에는 인증 등급처럼 등급 사이에 높고 낮음이 있는 경우를 보겠습니다. 손님(GUEST), 로그인 회원(LOGIN), 관리자(ADMIN)를 이 순서로 선언하면 ordinal()이 0, 1, 2를 돌려주므로, 이 번호로 권한을 비교하고 싶어질 수 있습니다. 하지만 앞 글에서 본 것처럼 ordinal()은 선언 순서일 뿐이라서 상수를 중간에 끼워 넣으면 바뀝니다. 그래서 비교에 쓸 값은 level이라는 필드에 직접 적어 둡니다. 화면에 보여 줄 한국어 이름도 선언 이름과 따로 description 필드에 둡니다.

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

반복문은 상수마다 이름, 레벨, 설명, 관리 권한 여부를 한 줄씩 출력합니다. canManage()는 자기 level이 ADMIN.level, 즉 100 이상인지 비교하므로 ADMIN 줄에서만 admin=true가 나옵니다. 기준을 숫자 100으로 적지 않고 ADMIN.level로 쓴 덕분에, 나중에 관리자의 레벨 값을 바꾸어도 이 메서드는 고칠 필요가 없습니다.

canManage()를 ordinal() >= 2로 만들었다면 어떻게 될까요? 처음에는 ADMIN의 순서 번호가 2라서 올바르게 동작합니다. 그런데 나중에 LOGIN과 ADMIN 사이에 MANAGER(50)을 추가하면 MANAGER의 순서 번호가 2, ADMIN이 3이 되어 MANAGER도 관리 권한을 얻습니다. 컴파일 오류도 예외도 없이 권한이 새어 나가는 것입니다. level로 비교하면 MANAGER의 레벨 50은 100보다 작으므로 이런 일이 생기지 않습니다.

level은 저장하거나 비교하는 데 쓸 수 있는 업무 값이지만, 숫자 하나에 두 가지 역할을 맡긴다는 점은 기억해 둘 만합니다. 데이터베이스에 저장하는 식별 코드는 한번 정하면 바꾸기 어려운데, 권한의 크기는 정책에 따라 바뀔 수 있습니다. 둘 중 하나라도 바뀔 가능성이 있다면 저장용 코드와 권한 크기를 별도의 필드로 나눕니다. 또 게시판 관리자와 결제 관리자처럼 서로를 포함하지 않는 권한이 생기면 '숫자가 크면 아래 등급의 권한을 모두 가진다'는 가정이 깨집니다. 그때는 숫자 비교 대신 등급마다 허용된 권한의 집합을 두는 설계로 바꾸어야 합니다.

valueOf는 선언 이름과 정확히 같은 문자열만 받는다

이제 두 번째 질문인 입력 변환으로 넘어갑니다. 콘솔이나 웹 폼에서 들어오는 등급은 문자열이므로 어딘가에서 enum 상수로 바꾸어야 합니다. 모든 enum에는 컴파일러가 만들어 주는 valueOf(String) 메서드가 있으니 이것을 그대로 쓰면 될 것 같지만, 사용자 입력을 넘기면 문제가 금세 드러납니다.

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 EnumValueOfFailure.AuthGrade.gold

메시지는 EnumValueOfFailure.AuthGrade라는 enum에 gold라는 이름의 상수가 없다는 뜻이고, 그 아래로 예외가 발생한 위치를 보여 주는 스택 트레이스가 이어집니다. 이 입력이 실패한 이유는 두 가지입니다. 하나는 소문자라는 점이고, 더 근본적인 다른 하나는 AuthGrade에 GOLD라는 상수가 아예 없다는 점입니다. GOLD는 할인 등급 Grade의 상수였지 인증 등급의 상수가 아니므로, 대문자로 바꾸어 valueOf("GOLD")를 호출해도 똑같이 실패합니다. 입력을 아무리 다듬어도 선언되지 않은 상수가 생기지는 않습니다.

Enum.valueOf의 API 문서는 이름이 상수를 선언할 때 쓴 식별자와 정확히 일치해야 하고, 앞뒤 공백도 허용하지 않는다고 적고 있습니다. 실제로 입력별 결과를 정리하면 다음과 같습니다.

호출결과
AuthGrade.valueOf("ADMIN")ADMIN 상수
AuthGrade.valueOf("admin")IllegalArgumentException
AuthGrade.valueOf(" ADMIN ")IllegalArgumentException
AuthGrade.valueOf("")IllegalArgumentException
AuthGrade.valueOf(null)NullPointerException (메시지 Name is null)

이렇게 엄격한 것은 valueOf가 사람이 입력한 문자열을 해석하려고 만든 메서드가 아니기 때문입니다. 프로그램이 name()으로 저장해 둔 이름을 다시 상수로 되돌리는 것처럼, 이름이 정확하다고 믿을 수 있는 곳에 쓰는 메서드입니다. 반면 사용자 입력은 대소문자와 공백이 제각각이고, 없는 등급을 입력하는 일도 흔합니다. 그런 입력에 예외와 스택 트레이스를 보여 주기보다는, 입력을 먼저 정리하고 실패하면 사용할 수 있는 값을 안내하는 별도의 메서드를 두는 편이 낫습니다.

사용자 입력을 enum으로 바꾸는 파서

파서가 할 일은 세 단계입니다. 먼저 strip()으로 양끝 공백을 지우고 toUpperCase(Locale.ROOT)로 대문자로 바꿉니다. 그다음 정리된 문자열로 valueOf를 호출합니다. 마지막으로 valueOf가 상수를 찾지 못해 던진 IllegalArgumentException만 잡아서 '값 없음'이라는 결과로 바꿉니다. 문자열 등급을 다룬 글에서는 정리한 문자열을 허용 목록과 하나씩 비교했는데, 이번에는 그 비교를 valueOf가 대신하고 결과도 문자열이 아닌 AuthGrade 타입으로 돌려준다는 점이 다릅니다.

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]

첫 호출의 " admin "은 strip()을 거쳐 "admin"이 되고, toUpperCase로 "ADMIN"이 되어 valueOf가 ADMIN 상수를 돌려줍니다. 그래서 첫 줄에 grade=ADMIN이 출력됩니다. 두 번째 호출의 "owner"는 정리하면 "OWNER"가 되지만 그런 상수는 없으므로 valueOf가 예외를 던지고, catch가 이를 null로 바꿉니다. print는 null을 받으면 정리한 입력과 함께 AuthGrade.values()로 만든 허용 목록을 보여 주므로 둘째 줄이 나옵니다. 허용 목록을 문자열로 직접 적지 않고 values()로 만들었기 때문에, 상수를 추가하면 안내 문구도 저절로 바뀝니다.

코드의 세부 선택에도 각각 이유가 있습니다.

  • toUpperCase에 Locale.ROOT를 넘긴 것은 실행 환경의 언어 설정에 따라 결과가 달라지지 않게 하기 위해서입니다. 인수 없는 toUpperCase()는 기본 로케일의 규칙을 따르는데, 터키어 로케일에서는 "admin"이 점 있는 대문자 İ가 들어간 "ADMİN"이 되어 valueOf가 실패합니다.
  • catch는 Exception 전체가 아니라 IllegalArgumentException만 잡습니다. valueOf가 '그런 이름의 상수가 없다'는 뜻으로 던지는 예외가 바로 이것이기 때문입니다. 모든 예외를 잡으면 파서 안의 다른 버그까지 '잘못된 입력'으로 둔갑해 원인을 찾기 어려워집니다.
  • 실패 결과로 null을 쓰는 대신 메서드 이름을 parseOrNull로 지어 호출하는 쪽이 null을 예상하게 했고, print는 결과를 받자마자 검사합니다. null을 여러 메서드에 걸쳐 넘기면 검사를 빠뜨리기 쉬우므로, 이렇게 반환 직후에 확인하는 짧은 범위에서만 씁니다. 값이 없을 수 있다는 사실을 반환 타입으로 드러내는 Optional은 33장에서 배웁니다.

null을 넘기면 파서는 견디지만 print는 실패한다

파서의 첫 줄 if (raw == null) return null; 덕분에 parseOrNull은 null 입력에도 예외 없이 null을 돌려줍니다. 그렇다면 main에 print(null);을 한 줄 추가하면 어떻게 될까요? 앞의 두 줄이 출력된 뒤 다음 예외가 납니다.

세 번째 호출 print(null)에서 나는 예외의 첫 줄
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.strip()" because "<parameter1>" is null

파서는 null을 무사히 돌려주었지만, 그 결과를 받은 print가 안내 문구를 만들려고 raw.strip()을 호출하는 순간 NullPointerException이 납니다. 메시지의 <parameter1>은 print의 첫 번째 매개변수인 raw를 가리킵니다. 옵션 없이 컴파일하면 변수 이름 정보가 클래스 파일에 남지 않아 번호로 표시되는 것으로, 클래스와 인스턴스를 다룬 글에서 본 <local1>과 같은 이유입니다. 파서가 null을 견딘다고 해서 그 결과를 쓰는 코드까지 안전해지는 것은 아니라는 점이 핵심입니다. 아래 단계 그림은 세 입력이 파서와 print를 거치며 각각 어떤 결과가 되는지 보여 줍니다.

enum 파싱 결과와 호출자의 null 경계

admin/owner/null의 파서 반환과 호출자 출력·NPE 도달을 비교하는 실행 추적입니다. 입력→정규화/반환→호출자 관찰을 같은 세 영역에서 순차 강조하면 null 경계가 보입니다.

enum 파싱 결과와 호출자의 null 경계

SafeAuthGradeParser는 공백과 대소문자를 정규화하여 admin을 ADMIN으로 바꾸고 owner는 null로 반환한다. null도 파서는 반환하지만 print는 실패 메시지의 raw.strip에서 NPE가 나므로 별도 입력 경계가 필요하다.

파서의 null 처리와 호출자의 null 처리는 별개입니다

SafeAuthGradeParser의 parseOrNull 결과와 print의 처리를 분리해 봅니다.

파서의 null 처리와 호출자의 null 처리는 별개입니다 원 print의 독립 입력admin→ADMIN,owner→null 뒤 invalid 안내,null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다. 원 print의 독립 입력 입력 " admin " 파서 결과 ADMIN 호출자 grade=ADMIN 원 print의 독립 입력 입력 "owner" 파서 결과 존재하지 않음 → null 호출자 invalid=owner 원 print의 독립 입력 입력 null 파서 결과 먼저 null 반환 호출자 원 print: NPE 원 main은 null을 입력하지 않음
파서의 null 처리와 호출자의 null 처리는 별개입니다 원 print의 독립 입력admin→ADMIN,owner→null 뒤 invalid 안내,null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다. 원 print의 독립 입력 입력 " admin " 파서 결과 ADMIN 호출자 grade=ADMIN 원 print의 독립 입력 입력 "owner" 파서 결과 존재하지 않음 → null 호출자 invalid=owner 원 print의 독립 입력 입력 null 파서 결과 먼저 null 반환 호출자 원 print: NPE 원 main은 null을 입력하지 않음
enum 파싱 결과와 호출자의 null 경계
입력파서 내부호출자의 결과
" admin "

원문 실행

strip()와 toUpperCase(Locale.ROOT) 후 ADMINgrade=ADMIN 출력
"owner"

원문 실행

OWNER를 찾지 못한 예외를 null로 변환invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력
null

추가 경계

정규화 전에 null 반환print(null)은 안내문의 raw.strip()에서 NullPointerException
" admin " · 원문 실행

파서 내부: strip()와 toUpperCase(Locale.ROOT) 후 ADMIN

호출자의 결과: grade=ADMIN 출력

"owner" · 원문 실행

파서 내부: OWNER를 찾지 못한 예외를 null로 변환

호출자의 결과: invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력

null · 추가 경계

파서 내부: 정규화 전에 null 반환

호출자의 결과: print(null)은 안내문의 raw.strip()에서 NullPointerException

원문 main은 null을 전달하지 않습니다. 별도 실패 예제의 "gold"는 소문자 문제뿐 아니라 AuthGrade에 GOLD 상수 자체가 없어서 실패합니다. 정규화는 존재하지 않는 상수를 만들지 않습니다.

이 예의 조건

  • null은 원 main에 없는 별도 원 print 입력이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 print의 독립 입력 admin→ADMIN, owner→null 뒤 invalid 안내, null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다.
원 도표의 설명과 주석
구분내용
설명원 print의 독립 입력 admin→ADMIN, owner→null 뒤 invalid 안내, null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다.
전체 읽기 정보enum 파싱 결과와 호출자의 null 경계 enum 파싱 결과와 호출자의 null 경계 SafeAuthGradeParser는 공백과 대소문자를 정규화하여 admin을 ADMIN으로 바꾸고 owner는 null로 반환한다. null도 파서는 반환하지만 print는 실패 메시지의 raw.strip에서 NPE가 나므로 별도 입력 경계가 필요하다. 파서의 null 처리와 호출자의 null 처리는 별개입니다 SafeAuthGradeParser 의 parseOrNull 결과와 print 의 처리를 분리해 봅니다. enum 파싱 결과와 호출자의 null 경계 입력 파서 내부 호출자의 결과 " admin " 원문 실행 strip() 와 toUpperCase(Locale.ROOT) 후 ADMIN grade=ADMIN 출력 "owner" 원문 실행 OWNER 를 찾지 못한 예외를 null 로 변환 invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력 null 추가 경계 정규화 전에 null 반환 print(null) 은 안내문의 raw.strip() 에서 NullPointerException " admin " · 원문 실행 파서 내부: strip() 와 toUpperCase(Locale.ROOT) 후 ADMIN 호출자의 결과: grade=ADMIN 출력 "owner" · 원문 실행 파서 내부: OWNER 를 찾지 못한 예외를 null 로 변환 호출자의 결과: invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력 null · 추가 경계 파서 내부: 정규화 전에 null 반환 호출자의 결과: print(null) 은 안내문의 raw.strip() 에서 NullPointerException 원문 main은 null을 전달하지 않습니다. 별도 실패 예제의 "gold"는 소문자 문제뿐 아니라 AuthGrade에 GOLD 상수 자체가 없어서 실패합니다. 정규화는 존재하지 않는 상수를 만들지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 전달

원 print가 파서에 입력을 전달합니다.

  • 세 입력: admin , owner, null
  • 파서와 호출자의 null 책임을 분리한다.
파서 반환

admin은 ADMIN, owner/null은 null을 반환합니다.

  • 정상: ADMIN
  • 미등록: null
  • null 입력: 먼저 null 반환
  • 파서의 안전한 null 반환이 호출자 후속 접근까지 보호하지 않는다.
호출자 관찰

정상/안내/NPE가 각각 발생합니다.

  • admin: grade=ADMIN
  • owner: invalid=owner
  • null: raw.strip에서 NPE
  • 호출자가 반환값과 원시 입력을 어떻게 쓰는지가 최종 실패를 정한다.

enum 파싱 결과와 호출자의 null 경계

admin/owner/null의 파서 반환과 호출자 출력·NPE 도달을 비교하는 실행 추적입니다. 입력→정규화/반환→호출자 관찰을 같은 세 영역에서 순차 강조하면 null 경계가 보입니다.

enum 파싱 결과와 호출자의 null 경계

SafeAuthGradeParser는 공백과 대소문자를 정규화하여 admin을 ADMIN으로 바꾸고 owner는 null로 반환한다. null도 파서는 반환하지만 print는 실패 메시지의 raw.strip에서 NPE가 나므로 별도 입력 경계가 필요하다.

파서의 null 처리와 호출자의 null 처리는 별개입니다

SafeAuthGradeParser의 parseOrNull 결과와 print의 처리를 분리해 봅니다.

파서의 null 처리와 호출자의 null 처리는 별개입니다 원 print의 독립 입력admin→ADMIN,owner→null 뒤 invalid 안내,null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다. 원 print의 독립 입력 입력 " admin " 파서 결과 ADMIN 호출자 grade=ADMIN 원 print의 독립 입력 입력 "owner" 파서 결과 존재하지 않음 → null 호출자 invalid=owner 원 print의 독립 입력 입력 null 파서 결과 먼저 null 반환 호출자 원 print: NPE 원 main은 null을 입력하지 않음
파서의 null 처리와 호출자의 null 처리는 별개입니다 원 print의 독립 입력admin→ADMIN,owner→null 뒤 invalid 안내,null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다. 원 print의 독립 입력 입력 " admin " 파서 결과 ADMIN 호출자 grade=ADMIN 원 print의 독립 입력 입력 "owner" 파서 결과 존재하지 않음 → null 호출자 invalid=owner 원 print의 독립 입력 입력 null 파서 결과 먼저 null 반환 호출자 원 print: NPE 원 main은 null을 입력하지 않음
enum 파싱 결과와 호출자의 null 경계
입력파서 내부호출자의 결과
" admin "

원문 실행

strip()와 toUpperCase(Locale.ROOT) 후 ADMINgrade=ADMIN 출력
"owner"

원문 실행

OWNER를 찾지 못한 예외를 null로 변환invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력
null

추가 경계

정규화 전에 null 반환print(null)은 안내문의 raw.strip()에서 NullPointerException
" admin " · 원문 실행

파서 내부: strip()와 toUpperCase(Locale.ROOT) 후 ADMIN

호출자의 결과: grade=ADMIN 출력

"owner" · 원문 실행

파서 내부: OWNER를 찾지 못한 예외를 null로 변환

호출자의 결과: invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력

null · 추가 경계

파서 내부: 정규화 전에 null 반환

호출자의 결과: print(null)은 안내문의 raw.strip()에서 NullPointerException

원문 main은 null을 전달하지 않습니다. 별도 실패 예제의 "gold"는 소문자 문제뿐 아니라 AuthGrade에 GOLD 상수 자체가 없어서 실패합니다. 정규화는 존재하지 않는 상수를 만들지 않습니다.

이 예의 조건

  • null은 원 main에 없는 별도 원 print 입력이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 print의 독립 입력 admin→ADMIN, owner→null 뒤 invalid 안내, null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다.
원 도표의 설명과 주석
구분내용
설명원 print의 독립 입력 admin→ADMIN, owner→null 뒤 invalid 안내, null→parse에서 null 뒤 원 print의 raw.strip에서 NPE. 새 printIncludingNull의 흐름은 아니다.
전체 읽기 정보enum 파싱 결과와 호출자의 null 경계 enum 파싱 결과와 호출자의 null 경계 SafeAuthGradeParser는 공백과 대소문자를 정규화하여 admin을 ADMIN으로 바꾸고 owner는 null로 반환한다. null도 파서는 반환하지만 print는 실패 메시지의 raw.strip에서 NPE가 나므로 별도 입력 경계가 필요하다. 파서의 null 처리와 호출자의 null 처리는 별개입니다 SafeAuthGradeParser 의 parseOrNull 결과와 print 의 처리를 분리해 봅니다. enum 파싱 결과와 호출자의 null 경계 입력 파서 내부 호출자의 결과 " admin " 원문 실행 strip() 와 toUpperCase(Locale.ROOT) 후 ADMIN grade=ADMIN 출력 "owner" 원문 실행 OWNER 를 찾지 못한 예외를 null 로 변환 invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력 null 추가 경계 정규화 전에 null 반환 print(null) 은 안내문의 raw.strip() 에서 NullPointerException " admin " · 원문 실행 파서 내부: strip() 와 toUpperCase(Locale.ROOT) 후 ADMIN 호출자의 결과: grade=ADMIN 출력 "owner" · 원문 실행 파서 내부: OWNER 를 찾지 못한 예외를 null 로 변환 호출자의 결과: invalid=owner, allowed=[GUEST, LOGIN, ADMIN] 출력 null · 추가 경계 파서 내부: 정규화 전에 null 반환 호출자의 결과: print(null) 은 안내문의 raw.strip() 에서 NullPointerException 원문 main은 null을 전달하지 않습니다. 별도 실패 예제의 "gold"는 소문자 문제뿐 아니라 AuthGrade에 GOLD 상수 자체가 없어서 실패합니다. 정규화는 존재하지 않는 상수를 만들지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 전달

원 print가 파서에 입력을 전달합니다.

  • 세 입력: admin , owner, null
  • 파서와 호출자의 null 책임을 분리한다.
파서 반환

admin은 ADMIN, owner/null은 null을 반환합니다.

  • 정상: ADMIN
  • 미등록: null
  • null 입력: 먼저 null 반환
  • 파서의 안전한 null 반환이 호출자 후속 접근까지 보호하지 않는다.
호출자 관찰

정상/안내/NPE가 각각 발생합니다.

  • admin: grade=ADMIN
  • owner: invalid=owner
  • null: raw.strip에서 NPE
  • 호출자가 반환값과 원시 입력을 어떻게 쓰는지가 최종 실패를 정한다.

입력이 아예 없는 경우까지 안내하려면 안내 문구를 만드는 쪽도 null을 처리해야 합니다. 같은 클래스에 다음 메서드를 추가하고 print 대신 호출해 보세요.

SafeAuthGradeParser에 추가할 메서드
private static void printIncludingNull(String raw) {
    AuthGrade parsed = parseOrNull(raw);
    if (parsed != null) {
        System.out.println("grade=" + parsed);
    } else {
        String label = raw == null ? "<missing>" : raw.strip();
        System.out.println("invalid=" + label
                + ", allowed=" + Arrays.toString(AuthGrade.values()));
    }
}

printIncludingNull(null)은 invalid=<missing>, allowed=[GUEST, LOGIN, ADMIN]을 출력합니다. raw가 null이면 strip()을 호출하지 않고 <missing>이라는 표시를 대신 쓰기 때문입니다. 빈 문자열 ""은 null과 달리 실제로 존재하는 문자열이므로 strip()을 호출할 수 있고, 정리한 결과도 빈 문자열이라서 invalid=, allowed=[GUEST, LOGIN, ADMIN]이 출력됩니다. 입력이 없는 경우와 빈 문자열을 같은 오류로 볼지는 프로그램이 정할 문제입니다. 다만 어느 쪽이든 null에 메서드를 호출하는 코드가 남아 있으면 안내까지 도달하지 못합니다.

모든 입력을 이렇게 너그럽게 받아야 하는 것은 아닙니다. 사용자가 콘솔에 잘못 입력한 등급은 다시 입력하게 하면 되는 평범한 실수이므로 안내가 어울립니다. 반대로 프로그램이 시작할 때 읽는 설정 파일에 없는 등급이 적혀 있다면 그것은 배포 과정의 실수입니다. 이때는 valueOf의 예외를 그대로 내보내 시작 단계에서 바로 멈추는 편이, 잘못된 설정으로 계속 실행되다가 엉뚱한 곳에서 문제가 드러나는 것보다 원인을 찾기 쉽습니다.

정리

  • 등급마다 다른 데이터와 계산은 enum의 필드와 메서드로 옮깁니다. 그러면 grade.discount(price) 한 번의 호출로 끝나고, 상수를 추가할 때 고칠 곳이 선언 한 줄로 줄어듭니다.
  • 화면 색상처럼 바뀌는 이유가 다른 분기는 enum 밖에 둡니다. 이때 default 없는 switch 식으로 모든 상수를 나열하면, 상수가 추가될 때 컴파일러가 빠진 분기를 알려 줍니다.
  • 비교하거나 저장할 값은 ordinal()이 아니라 level 같은 명시적인 필드로 둡니다. 순서 번호는 상수를 끼워 넣으면 바뀝니다.
  • valueOf는 선언 이름과 정확히 같은 문자열만 받습니다. 사용자 입력은 strip()과 toUpperCase(Locale.ROOT)로 정리한 뒤 변환하고, IllegalArgumentException만 잡아 안내로 바꿉니다.
  • 파서가 null을 돌려주면 받는 쪽이 즉시 검사해야 합니다. 파서가 null 입력을 견딘다고 해서 호출하는 코드까지 안전해지지는 않습니다.

연습 문제

문제 1: 등급에 따라 볼 수 있는 메뉴

인증 등급의 레벨은 GUEST가 1, LOGIN이 10, ADMIN이 100입니다. 메뉴도 enum Menu로 만들고, 메뉴마다 그 메뉴를 보는 데 필요한 레벨 requiredLevel을 HOME은 1, BOARD는 10, ADMIN_PANEL은 100으로 둡니다. 등급 하나를 받아 그 등급이 볼 수 있는 메뉴를 선언 순서대로 쉼표로 이어 출력하는 메서드를 작성하고, LOGIN과 ADMIN으로 호출하세요. 코드를 쓰기 전에 GUEST와 LOGIN이 볼 수 있는 메뉴를 먼저 종이에 적어 보세요.

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

printMenus는 Menu.values()를 선언 순서대로 돌면서, 등급의 레벨이 메뉴의 requiredLevel보다 작으면 continue로 건너뜁니다. LOGIN의 레벨 10은 HOME(1)과 BOARD(10)의 조건을 만족하지만 ADMIN_PANEL(100)에는 모자라므로 두 메뉴만 출력됩니다. first 변수는 첫 메뉴 앞에 쉼표를 찍지 않으려고 둔 것입니다. 같은 메서드를 GUEST로 호출하면 GUEST=HOME이 출력됩니다.

등급과 메뉴가 각자 자기 레벨을 가지고, 둘을 비교하는 일은 printMenus가 맡는 구조입니다. private 필드인 level과 requiredLevel을 바깥 클래스의 메서드에서 읽을 수 있는 것은 두 enum이 같은 최상위 클래스 안에 중첩되어 있기 때문입니다. 나중에 권한이 단순한 크기 순서로 표현되지 않게 되면, 숫자 비교를 메뉴별로 허용된 등급의 집합으로 바꿉니다.

문제 2: 파서의 결과 예상하기

본문의 parseOrNull에 "Login", "\tguest\n", "admins", ""를 차례로 넣으면 각각 무엇을 돌려줄지 예상하고 이유를 설명하세요. 두 번째 입력은 앞에 탭, 뒤에 줄바꿈 문자가 붙은 문자열입니다.

해설 보기

차례로 LOGIN, GUEST, null, null입니다. "Login"은 대문자로 바꾸면 "LOGIN"이 되어 상수를 찾습니다. "\tguest\n"의 탭과 줄바꿈은 strip()이 지우는 공백 문자이므로 "guest"가 남고, 대문자로 바꾸면 GUEST를 찾습니다.

"admins"는 "ADMINS"가 되는데, valueOf는 이름이 정확히 같아야 하고 앞부분만 같은 것은 인정하지 않으므로 예외가 나고 파서는 null을 돌려줍니다. ""은 정리해도 빈 문자열이고, 빈 이름의 상수는 없으므로 역시 null입니다. 빈 문자열은 null이 아니므로 파서의 첫 줄 검사에 걸리지 않고 valueOf까지 간다는 점도 확인해 두세요.

문제 3: 사용자 입력과 설정 파일

다음 두 상황에서 등급 문자열을 변환할 때 parseOrNull처럼 실패를 안내로 바꾸는 방식과 valueOf의 예외를 그대로 두는 방식 중 무엇이 알맞은지 고르고, 이유를 설명하세요.

  1. 콘솔에서 사용자가 자기 등급을 입력합니다.
  2. 서버가 시작할 때 설정 파일에서 신규 회원의 기본 등급을 읽습니다.
해설 보기

1번은 안내 방식이 알맞습니다. 사용자는 대소문자나 공백을 다르게 입력하거나 없는 등급을 입력할 수 있고, 이것은 프로그램의 결함이 아니라 다시 입력하면 해결되는 실수입니다. 스택 트레이스 대신 허용된 값을 보여 주면 사용자가 스스로 고칠 수 있습니다.

2번은 예외를 그대로 두는 편이 낫습니다. 설정 파일의 잘못된 값은 배포한 사람의 실수이고, 기본 등급이 없는 채로 서버가 계속 실행되면 회원이 가입할 때에야 문제가 드러납니다. 시작 단계에서 IllegalArgumentException으로 멈추면 메시지에 잘못된 이름이 그대로 나오므로 원인을 바로 찾을 수 있습니다. 같은 enum이라도 입력이 어디서 오는지에 따라 실패를 다루는 방식이 달라집니다.

다음 글

다음 글에서는 HTTP 상태 코드처럼 정수 코드로 enum 상수를 찾는 조회 메서드를 만들고, 찾지 못했을 때 돌려주는 null을 이 글과 같은 방식으로 다룹니다. 이어서 날짜와 시간 계산이 왜 까다로운지 살펴보고, java.time 패키지의 여러 타입을 고르는 기준을 세웁니다.