본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
28장 : 리플렉션과 애노테이션

애노테이션 검증기

공백 금지·범위 제약을 실행 시점 필드 메타데이터로 읽어 타입과 접근을 검증하고 모든 위반을 경로별 결과로 모으며 Java 기본 애노테이션의 검사 시점을 구분합니다.

UserTeam마다 이름 공백과 숫자 범위를 직접 검사하면 객체 종류가 늘 때 같은 조건 코드가 반복됩니다. 필드에 @NotBlank, @Range를 선언하고 공통 검증기가 리플렉션으로 읽으면 제약 위치와 메시지를 모델 옆에 둘 수 있습니다. 이 방식은 선언 중복을 줄이지만 타입 검증, 비공개 접근, 상속 필드, 오류 수집 정책을 프레임워크 코드가 책임지게 합니다.

좋은 검증기는 첫 오류에서 예외를 던지는 대신 가능한 모든 위반을 안정적인 목록으로 반환합니다. 각 항목에는 객체 타입, 필드 경로, 제약 이름, 메시지, 거절된 값의 안전한 표현이 필요합니다. 개인정보인 실제 값을 무조건 메시지에 넣지 않고, 호출자가 사용자 응답과 서버 로그를 다른 형태로 만들 수 있게 합니다.

취약한 필드 검증기

다음 구현은 NotEmpty가 붙은 필드를 무조건 String으로 형변환하고 첫 빈 값에서 RuntimeException을 던집니다. 숫자 필드에 잘못 붙이면 ClassCastException이 되며 다른 오류는 검사되지 않습니다. static·합성 필드도 구분하지 않고 모듈 접근 실패를 모두 throws로 노출합니다.

bad/FailFastReflectiveValidator.java
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());
                }
            }
        }
    }

    public static void main(String[] args) {
        System.out.println("compile-only fail-fast validator counterexample");
    }
}

애노테이션의 @Target(FIELD)는 다른 원소에 붙이는 실수는 막지만 필드 Java 타입까지 제한하지 못합니다. @NotBlank int count는 컴파일될 수 있으므로 검증기 시작 시점 또는 실행 시 명확한 구성 위반으로 보고해야 합니다. 사용자 입력 위반과 잘못된 애노테이션 배치를 다른 코드로 분류합니다.

비공개 필드 접근은 trySetAccessible() 결과를 확인합니다. 애플리케이션 모델 패키지가 프레임워크 모듈에 열려 있지 않다면 접근 실패를 검증 성공으로 간주해서는 안 됩니다. public 접근자 기반 속성 모델이나 record 구성 요소 접근을 지원하면 모듈 캡슐화를 덜 우회할 수 있습니다.

제약 조건 전체 평가

아래 검증기는 현재 클래스와 상위 클래스의 선언된 필드를 순회합니다. static과 합성 필드를 제외하고, 애노테이션이 있는 필드만 접근합니다. NotBlankString만, Range는 기본형 숫자와 Number 래퍼만 허용합니다. null 숫자는 별도 필수 제약이 없으므로 Range를 건너뛴다는 정책을 택합니다.

src/CollectingAnnotationValidator.java
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;
        }
    }

    public static void main(String[] args) {
        Member member = new Member(" ", "", 0);
        validate(member).forEach(System.out::println);
    }
}

기본형 필드를 Field.get으로 읽으면 래퍼로 박싱되므로 Number 형변환이 가능합니다. doublefloatlong으로 바꾸면 소수 정보가 사라지므로 이 @Range 정책에서는 제외했습니다. 실수 범위 제약은 별도 애노테이션과 BigDecimal 변환 규칙을 두는 편이 정확합니다.

상속 필드를 포함할지 여부는 프레임워크 계약입니다. getDeclaredFields()만 쓰면 부모 제약이 빠지고, 계층을 순회하면 같은 이름 필드 가림을 어떻게 경로로 표현할지 정해야 합니다. 예제는 단순 필드 이름을 쓰지만 실제 보고서는 DeclaringClass.field 또는 속성 경로를 사용할 수 있습니다.

기본 애노테이션 소비 시점

@OverrideSOURCE 유지 정책이며 컴파일러가 재정의 오타를 검사합니다. @DeprecatedRUNTIME까지 남아 컴파일러·IDE 경고와 리플렉션 메타데이터에 쓰이고 since, forRemoval 정보를 가집니다. @SuppressWarningsSOURCE이며 컴파일러 경고를 좁은 범위에서 의도적으로 억제합니다.

