본문 바로가기

문자열 등급과 타입 안전성

오타 등급이 조용히 할인 정책을 우회하는 문제를 재현하고 문자열 상수의 한계와 enum 도입 전 선택 공간을 분석합니다.

문자열은 어디서든 만들 수 있어 외부 입력에는 편리하지만, 허용 가능한 값의 집합을 타입으로 제한하지 못합니다.

게시글 난이도나 인증 등급처럼 선택지가 닫혀 있으면 오타가 컴파일을 통과하고 실행 중 기본 분기로 빠질 수 있습니다.

enum은 이 문제를 해결하지만 먼저 문자열 설계가 왜 취약한지 관찰합니다.


문자열 오타와 등급 오류

할인 서비스는 알 수 없는 문자열을 0%로 처리합니다.

호출자는 오타를 냈지만 예외도 컴파일 오류도 없어 잘못된 금액이 정상 결과처럼 흐릅니다.

lab/StringGradeTypoBug.java
public final class StringGradeTypoBug {
    public static void main(String[] args) {
        DiscountService service = new DiscountService();
        int price = 10_000;

        System.out.println("DIAMOND=" + service.discount("DIAMOND", price));
        System.out.println("DIAMONDD=" + service.discount("DIAMONDD", price));
        System.out.println("gold=" + service.discount("gold", price));
    }

    private static final class DiscountService {
        int discount(String grade, int price) {
            int percent = switch (grade) {
                case "BASIC" -> 10;
                case "GOLD" -> 20;
                case "DIAMOND" -> 30;
                default -> 0;
            };
            return price * percent / 100;
        }
    }
}
잘못된 정책 결과
DIAMOND=3000
DIAMONDD=0
gold=0

문자열은 대소문자, 공백, 철자 변형을 모두 다른 값으로 봅니다.

알 수 없는 입력을 0%라는 유효한 결과와 합치면 원인을 추적하기도 어렵습니다.

외부 문자열은 경계에서 검증해 내부의 제한된 타입으로 변환해야 합니다.


문자열 상수의 한계

상수를 사용하면 IDE 자동 완성과 이름 변경 도움을 받을 수 있습니다.

그러나 메서드 매개변수 타입은 여전히 String이므로 임의 문자열 전달이 가능합니다.

src/StringGradeConstants.java
public final class StringGradeConstants {
    public static void main(String[] args) {
        DiscountService service = new DiscountService();
        System.out.println(service.discount(GradeName.GOLD, 10_000));
        try {
            System.out.println(service.discount("SILVER", 10_000));
        } catch (IllegalArgumentException error) {
            System.out.println("rejected=SILVER");
        }
    }

    private static final class GradeName {
        static final String BASIC = "BASIC";
        static final String GOLD = "GOLD";
        static final String DIAMOND = "DIAMOND";
        private GradeName() { }
    }

    private static final class DiscountService {
        int discount(String grade, int price) {
            return switch (grade) {
                case GradeName.BASIC -> price / 10;
                case GradeName.GOLD -> price / 5;
                case GradeName.DIAMOND -> price * 3 / 10;
                default -> throw new IllegalArgumentException("grade=" + grade);
            };
        }
    }
}
2000
rejected=SILVER

두 번째 호출은 경계에서 예외를 잡아 잘못된 입력을 명시합니다.

상수는 올바른 이름을 재사용하게 돕고 default 거부는 조용한 오류를 막습니다.

컴파일 단계에서 SILVER를 막으려면 매개변수 자체가 제한된 등급 타입이어야 합니다.

문자열 등급의 기본값 처리와 명시적 거부

문자열을 받는 두 DiscountService는 모두 임의 문자열 호출이 컴파일된다. 첫 구현의 DIAMONDD와 gold는0을 반환하고 상수를 사용한 두 번째 구현의 SILVER는 실행 중 예외로 거부한다.

알 수 없는 등급을 0으로 처리하면 입력 오류가 숨습니다

두 DiscountService 구현 모두 매개변수는 String입니다. 가격 10_000에 대한 원문 호출 결과를 비교합니다. 반환값은 결제 금액이 아니라 할인액입니다.

