스터디에서 코드리뷰를 하던 중,
{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
읽어주셔서 감사합니다.
멋있어요~! 배워갑니다