
스터디에서 코드리뷰를 하던 중,
{date: "2023-12-12"} 꼴을
public class CreateDTO {
//`@DateTimeFormat("yyyy-MM-dd)`
private LocalDate date;
}
...
@RequestMapping(...)
public ResponeEntity<Response> create(@RequestBody CreateDTO) {
...
}
로 변환하는 방법에 대해 인원마다 차이가 있는 걸 발견했습니다.
어느 인원은 @DateTimeFormat을 작성해주었으나, 저는 해당 어노테이션 없이도 변환이 잘 이루어졌습니다.
이에 대해 토론하며, 궁금증이 생겼고 해당 주제에 대해 조사하게 되었습니다.
예제로 사용한 API입니다. 플레이그라운드에서 학습하고 있는 방탈출 API입니다.
LocalDate date입니다.
(컨트롤러 로직)

(입력에 들어온 DTO)

(입력에 대한 테스트)
RequsetBody에 @DateTimeFormat이 꼭 있어야 할까?
(위 예제에 대한 테스트 결과)
예제 코드에서 봤듯이, 별도 어노테이션이 없어도 테스트가 잘 통과합니다!
@DateTimeFormat으로 형식을 필터링해줄 수 있을까?
(테스트의 형식을 변경)

(어노테이션과 패턴을 추가한 모습)

(테스트 결과)
@DateTimeFormat의 패턴을 이용하여 필터링할 수 없는 걸 확인했습니다.
@DateTimeFormat 대신 @JsonFormat을 활용하여 필터링할 수 있습니다.

(어노테이션을 변경한 모습)

(성공한 테스트)
@JsonFormat을 이용하니 필터링이 가능해졌습니다.
(자세한 원리는 하단에 다룹니다.)
@DateTimeFormat은 어디에 쓰이는걸까?@DateTimeFormat은 @RequestBody가 아니라 @RequestParam에 사용됩니다. 이 때, 필터링 기능 역시 활용될 수 있습니다.
yyyy-MM-dd)yyyy/MM/hh)에 대해 어노테이션 패턴, 입력 패턴 형식에 따라 값들을 처리할 수 있는지를 테스트해봤습니다.

(RequestParam을 통해 localDate만을 입력받는 모습)

(Param을 이용한 테스트 메서드)
테스트해본 결과, 아래와 같은 사실를 확인해볼 수 있습니다.
@DateTimeFormat \ ParamValue | ISO Format | Another Format |
|---|---|---|
| ISO Format | O | X |
| Another Format | O | O |
어떻게 이와같은 결과들이 나왔는지 동작원리와 함께 살펴봅시다.
알아야 하는 키워드들에 대해 정리합니다.
DispatcherServlet: 컨트롤러 처리 이전에 동작을 처리하는 전처리 컨트롤러RequestMappingHandlerAdapter: @RequestMapping 어노테이션을 처리하는 핸들러 어댑터ArgumentResolver을 호출하여 파라미터를 생성한다.ArgumentResolver: 컨트롤러에 들어온 입력을 객체와 매핑할 때 사용하는 객체Converter: 입력을 객체와 변환하는 로직이 정의된 객체직렬화: Object(DTO) -> File(JSON)으로 변환하는 행위역직렬화: File(JSON) -> Object(DTO)으로 변환하는 행위
RequestBody를 활용하였을 때의 처리 순서입니다.
RequsetResponseBodyMetohdProcessor이라는 ArgumentResolver가 선택되었습니다.MappingJackson2HttpMessageConverter라는 Jackson 라이브러리에서 제공하는 컨버터가 선택되어 동작했습니다.ObjectMapper가 LocalDateDeserializer을 등록합니다.ObjectReader가 LocalDateDeserializer을 이용하여 역직렬화합니다.@DateTimeFormat은 무시되고, Jackson에서 제공하는 @JsonFormat은 사용됩니다.
RequestParam을 이용하였을 때의 처리입니다. (파란색으로 RequestBody와 다른 부분을 표현했습니다.)
AbstractNamedValueMethodArgumentResolver이라는 ArgumentResolver가 선택되었습니다.FormattingConversionService라는 스프링에서 제공하는 컨버터가 선택되어 동작했습니다.Service이기에 컨버터(AnnotationParserConverter)가 등록되어 있는 집합입니다.targetType에 @DateTimeFormat에 대한 정보가 포함되어 있습니다. 이를 이용하여 형식 필터링도 일어납니다.targetType이라는 매개변수에 @DateTimeFormat에 대한 정보가 포함되어 있습니다.워낙 복잡하여 생략해도 좋을 것 같습니다.
(100개에 임박하는 함수 스택을 가지고 있어, 간략하게만 설명합니다.)
DispatcherServlet에 할당됩니다.DispatcherServlet.doDispatch()가 의해 동작이 실행됩니다.@RequestMapping이 붙어있기에 RequestMappingHandlerAdapter가 탐색됩니다.

