본문으로 건너뛰기

안동민 개발노트

본문 시작

문자열 등급과 타입 안전성

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

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

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

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

HTML 다이어그램: /docs/java/ch10/ch10-3/1.html

문자열 오타와 등급 오류

할인 서비스는 알 수 없는 문자열을 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를 막으려면 매개변수 자체가 제한된 등급 타입이어야 합니다.

HTML 다이어그램: /docs/java/ch10/ch10-3/2.html

외부 문자열의 타입 변환

아직 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를 거치지 않고 임의 값을 만들 수 있습니다.

다음 절에서 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의 출력 순서가 추출 시도 횟수에 덜 의존하게 했습니다.

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

HTML 다이어그램: /docs/java/ch10/ch10-3/3.html

닫힌 선택 집합의 타입화

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

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

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

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

HTML 다이어그램: /docs/java/ch10/ch10-3/4.html

연습 문제

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

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

해설 보기
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으로 바꾸는 경계가 됩니다.