애노테이션으로 필드 값 검증하기
필드에 붙인 애노테이션을 리플렉션으로 읽어 값을 검사하고, 첫 오류에서 멈추지 않고 모든 위반을 모아 돌려주는 검증기를 직접 만듭니다.
회원 가입 요청을 처리한다고 해 봅시다. User 객체를 받으면 이름이 비어 있지 않은지, 나이가 1부터 120까지의 범위에 있는지 확인해야 합니다. 이런 검사를 손으로 쓰면 보통 다음과 같은 모양이 됩니다.
static List<String> check(User user) {
List<String> errors = new ArrayList<>();
if (user.name == null || user.name.isBlank()) {
errors.add("name is required");
}
if (user.age < 1 || user.age > 120) {
errors.add("age must be 1 through 120");
}
return errors;
}이 코드 자체는 틀린 데가 없습니다. 하지만 Team에도 이름과 인원수 검사가 필요하고 Order에도 수량 검사가 필요해지면, 필드 이름만 다른 if 문을 객체 종류마다 다시 써야 합니다. 게다가 '나이는 1부터 120까지'라는 규칙이 User 클래스가 아니라 멀리 떨어진 검사 메서드에 적혀 있습니다. 그래서 필드를 바꿀 때 검사 코드도 함께 고쳐야 한다는 사실을 잊기 쉽습니다.
앞에서 다룬 실행 시점 애노테이션을 쓰면 규칙을 필드 바로 옆에 적을 수 있습니다.
class User {
@NotBlank(message = "name is required")
String name;
@Range(min = 1, max = 120, message = "age must be 1 through 120")
int age;
}@NotBlank와 @Range는 이 글에서 직접 정의할 애노테이션입니다. 애노테이션은 붙여 두기만 해서는 아무 일도 하지 않으므로, 객체를 받아 필드마다 붙은 애노테이션을 리플렉션으로 읽고 값을 검사하는 공통 검증기(validator)가 있어야 합니다. 검증기를 한 번 만들어 두면 User든 Team이든 애노테이션만 붙여서 같은 방식으로 검사할 수 있습니다. Jakarta EE의 표준인 Jakarta Bean Validation(대표 구현체는 Hibernate Validator)이 바로 이 방식으로 동작하며, 이 글에서는 그 핵심을 작게 직접 만들어 봅니다.
반복 코드가 사라지는 대신 새로 맡아야 할 일도 생깁니다. 손으로 쓴 if 문이라면 숫자 필드에 isBlank()를 호출하는 실수를 컴파일러가 막아 주지만, 검증기는 이런 실수를 실행 중에 스스로 찾아내야 합니다. private 필드를 읽을 수 없을 때와 부모 클래스에 선언된 필드를 만났을 때 어떻게 할지도 정해야 합니다. 그리고 쓸 만한 검증기는 첫 오류에서 예외를 던지고 멈추지 않고, 찾을 수 있는 위반을 모두 모아 목록으로 돌려줍니다. 사용자가 폼을 한 번 제출했을 때 고쳐야 할 곳을 한꺼번에 알려 주려면 그래야 합니다.
첫 오류에서 멈추는 검증기의 문제
흔히 처음 떠올리는 모양부터 실행해 보겠습니다. 아래 validate는 객체의 필드를 모두 돌면서 @NotEmpty가 붙은 필드를 찾고, 값이 비어 있으면 그 자리에서 RuntimeException을 던집니다. main은 두 객체로 이 검증기를 실행합니다. Signup은 이메일과 닉네임이 둘 다 빈 문자열이고, Order는 개발자가 실수로 int 필드에 @NotEmpty를 붙인 경우입니다.
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
public final class FailFastReflectiveValidator {
@Retention(RetentionPolicy.RUNTIME)
@interface NotEmpty {
String message();
}
static void validate(Object target) throws Exception {
for (var field : target.getClass().getDeclaredFields()) {
field.setAccessible(true);
if (field.isAnnotationPresent(NotEmpty.class)) {
String value = (String) field.get(target);
if (value == null || value.isEmpty()) {
throw new RuntimeException(
field.getAnnotation(NotEmpty.class).message());
}
}
}
}
static final class Signup {
@NotEmpty(message = "email is required")
private final String email;
@NotEmpty(message = "nickname is required")
private final String nickname;
Signup(String email, String nickname) {
this.email = email;
this.nickname = nickname;
}
}
static final class Order {
@NotEmpty(message = "quantity is required")
private final int quantity;
Order(int quantity) {
this.quantity = quantity;
}
}
static void report(Object target) {
try {
validate(target);
System.out.println("valid");
} catch (Exception error) {
System.out.println(error.getClass().getSimpleName()
+ ": " + error.getMessage());
}
}
public static void main(String[] args) {
report(new Signup("", ""));
report(new Order(3));
}
}report는 validate가 던진 예외를 잡아 예외 클래스의 짧은 이름과 메시지를 한 줄로 찍습니다. 예외 없이 검증을 통과했다면 valid가 찍혔을 것입니다.
RuntimeException: email is required
ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')첫 줄을 보면 닉네임도 비어 있는데 이메일 오류 하나만 나왔습니다. 첫 위반에서 예외를 던지는 순간 반복문을 빠져나오기 때문입니다. 사용자는 이메일을 고쳐 다시 제출한 뒤에야 닉네임도 틀렸다는 것을 알게 됩니다. 어느 필드가 먼저 검사되는지도 getDeclaredFields()가 돌려주는 순서에 달려 있습니다. 이 실행에서는 선언한 순서대로 나왔지만, Javadoc은 이 배열의 원소가 정렬되어 있지 않고 특정한 순서도 없다고 밝힙니다.
두 번째 줄은 더 곤란합니다. quantity는 int 필드이므로 field.get(target)은 값을 Integer 객체로 감싸서 돌려줍니다. 이것을 (String)으로 형변환하는 순간 ClassCastException이 발생했습니다. 애노테이션을 잘못 붙인 개발자의 실수인데, 무엇이 잘못됐는지 알려 주는 대신 형변환 오류가 튀어나온 것입니다.
실행 결과에 드러나지 않은 문제도 있습니다. 먼저 애노테이션이 있든 없든 모든 필드에 setAccessible(true)를 호출합니다. 검사할 필요가 없는 필드까지 접근 제한을 풀려고 하므로, 열 수 없는 필드가 하나만 있어도 InaccessibleObjectException으로 검증 전체가 실패합니다. static 필드도 가리지 않아서, @NotEmpty가 붙은 static 필드가 있으면 이 객체가 아니라 클래스 전체가 공유하는 값을 검사하게 됩니다. 마지막으로 사용자 입력 위반을 일반 RuntimeException으로 던지는데, ClassCastException과 InaccessibleObjectException도 RuntimeException의 하위 클래스입니다. 그래서 호출하는 쪽에서 RuntimeException을 잡으면 입력 오류와 개발자의 실수, 접근 설정 문제가 한데 섞여 구별할 수 없습니다.
컴파일러가 잡지 못하는 잘못된 애노테이션 배치
Order 같은 실수를 컴파일러가 막아 줄 수는 없을까요? 애노테이션 정의에 @Target(ElementType.FIELD)를 붙이면 메서드나 클래스에 붙이는 실수는 컴파일 오류가 됩니다. 하지만 @Target이 정하는 것은 어떤 종류의 선언에 붙일 수 있는지까지이고, 그 필드의 타입은 제한하지 못합니다. 그래서 @NotBlank int count 같은 선언도 문제없이 컴파일됩니다.
결국 이런 배치는 검증기가 실행 중에 찾아내야 하고, 이때 사용자 입력 위반과 다른 종류로 보고하는 것이 중요합니다. 둘은 고칠 사람이 다르기 때문입니다. 이름이 비어 있는 것은 사용자가 입력을 고치면 되는 문제라서 보통 HTTP 400 응답이나 폼의 오류 표시로 이어집니다. 반면 int 필드에 @NotBlank를 붙인 것은 개발자가 코드를 고쳐야 하는 버그이므로, 사용자에게 보여 줄 것이 아니라 테스트나 서버 시작 단계에서 드러나야 합니다. 이 글에서는 앞의 것을 입력 위반(INPUT), 뒤의 것을 구성 오류(CONFIGURATION)라고 부르겠습니다.
위반을 모두 모으는 검증기 만들기
이제 앞의 문제를 고친 검증기를 만들겠습니다. 검증기는 대상 객체의 클래스에서 시작해 Object에 닿을 때까지 부모 클래스로 올라가며, 각 클래스에 선언된 필드를 하나씩 살펴봅니다. 필드마다 static 필드나 합성 필드인지 먼저 확인해 건너뛰고, @NotBlank나 @Range가 붙은 필드에만 접근을 시도합니다. 값을 읽은 뒤에는 애노테이션과 필드 타입이 맞는지 확인합니다. 맞지 않으면 구성 오류로 기록하고, 타입은 맞는데 값이 규칙을 어기면 입력 위반으로 기록합니다. 어느 경우에도 예외를 던지지 않고 기록만 하므로, 반복이 끝나면 찾을 수 있는 위반이 모두 목록에 모입니다.
위반 하나는 Violation 레코드에 담습니다. 필드 이름, 제약 이름, 메시지, 위반 종류를 담고, 거절된 실제 값은 일부러 담지 않았습니다. 입력값에는 전화번호나 주소 같은 개인정보가 들어 있을 수 있기 때문입니다. 값을 사용자 응답에 보여 줄지, 서버 로그에 남길지, 가려서 남길지는 검증기가 아니라 결과를 받아 응답과 로그를 만드는 쪽이 정하게 하는 편이 안전합니다. 실무에서는 여기에 객체 타입이나 address.city 같은 필드 경로를 더 담기도 합니다.
main은 두 객체를 검사합니다. Member는 사용자가 잘못 입력한 값을 담은 객체이고, Misconfigured는 애노테이션을 잘못 붙인 클래스입니다.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
public final class CollectingAnnotationValidator {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface NotBlank {
String message() default "must not be blank";
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Range {
long min();
long max();
String message() default "out of range";
}
record Violation(String field, String constraint,
String message, String kind) {
}
static List<Violation> validate(Object target) {
if (target == null) {
return List.of(new Violation(
"<root>",
"NotNull",
"target is null",
"INPUT"));
}
List<Violation> violations = new ArrayList<>();
Class<?> type = target.getClass();
while (type != Object.class) {
for (Field field : type.getDeclaredFields()) {
inspectField(target, field, violations);
}
type = type.getSuperclass();
}
return List.copyOf(violations);
}
private static void inspectField(
Object target,
Field field,
List<Violation> violations) {
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) {
return;
}
NotBlank notBlank = field.getAnnotation(NotBlank.class);
Range range = field.getAnnotation(Range.class);
if (notBlank == null && range == null) {
return;
}
if (!field.trySetAccessible()) {
violations.add(new Violation(
field.getName(),
"Access",
"field package is not open",
"CONFIGURATION"));
return;
}
Object value;
try {
value = field.get(target);
} catch (IllegalAccessException error) {
violations.add(new Violation(
field.getName(),
"Access",
error.getClass().getSimpleName(),
"CONFIGURATION"));
return;
}
if (notBlank != null) {
if (field.getType() != String.class) {
violations.add(new Violation(
field.getName(),
"NotBlank",
"constraint requires String",
"CONFIGURATION"));
} else if (value == null || ((String) value).isBlank()) {
violations.add(new Violation(
field.getName(),
"NotBlank",
notBlank.message(),
"INPUT"));
}
}
if (range != null) {
if (range.min() > range.max()) {
violations.add(new Violation(
field.getName(),
"Range",
"min is greater than max",
"CONFIGURATION"));
} else if (!numeric(field.getType())) {
violations.add(new Violation(
field.getName(),
"Range",
"constraint requires numeric field",
"CONFIGURATION"));
} else if (value != null) {
long number = ((Number) value).longValue();
if (number < range.min() || number > range.max()) {
violations.add(new Violation(
field.getName(),
"Range",
range.message(),
"INPUT"));
}
}
}
}
private static boolean numeric(Class<?> type) {
return type == byte.class
|| type == short.class
|| type == int.class
|| type == long.class
|| Number.class.isAssignableFrom(type);
}
static class AccountBase {
@NotBlank(message = "tenant is required")
private final String tenant;
AccountBase(String tenant) {
this.tenant = tenant;
}
}
static final class Member extends AccountBase {
@NotBlank(message = "name is required")
private final String name;
@Range(min = 1, max = 120, message = "age must be 1 through 120")
private final int age;
Member(String tenant, String name, int age) {
super(tenant);
this.name = name;
this.age = age;
}
}
static final class Misconfigured {
@NotBlank
private final int count;
@Range(min = 10, max = 1)
private final long limit;
Misconfigured(int count, long limit) {
this.count = count;
this.limit = limit;
}
}
public static void main(String[] args) {
validate(new Member(" ", "", 0)).forEach(System.out::println);
System.out.println("---");
validate(new Misconfigured(5, 5)).forEach(System.out::println);
}
}--- 위는 Member의 결과이고 아래는 Misconfigured의 결과입니다. 각 줄은 Violation 레코드이며, 마지막 칸 kind가 사용자의 입력 문제인지 애노테이션을 잘못 붙인 문제인지를 나눕니다.
Violation[field=name, constraint=NotBlank, message=name is required, kind=INPUT]
Violation[field=age, constraint=Range, message=age must be 1 through 120, kind=INPUT]
Violation[field=tenant, constraint=NotBlank, message=tenant is required, kind=INPUT]
---
Violation[field=count, constraint=NotBlank, message=constraint requires String, kind=CONFIGURATION]
Violation[field=limit, constraint=Range, message=min is greater than max, kind=CONFIGURATION]new Member(" ", "", 0)은 tenant에 공백 한 칸, name에 빈 문자열, age에 0을 넣습니다. 처음 세 줄이 이 세 필드의 입력 위반입니다. name과 tenant는 isBlank()가 true를 돌려주는 문자열이고, age는 1부터 120까지의 범위를 벗어났습니다. 앞의 검증기와 달리 위반 세 개가 한꺼번에 나왔습니다. tenant가 맨 뒤에 나온 것은 검증기가 Member에 직접 선언된 필드를 먼저 검사한 다음 부모 클래스 AccountBase로 올라가기 때문입니다.
--- 아래 두 줄은 구성 오류입니다. count는 int인데 @NotBlank가 붙어 있고, limit의 @Range는 최솟값 10이 최댓값 1보다 큽니다. 둘 다 사용자가 어떤 값을 넣어도 고칠 수 없는 문제이므로, 실제 값 5와 상관없이 CONFIGURATION으로 기록되었습니다. 앞에서 ClassCastException이 튀어나왔던 것과 같은 종류의 실수지만, 이번에는 어느 필드에서 무엇이 잘못됐는지 알려 줍니다.
필드를 찾고 읽는 방법
필드를 모으는 데 getDeclaredFields()를 쓴 이유부터 보겠습니다. 이 메서드는 접근 수준과 상관없이 그 클래스에 직접 선언된 필드를 모두 돌려주지만, 상속받은 필드는 포함하지 않습니다. 반대로 getFields()는 상속받은 필드까지 돌려주지만 public 필드만 담습니다. 모델의 필드는 대개 private이므로 getDeclaredFields()를 쓰고, 상속받은 필드는 getSuperclass()로 한 단계씩 올라가며 직접 모읍니다.
합성 필드(synthetic field)는 소스 코드에 없는데 컴파일러가 만들어 넣은 필드입니다. 예를 들어 javac는 바깥 객체를 사용하는 내부 클래스에 그 바깥 객체를 가리키는 this$0 필드를 만들어 넣습니다. 사용자가 선언한 값이 아니므로 isSynthetic()으로 걸러 냅니다. 이 예제의 중첩 클래스는 모두 static이라 합성 필드가 없지만, 검증기는 어떤 클래스를 받을지 모르므로 미리 대비해 둡니다.
inspectField가 애노테이션부터 확인하고, 제약이 없는 필드라면 접근을 시도하지 않고 바로 돌아간다는 점도 중요합니다. 계층을 올라가다 보면 JDK 클래스처럼 우리가 열 수 없는 부모 클래스를 만날 수 있습니다. 그런 클래스의 필드에는 우리 애노테이션이 없으므로 접근을 시도할 일도 없고, 따라서 실패할 일도 없습니다.
trySetAccessible()은 setAccessible(true)와 같은 요청을 하지만, 실패했을 때 예외를 던지지 않고 false를 돌려줍니다. 그래서 반복 도중에 예외로 빠져나가지 않고, 실패를 위반으로 기록한 뒤 다음 필드로 넘어갈 수 있습니다. 여기서 false를 '검사할 것이 없음'으로 보고 넘기면 안 됩니다. 그러면 접근 설정이 하나 잘못되었을 때 그 필드의 제약이 조용히 사라지고, 잘못된 입력이 검증을 통과합니다. 접근 해제에 성공한 뒤에도 field.get은 검사 예외 IllegalAccessException을 선언하므로, catch로 받아 구성 오류로 기록합니다.
나머지 처리도 같은 원칙을 따릅니다. 대상 객체가 null이면 살펴볼 필드가 없으므로 필드 이름 자리에 <root>를 쓴 위반 하나를 돌려줍니다. 마지막의 List.copyOf는 수정할 수 없는 목록을 만들어, 호출하는 쪽이 결과 목록을 실수로 바꾸지 못하게 합니다.
상속 필드와 보고 순서
부모 클래스의 필드까지 검사할지는 검증기를 설계하는 사람이 정할 문제입니다. getDeclaredFields()만 쓰면 부모 클래스에 선언된 제약이 빠지고, 이 예제처럼 계층을 올라가면 부모의 제약까지 검사됩니다. 계층을 올라갈 때는 부모와 자식에 같은 이름의 필드가 있는 경우도 생각해야 합니다. 자식의 필드가 부모의 필드를 가리더라도 리플렉션으로는 둘 다 보이므로, 단순 필드 이름만으로는 위반이 어느 쪽 것인지 알 수 없습니다. 실무 검증기라면 AccountBase.tenant처럼 선언 클래스를 붙이거나 속성 경로를 쓰는 편이 낫습니다.
보고 순서도 따로 정해야 합니다. 앞에서 본 것처럼 getDeclaredFields()의 순서는 명세가 정하지 않으므로, 이 검증기의 출력 순서는 같은 JDK에서 실행하면 대개 같게 나올 뿐입니다. 테스트에서 결과를 순서까지 비교하거나 사용자에게 일정한 순서로 보여 줘야 한다면, 선언 클래스와 필드 이름 같은 기준으로 정렬해서 돌려줘야 합니다. 참고로 Jakarta Bean Validation의 Validator.validate도 결과를 Set으로 돌려주므로 순서를 약속하지 않습니다.
더 깊이: trySetAccessible()이 false를 돌려주는 경우
이 글의 예제는 모두 한 파일 안의 중첩 클래스입니다. 검증기와 모델 클래스가 같은 이름 없는 모듈(unnamed module)에 있으므로 trySetAccessible()은 언제나 성공합니다. 하지만 실제 서비스에서는 검증기가 라이브러리 모듈에 있고, 검사할 모델 클래스는 애플리케이션 모듈에 있을 수 있습니다. 모듈 시스템에서 다른 모듈의 private 멤버까지 리플렉션으로 다루려면 그 패키지를 가진 모듈이 opens로 허락해야 합니다. exports만으로는 public 멤버에만 접근할 수 있습니다.
열리지 않은 패키지에서 어떤 일이 생기는지는 JDK 자신의 클래스로 확인할 수 있습니다. java.base 모듈은 java.lang 패키지를 공개(exports)하지만 열지는(opens) 않습니다. 다음 코드는 String의 private 필드 value를 열어 보려고 합니다.
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;
public final class ClosedPackageProbe {
public static void main(String[] args) throws Exception {
Field value = String.class.getDeclaredField("value");
System.out.println("trySetAccessible=" + value.trySetAccessible());
try {
value.setAccessible(true);
System.out.println("setAccessible succeeded");
} catch (InaccessibleObjectException error) {
System.out.println(error.getMessage());
}
}
}getDeclaredField("value")로 필드를 찾는 것까지는 성공합니다. 막히는 것은 그 필드의 접근 검사를 끄려고 할 때입니다.
trySetAccessible=false
Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @2a139a55trySetAccessible()은 실패를 false로 알려 주고, 같은 요청을 setAccessible(true)로 하면 InaccessibleObjectException이 발생합니다. 메시지는 java.base 모듈이 java.lang 패키지를 우리 코드가 속한 이름 없는 모듈에 열지 않았다는 뜻입니다. 끝의 @2a139a55는 모듈 객체의 해시 코드라서 실행 방식이나 환경에 따라 다른 값이 나옵니다. 실행할 때 --add-opens java.base/java.lang=ALL-UNNAMED 옵션을 주면 패키지가 열려서 trySetAccessible=true와 setAccessible succeeded가 출력됩니다.
이 동작이 기본값이 된 것은 JDK 16부터입니다. JDK 9에서 모듈 시스템이 들어온 뒤에도 JDK 15까지는 JDK 8 시절부터 있던 패키지에 대한 이런 접근을 처음 한 번 경고하고 허용했습니다. JEP 396이 JDK 16에서 기본 동작을 거부로 바꾸었고, JEP 403이 JDK 17에서 이 기본값을 예전처럼 되돌릴 수 있던 --illegal-access 옵션을 없앴습니다. 그래서 JDK 17부터는 옵션 하나로 이 제한을 한꺼번에 풀 수 없고, 패키지를 가진 모듈이 opens로 허락하거나 실행할 때 --add-opens 같은 옵션으로 패키지를 하나씩 열어 주어야 합니다.
검증기 입장에서는 애플리케이션이 모델 패키지를 검증기 모듈에 열지 않았을 때 바로 이 상황이 됩니다. 앞의 예제가 false를 구성 오류로 보고하는 것도 그래서입니다. opens를 요구하고 싶지 않다면 비공개 필드 대신 public 읽기 메서드나 레코드의 읽기 메서드를 호출해 값을 읽는 방법도 있습니다. 공개 타입의 public 메서드는 패키지가 exports되어 있기만 하면 리플렉션으로 호출할 수 있으므로, 캡슐화를 덜 깨뜨립니다.
Range가 받아들이는 숫자 타입
@Range의 min과 max는 long 타입입니다. 검증기는 numeric 메서드로 필드 타입을 확인한 뒤, 값을 longValue()로 바꿔 이 둘과 비교합니다. 이름만 보면 정수 범위를 검사하는 제약 같지만, numeric이 허용하는 타입과 longValue() 변환을 함께 놓고 보면 실제 동작은 조금 다릅니다.
Range가 실제로 허용하는 타입과 변환
필드 범위 검증 · 타입 허용과 값 변환
Range가 실제로 허용하는 타입과 변환
기본형 float·double은 거절하지만 Number 참조형은 허용합니다. longValue로 비교하면 소수나 큰 수 정보가 손실될 수 있습니다.
정수 기본형·정수 래퍼
- numeric 판정
- byte·short·int·long 및 각 래퍼 허용
- 원문의 후속 처리
- 박싱된 Number의 longValue로 비교
실수 기본형
- numeric 판정
- float·double은 거절
- 원문의 후속 처리
- CONFIGURATION 위반
그 밖의 Number 참조형
- numeric 판정
- Float·Double·BigDecimal 등도 허용
- 원문의 후속 처리
- longValue 변환에서 소수·크기 정보가 손실될 수 있음
허용된 참조형의 null
- numeric 판정
- 타입 검사를 통과해도 값은 없음
- 원문의 후속 처리
- 범위 비교를 건너뜀
min≤max인 구성에서 numeric과 값 변환 분기를 비교했습니다. 이 구현은 정수 전용 정책을 완전히 강제하지 않으며 null의 필수 여부는 별도 제약입니다.
정수 기본형 네 가지(byte, short, int, long)와 그 래퍼는 값이 그대로 비교됩니다. 기본형 float와 double은 numeric의 목록에 없으므로 구성 오류가 됩니다. 그런데 Double, Float, BigDecimal 같은 참조형은 모두 Number의 하위 타입이라서 Number.class.isAssignableFrom(type) 검사를 통과합니다. 이 값들은 longValue()를 거치며 소수점 아래가 버려지고, long의 범위를 넘는 큰 수는 크기 정보까지 잃습니다. 예를 들어 new BigDecimal("1E+400").longValue()는 0입니다. 그리고 허용된 참조형 필드의 값이 null이면 비교할 값이 없으므로 범위 검사를 건너뜁니다.
그래서 이 제약을 정수 전용으로 쓰려면 numeric이 허용하는 타입을 정수 기본형과 Byte, Short, Integer, Long으로 좁혀야 합니다. 소수나 큰 수까지 지원하려면 소수 범위를 어떻게 해석할지 먼저 정하고, BigDecimal이라면 compareTo처럼 그 타입이 제공하는 비교 연산을 써야 합니다. 값이 꼭 있어야 한다는 조건은 @Range가 아니라 별도의 필수값 제약으로 표현합니다.
확인 문제
@Range(min = 1, max = 120)이 붙은 Double 필드에 0.9, 1.9, null을 각각 넣고 앞의 CollectingAnnotationValidator로 검사합니다. 다른 제약은 없고 필드 접근은 성공한다고 가정합니다. 각 값은 어떤 결과가 될까요? 같은 숫자를 기본형 double 필드에 넣으면 무엇이 달라지는지도 생각해 보세요.
Double과 double 필드의 결과 해설
Double은 Number의 하위 타입이므로 numeric 검사를 통과하고, 값은 longValue()로 바뀐 뒤 비교됩니다. 0.9는 0이 되어 범위를 벗어나므로 입력 위반이 하나 생기고, 메시지는 기본값인 out of range입니다. 1.9는 1이 되어 통과합니다. 같은 이유로 120.5도 120으로 잘려서, 최댓값을 넘는데도 통과합니다. null은 비교할 값이 없으므로 범위 검사를 건너뛰어 위반이 없습니다.
기본형 double 필드는 값을 보기 전에 결과가 정해집니다. numeric의 목록에 double.class가 없으므로, 0.9든 1.9든 constraint requires numeric field라는 구성 오류가 됩니다. 이름이 비슷한 double과 Double이 이 검증기에서는 전혀 다르게 취급되는 셈입니다.
자바 기본 애노테이션은 언제 읽힐까
우리가 만든 @NotBlank와 @Range에는 실행 중에 리플렉션으로 읽히도록 RUNTIME 유지 정책을 붙였습니다. 자바가 기본으로 제공하는 애노테이션은 누가 언제 읽느냐에 따라 유지 정책이 다릅니다. 앞 글에서 본 SOURCE, CLASS, RUNTIME 유지 정책을 자주 쓰는 세 애노테이션에 대입하면 다음과 같습니다.
| 애노테이션 | 유지 정책 | 읽는 쪽 | 하는 일 |
|---|---|---|---|
@Override | SOURCE | 컴파일러 | 상위 타입의 메서드를 실제로 재정의하는지 검사 |
@SuppressWarnings | SOURCE | 컴파일러 | 지정한 종류의 경고를 그 선언 안에서만 숨김 |
@Deprecated | RUNTIME | 컴파일러, IDE, 리플렉션 | 사용 중단 경고와 since·forRemoval 정보 제공 |
다음 코드는 세 애노테이션을 모두 쓰고, 실행 중에 무엇이 남아 있는지 리플렉션으로 확인합니다.
import java.lang.annotation.Retention;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class JavaAnnotationLifecycle {
static class Parent {
public String call() {
return "parent";
}
}
static final class Child extends Parent {
@Override
public String call() {
return "child";
}
}
static final class LegacyApi {
@Deprecated(since = "2.4", forRemoval = true)
public void oldCall() {
}
public void newCall() {
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
static List<String> legacyList() {
List raw = new ArrayList();
raw.add("value");
return (List<String>) raw;
}
public static void main(String[] args) throws Exception {
System.out.println(new Child().call());
Method old = LegacyApi.class.getMethod("oldCall");
Deprecated deprecated = old.getAnnotation(Deprecated.class);
System.out.println("deprecated=" + (deprecated != null));
System.out.println("since=" + deprecated.since());
System.out.println("forRemoval=" + deprecated.forRemoval());
System.out.println("override retention="
+ Override.class.getAnnotation(Retention.class).value());
Method childCall = Child.class.getDeclaredMethod("call");
System.out.println("runtime child annotations="
+ Arrays.toString(childCall.getDeclaredAnnotations()));
System.out.println(legacyList());
}
}첫 줄만 메서드를 호출한 결과이고, 나머지 줄은 리플렉션으로 애노테이션 정보를 읽거나 legacyList()의 반환값을 찍은 것입니다. @SuppressWarnings는 컴파일러 경고만 끄므로 legacyList()의 동작에는 영향이 없습니다.
child
deprecated=true
since=2.4
forRemoval=true
override retention=SOURCE
runtime child annotations=[]
[value]첫 줄 child는 @Override가 붙은 Child.call()이 호출된 결과입니다. 다음 세 줄은 oldCall()에 붙은 @Deprecated를 실행 중에 읽은 것입니다. RUNTIME 유지 정책이라 getAnnotation이 애노테이션 객체를 돌려주고, since와 forRemoval에 적은 값도 그대로 읽힙니다. override retention=SOURCE는 Override 타입 자체에 붙은 @Retention을 읽은 결과입니다. 그래서 다음 줄에서 Child.call()의 애노테이션을 조회하면 빈 배열이 나옵니다. 컴파일러가 검사를 마친 뒤 @Override를 클래스 파일에 기록하지 않았기 때문입니다. 마지막 줄은 legacyList()가 돌려준 목록입니다.
@Override가 하는 일은 컴파일할 때 드러납니다. Child의 메서드 이름을 cal로 잘못 쓰면 재정의가 아니라 새 메서드를 선언한 것이 되는데, @Override가 붙어 있으므로 컴파일러가 이를 오류로 알려 줍니다.
JavaAnnotationLifecycle.java:15: error: method does not override or implement a method from a supertype
@Override
^
1 error@Deprecated도 실행 전에 컴파일러가 먼저 읽습니다. 다만 이 예제에서는 main이 oldCall()을 직접 호출하더라도 경고가 나오지 않습니다. Java 언어 명세가 선언과 사용이 같은 최상위 클래스 안에 있으면 경고하지 않도록 정하고 있기 때문입니다. 그래서 다른 클래스에서 호출해 보겠습니다. 아래 Caller.java를 같은 폴더에 두고 두 파일을 함께 컴파일하면 javac가 별도 옵션 없이도 경고를 냅니다.
public class Caller {
public static void main(String[] args) {
new JavaAnnotationLifecycle.LegacyApi().oldCall();
}
}Caller.java:3: warning: [removal] oldCall() in LegacyApi has been deprecated and marked for removal
new JavaAnnotationLifecycle.LegacyApi().oldCall();
^
1 warningforRemoval = true라서 [removal] 경고가 바로 나온 것입니다. forRemoval이 기본값 false인 일반 사용 중단이라면, 기본 설정에서는 사용 중단된 API를 쓴다는 요약 안내(Note:)만 나오고 -Xlint:deprecation을 켜야 위치별 경고가 보입니다.
경고를 숨길 때와 사용 중단을 알릴 때
legacyList()는 raw 타입(타입 인자 없이 쓴 제네릭 타입) List를 돌려주는 옛 API를 감싸는 상황을 흉내 낸 메서드입니다. @SuppressWarnings({"rawtypes", "unchecked"})를 이 메서드 하나에만 붙였으므로, 메서드 밖에서 새로 생기는 raw 타입 경고는 그대로 보입니다. 같은 애노테이션을 클래스에 붙이면 앞으로 그 클래스에 추가되는 코드에서 생기는 같은 종류의 경고까지 모두 숨겨집니다. 그래서 경고 억제는 가능한 한 가장 좁은 선언에 구체적인 키로 붙이고, 왜 안전한지 주석으로 적어 둡니다. raw 타입을 다루는 코드를 이렇게 메서드 하나에 가둬 두면 나머지 코드는 제네릭 타입 검사를 그대로 받습니다.
키를 고를 때 "all"은 피하는 편이 좋습니다. Eclipse 컴파일러 같은 일부 도구는 이 키를 모든 경고를 숨기라는 뜻으로 받아들이지만, javac 문서는 -Xlint 키 가운데 all과 none은 @SuppressWarnings에 쓸 수 없다고 밝힙니다. 실제로 JDK 21의 javac에서 -Xlint:all로 컴파일하면 @SuppressWarnings("all")을 붙인 메서드의 raw 타입 경고도 그대로 출력됩니다. 도구마다 해석이 다른 키보다 unchecked, rawtypes, deprecation처럼 javac가 인식하는 키를 쓰는 것이 확실합니다.
@Deprecated를 붙일 때는 언제부터인지 since에 적고, 실제로 제거할 계획이 있을 때만 forRemoval = true를 씁니다. 무엇으로 바꿔야 하는지는 Javadoc의 @deprecated 태그로 안내합니다. 이 애노테이션은 RUNTIME까지 남으므로, 라우트를 찾는 스캐너 같은 프레임워크 코드가 시작할 때 사용 중단된 핸들러를 찾아 경고할 수도 있습니다. 경고에서 그칠지 실행을 막을지는 기존 사용자와의 호환성을 따져 정합니다.
애노테이션마다 처리기 나누기
지금 검증기는 inspectField 한 메서드 안에서 NotBlank와 Range를 if 문으로 처리합니다. 제약이 두 개일 때는 괜찮지만, @Email이나 @Size 같은 제약이 추가될 때마다 이 메서드에 분기가 늘어납니다. 그러면 필드를 순회하는 코드와 제약별 규칙이 한 메서드에 뒤섞여, 제약 하나를 고치다가 순회 코드를 건드리게 됩니다.
해결 방법은 제약마다 처리기(handler)를 따로 만들고, 애노테이션 타입을 키로 처리기를 찾는 레지스트리(registry)를 두는 것입니다. 처리기 하나가 자기 애노테이션이 붙을 수 있는 필드 타입, 애노테이션 요소 사이의 조건(예: min <= max), 실제 값 검사를 함께 맡습니다. 순회 코드는 필드에서 애노테이션을 꺼내 레지스트리에 물어보기만 하면 되므로, 새 제약을 추가해도 바뀌지 않습니다. 레지스트리를 만든 뒤에는 바꿀 수 없게 해 두면, 검증이 진행되는 도중에 처리기 목록이 바뀌는 상황도 생각할 필요가 없습니다.
검증 과정을 단계로 나눠 보면, 단계마다 생길 수 있는 문제와 그 결과가 다릅니다.
| 단계 | 언제 | 입력 | 생길 수 있는 문제 | 결과 |
|---|---|---|---|---|
| 검증 계획 만들기 | 클래스마다 한 번 | Field와 Annotation | 필드 타입 불일치, min과 max 역전 | CONFIGURATION 문제 |
| 접근 준비 | 클래스마다 한 번 | Field와 모듈 설정 | 패키지가 열려 있지 않음 | 시작할 때 실패 |
| 값 읽기 | 검증할 때마다 | 객체 | IllegalAccessException | 프레임워크 오류 |
| 제약 평가 | 검증할 때마다 | 필드 값 | 사용자 입력이 규칙을 어김 | 위반 목록 |
| 응답 만들기 | 검증할 때마다 | 위반 목록 | 사용자에게 보여 줄 문구와 형식 | HTTP 400 상세 정보 또는 화면 표시 |
앞의 두 단계는 객체의 값과 상관없이 클래스만 보고 결정됩니다. 그래서 클래스마다 처음 한 번만 수행하고, 그 결과를 검증 계획(ValidationPlan)으로 캐시해 둘 수 있습니다. 계획에는 제약이 붙은 Field 목록과 필드마다 쓸 처리기가 들어갑니다. 이렇게 하면 요청마다 모든 필드와 애노테이션을 다시 조회하지 않아도 되고, 구성 오류도 첫 요청이 들어왔을 때가 아니라 서버가 시작하면서 계획을 만들 때 드러납니다.
더 깊이: 계획 캐시와 클래스 로더
계획을 static Map<Class<?>, ValidationPlan>에 담아 두는 것이 가장 간단합니다. 하지만 검증기는 여러 애플리케이션이 함께 쓰는 공용 클래스 로더에 올라가 있고, 애플리케이션은 다시 배포할 때마다 새 클래스 로더로 불러오는 서버라면 이 맵이 문제가 됩니다. 검증기 쪽의 맵이 옛 애플리케이션의 Class 객체를 계속 참조하므로, 옛 클래스 로더와 그것이 불러온 클래스 전체가 메모리에서 해제되지 못합니다. 그래서 캐시의 수명은 클래스 로더의 수명에 맞춰야 합니다. 검증기 객체를 애플리케이션마다 하나씩 만들어 그 객체 안에 캐시를 두거나, 클래스마다 값을 한 번 계산해 연결해 두는 JDK의 ClassValue를 쓰는 방법이 있습니다.
직접 만든 검증기를 테스트할 때
직접 만든 검증기를 테스트한다면 정상 입력에서 빈 목록이 나오는지부터 확인합니다. 그다음 필드 타입 불일치, min과 max 역전, 접근할 수 없는 필드가 모두 입력 위반이 아닌 구성 오류로 나오는지 확인합니다. 부모 클래스의 필드를 검사하는지와 숫자 필드가 null일 때 어떻게 되는지도 테스트로 정해 두면, 나중에 누군가 검증기를 고치다가 이 정책을 모르고 바꾸는 일을 막을 수 있습니다. 결과를 순서까지 비교하는 테스트라면 검증기가 정렬된 목록을 돌려주는지도 확인해야 합니다.
이 글의 검증기는 원리를 익히기 위한 것입니다. 실제 서비스라면 Jakarta Bean Validation처럼 오래 쓰이며 다듬어진 표준 구현을 먼저 검토하세요. 그래도 원리를 알고 나면 프레임워크가 시작할 때 클래스마다 검증 계획을 만들고 요청마다 값만 검사한다는 것, 그리고 설정 실수와 입력 오류를 왜 다르게 보고하는지가 눈에 들어옵니다.
정리
- 애노테이션은 붙여 두기만 해서는 아무 일도 하지 않습니다.
RUNTIME유지 정책을 붙이고, 검증기가 리플렉션으로 읽어 값을 검사해야 규칙으로 동작합니다. - 검증기는 첫 오류에서 멈추지 말고 위반을 모두 모아 돌려줍니다. 사용자가 고칠 입력 위반과 개발자가 고칠 구성 오류는 종류를 나눠 보고합니다.
@Target은 필드의 타입을 제한하지 못합니다. 애노테이션과 필드 타입이 맞는지는 검증기가 확인합니다.trySetAccessible()이false이면 건너뛰지 말고 구성 오류로 보고합니다. JDK 17부터는opens나--add-opens로 열리지 않은 패키지의 비공개 멤버를 리플렉션으로 열 수 없습니다.- 숫자를
longValue()나doubleValue()하나로 바꿔 비교하면 소수, 큰 수, NaN과 무한대를 잘못 다루게 됩니다. 지원할 타입을 정하고 타입에 맞는 비교를 씁니다. getDeclaredFields()의 순서는 정해져 있지 않으므로, 일정한 보고 순서가 필요하면 정렬합니다.@Override와@SuppressWarnings는 컴파일러만 읽고 클래스 파일에 남지 않습니다.@Deprecated는 실행 중에도 리플렉션으로 읽을 수 있습니다.
연습 문제
1. 처리기 레지스트리 만들기
앞의 검증기는 제약을 if 문으로 직접 처리합니다. 이것을 처리기 레지스트리 구조로 바꿔 @Required와 @Positive를 지원하세요. 각 처리기는 자기 애노테이션 타입을 돌려주는 annotationType()과, validate(Field, annotation, value)로 0개 이상의 Violation을 만드는 메서드를 가집니다. 레지스트리는 만든 뒤 바꿀 수 없어야 하고, 같은 애노테이션 타입의 처리기를 두 번 등록하면 거절해야 합니다.
제네릭 처리기 레지스트리를 만드는 풀이
처리기 인터페이스를 Handler<A extends Annotation>처럼 제네릭으로 만들면, RequiredHandler가 Required 애노테이션을 받는다는 사실을 타입으로 표현할 수 있습니다. 문제는 레지스트리입니다. 서로 다른 처리기를 한 맵에 담으려면 값 타입을 Handler<?>로 둘 수밖에 없고, Map의 타입만으로는 'Required.class 키에는 Handler<Required>가 들어 있다'는 키와 값의 관계를 나타낼 수 없습니다. 그래서 꺼낼 때 형변환이 한 번 필요하며, 이 풀이는 그 형변환을 call 메서드 한 곳에 모아 둡니다.
import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ConstraintHandlerRegistrySolution {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Required {
String message() default "value is required";
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Positive {
String message() default "value must be positive";
}
record Violation(String field, String message) {
}
interface Handler<A extends Annotation> {
Class<A> annotationType();
List<Violation> validate(Field field, A annotation, Object value);
}
static final class RequiredHandler implements Handler<Required> {
@Override
public Class<Required> annotationType() {
return Required.class;
}
@Override
public List<Violation> validate(
Field field,
Required annotation,
Object value) {
if (value == null
|| value instanceof String text && text.isBlank()) {
return List.of(new Violation(
field.getName(),
annotation.message()));
}
return List.of();
}
}
static final class PositiveHandler implements Handler<Positive> {
@Override
public Class<Positive> annotationType() {
return Positive.class;
}
@Override
public List<Violation> validate(
Field field,
Positive annotation,
Object value) {
if (!(value instanceof Number number)) {
return List.of(new Violation(
field.getName(),
"Positive requires Number"));
}
if (number.doubleValue() <= 0) {
return List.of(new Violation(
field.getName(),
annotation.message()));
}
return List.of();
}
}
private final Map<Class<? extends Annotation>, Handler<?>> handlers;
ConstraintHandlerRegistrySolution(List<Handler<?>> handlers) {
Map<Class<? extends Annotation>, Handler<?>> map =
new LinkedHashMap<>();
for (Handler<?> handler : handlers) {
if (map.putIfAbsent(handler.annotationType(), handler) != null) {
throw new IllegalArgumentException(
"duplicate handler: " + handler.annotationType());
}
}
this.handlers = Map.copyOf(map);
}
List<Violation> validate(Object target) throws IllegalAccessException {
List<Violation> result = new ArrayList<>();
for (Field field : target.getClass().getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
if (!field.trySetAccessible()) {
result.add(new Violation(field.getName(), "field inaccessible"));
continue;
}
Object value = field.get(target);
for (Annotation annotation : field.getDeclaredAnnotations()) {
Handler<?> handler = handlers.get(annotation.annotationType());
if (handler != null) {
result.addAll(call(handler, field, annotation, value));
}
}
}
return List.copyOf(result);
}
@SuppressWarnings("unchecked")
private static <A extends Annotation> List<Violation> call(
Handler<?> raw,
Field field,
Annotation annotation,
Object value) {
Handler<A> handler = (Handler<A>) raw;
A typed = handler.annotationType().cast(annotation);
return handler.validate(field, typed, value);
}
static final class Product {
@Required
private final String name;
@Positive(message = "price must be above zero")
private final int price;
Product(String name, int price) {
this.name = name;
this.price = price;
}
}
public static void main(String[] args) throws Exception {
var validator = new ConstraintHandlerRegistrySolution(List.of(
new RequiredHandler(),
new PositiveHandler()));
validator.validate(new Product(" ", 0))
.forEach(System.out::println);
}
}실행 결과:
Violation[field=name, message=value is required]
Violation[field=price, message=price must be above zero]Product의 name은 공백 문자열이라 RequiredHandler가, price는 0이라 PositiveHandler가 위반을 하나씩 만들었습니다. 두 줄의 순서는 getDeclaredFields()의 순서를 따르므로 명세상 정해져 있지 않습니다.
call의 (Handler<A>) raw는 검사되지 않는(unchecked) 형변환입니다. 실행 중에는 제네릭 타입 인자가 지워지므로(타입 소거), JVM은 이 처리기가 정말 Handler<A>인지 확인하지 못합니다. 그래도 이 형변환이 안전한 이유는 생성자가 각 처리기를 그 처리기의 annotationType()을 키로 등록하기 때문입니다. raw 타입이나 다른 unchecked 형변환을 쓰지 않는 한 키와 처리기가 어긋날 수 없으니, 애노테이션 타입으로 꺼낸 처리기는 언제나 그 애노테이션을 다룹니다. 바로 다음 줄의 annotationType().cast(annotation)은 실제로 타입을 확인하는 형변환이라, 만에 하나 어긋나면 그 자리에서 ClassCastException이 발생합니다. @SuppressWarnings("unchecked")를 클래스가 아니라 이 메서드에만 붙인 것도 앞에서 본 가장 좁은 범위의 원칙을 따른 것입니다.
이 풀이는 레지스트리 구조에 집중하느라 앞 검증기의 정책 일부를 생략했습니다. 현재 클래스의 static이 아닌 필드만 순회하고, 합성 필드 제외와 부모 클래스 순회, target == null 처리가 없습니다. 제약이 없는 필드에도 접근을 먼저 시도하고, 접근 실패나 타입 오류를 입력 위반과 다른 종류로 나누지도 않습니다. 더 연습하고 싶다면 제약이 붙은 필드만 고른 계획을 클래스별로 캐시하고, 이 정책들을 앞의 CollectingAnnotationValidator와 맞춰 보세요. 레지스트리에 처리기가 없는 애노테이션을 만났을 때 경고할지 무시할지도 정해야 합니다.
2. 처리기가 놓치는 값 찾기
1번 풀이의 두 처리기가 위반으로 잡지 못하는 값을 찾아보세요. 특히 PositiveHandler의 number.doubleValue() <= 0 조건을 통과하는 잘못된 Double 값 두 가지를 찾고, 타입에 맞게 비교하도록 고치세요.
NaN과 무한대를 거르는 풀이
풀이의 두 처리기가 검사하는 값
제약 처리기 · 조건식과 누락된 검사
풀이의 두 처리기가 검사하는 값
Required는 null과 공백 문자열을 검사합니다. Positive의 doubleValue ≤ 0 비교에는 NaN과 양의 무한대가 걸리지 않습니다.
필수값: null 또는 공백 문자열
- 원문 판정
- Violation 1개
- 결과의 의미
- Required 메시지
필수값: 빈 컬렉션
- 원문 판정
- Violation 없음
- 결과의 의미
- 컬렉션 크기는 검사하지 않음
양수: null 또는 Number 아님
- 원문 판정
- Violation 1개
- 결과의 의미
- Positive requires Number
양수: doubleValue가 0 이하
- 원문 판정
- Violation 1개
- 결과의 의미
- Positive 메시지
양수: 양의 무한대 또는 NaN
- 원문 판정
- Violation 없음
- 결과의 의미
- 유한성 검사가 없고 NaN의 ≤ 비교는 false
처리기 조건식을 해석한 표로, main이 사용하지 않은 값도 포함합니다. main의 Product는 공백 name과 0인 price이며, 두 필드의 위반 출력 순서는 보장되지 않습니다.
그림은 두 처리기의 조건식에 여러 값을 대입해 본 것입니다. RequiredHandler는 null과 공백 문자열만 검사하므로, 빈 컬렉션은 값이 있는 것으로 보고 통과시킵니다. 컬렉션이 비어 있으면 안 된다는 규칙이 필요하다면 별도의 제약으로 만드는 편이 의미가 분명합니다.
PositiveHandler가 놓치는 두 값은 Double.NaN과 Double.POSITIVE_INFINITY입니다. NaN은 어떤 수와 크기를 비교해도 결과가 false이므로 NaN <= 0도 false가 되어 위반이 생기지 않습니다. 양의 무한대는 0보다 크니 조건에 걸리지 않습니다. 하지만 둘 다 가격이나 수량 같은 업무 값으로는 받아들일 수 없습니다.
고치는 방법은 모든 값을 doubleValue() 하나로 바꾸지 않고 타입마다 맞는 비교를 쓰는 것입니다. 정수 타입은 longValue()로 정확히 비교할 수 있고, Float와 Double은 유한한 값인지 먼저 확인해야 합니다. BigDecimal은 doubleValue()로 바꾸면 아주 큰 값이 무한대가 되므로, 부호를 알려 주는 signum()을 씁니다. 지원하지 않는 타입은 구성 오류로 다룰 수 있도록 예외를 던집니다.
import java.math.BigDecimal;
public final class FinitePositiveCheck {
static boolean isPositive(Number number) {
if (number instanceof Byte || number instanceof Short
|| number instanceof Integer || number instanceof Long) {
return number.longValue() > 0;
}
if (number instanceof Float || number instanceof Double) {
double value = number.doubleValue();
return Double.isFinite(value) && value > 0;
}
if (number instanceof BigDecimal decimal) {
return decimal.signum() > 0;
}
throw new IllegalArgumentException(
"unsupported type: " + number.getClass().getName());
}
public static void main(String[] args) {
Number[] samples = {5, 0, -3L, 0.5, Double.NaN,
Double.POSITIVE_INFINITY, new BigDecimal("1E+400")};
for (Number sample : samples) {
System.out.println(sample + " -> " + isPositive(sample));
}
BigDecimal huge = new BigDecimal("1E+400");
System.out.println("doubleValue=" + huge.doubleValue()
+ ", longValue=" + huge.longValue());
}
}실행 결과:
5 -> true
0 -> false
-3 -> false
0.5 -> true
NaN -> false
Infinity -> false
1E+400 -> true
doubleValue=Infinity, longValue=0NaN과 무한대가 이제 false로 걸러집니다. 마지막 줄은 BigDecimal을 다른 타입으로 바꾸면 어떻게 되는지 보여 줍니다. 1E+400을 doubleValue()로 바꾸면 Infinity가 되므로, 유한성 검사만 했다면 정상적인 양수를 거절했을 것입니다. longValue()로 바꾸면 0이 되므로, 앞의 Range 검증기는 이 값을 0으로 보고 비교합니다. 이 판정을 1번 풀이의 PositiveHandler에 넣을 때는 IllegalArgumentException을 구성 오류 위반으로 바꿔 목록에 담으면 됩니다.
3. 접근 실패를 건너뛰면 생기는 일
검증기가 trySetAccessible()이 false인 필드를 위반으로 기록하지 않고 조용히 건너뛰도록 바꾸면 어떤 일이 생길지 설명하세요.
접근 실패를 건너뛸 때 생기는 일 해설
접근할 수 없는 필드의 제약이 아무 표시 없이 사라집니다. 예를 들어 애플리케이션 모듈이 모델 패키지를 검증기 모듈에 opens하지 않았다면, 그 패키지에 있는 모델의 private 필드마다 trySetAccessible()이 false를 돌려줍니다. 이것을 건너뛰면 검증기는 빈 목록을 돌려주고, 호출하는 쪽은 위반이 없다고 받아들여 빈 이름이나 범위를 벗어난 나이를 그대로 저장합니다. 검증에서 위반이 나오지 않은 것과 검증을 아예 하지 못한 것은 전혀 다른 일입니다. 그래서 앞의 예제처럼 구성 오류로 기록하거나 시작 단계에서 예외로 멈춰야 테스트나 배포 직후에 설정 문제를 바로 발견할 수 있습니다.
다음 장
다음 장에서는 같은 구조를 웹 요청 처리에 적용합니다. HTTP 메서드와 경로를 애노테이션으로 메서드 옆에 선언하고, 서버가 시작할 때 그 선언을 검사해 요청마다 바로 찾아 쓸 수 있는 실행 계획을 만듭니다. 이 글에서 본 '클래스마다 한 번 계획을 만들고, 요청이 올 때마다 값만 검사한다'는 흐름이 그대로 이어집니다.