src/JavaAnnotationLifecycle.java
import java.lang.annotation.Retention;
import java.lang.reflect.Method;
import java.util.Arrays;

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 java.util.List<String> legacyList() {
        java.util.List raw = new java.util.ArrayList();
        raw.add("value");
        return (java.util.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());
        System.out.println("runtime child annotations="
                + Arrays.toString(Child.class.getDeclaredMethods()[0]
                .getDeclaredAnnotations()));
        System.out.println(legacyList());
    }
}

마지막 실행 시점 메서드 애노테이션 목록에는 @Override가 나타나지 않습니다. @SuppressWarnings("all")을 클래스 전체에 붙이면 새 경고까지 숨기므로 가능한 가장 좁은 원소에 구체 키를 사용합니다. 경고가 왜 안전한지 주석이나 래퍼 메서드로 남기고, 원시 타입 경계를 일반 업무 코드로 퍼뜨리지 않습니다.

@Deprecated API는 제거 계획이 없다면 forRemoval=false를 유지하고 대체 API를 문서화합니다. 리플렉션 스캐너가 사용 중단 라우트를 발견하면 시작 시점 경고를 낼 수 있지만 즉시 실행을 막을지는 호환 정책으로 정합니다.

애노테이션별 핸들러

if 문으로 애노테이션 종류를 계속 추가하면 검증기 핵심부가 커집니다. 애노테이션 타입을 ConstraintHandler에 매핑한 불변 레지스트리를 만들면 새 제약이 기존 순회 코드를 바꾸지 않습니다. 처리기는 지원 필드 타입, 애노테이션 관계 규칙, 실제 값 검사를 함께 책임집니다.

단계입력실패 종류결과
스키마 컴파일FieldAnnotation타입·범위 관계CONFIGURATION 문제
접근 계획Field·모듈접근 불가시작 시점 실패
값 읽기객체 인스턴스IllegalAccessException프레임워크 오류
제약 평가업무 입력 위반위반 목록
응답 매핑위반사용자 문맥400 상세 또는 UI 표시

성능이 필요하면 클래스별 ValidationPlan을 처음 한 번 만들고 Field와 처리기를 캐시합니다. 매 요청마다 모든 애노테이션을 다시 찾지 않습니다. Class 로더가 바뀌는 환경에서는 계획 캐시의 수명을 로더와 맞춥니다.

연습 문제

@Required@Positive를 지원합니다. 각 처리기는 자기 애노테이션 타입을 반환하고 validate(Field, annotation, value)로 0개 이상의 Violation을 만듭니다. 검증기는 레지스트리를 불변으로 만들고 중복 처리기 등록을 거절합니다.

제네릭 처리기와 필드 계획을 포함한 풀이

Java 제네릭 소거 때문에 이종 처리기 Map에서 제한된 형변환이 필요합니다. 형변환을 레지스트리 내부 한 곳에 격리하고 애노테이션 타입 키가 맞는지 생성 시 보장합니다.

solution/ConstraintHandlerRegistrySolution.java
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);
    }
}

unchecked 형변환은 애노테이션 타입 키와 처리기가 같은 생성 경계 안에서 등록된다는 불변식으로 제한됩니다. 확장 과제로 클래스별 Field 계획을 캐시하고, 지원되지 않는 제약처럼 보이는 애노테이션을 경고할지 무시할지 정책을 추가합니다.

검증기 합격 기준

정상 객체는 빈 목록을 반환하고, 여러 필드가 잘못되면 모든 위반이 안정적인 순서로 나옵니다. 잘못된 필드 타입, minmax 역전, 접근할 수 없는 필드는 사용자 입력 위반과 다른 구성 결과가 되어야 합니다. 부모 클래스 필드 포함 정책과 null 숫자 처리도 테스트에 고정합니다.

기본 애노테이션 검증에서는 @Override 오타가 컴파일러에서 막히고, @Deprecated 메타데이터가 실행 시점에 남으며, @SuppressWarnings 범위가 최소인지 확인합니다. 직접 만든 검증기는 교육용이며 실제 서비스에서는 Jakarta Bean Validation 같은 검증된 표준을 우선 검토합니다. 원리를 이해하면 프레임워크가 리플렉션과 애노테이션으로 수행하는 시작 시점 계획과 요청별 값 검사를 구분할 수 있습니다.