알 수 없는 등급을 0으로 처리하면 입력 오류가 숨습니다 첫 String서비스는 DIAMONDD/gold에서default0,상수판SILVER는예외. 양쪽임의String호출은컴파일되며 비교영역은실행사슬이아니다. 첫 구현 String 입력 DIAMONDD / gold 분기 결과 default → 할인액0 상수를 사용한 구현 String 입력 SILVER 분기 결과 default → 예외 양쪽 모두 임의 String 호출이 컴파일됨
알 수 없는 등급을 0으로 처리하면 입력 오류가 숨습니다 첫 String서비스는 DIAMONDD/gold에서default0,상수판SILVER는예외. 양쪽임의String호출은컴파일되며 비교영역은실행사슬이아니다. 첫 구현 String 입력 DIAMONDD / gold 분기 결과 default → 할인액0 상수를 사용한 구현 String 입력 SILVER 분기 결과 default → 예외 양쪽 모두 임의 String으로 호출하면 컴파일됨
StringGradeTypoBug와 StringGradeConstants의 실제 입력 및 처리
구현·입력분기 결과실제 출력
첫 구현 · "DIAMOND"정의된 등급에 일치하여 30% 할인액을 계산합니다.DIAMOND=3000
첫 구현 · "DIAMONDD" 또는 "gold"둘 다 컴파일되지만 일치하는 case가 없어 default → 0으로 진행합니다.
DIAMONDD=0
gold=0
상수 구현 · GradeName.GOLD상수 값 "GOLD"에 일치하여 가격의 1/5을 반환합니다.2000
상수 구현 · "SILVER"이 호출도 컴파일됩니다. default에서 IllegalArgumentException을 던지고 호출자가 잡습니다.rejected=SILVER
첫 구현 · "DIAMOND"
분기 결과: 정의된 등급에 일치하여 30% 할인액을 계산합니다.
실제 출력: DIAMOND=3000
첫 구현 · "DIAMONDD" 또는 "gold"
분기 결과: 둘 다 컴파일되지만 일치하는 case가 없어 default → 0으로 진행합니다.
실제 출력:
DIAMONDD=0
gold=0
상수 구현 · GradeName.GOLD
분기 결과: 상수 값 "GOLD"에 일치하여 가격의 1/5을 반환합니다.
실제 출력: 2000
상수 구현 · "SILVER"
분기 결과: 이 호출도 컴파일됩니다. default에서 IllegalArgumentException을 던지고 호출자가 잡습니다.
실제 출력: rejected=SILVER

상수는 이름 재사용을 돕고, 예외 분기는 알 수 없는 입력을 실행 중 거부합니다. 둘 다 메서드의 String 선택 집합 자체를 닫지는 않습니다.


외부 문자열의 타입 변환

아직 enum을 사용하지 않더라도 입력을 정규화하고 허용 목록으로 검증하는 함수를 한곳에 둘 수 있습니다.

내부 서비스가 매번 대소문자와 공백을 처리하지 않게 합니다.

src/ValidatedGradeText.java
import java.util.Locale;

public final class ValidatedGradeText {
    public static void main(String[] args) {
        System.out.println(parse(" gold "));
        try {
            System.out.println(parse("silver"));
        } catch (IllegalArgumentException error) {
            System.out.println("rejected=" + error.getMessage());
        }
    }

    private static String parse(String raw) {
        if (raw == null) throw new IllegalArgumentException("grade=null");
        String normalized = raw.strip().toUpperCase(Locale.ROOT);
        boolean allowed = normalized.equals("BASIC")
                || normalized.equals("GOLD")
                || normalized.equals("DIAMOND");
        if (!allowed) throw new IllegalArgumentException("grade=" + raw);
        return normalized;
    }
}
GOLD
rejected=grade=silver

검증된 뒤에도 반환 타입이 String이라는 한계는 남습니다.

다른 코드가 parse를 거치지 않고 임의 값을 만들 수 있습니다.

문자열 검증은 성공해도 반환 타입은 String이다

외부 문자열 정규화→허용 목록 검사→반환과 silver/null 거부가 실제 경계 처리입니다. 본문의 검증된 String이 여전히 열린 타입이라는 결과도 마지막 장면에 남깁니다.

문자열 검증은 성공해도 반환 타입은 String이다

ValidatedGradeText의 parse는 gold를 GOLD로 정규화하여 String을 반환하고 silver는 원래 입력을 포함한 예외로 거부한다. null은 strip전에 거부하는 소스 분기이지만 원문main은 호출하지 않는다.

허용 목록을 검사해도 반환 타입은 String입니다

ValidatedGradeText.parse의 허용 목록은 BASIC / GOLD / DIAMOND입니다. 정상화된 값으로 검사하되, 거부 메시지에는 원래 입력을 사용합니다.

