1. 배경
GET방식의 요청에서 쿼리 스트링으로 전달되는 값을 LocalDate 타입으로 변환하고, 검증할 때 겪은 문제다. 아래 처음에 작성한 코드다. startDate, endDate 두 개의 파라미터를 전달받아 LocalDate 타입으로 변환하고, null여부를 확인한다.
이 때 "http://localhost:8080/get?endDate=2024-04-01"로 요청하면 발생하는 문제는 크게 두 가지다.
- String -> LocalDate 변환 안됨
- LocalDate의 null 검증 작동 안함 - null검증이 안되기 때문에 validateStartDtEarlierEndDt() 호출 후 NPE발생
@GetMapping("/get")
public Object get(@Valid MyObject myObject) {
...
}
@Getter
public class MyObject {
@NotNull
private LocalDate startDate;
@NotNull
private LocalDate endDate;
public MyObject(LocalDate startDate, LocalDate endDate) {
validateStartDtEarlierEndDt(startDate, endDate);
this.startDate = startDate;
this.endDate = endDate;
}
private void validateStartDtEarlierEndDt(LocalDate startDate, LocalDate endDate) {
if (startDate.isAfter(endDate)) {
throw new IllegalStateException("startDate, endDate :: " + startDate + ", " + endDate);
}
}
}
2. 원인
요청 파라미터를 @RequestParam을 명시해서 받는 것이 아닌 POJO를 이용해서 바인딩 하게 된다. 그렇기에 자동으로 @ModelAttribute가 붙어 해당 객체가 바인딩 될 때 사용되는 resolver는 ModelAttributeMethodProcessor가 된다.
POST방식의 요청을 받을 땐 @RequestBody를 이용해 받게 되는데,이 때 POST 방식의 객체 바인딩 과정과 GET 방식의 객체 바인딩 과정의 차이 때문에 문제가 발생했다.
2-1. 요청 파라미터를 LocalDate로 변환
요청 파라미터를 LocalDate로 변환할 때 @RequestBody와 달리 자동으로 직렬화해주지 않기 때문이다. 별도의 코드를 추가해 해결할 수 있다.
2-2. @NotNull 미작동
GET방식에서 요청 파라미터 바인딩 과정을 디버깅하면서 원인을 알게 됐다.
코드에 앞서 문제 상황을 알아보자.
- Spring에선 객체에 바인딩 하기 위해 객체를 먼저 생성한다.
- 이 때 생성자가 하나인 경우 해당 생성자를 우선하여 생성, 두 개 이상인 경우 기본 생성자를 사용
- 객체 생성이 끝난 뒤 Bean Validation이 작동
요약하자면 이런 상황인데 위 코드를 보면 알 수 있듯 생성자는 하나이고 해당 생성자에서 종료일이 시작일보다 앞서있는 지를 검증한다. @NotNull 검증이 이뤄지기 전에 해당 검증을 먼저 하기 때문에 null값이 들어오면 NPE가 발생하는 것이다.
해당 과정을 디버깅을 통해 코드로 알아보자.
먼저, DispatcherServlet의 doDispatch() 중간에 위치한 HandlerAdapter.handle()호출 부분에 breaking point를 건다.
해당 어댑터의 메서드 호출을 따라가다 보면 HandlerMethodArgumentResolver를 구현한 ModelAttributeMethodProcessor를 만나게 된다.
이 부분부터 중요하다.
resolveArgument()의 중간 쯤에 createAttribute()를 호출해 바인딩할 객체 생성
- 객체 생성을 위해 생성자를 가져온다. 상세 코드는 아래와 같다.
public static <T> Constructor<T> getResolvableConstructor(Class<T> clazz) {
...
Constructor<?>[] ctors = clazz.getConstructors();
if (ctors.length == 1) { // 생성자가 하나인 경우
// A single public constructor
return (Constructor<T>) ctors[0];
}
else if (ctors.length == 0) { // 생성자가 없는 경우 non-public도 찾음
// No public constructors -> check non-public
ctors = clazz.getDeclaredConstructors();
if (ctors.length == 1) {
// A single non-public constructor, e.g. from a non-public record type
return (Constructor<T>) ctors[0];
}
}
// 여러개를 찾았다면 기본 생성자를 반환
try {
return clazz.getDeclaredConstructor();
}
catch (NoSuchMethodException ex) {
// Giving up...
}
// No unique constructor at all
throw new IllegalStateException("No primary or single unique constructor found for " + clazz);
}
- 반환된 생성자를 이용해 바인딩할 객체를 생성. 내용은 아래와 같다.
protected Object constructAttribute(Constructor<?> ctor, String attributeName, MethodParameter parameter,
WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {
// 생성자에 인자가 없을 경우 - 기본 생성자로 객체 생성
if (ctor.getParameterCount() == 0) {
// A single default constructor -> clearly a standard JavaBeans arrangement.
return BeanUtils.instantiateClass(ctor);
}
// 파라미터, 바인더 관련 변수 생성
...
// 파라미터 이름 배열로 반복하며 파라미터 관련 처리
for (int i = 0; i < paramNames.length; i++) {
...
// 필요한 경우 타입 변환해서 배열에 저장
// 이 때 변환에 실패하면 TypeMismatchException 발생
try {
...
else {
args[i] = binder.convertIfNecessary(value, paramType, methodParam);
}
}
catch (TypeMismatchException ex) {
...
bindingFailure = true;
}
}
// 반복문을 돌며 바인딩일 실패하면 아래 로직을 수행
// startDate는 앞선 로직에서 파라미터 처리 자체는 통과했기 때문에 아래 로직을 수행하지 않음
if (bindingFailure) {
...
}
// 가공된 파라미터와 앞서 조회한 생성자로 객체 생성
// 이 때 MyObject의 생성자에서 검증을 할 때 NPE가 발생해 객체 생성이 안됨
try {
return BeanUtils.instantiateClass(ctor, args);
}
catch (BeanInstantiationException ex) {
...
}
}
코드가 길어 많은 부분을 간소화 했다. 상세한 코드는 ModelAttributeMethodProcessor.constructAttribute()에서 확인할 수 있다.
앞선 과정을 통해 알 수 있듯 @NotNull 검증이 이뤄지기 전에 객체를 생성한다. 그렇기에 startDate가 null인 경우 validateStartDtEarlierEndDt()호출에서 NPE가 발생하게 된다.
3. 해결 방법
3-1. 요청 파라미터를 LocalDate로 변환
여러가지 방법이 있는데 두 가지 방식만 알아보자.
- @DateTimeFormat 활용. Spring에서 날짜 타입 직렬화를 지원하는 어노테이션이다.
@Getter
public class MyObject {
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
...
}
- Formatter인터페이스 구현
public class LocalDateFormatter implements Formatter<LocalDate> {
@Override
public LocalDate parse(String text, Locale locale) {
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
@Override
public String print(LocalDate object, Locale locale) {
return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(object);
}
}
@Configuration
public class AppConfig {
@Bean
public LocalDateFormatter localDateFormatter() {
return new LocalDateFormatter();
}
}
이외에도 여러 방식이 존재하지만 편의성 측면에서 위 두 가지 방식을 사용하는 편이 좋을 듯하다. 만약 두 가지 방식 중 하나를 고르자면 SRP를 지킬 수 있기에 두 번째 방식을 택하는 편이 좋다.
더불어 아래와 같은 이슈도 존재하기 때문에 두 번째 방식을 선택하는 것이 타당하다.
https://jojoldu.tistory.com/503
3-2. @NotNull 작동
앞서 문제의 핵심은 '종료일이 시작일 보다 앞서있는 지'에 대한 검증 전에 @NotNull 검증을 해야한다는 것이다.
해당 문제를 해결하기 위한 여러 방법이 있겠지만 내가 사용한 방법은 다음과 같다.
- ConstraintValidator를 활용한 Custom 검증기 + @Validated의 groups를 활용한 검증 순서 지정
코드로 알아보자.
ConstraintValidator를 활용한 Custom 검증기
- ConstraintValidator를 구현한다. 이 때 제네릭 타입에 첫 번째 인자는 적용할 어노테이션, 두 번째 인자는 적용할 대상이다.
- initialize()는 오버라이딩하지 않아도 괜찮다. isValid엔 검증할 코드를 작성한다.
- 어노테이션을 만들어준다. @Constraint을 통해 사용할 Validator를 지정한다.
public class PeriodValidator implements ConstraintValidator<EndDateLaterThanStartDate, Period> {
@Override
public void initialize(EndDateLaterThanStartDate constraintAnnotation) {
}
@Override
public boolean isValid(Period value, ConstraintValidatorContext context) {
return value.getStartDate().isBefore(value.getEndDate());
}
}
@Documented
@Constraint(validatedBy = PeriodValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface EndDateLaterThanStartDate {
String message() default "종료일은 시작일보다 나중이어야 합니다.";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
- 어노테이션을 적용할 클래스에 붙여준다.
@GetMapping("/get")
public Object get(@Valid MyObject myObject) {
...
}
@EndDateLaterThanStartDate
@Getter
public class MyObject {
@NotNull
private LocalDate startDate;
@NotNull
private LocalDate endDate;
public MyObject(LocalDate startDate, LocalDate endDate) {
this.startDate = startDate;
this.endDate = endDate;
}
}
@Validated의 groups를 활용한 검증 순서 지정
- 지정할 그룹을 인터페이스로 지정 - EndDateLaterThanStartDateGroup
- Bean Validation에서 제공하는 @NotNull을 먼저 수행하기 위해 @GroupSequence를 활용해 순서 지정
- @EndDateLaterThanStartDate에 groups 옵션 지정
- Controller에서 @Valid -> @Validated(그룹순서 클래스) 지정
public interface EndDateLaterThanStartDateGroup {
}
@GroupSequence({ Default.class, EndDateLaterThanStartDateGroup.class})
public interface ValidatorGroupSequence {
}
@EndDateLaterThanStartDate(groups = EndDateLaterThanStartDateGroup.class)
@Getter
public class MyObject {
...
}
@GetMapping("/get")
public Object get(@Validated(ValidatorGroupSequence.class) MyObject myObject) {
...
}
위와 같은 코드를 추가하면 종료일, 시작일 검증에 앞서 null 검증을 수행하게 된다.
또 다른 해결 방법
초기 코드의 검증에서 파라미터 값이 null인 경우 바로 return하게 되면 정상적으로 객체가 생성이 된다. 이후 Bean Validation이 정상 수행된다. null이 아닌 경우는 종료일이 시작일 보다 앞섰는 지를 검증하게 되므로 정상적으로 예외가 발생하게 된다.
하지만 IllegalStateException에 대한 별도의 처리, 입력 검증시 발생하는 예외와 달라 통일성이 깨지는 문제때문에 앞선 방식을 사용했다.
private void validateStartDtEarlierEndDt(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
return;
}
if (startDate.isAfter(endDate)) {
throw new IllegalStateException("startDate, endDate :: " + startDate + ", " + endDate);
}
}
4. 정리
- 바인딩할 객체 생성시
- 생성자가 하나면 해당 생성자를
- 여러개면 기본 생성자를
- 없으면 예외를 던진다.
- 만약 기본 생성자로 객체가 생성됐다면
- setter를 추가하면 Spring에서 생성 이후에도 값을 넣어준다.
- 하지만 불변성이 깨진다.
- @InitBinder와 binder.initDirectFieldAccess();를 통해 리플렉션이 private 필드에 접근할 수 있게 우회하는 방법도 존재한다.
- 요청 파라미터를 @RequestParam이 아닌 객체로 바인딩하고 싶을 땐 Java 빈 규약을 지킨 POJO를 사용한다.
- Bean Validation은 객체 바인딩이 완료된 다음 이뤄진다.
참고 :
'Spring' 카테고리의 다른 글
Spring - @Transactional 활용해 멀티 스레드 환경을 테스트할 때 주의점 (0) | 2024.05.06 |
---|---|
Spring - Springboot + Vue3 + MariaDB를 카페24에 배포할 때 Tip(주의사항) (1) | 2023.07.26 |
Spring - ArgumentResolver (0) | 2023.07.22 |
Spring - DispatcherServlet에 대해 알아보자 (0) | 2023.07.15 |