스프링은 날짜를 어떻게 역직렬화할까? (@DateTimeFormat, @JsonFormat)

Hansu Park·2023년 12월 8일
3
post-thumbnail
post-custom-banner

도입

스터디에서 코드리뷰를 하던 중,
{date: "2023-12-12"} 꼴을

public class CreateDTO {
	//`@DateTimeFormat("yyyy-MM-dd)`
	private LocalDate date;
}

...

@RequestMapping(...)
public ResponeEntity<Response> create(@RequestBody CreateDTO) {
...
}

로 변환하는 방법에 대해 인원마다 차이가 있는 걸 발견했습니다.

어느 인원은 @DateTimeFormat을 작성해주었으나, 저는 해당 어노테이션 없이도 변환이 잘 이루어졌습니다.

이에 대해 토론하며, 궁금증이 생겼고 해당 주제에 대해 조사하게 되었습니다.

사용한 예제

예제로 사용한 API입니다. 플레이그라운드에서 학습하고 있는 방탈출 API입니다.

  • id, name, time, date을 필드로 가지는 예약(Reservation)을 생성합니다.
  • time은 연관관계의 id값이고, 실질적으로 다룰 부분은 LocalDate date입니다.

(컨트롤러 로직)

(입력에 들어온 DTO)

(입력에 대한 테스트)

갖게된 질문 해결

Q1. RequsetBody@DateTimeFormat이 꼭 있어야 할까?

(위 예제에 대한 테스트 결과)

예제 코드에서 봤듯이, 별도 어노테이션이 없어도 테스트가 잘 통과합니다!

Q2. @DateTimeFormat으로 형식을 필터링해줄 수 있을까?

(테스트의 형식을 변경)

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

(테스트 결과)

@DateTimeFormat의 패턴을 이용하여 필터링할 수 없는 걸 확인했습니다.

Q3. 그렇다면 어떻게 필터링할 수 있을까?

@DateTimeFormat 대신 @JsonFormat을 활용하여 필터링할 수 있습니다.

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

(성공한 테스트)

@JsonFormat을 이용하니 필터링이 가능해졌습니다.
(자세한 원리는 하단에 다룹니다.)

Q4. 그렇다면 @DateTimeFormat은 어디에 쓰이는걸까?

@DateTimeFormat@RequestBody가 아니라 @RequestParam에 사용됩니다. 이 때, 필터링 기능 역시 활용될 수 있습니다.

  • ISO Format (yyyy-MM-dd)
  • Another Format(yyyy/MM/hh)

에 대해 어노테이션 패턴, 입력 패턴 형식에 따라 값들을 처리할 수 있는지를 테스트해봤습니다.

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

(Param을 이용한 테스트 메서드)

테스트해본 결과, 아래와 같은 사실를 확인해볼 수 있습니다.

@DateTimeFormat \ ParamValueISO FormatAnother Format
ISO FormatOX
Another FormatOO

동작 원리

어떻게 이와같은 결과들이 나왔는지 동작원리와 함께 살펴봅시다.

키워드 정리

알아야 하는 키워드들에 대해 정리합니다.

  • DispatcherServlet: 컨트롤러 처리 이전에 동작을 처리하는 전처리 컨트롤러
    - 핸들러 어댑터를 호출한다.
  • RequestMappingHandlerAdapter: @RequestMapping 어노테이션을 처리하는 핸들러 어댑터
    - ArgumentResolver을 호출하여 파라미터를 생성한다.
  • ArgumentResolver: 컨트롤러에 들어온 입력을 객체와 매핑할 때 사용하는 객체
  • Converter: 입력을 객체와 변환하는 로직이 정의된 객체
  • 직렬화: Object(DTO) -> File(JSON)으로 변환하는 행위
  • 역직렬화: File(JSON) -> Object(DTO)으로 변환하는 행위

RequestBody 처리

RequestBody를 활용하였을 때의 처리 순서입니다.

  • (3)의 결과로 RequsetResponseBodyMetohdProcessor이라는 ArgumentResolver가 선택되었습니다.
  • 컨버터로써 MappingJackson2HttpMessageConverter라는 Jackson 라이브러리에서 제공하는 컨버터가 선택되어 동작했습니다.
  • ObjectMapperLocalDateDeserializer을 등록합니다.
  • ObjectReaderLocalDateDeserializer을 이용하여 역직렬화합니다.

알 수 있는 것들.

  • Jackson 라이브러리를 통해 역직렬화를 합니다.
  • 따라서 Spring에서 제공하는 @DateTimeFormat은 무시되고, Jackson에서 제공하는 @JsonFormat은 사용됩니다.