허용 목록을 검사해도 반환 타입은 String입니다 외부gold입력→strip/대문자→허용 목록→String GOLD. silver거부/null조기거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다. 외부 입력 " gold " 정규화 strip → GOLD 허용 목록 검사 BASIC / GOLD / DIAMOND 반환값 String "GOLD" 별도 입력 silver 목록에 없음 → 예외 별도 입력 null 정규화 전 거부 검증 뒤에도 String · 타입의 선택 집합은 열려 있음
허용 목록을 검사해도 반환 타입은 String입니다 외부gold입력→strip/대문자→허용 목록→String GOLD. silver거부/null조기거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다. 외부 입력 " gold " 정규화 strip → GOLD 허용 목록 검사 BASIC / GOLD / DIAMOND 반환값 String "GOLD" 별도 입력 silver 목록에 없음 → 예외 별도 입력 null 정규화 전에 거부 String 타입 자체는 임의 문자열도 허용
정규화와 허용 목록 검사, 원문 실행 및 소스 분기 구분
입력정규화·검사반환 또는 실패
" gold "

원문 main 호출

strip() 후 toUpperCase(Locale.ROOT)로 "GOLD"가 되어 허용됩니다.String "GOLD"를 반환합니다. · 출력 GOLD
"silver"

원문 main 호출

정규화된 "SILVER"가 허용 목록에 없어 거부됩니다.IllegalArgumentException을 잡아 rejected=grade=silver를 출력합니다.
null

소스 분기

main 미호출

첫 if (raw == null)에서 거부하므로 strip()에 도달하지 않습니다.IllegalArgumentException · 메시지 grade=null
" gold " · 원문 main 호출
정규화·검사: strip() 후 toUpperCase(Locale.ROOT)로 "GOLD"가 되어 허용됩니다.
반환 또는 실패: String "GOLD"를 반환합니다. · 출력 GOLD
"silver" · 원문 main 호출
정규화·검사: 정규화된 "SILVER"가 허용 목록에 없어 거부됩니다.
반환 또는 실패: IllegalArgumentException을 잡아 rejected=grade=silver를 출력합니다.
null · 소스 분기 · main 미호출
정규화·검사: 첫 if (raw == null)에서 거부하므로 strip()에 도달하지 않습니다.
반환 또는 실패: IllegalArgumentException · 메시지 grade=null

반환 타입은 여전히 String입니다. 다른 코드가 이 함수를 거치지 않고 임의 문자열을 만드는 것을 막지 못합니다. 이 예제는 아직 Grade.GOLD 같은 enum을 반환하지 않습니다.

이 예의 조건

  • gold/silver/null은 독립 입력이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2외부 gold 입력→strip/대문자→허용 목록→String GOLD. silver 거부/null 조기 거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다.
8BASIC
10DIAMOND
원 도표의 설명과 주석
구분내용
설명외부 gold 입력→strip/대문자→허용 목록→String GOLD. silver 거부/null 조기 거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다.
전체 읽기 정보문자열 검증은 성공해도 반환 타입은 String이다 문자열 검증은 성공해도 반환 타입은 String이다 ValidatedGradeText의 parse는 gold를 GOLD로 정규화하여 String을 반환하고 silver는 원래 입력을 포함한 예외로 거부한다. null은 strip 전에 거부하는 소스 분기이지만 원문 main은 호출하지 않는다. 허용 목록을 검사해도 반환 타입은 String입니다 ValidatedGradeText.parse 의 허용 목록은 BASIC / GOLD / DIAMOND 입니다. 정상화된 값으로 검사하되, 거부 메시지에는 원래 입력을 사용합니다. 정규화와 허용 목록 검사, 원문 실행 및 소스 분기 구분 입력 정규화·검사 반환 또는 실패 " gold " 원문 main 호출 strip() 후 toUpperCase(Locale.ROOT) 로 "GOLD" 가 되어 허용됩니다. String "GOLD" 를 반환합니다. · 출력 GOLD "silver" 원문 main 호출 정규화된 "SILVER" 가 허용 목록에 없어 거부됩니다. IllegalArgumentException 을 잡아 rejected=grade=silver 를 출력합니다. null 소스 분기 main 미호출 첫 if (raw == null) 에서 거부하므로 strip() 에 도달하지 않습니다. IllegalArgumentException · 메시지 grade=null " gold " · 원문 main 호출 정규화·검사: strip() 후 toUpperCase(Locale.ROOT) 로 "GOLD" 가 되어 허용됩니다. 반환 또는 실패: String "GOLD" 를 반환합니다. · 출력 GOLD "silver" · 원문 main 호출 정규화·검사: 정규화된 "SILVER" 가 허용 목록에 없어 거부됩니다. 반환 또는 실패: IllegalArgumentException 을 잡아 rejected=grade=silver 를 출력합니다. null · 소스 분기 · main 미호출 정규화·검사: 첫 if (raw == null) 에서 거부하므로 strip() 에 도달하지 않습니다. 반환 또는 실패: IllegalArgumentException · 메시지 grade=null 반환 타입은 여전히 String 입니다. 다른 코드가 이 함수를 거치지 않고 임의 문자열을 만드는 것을 막지 못합니다. 이 예제는 아직 Grade.GOLD 같은 enum을 반환하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
외부 입력

