문자열로 등급을 나타낼 때의 문제
회원 등급처럼 정해진 몇 가지 값을 문자열로 다룰 때 오타가 어떻게 조용한 버그가 되는지 확인하고, 문자열 상수와 입력 검증이 어디까지 막아 주는지 살펴봅니다.
회원 등급에 따라 할인을 해 주는 기능을 만든다고 해 봅시다. 등급은 BASIC, GOLD, DIAMOND 세 가지이고, 할인율은 각각 10%, 20%, 30%입니다. 등급을 무엇으로 나타낼지 고민할 때 가장 먼저 떠오르는 것은 문자열입니다. 사용자가 고른 값도 문자열로 들어오고, 화면에 보여 줄 때도 문자열이 필요하니 "GOLD" 같은 문자열을 그대로 쓰면 편해 보입니다.
그런데 String 타입은 세상의 모든 문자열을 받아들입니다. 메서드의 매개변수를 String grade로 선언하면 컴파일러는 그 자리에 "GOLD"가 오든 "GOLDD"가 오든 "gold"가 오든 똑같이 통과시킵니다. 등급은 세 가지뿐인데 타입은 무한히 많은 값을 허용하는 셈이고, 이 차이가 버그가 숨을 자리가 됩니다. 이 글에서는 문자열로 등급을 다룰 때 생기는 버그를 직접 재현하고, 문자열 상수와 입력 검증으로 얼마나 막을 수 있는지 확인합니다. 그리고 끝내 남는 한계가 무엇인지 살펴서, 다음 글에서 배울 enum이 왜 필요한지 준비합니다.
오타가 할인율 0%가 되는 과정
다음 DiscountService는 등급 문자열과 가격을 받아 할인액을 돌려줍니다. 등급은 switch 식으로 나누고, 알 수 없는 등급이면 할인하지 않도록 default에서 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출력되는 값은 최종 결제 금액이 아니라 할인액입니다. 첫 줄은 의도대로 동작했습니다. "DIAMOND"가 세 번째 case와 일치해 30%가 골라졌고, 10,000원의 30%인 3000이 나왔습니다. 문제는 나머지 두 줄입니다. "DIAMONDD"는 D가 하나 더 붙은 오타이고, "gold"는 소문자로 쓴 것입니다. 문자열 switch는 equals처럼 글자 하나하나와 대소문자까지 정확히 같아야 일치로 보므로, 둘 다 어느 case에도 맞지 않고 default로 가서 할인액 0이 나옵니다.
이 결과에서 눈여겨볼 점은 아무도 문제를 알려 주지 않았다는 것입니다. 컴파일러는 문자열이면 무엇이든 통과시키니 컴파일 오류가 없고, default가 0%라는 정상적인 값을 돌려주니 예외도 없습니다. 0원 할인은 BASIC보다 낮은 등급이 있다면 충분히 나올 법한 결과라서, 출력만 보고는 이것이 오타 때문인지 의도한 결과인지 구별할 수도 없습니다. DIAMOND 회원은 할인을 받지 못한 채 결제를 마치고, 누군가 고객 문의를 받고서야 원인을 찾기 시작하게 됩니다.
이렇게 '알 수 없는 입력'을 '정상적인 기본값'과 같은 결과로 합쳐 버리면, 잘못된 값이 프로그램 깊숙이 흘러간 뒤에야 문제가 드러납니다. 잘못된 등급은 들어온 그 자리에서 거부되어야 원인을 쉽게 찾을 수 있습니다.
문자열 상수로 오타 줄이기
첫 번째 개선은 등급 문자열을 상수로 한곳에 모으는 것입니다. 다음 코드는 GradeName 클래스에 세 등급을 static final 상수로 선언하고, DiscountService의 case와 호출하는 쪽 모두 이 상수를 씁니다. 또 default에서 0을 돌려주는 대신 예외를 던지도록 바꿨습니다.
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첫 호출은 상수 GradeName.GOLD를 넘겼으므로 두 번째 case와 일치하고, 10,000원의 5분의 1인 2000이 나옵니다. 두 번째 호출의 "SILVER"는 없는 등급이라 default로 가는데, 이번에는 0을 돌려주지 않고 IllegalArgumentException을 던집니다. main이 이 예외를 잡아 rejected=SILVER를 출력했습니다. 앞의 코드에서는 조용히 0원이 되었을 입력이 이제는 실패로 드러나는 것입니다.
상수가 주는 이점은 이름을 컴파일러가 검사해 준다는 데 있습니다. 호출하는 쪽에서 GradeName.GOLDD라고 잘못 쓰면 그런 상수가 없으므로 cannot find symbol 컴파일 오류가 납니다. 문자열 "GOLDD"는 컴파일러가 그냥 통과시켰지만, 상수 이름의 오타는 실행하기도 전에 잡히는 것입니다. IDE의 자동 완성도 상수 이름을 제안해 주므로 애초에 오타를 낼 일이 줄어듭니다. GradeName의 생성자를 private으로 둔 것은 상수만 모아 둔 클래스라서 객체를 만들 이유가 없기 때문입니다.
그런데 main의 두 번째 호출을 다시 보세요. discount("SILVER", 10_000)은 아무 문제 없이 컴파일되었습니다. 상수를 만들어 두었다고 해서 모두가 상수를 쓰도록 강제할 수는 없습니다. 매개변수 타입이 여전히 String이므로 상수를 쓰지 않고 문자열을 직접 넘기는 코드도, 사용자 입력을 그대로 넘기는 코드도 모두 컴파일됩니다. default의 예외 덕분에 잘못된 값이 조용히 넘어가지는 않지만, 그 사실을 알게 되는 것은 여전히 실행 중이고, 그 코드가 실제로 실행되어야만 알 수 있습니다.
case 라벨에 GradeName.GOLD처럼 상수 이름을 쓸 수 있는 것은 이 상수가 컴파일할 때 값이 정해지는 상수이기 때문입니다. String을 고르는 switch에서 case null과 패턴을 빼면, case 뒤에는 이런 상수만 올 수 있습니다. 같은 static final이라도 new String("GOLD")처럼 실행해야 값이 정해지는 식으로 초기화하면 case에 쓸 수 없고, constant string expression required라는 컴파일 오류가 납니다.
아래 그림은 지금까지 본 두 구현을 나란히 비교합니다. 두 구현 모두 아무 문자열이나 넘겨도 컴파일되지만, 알 수 없는 등급이 들어왔을 때 첫 구현은 0을 돌려주고 두 번째 구현은 예외를 던진다는 차이가 있습니다.
문자열을 받는 두 DiscountService는 모두 임의 문자열 호출이 컴파일된다. 첫 구현의 DIAMONDD와 gold는0을 반환하고 상수를 사용한 두 번째 구현의 SILVER는 실행 중 예외로 거부한다.
알 수 없는 등급을 0으로 처리하면 입력 오류가 숨습니다
두 DiscountService 구현 모두 매개변수는 String입니다. 가격 10_000에 대한 원문 호출 결과를 비교합니다. 반환값은 결제 금액이 아니라 할인액입니다.
| 구현·입력 | 분기 결과 | 실제 출력 |
|---|---|---|
첫 구현 · "DIAMOND" | 정의된 등급에 일치하여 30% 할인액을 계산합니다. | DIAMOND=3000 |
첫 구현 · "DIAMONDD" 또는 "gold" | 둘 다 컴파일되지만 일치하는 case가 없어 default → 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 선택 집합 자체를 닫지는 않습니다.
바깥에서 들어온 문자열을 한곳에서 검사하기
사용자 입력이나 파일, 다른 시스템에서 들어오는 등급은 상수를 쓰게 할 방법이 없습니다. 이런 값은 " gold "처럼 앞뒤에 공백이 붙어 있거나 소문자로 들어오는 일도 흔합니다. 그렇다고 등급을 쓰는 메서드마다 공백을 지우고 대소문자를 맞추게 하면, 같은 처리가 곳곳에 흩어지고 어딘가에서는 빠뜨리게 됩니다.
그래서 바깥에서 들어온 문자열은 프로그램 안으로 들어오는 입구에서 한 번만 정리하고 검사하는 편이 좋습니다. 다음 parse 메서드는 문자열을 받아 양끝 공백을 지우고 대문자로 바꾼 다음, 허용하는 세 등급 중 하나인지 확인합니다. 이처럼 값을 비교하기 좋은 일정한 형태로 맞추는 일을 정규화(normalization)라고 합니다.
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=silverparse는 세 단계로 일합니다. 먼저 null인지 확인합니다. null을 그대로 두면 다음 줄에서 strip()을 호출하는 순간 NullPointerException이 나므로, 그보다 앞에서 '등급이 없다'는 뜻이 담긴 예외로 바꿔 줍니다. 이 예제의 main은 null을 넘기지 않지만, 바깥 입력에는 null이 섞여 올 수 있으므로 미리 대비한 것입니다. 다음으로 strip()이 양끝 공백을 지우고 toUpperCase(Locale.ROOT)가 대문자로 바꿔 정규화합니다. 마지막으로 정규화한 값이 허용 목록에 있는지 확인합니다.
첫 호출의 " gold "는 공백이 지워져 "gold"가 되고, 대문자로 바뀌어 "GOLD"가 됩니다. 허용 목록에 있으므로 "GOLD"가 반환되어 첫 줄에 출력됩니다. 두 번째 호출의 "silver"는 정규화하면 "SILVER"가 되지만 허용 목록에 없으므로 예외가 납니다. 예외 메시지에는 정규화한 값이 아니라 원래 입력 raw를 넣었기 때문에 출력이 grade=silver입니다. 무엇이 잘못 들어왔는지 확인하려는 사람에게는 사용자가 실제로 보낸 값이 더 도움이 되기 때문입니다.
toUpperCase에 Locale.ROOT를 넘긴 데도 이유가 있습니다. 인수 없이 toUpperCase()를 호출하면 프로그램을 실행하는 컴퓨터의 언어 설정에 따른 대소문자 규칙을 씁니다. 그런데 터키어에서는 소문자 i의 대문자가 영어의 I가 아니라 점이 찍힌 İ입니다. 그래서 언어 설정이 터키어인 컴퓨터에서는 "basic"의 대문자가 "BASİC"이 되어 "BASIC"과 같지 않다는 판정이 나옵니다. Locale.ROOT는 특정 언어에 치우치지 않은 기본 규칙을 쓰라는 뜻이라서, 어느 컴퓨터에서 실행해도 "BASIC"이 나옵니다. 문자열 비교와 String 메서드에서 식별자를 정규화할 때 로케일을 명시하라고 한 것도 이 때문입니다.
문자열 검증은 성공해도 반환 타입은 String이다
외부 문자열 정규화→허용 목록 검사→반환과 silver/null 거부가 실제 경계 처리입니다. 본문의 검증된 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은 독립 입력이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 외부 gold 입력→strip/대문자→허용 목록→String GOLD. silver 거부/null 조기 거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다. |
| 8 | BASIC |
| 10 | DIAMOND |
| 구분 | 내용 |
|---|---|
| 설명 | 외부 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을 반환하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 외부 입력 정규화할 문자열을 받습니다. |
|
|
| 정규화와 허용 검사 strip/대문자로 GOLD를 얻고 허용 목록과 비교합니다. |
|
|
| 반환 경계 정상은 String GOLD를 반환하고 오류는 예외로 끝납니다. |
|
|
문자열 검증은 성공해도 반환 타입은 String이다
외부 문자열 정규화→허용 목록 검사→반환과 silver/null 거부가 실제 경계 처리입니다. 본문의 검증된 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은 독립 입력이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 외부 gold 입력→strip/대문자→허용 목록→String GOLD. silver 거부/null 조기 거부는 독립 입력. 검증 뒤에도 반환 타입은 String이다. |
| 8 | BASIC |
| 10 | DIAMOND |
| 구분 | 내용 |
|---|---|
| 설명 | 외부 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을 반환하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 외부 입력 정규화할 문자열을 받습니다. |
|
|
| 정규화와 허용 검사 strip/대문자로 GOLD를 얻고 허용 목록과 비교합니다. |
|
|
| 반환 경계 정상은 String GOLD를 반환하고 오류는 예외로 끝납니다. |
|
|
검사를 통과해도 String은 String입니다
parse를 거친 문자열은 분명히 "BASIC", "GOLD", "DIAMOND" 중 하나입니다. 그런데 이 사실은 parse를 짠 사람의 머릿속에만 있을 뿐, 반환 타입 어디에도 남아 있지 않습니다. parse가 돌려주는 값의 타입도, DiscountService.discount가 받는 매개변수의 타입도 그냥 String입니다. 그러니 컴파일러가 보기에는 parse의 반환값과 사용자가 입력한 날것의 문자열이 아무 차이가 없습니다.
그래서 다른 코드가 parse를 거치지 않고 discount("Gold", price)처럼 문자열을 직접 넘겨도 막을 방법이 없습니다. 결국 discount 안의 default 분기도 지울 수 없습니다. 바깥에서 한 번 검사했더라도, discount 입장에서는 들어온 문자열이 검사를 거친 것인지 알 수 없기 때문입니다. 등급을 쓰는 메서드가 늘어날수록 이 방어 코드도 함께 늘어납니다.
정말로 원하는 것은 BASIC, GOLD, DIAMOND 세 값만 가질 수 있는 타입입니다. 매개변수가 그런 타입이라면 discount에 "SILVER"를 넘기는 코드는 컴파일조차 되지 않을 것이고, 바깥에서 들어온 문자열은 입구에서 한 번 그 타입으로 바꾸고 나면 그 뒤로는 다시 검사할 필요가 없습니다. 다음 글에서는 이런 타입을 클래스로 직접 만들어 본 뒤, 자바가 같은 일을 언어 차원에서 해 주는 enum을 배웁니다.
문자열과 enum 중 무엇을 쓸까
그렇다고 정해진 값이 있는 곳마다 문자열을 없애야 하는 것은 아닙니다. 다음 질문에 '예'가 많을수록 문자열 대신 전용 타입으로 바꿀 이유가 커집니다.
- 허용하는 값이 코드를 작성할 때 이미 정해져 있는가?
- 값마다 설명, 할인율 같은 데이터나 동작을 붙여야 하는가?
switch의default가 잘못된 값을 조용히 삼키고 있지는 않은가?- 바깥에서 들어온 같은 문자열을 프로그램 곳곳에서 반복해서 비교하고 있는가?
- 새 값을 추가할 때 함께 고쳐야 하는 곳을 컴파일러가 찾아 주기를 바라는가?
회원 등급, 요일, HTTP 상태 코드처럼 선택지가 몇 개로 정해져 있고 코드와 함께 바뀌는 값은 enum으로 나타내기 좋습니다. 반대로 사용자가 자유롭게 붙이는 태그나 검색어는 값의 종류가 정해져 있지 않으므로 문자열이 맞습니다. 관리자가 운영 중에 새 게시판 분류를 추가하는 식으로 값의 목록이 데이터베이스에서 관리된다면, 코드에 고정하는 enum보다 데이터로 다루는 편이 낫습니다. enum에 값을 추가하려면 코드를 고쳐서 다시 배포해야 하기 때문입니다.
정리
String매개변수는 어떤 문자열이든 받아들이므로, 정해진 몇 가지 값을 문자열로 나타내면 오타가 컴파일을 그대로 통과합니다.- 알 수 없는 값을
default에서 정상적인 기본값으로 처리하면 버그가 조용히 숨습니다. 알 수 없는 값은 예외로 거부하는 편이 원인을 찾기 쉽습니다. - 문자열 상수는 상수 이름의 오타를 컴파일 단계에서 잡아 주지만, 문자열을 직접 넘기는 코드까지 막지는 못합니다.
- 바깥에서 들어온 문자열은 입구에서 한 번
null확인, 정규화, 허용 목록 검사를 합니다. 대소문자를 바꿀 때는Locale.ROOT를 명시합니다. - 검사를 통과해도 타입이
String이면 컴파일러는 그 사실을 모릅니다. 정해진 값만 갖는 타입이 필요하며, 다음 글의 enum이 그 답입니다.
연습 문제
-
게시글 난이도 EASY, NORMAL, HARD만 허용하는
parseDifficulty를 작성하세요. 입력의 양끝 공백과 대소문자는 정규화하고, 허용하지 않는 값은 원래 입력을 담은IllegalArgumentException으로 거부합니다.null도NullPointerException이 아니라 같은 예외로 거부해야 합니다. 작성한 뒤" hard ","Normal","extreme", 빈 문자열,null이 각각 어떻게 처리될지 먼저 예상해 보세요. -
본문의
StringGradeConstants를 기준으로, 다음 세 호출이 컴파일 단계에서 막히는지, 실행 중에 예외가 나는지, 정상적으로 할인액을 돌려주는지 구별하세요.service.discount(GradeName.GOLDD, 10_000); service.discount("GOLDD", 10_000); service.discount(" GOLD", 10_000); -
ValidatedGradeText.parse로 검사한 값만discount에 넘기기로 팀에서 약속했습니다. 그런데도discount안의default분기를 지우지 못하는 이유를 설명하세요.
해설 보기
1번. 본문의 parse와 같은 순서로 null 확인, 정규화, 허용 목록 검사를 합니다. 여러 입력을 한 번에 시험하도록 main을 반복문으로 작성했습니다.
import java.util.Locale;
public final class DifficultyTextExercise {
public static void main(String[] args) {
String[] inputs = {" hard ", "Normal", "extreme", "", null};
for (String input : inputs) {
try {
System.out.println("ok=" + parseDifficulty(input));
} catch (IllegalArgumentException error) {
System.out.println("rejected=" + error.getMessage());
}
}
}
private static String parseDifficulty(String raw) {
if (raw == null) {
throw new IllegalArgumentException("difficulty=null");
}
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;
}
}ok=HARD
ok=NORMAL
rejected=difficulty=[extreme]
rejected=difficulty=[]
rejected=difficulty=null" hard "는 공백이 지워지고 대문자가 되어 HARD로, "Normal"은 대문자가 되어 NORMAL로 받아들여집니다. "extreme"은 정규화해도 허용 목록에 없으므로 거부됩니다. 빈 문자열은 strip()을 거쳐도 빈 문자열이고, 이것 역시 허용 목록에 없으므로 같은 방식으로 거부됩니다. 메시지에서 입력을 대괄호로 감싼 것은 빈 문자열처럼 눈에 보이지 않는 입력도 알아볼 수 있게 하려는 것입니다. null은 첫 if에서 걸러지므로 strip()까지 가지 않습니다. 이 검사를 빼면 null은 strip()을 호출하는 줄에서 NullPointerException을 일으키고, catch가 IllegalArgumentException만 잡고 있으므로 프로그램이 거기서 멈춥니다.
2번. 첫 호출은 컴파일 단계에서 막힙니다. GradeName에 GOLDD라는 상수가 없으므로 cannot find symbol 오류가 납니다. 두 번째 호출은 컴파일됩니다. 매개변수 타입이 String이라 어떤 문자열이든 받기 때문입니다. 실행하면 "GOLDD"가 어느 case에도 맞지 않아 default에서 IllegalArgumentException이 납니다. 세 번째 호출도 마찬가지로 컴파일되고 실행 중에 예외가 납니다. DiscountService는 정규화를 하지 않으므로 앞에 공백이 붙은 " GOLD"는 "GOLD"와 다른 문자열입니다. 상수가 막아 주는 것은 상수 이름의 오타뿐이고, 문자열을 직접 쓰는 순간 그 보호는 사라진다는 점을 확인할 수 있습니다.
3번. parse가 돌려주는 값과 사용자가 입력한 날것의 문자열은 둘 다 String이라서 컴파일러가 구별하지 못하기 때문입니다. 팀의 약속은 컴파일러가 검사하지 않으므로, 누군가 약속을 모르거나 잊고 discount("gold", price)를 호출해도 아무 경고 없이 컴파일됩니다. default 분기를 지우면 그런 호출을 처리할 방법이 없어집니다. 실제로 지금의 switch 식은 default를 지우면 모든 문자열을 처리하지 못한다는 이유로 컴파일조차 되지 않습니다. 매개변수 타입을 세 값만 갖는 타입으로 바꿔야 비로소 이 방어 코드가 필요 없어집니다.
다음 글
enum으로 정해진 값만 갖는 타입 만들기에서는 BASIC, GOLD, DIAMOND 세 값만 가질 수 있는 등급 타입을 먼저 클래스로 직접 만들어 보고, 자바의 enum이 같은 일을 어떻게 더 간결하고 안전하게 해 주는지 살펴봅니다.