RequestParam 처리

RequestParam을 이용하였을 때의 처리입니다. (파란색으로 RequestBody와 다른 부분을 표현했습니다.)

  • (3)의 결과로 AbstractNamedValueMethodArgumentResolver이라는 ArgumentResolver가 선택되었습니다.
  • 컨버터로써 FormattingConversionService라는 스프링에서 제공하는 컨버터가 선택되어 동작했습니다.
    - 정확히는 Service이기에 컨버터(AnnotationParserConverter)가 등록되어 있는 집합입니다.
  • 컨버터의 동작 과정중에 targetType@DateTimeFormat에 대한 정보가 포함되어 있습니다. 이를 이용하여 형식 필터링도 일어납니다.

알 수 있는 것들.

  • Jackson 라이브러리를 통하지 않고 역직렬화를 합니다.
  • 컨버터의 동작 과정중에 targetType이라는 매개변수에 @DateTimeFormat에 대한 정보가 포함되어 있습니다.
    - 따라서, 어노테이션이 없다면 역직렬화가 실패합니다.
    - 이를 이용하여 알맞은 형식인지에 대한 검증이 일어납니다.

내부 코드 분석

워낙 복잡하여 생략해도 좋을 것 같습니다.

Request Body 동작 원리 (내부)

(100개에 임박하는 함수 스택을 가지고 있어, 간략하게만 설명합니다.)

1. DispatcherServlet

  1. 입력이 DispatcherServlet에 할당됩니다.
  2. DispatcherServlet.doDispatch()가 의해 동작이 실행됩니다.
  3. 요청을 핸들링할 수 있는 적절한 핸들러 어댑터를 찾습니다.
  • @RequestMapping이 붙어있기에 RequestMappingHandlerAdapter가 탐색됩니다.
  1. 핸들링 메서드를 호출합니다.

2. RequestMappingHandlerAdapter

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

3. ServletInvokableMethod

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

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

4. RequestResponseBodyMethodProcessor

@RquestBody를 해결할 수 있는 리졸버입니다.

컨버터를 찾아 호출하여 작업을 이어나갑니다.

5. AbstractMessageConverterMethodArgumentResolver

방금까지 보았던 리졸버의 부모 관계에 속하는 추상객체입니다. 반복문안에서 해당 파라미터를 해결할 수 있는 컨버터를 찾습니다.

처리할 수 있는 MappingJackson2HttpMessageConverter을 찾은 후(canRead()), 이를 실행(read())합니다.

6. ObjectMapper

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

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

7. ObjectReader

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

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

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

이 과정을 거쳐 우리가 요청했던 입력 "2023-12-12"가 객체의 DateTime으로 변환되었다.

RequestParam 내부 동작 원리

(1) ~ (4)까지는 동일하여 (5) 부터 설명합니다.

5. AbstractNamedValueMethodArgumentResolver

binder에게 convert를 위임하여 역직렬화를 시도합니다.

binder는 conversionDelegate에게 위임합니다.

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

6. FormattingConversionService

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

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

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

요약

  1. RequestBody는 아무런 어노테이션 없이도 역직렬화가 가능하다.
  2. RequestBody는 필터링을 위해 @JsonFormat을 사용한다.
  3. 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

느낀점

  • Spring MVC의 동작원리에 대한 이해를 키울 수 있었습니다.
  • 또한, 키워드들을 충분히 학습한 후 키워드를 이용하여 다시 검색해보니 좀 더 다양한 정보를 얻을 수 있었습니다. 향로님의 정리 글에서도 처음에 봤을 땐 미쳐 못봤던 내용들(라이브러리, Body-Param 구분)을 확인할 수 있었습니다.
  • 궁금했던 내용을 스스로 직접 테스트해보며 학습하니 재밌었습니다.

글을 쓰며 느낀점

  • 기술적인 내용을 (저한테는) 깊게 다루려하니 정말 어려웠습니다....
  • 독자 입장을 좀 더 고려해야겠다고 느꼈습니다. 영화감독들이 한 장면을 위해 수많은 재촬영을 한다고 하던데, 그러한 자세가 필요할 것 같습니다.
  • 첫 글또 활동이지만 독자 입장을 고려해보며 성장함을 느꼈고, 다른 블로그 글을 볼 때에도 얼마나 독자들을 배려했는지가 조금이나마 보이게 되는 것 같습니다.

읽어주셔서 감사합니다.

post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 12월 15일

멋있어요~! 배워갑니다

답글 달기