정규화할 문자열을 받습니다.

  • 정상: gold
  • 별도 입력: silver, null
  • null 입력은 문자열 메서드 실행 전에 거절해야 한다.
정규화와 허용 검사

strip/대문자로 GOLD를 얻고 허용 목록과 비교합니다.

  • 정상 값: GOLD
  • 허용 목록: BASIC, GOLD, DIAMOND
  • silver: 목록에 없음
  • 목록 검사 성공과 형식 변환은 별개 조건이다.
반환 경계

정상은 String GOLD를 반환하고 오류는 예외로 끝납니다.

  • 반환 타입: String
  • null: 정규화 전 거부
  • silver: 허용 검사 거부
  • 값 검증을 통과해도 반환 타입의 선택 집합은 열린 String이다.

문자열 검증은 성공해도 반환 타입은 String이다

외부 문자열 정규화→허용 목록 검사→반환과 silver/null 거부가 실제 경계 처리입니다. 본문의 검증된 String이 여전히 열린 타입이라는 결과도 마지막 장면에 남깁니다.

문자열 검증은 성공해도 반환 타입은 String이다

ValidatedGradeText의 parse는 gold를 GOLD로 정규화하여 String을 반환하고 silver는 원래 입력을 포함한 예외로 거부한다. null은 strip전에 거부하는 소스 분기이지만 원문main은 호출하지 않는다.

허용 목록을 검사해도 반환 타입은 String입니다

ValidatedGradeText.parse의 허용 목록은 BASIC / GOLD / DIAMOND입니다. 정상화된 값으로 검사하되, 거부 메시지에는 원래 입력을 사용합니다.

허용 목록을 검사해도 반환 타입은 String입니다 외부gold입력→strip/대문자→허용 목록→String GOLD. silver거부/null조기거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다. 외부 입력 " gold " 정규화 strip → GOLD 허용 목록 검사 BASIC / GOLD / DIAMOND 반환값 String "GOLD" 별도 입력 silver 목록에 없음 → 예외 별도 입력 null 정규화 전 거부 검증 뒤에도 String · 타입의 선택 집합은 열려 있음
허용 목록을 검사해도 반환 타입은 String입니다 외부gold입력→strip/대문자→허용 목록→String GOLD. silver거부/null조기거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다. 외부 입력 " gold " 정규화 strip → GOLD 허용 목록 검사 BASIC / GOLD / DIAMOND 반환값 String "GOLD" 별도 입력 silver 목록에 없음 → 예외 별도 입력 null 정규화 전에 거부 String 타입 자체는 임의 문자열도 허용
정규화와 허용 목록 검사, 원문 실행 및 소스 분기 구분
입력정규화·검사반환 또는 실패
" gold "

원문 main 호출

strip() 후 toUpperCase(Locale.ROOT)로 "GOLD"가 되어 허용됩니다.String "GOLD"를 반환합니다. · 출력 GOLD
"silver"

원문 main 호출

정규화된 "SILVER"가 허용 목록에 없어 거부됩니다.IllegalArgumentException을 잡아 rejected=grade=silver를 출력합니다.
null

소스 분기

main 미호출

첫 if (raw == null)에서 거부하므로 strip()에 도달하지 않습니다.IllegalArgumentException · 메시지 grade=null
" gold " · 원문 main 호출
정규화·검사: strip() 후 toUpperCase(Locale.ROOT)로 "GOLD"가 되어 허용됩니다.
반환 또는 실패: String "GOLD"를 반환합니다. · 출력 GOLD
"silver" · 원문 main 호출
정규화·검사: 정규화된 "SILVER"가 허용 목록에 없어 거부됩니다.
반환 또는 실패: IllegalArgumentException을 잡아 rejected=grade=silver를 출력합니다.
null · 소스 분기 · main 미호출
정규화·검사: 첫 if (raw == null)에서 거부하므로 strip()에 도달하지 않습니다.
반환 또는 실패: IllegalArgumentException · 메시지 grade=null

