문자열 등급과 타입 안전성
오타 등급이 조용히 할인 정책을 우회하는 문제를 재현하고 문자열 상수의 한계와 enum 도입 전 선택 공간을 분석합니다.
문자열은 어디서든 만들 수 있어 외부 입력에는 편리하지만, 허용 가능한 값의 집합을 타입으로 제한하지 못합니다.
게시글 난이도나 인증 등급처럼 선택지가 닫혀 있으면 오타가 컴파일을 통과하고 실행 중 기본 분기로 빠질 수 있습니다.
enum은 이 문제를 해결하지만 먼저 문자열 설계가 왜 취약한지 관찰합니다.
문자열 오타와 등급 오류
할인 서비스는 알 수 없는 문자열을 0%로 처리합니다.
호출자는 오타를 냈지만 예외도 컴파일 오류도 없어 잘못된 금액이 정상 결과처럼 흐릅니다.
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이므로 임의 문자열 전달이 가능합니다.
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를 막으려면 매개변수 자체가 제한된 등급 타입이어야 합니다.
외부 문자열의 타입 변환
아직 enum을 사용하지 않더라도 입력을 정규화하고 허용 목록으로 검증하는 함수를 한곳에 둘 수 있습니다.
내부 서비스가 매번 대소문자와 공백을 처리하지 않게 합니다.
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는 결과 재현을 돕습니다.
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이 의도를 표현합니다.
데이터베이스에서 동적으로 추가되는 카테고리는 enum보다 엔티티와 검증 저장소가 적합합니다.
연습 문제
EASY, NORMAL, HARD만 허용하는 parseDifficulty를 작성하세요.
입력 양끝 공백과 대소문자는 정규화하고 잘못된 값은 예외로 거부합니다.
해설 보기
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으로 바꾸는 경계가 됩니다.