우당탕탕
Spring Boot Validation 커스텀 어노테이션 숫자 비교하며 만들어본 후기 본문
Spring Boot에서 Validation 커스텀 어노테이션을 직접 만들어야 하는 상황이었는데요. 특히 가격이나 비용, 요금 같은 숫자 데이터를 비교해서 유효성을 검사하는 부분에서 꽤 삽질을 많이 했어요. 처음에는 단순히 @Min, @Max 같은 기본 어노테이션을 쓰려고 했는데 조건이 복잡해지면서 커스텀이 필요하더라고요.
이 글에서는 제가 직접 구현한 커스텀 Validation 어노테이션과 그 과정에서 겪은 문제, 그리고 숫자 비교를 위한 전략과 비용 차이를 표로 비교한 내용을 담았어요. 같이 보면서 어떻게 하면 숫자 데이터 검증을 효과적으로 할 수 있을지 감 잡으실 수 있을 거예요.
개발 환경 / 버전 정보
이번 프로젝트는 Java 17과 Spring Boot 3.2를 사용했어요. Validation 라이브러리는 기본적으로 javax.validation (Bean Validation) API를 적용했습니다.
비용 비교용 커스텀 Validation 어떻게 구현했는지
사실 이 부분이 가장 골치 아팠는데요. 기본 어노테이션들은 단일 값에 대해서만 조건 검사가 가능한데, 저희는 두 가격이나 한도 같은 숫자 필드를 비교해서 앞의 값이 뒤보다 작거나 큰지 검사해야 했어요. 그래서 커스텀 어노테이션에 필드명을 파라미터로 받아서 두 값의 관계를 체크하도록 했습니다.
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { PriceComparisonValidator.class })
public @interface PriceComparison {
String message() default "첫 번째 가격이 두 번째 가격보다 커야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String basePriceField(); // 비교 기준 필드
String comparePriceField(); // 비교 대상 필드
}
위처럼 클래스 레벨 어노테이션을 만들고, 필드 이름을 변수로 받아서 Validator에서 값을 비교하는 방식이에요.
public class PriceComparisonValidator implements ConstraintValidator<PriceComparison, Object> {
private String basePriceField;
private String comparePriceField;
private String message;
@Override
public void initialize(PriceComparison constraintAnnotation) {
basePriceField = constraintAnnotation.basePriceField();
comparePriceField = constraintAnnotation.comparePriceField();
message = constraintAnnotation.message();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
try {
// 리플렉션으로 필드 값 읽기
Object basePriceObj = BeanUtils.getPropertyDescriptor(value.getClass(), basePriceField).getReadMethod().invoke(value);
Object comparePriceObj = BeanUtils.getPropertyDescriptor(value.getClass(), comparePriceField).getReadMethod().invoke(value);
if (basePriceObj == null || comparePriceObj == null) {
return true; // null은 다른 어노테이션에서 체크 가능
}
long basePrice = Long.parseLong(basePriceObj.toString());
long comparePrice = Long.parseLong(comparePriceObj.toString());
boolean valid = basePrice >= comparePrice;
if (!valid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message)
.addPropertyNode(basePriceField).addConstraintViolation();
}
return valid;
} catch (Exception e) {
// 예외나면 검증 불가로 처리
return false;
}
}
}
리플렉션이 익숙하지 않으면 여기서 막힐 수 있는데, 제가 이걸로 한참 헤맸거든요. 핵심은 두 필드 값을 모두 읽어와서 Long 타입으로 변환 후 비교하는 것과, 메시지를 커스텀해서 어느 필드가 문제인지 알 수 있도록 하는 점이에요.
가격/요금 비교 기준과 비용 차이 표로 정리
여기서 많이들 헷갈려 하시는 게, "얼마 차이 나는 걸로 기준을 둬야 할까?" 하는 부분인데요. 제가 실제로 여러 고객 요금 정책을 비교하며 느낀 차이를 다음 표로 정리해봤습니다.
| 항목 | 기준 가격 (원) | 비교 가격 (원) | 차이 (원) | 비고 |
|---|---|---|---|---|
| 월정액 요금 | 55,000 | 57,500 | 2,500 | 약 4.5% 차이, 고객 반응 민감 |
| 초과 요금 한도 | 100,000 | 120,000 | 20,000 | 20% 차이로 영향 큼 |
| 할인 한도 | 30,000 | 29,800 | 200 | 0.7% 차이, 거의 무시 가능 |
이런 차이 기준을 정하면 Validation 메시지나 정책 검증을 더 명확하게 할 수 있어요. 예를 들어 월정액 요금은 5% 이상 차이나면 경고 처리하자, 혹은 초과 한도는 무조건 비교 가격 이상이어야 한다 이런 식이었죠.
여기서 막혔던 리플렉션과 타입 변환 문제
이 에러가 왜 나는지 한참 찾았는데, 리플렉션으로 필드 값을 읽을 때 NullPointerException이나 메서드가 없다는 에러가 많이 났어요. 필드명이 맞는지, 접근자가 public인지 꼭 확인해야 했고, 타입 변환 때문에 NumberFormatException도 걸렸습니다.
java.lang.NullPointerException: Cannot invoke "java.lang.reflect.Method.invoke(Object, Object...)" because "..." is null
at org.springframework.beans.BeanUtils.getPropertyDescriptor(BeanUtils.java:123)
at ...
여기서 BeanUtils.getPropertyDescriptor 대신 Apache Commons BeanUtils 라이브러리를 쓸 때도 약간 차이가 있으니 주의하세요. 저는 리플렉션 호출 부분을 아래처럼 고쳐서 해결했어요.
// 리플렉션으로 직접 메서드 찾아 호출하는 안전한 방법
Method baseGetter = value.getClass().getDeclaredMethod("get" + StringUtils.capitalize(basePriceField));
Object basePriceObj = baseGetter.invoke(value);
Method compareGetter = value.getClass().getDeclaredMethod("get" + StringUtils.capitalize(comparePriceField));
Object comparePriceObj = compareGetter.invoke(value);
그리고 숫자 타입은 무조건 Long.parseLong이나 BigDecimal.valueOf() 같은 걸 썼는데, Null 체크는 꼭 하셔야 해요.
심화: 숫자 비교할 때 이렇게도 활용해보세요
숫자가 단순 비교 외에도 퍼센트 차이나 비율 비교가 필요할 때가 있는데요. BigDecimal을 쓰면 정밀도를 유지하면서 이런 계산이 쉽습니다. 예를 들어, 비용 할인율이 5% 이상이면 경고 등 메시지를 다르게 주는 복잡한 정책도 만들 수 있거든요.
BigDecimal base = new BigDecimal(basePriceObj.toString());
BigDecimal compare = new BigDecimal(comparePriceObj.toString());
BigDecimal diff = base.subtract(compare).abs();
BigDecimal percent = diff.divide(compare, 4, RoundingMode.HALF_UP).multiply(new BigDecimal(100));
if (percent.compareTo(new BigDecimal(5)) >= 0) {
// 5% 이상 차이나면 경고
}
저는 이런 퍼센트 기준을 정책 검증이나 배치 작업 룰에 자주 활용하고 있어요.
자주 물어보시는 것들
Q. 커스텀 Validation에서 필드명이 틀리면 어떻게 알 수 있나요?
A. 런타임에 에러가 나거나, 검증 자체가 무조건 실패해요. 그래서 테스트 케이스에서 실제 객체에 올바른 필드명을 넣어 실행해보는 게 좋아요. IDE 자동완성 기능은 안 먹으니 수동 확인 필수입니다.
Q. 숫자 비교 기준을 바꾸고 싶으면 어떻게 해야 하나요?
A. 어노테이션에 추가 파라미터를 만들어서, 최소 차이값이나 퍼센트 기준을 넘기도록 하고 Validator에서 이를 검사하면 됩니다. 기본값은 0으로 두고요.
이렇게 직접 만들어보니
직접 커스텀 Validation 어노테이션을 만들어보니까, 숫자 비교 기준을 한눈에 정리할 수 있었고, 정책 반영도 빠르게 할 수 있어서 정말 좋았어요. 실무에서 자주 필요한 로직이라서 금방 재활용도 가능하더라고요. 그리고 가격 차이가 얼마인지, 퍼센트 기준은 어느 정도로 할지를 명확히 하는 게 얼마나 큰 도움이 되는지도 다시 한번 느꼈습니다.
다른 Validation도 이런 방식으로 커스텀하는 게 가능하니, 꼭 실험해보세요. 숫자 비교 뿐 아니라 날짜나 문자열 조건 비교도 비슷한 패턴으로 접근하면 편해요.
'Tech > Spring' 카테고리의 다른 글
| JPA N+1 문제 실제 겪고 단계별로 해결한 경험담 (0) | 2026.06.20 |
|---|---|
| Spring Batch 처음 써보면서 비용 비교와 숫자 처리에서 막혔던 점들 (0) | 2026.06.18 |
| Spring Security 6 권한 설정, 이 부분부터 꼭 체크하세요 (0) | 2026.06.11 |
| Spring Cloud Gateway 도입하면서 겪은 라우팅 문제와 해결 체크리스트 (0) | 2026.06.09 |
| Spring Boot에서 Querydsl 도입, 직접 해보고 정리한 절차와 팁 (0) | 2026.06.03 |