반환 타입은 여전히 String입니다. 다른 코드가 이 함수를 거치지 않고 임의 문자열을 만드는 것을 막지 못합니다. 이 예제는 아직 Grade.GOLD 같은 enum을 반환하지 않습니다.

이 예의 조건

  • gold/silver/null은 독립 입력이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2외부 gold 입력→strip/대문자→허용 목록→String GOLD. silver 거부/null 조기 거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다.
8BASIC
10DIAMOND
원 도표의 설명과 주석
구분내용
설명외부 gold 입력→strip/대문자→허용 목록→String GOLD. silver 거부/null 조기 거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다.
전체 읽기 정보문자열 검증은 성공해도 반환 타입은 String이다 문자열 검증은 성공해도 반환 타입은 String이다 ValidatedGradeText의 parse는 gold를 GOLD로 정규화하여 String을 반환하고 silver는 원래 입력을 포함한 예외로 거부한다. null은 strip 전에 거부하는 소스 분기이지만 원문 main은 호출하지 않는다. 허용 목록을 검사해도 반환 타입은 String입니다 ValidatedGradeText.parse 의 허용 목록은 BASIC / GOLD / DIAMOND 입니다. 정상화된 값으로 검사하되, 거부 메시지에는 원래 입력을 사용합니다. 정규화와 허용 목록 검사, 원문 실행 및 소스 분기 구분 입력 정규화·검사 반환 또는 실패 " gold " 원문 main 호출 strip() 후 toUpperCase(Locale.ROOT) 로 "GOLD" 가 되어 허용됩니다. String "GOLD" 를 반환합니다. · 출력 GOLD "silver" 원문 main 호출 정규화된 "SILVER" 가 허용 목록에 없어 거부됩니다. IllegalArgumentException 을 잡아 rejected=grade=silver 를 출력합니다. null 소스 분기 main 미호출 첫 if (raw == null) 에서 거부하므로 strip() 에 도달하지 않습니다. IllegalArgumentException · 메시지 grade=null " gold " · 원문 main 호출 정규화·검사: strip() 후 toUpperCase(Locale.ROOT) 로 "GOLD" 가 되어 허용됩니다. 반환 또는 실패: String "GOLD" 를 반환합니다. · 출력 GOLD "silver" · 원문 main 호출 정규화·검사: 정규화된 "SILVER" 가 허용 목록에 없어 거부됩니다. 반환 또는 실패: IllegalArgumentException 을 잡아 rejected=grade=silver 를 출력합니다. null · 소스 분기 · main 미호출 정규화·검사: 첫 if (raw == null) 에서 거부하므로 strip() 에 도달하지 않습니다. 반환 또는 실패: IllegalArgumentException · 메시지 grade=null 반환 타입은 여전히 String 입니다. 다른 코드가 이 함수를 거치지 않고 임의 문자열을 만드는 것을 막지 못합니다. 이 예제는 아직 Grade.GOLD 같은 enum을 반환하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
외부 입력

정규화할 문자열을 받습니다.

  • 정상: gold
  • 별도 입력: silver, null
  • null 입력은 문자열 메서드 실행 전에 거절해야 한다.
정규화와 허용 검사

strip/대문자로 GOLD를 얻고 허용 목록과 비교합니다.

  • 정상 값: GOLD
  • 허용 목록: BASIC, GOLD, DIAMOND
  • silver: 목록에 없음
  • 목록 검사 성공과 형식 변환은 별개 조건이다.
반환 경계

정상은 String GOLD를 반환하고 오류는 예외로 끝납니다.

  • 반환 타입: String
  • null: 정규화 전 거부
  • silver: 허용 검사 거부
  • 값 검증을 통과해도 반환 타입의 선택 집합은 열린 String이다.

다음 절에서 private 생성자를 가진 타입 안전 열거 패턴과 언어 enum으로 생성 가능성을 닫습니다.


시드를 고정한 무작위 추출

배열과 Random 연습을 연결해 중복 없는 추천 게시글 ID 추출기를 만듭니다.

새 번호가 이미 배열의 사용 구간에 있는지 명시적 반복으로 확인하고, 중복이면 저장하지 않은 채 다시 뽑습니다.