invoke() 리플랙션 기능을 활용하여, 요청을 핸들링할 수 있는 메서드를 호출합니다.

getMethodArgumentValues()을 호출하여, HTTP request를 컨트롤러의 args(DTO)로 변환하는 작업을 수행합니다.

해당 메서드는 입력된 파라미터(request)를 해결할 수 있는 ArgumentResolver 획득한 후resolver.resolveArgument()를 통해 실행의 작업을 거칩니다.

@RquestBody를 해결할 수 있는 리졸버입니다.
컨버터를 찾아 호출하여 작업을 이어나갑니다.

방금까지 보았던 리졸버의 부모 관계에 속하는 추상객체입니다. 반복문안에서 해당 파라미터를 해결할 수 있는 컨버터를 찾습니다.
처리할 수 있는 MappingJackson2HttpMessageConverter을 찾은 후(canRead()), 이를 실행(read())합니다.

실제로 찾는 과정인 canRead()는 objectMapper.canDeserialize() 를 호출하여, 역직렬화가 가능한지 판별한다.

이 과정에서 역직렬화 객체가 캐시에 등록되어있지 않았다면 해당 객체(LocalDateDeserializer)를 등록한다.

실제로 찾는 과정인 read() 메서드를 살펴보자.

이는 objectReader.readValue()를 호출한다.

이후 몇 번의 함수 호출을 거쳐 역직렬화 객체의 deserialize()를 호출하여 역직렬화(JSON -> LocalDate)를 변환한다.

이 과정을 거쳐 우리가 요청했던 입력 "2023-12-12"가 객체의 DateTime으로 변환되었다.
(1) ~ (4)까지는 동일하여 (5) 부터 설명합니다.
binder에게 convert를 위임하여 역직렬화를 시도합니다.

binder는 conversionDelegate에게 위임합니다.

가지고 있는 스프링 기본 conversionServices에게 역직렬화를 위임합니다.

FormattingConversionService가 선택되었고, targetType에 있는 @DateTimeFormat어노테이션 정보/ 들어온 인자 정보, 역직렬화 해야하는 타입 정보를 이용하여 변환합니다.

parse()에 의해 변환이 일어납니다.

this.formatter에서 yyyy/MM/dd 형식이 저장되어 있고, 이를 통해 기본 형식인 ISO이 아니더라도 변환할 수 있습니다.
(Value(Year,4,19,EXCEEDS_PAD)'/'Value(MonthOfYear,2)'/'Value(DayOfMonth,2)이 저장되어 있었습니다.)

RequestBody는 아무런 어노테이션 없이도 역직렬화가 가능하다.RequestBody는 필터링을 위해 @JsonFormat을 사용한다.RequestParam은 @DateTimeFormat이 있어야 역직렬화가 가능하고, 필터링도 가능하다.spring: 6.0.9
spring boot: 3.1.0
java: 17.0.8
jackson: 2.15.0
JVM Locale
- LocaleContextHolder.getLocale()을 통해 감지됨.
- en_KR
읽어주셔서 감사합니다.
멋있어요~! 배워갑니다