고정 seed는 결과 재현을 돕습니다.

src/UniqueFeaturedPostIds.java
import java.util.Arrays;
import java.util.Random;

public final class UniqueFeaturedPostIds {
    public static void main(String[] args) {
        FeaturedPostIdGenerator generator = new FeaturedPostIdGenerator(new Random(12));
        System.out.println(Arrays.toString(generator.generate(6)));
    }

    private static final class FeaturedPostIdGenerator {
        private final Random random;

        FeaturedPostIdGenerator(Random random) {
            this.random = random;
        }

        int[] generate(int count) {
            if (count < 1 || count > 45) throw new IllegalArgumentException("count");
            int[] selected = new int[count];
            int size = 0;

            while (size < count) {
                int candidate = random.nextInt(45) + 1;
                boolean duplicate = false;
                for (int index = 0; index < size; index++) {
                    if (selected[index] == candidate) {
                        duplicate = true;
                        break;
                    }
                }
                if (!duplicate) selected[size++] = candidate;
            }

            Arrays.sort(selected);
            return selected;
        }
    }
}
[17, 20, 22, 33, 41, 44]

Random을 생성자에서 받아 테스트가 원하는 순서를 재현할 수 있습니다.

결과 배열을 정렬해 추천 게시글 ID의 출력 순서가 추출 시도 횟수에 덜 의존하게 했습니다.

추천 노출처럼 보안성이 필요하지 않은 기능에만 이 구현을 사용합니다.


닫힌 선택 집합의 타입화

  • 허용 값이 코드 배포 시점에 정해져 있는가?
  • 각 값에 설명·수치·행동을 붙여야 하는가?
  • switch의 default가 실수 값을 숨기고 있는가?
  • 외부 문자열을 내부 곳곳에서 반복 비교하는가?
  • 새 선택지 추가 때 수정 위치를 컴파일러가 찾아야 하는가?

사용자가 자유롭게 만드는 태그와 검색어는 String이 맞습니다.

BASIC/GOLD/DIAMOND, 요일, HTTP 상태처럼 제한된 집합은 enum이 의도를 표현합니다.

앞의 무작위 추출기는 생성자에서 받은 Random 객체를 보관합니다. 같은 인스턴스에서 generate를 다시 호출하면 진행된 난수 상태를 사용하고, new Random(12)로 새 추출기를 만들면 처음 순서를 재생합니다. 중복 없는 여섯 ID라는 조건과 매번 같은 여섯 ID라는 조건은 다릅니다. 재현하려면 시드와 호출 이력을 함께 고정하며, 재호출 결과가 우연히 같을 가능성까지 배제하는 것은 아닙니다.

데이터베이스에서 동적으로 추가되는 카테고리는 enum보다 엔티티와 검증 저장소가 적합합니다.


연습 문제

EASY, NORMAL, HARD만 허용하는 parseDifficulty를 작성하세요.

입력 양끝 공백과 대소문자는 정규화하고 잘못된 값은 예외로 거부합니다.

이번 연습의 입력 계약은 null이 아닌 문자열입니다. 따라서 빈 문자열이나 extreme은 허용 목록에서 거부하지만, 원래 답안에 null을 전달하면 strip 호출에서 NullPointerException이 납니다. 앞의 ValidatedGradeText처럼 null도 입력 오류 메시지로 거부하려면 strip보다 앞에서 null을 검사해야 합니다. 그 계약을 선택한 뒤 " normal "·빈 문자열·null의 처리 지점을 각각 예상해 보세요.

해설 보기
src/DifficultyTextExercise.java
import java.util.Locale;

public final class DifficultyTextExercise {
    public static void main(String[] args) {
        System.out.println(parseDifficulty(" hard "));
        try {
            parseDifficulty("extreme");
        } catch (IllegalArgumentException error) {
            System.out.println("rejected=extreme");
        }
    }

    private static String parseDifficulty(String raw) {
        String value = raw.strip().toUpperCase(Locale.ROOT);
        boolean allowed = value.equals("EASY")
                || value.equals("NORMAL")
                || value.equals("HARD");
        if (!allowed) {
            throw new IllegalArgumentException("difficulty=" + raw);
        }
        return value;
    }
}
HARD
rejected=extreme

검증은 성공하지만 반환 타입이 여전히 String이므로 내부 코드가 허용 목록을 우회할 수 있습니다.

enum 전환 뒤에는 이 함수가 문자열을 enum으로 바꾸는 경계가 됩니다.