Error/트러블슈팅 / / 2023. 5. 19. 18:39

[Trouble Shooting - RequestBody와 생성자] 스프링부트 LocalDateTime JsonFormat 안됨

개발 중 있었던 일, 통합 API 테스트 코드 작성 중 특정 도메인의 생성 테스트 코드가 동작하지 않고 아래와 같이 오류가 발생했다.

com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.time.LocalDateTime` from String "2023-05-19 16:51:32230519 16:51:32": Failed to deserialize java.time.LocalDateTime: (java.time.format.DateTimeParseException) Text '2023-05-19 16:51:32230519 16:51:32' could not be parsed at index 10

 

이 오류는 포멧이 맞지 않을때 발생하는 오류지만, 애초에 API 테스트시에 해당하는 DTO의 생성자를 통해서 테스트를 하기 때문에 저런 오류가 발생하는 이유가 감이 잡히지도 않았다.

문제의 DTO

@AllArgsConstructor
@Getter
@Setter
@Builder
public class PostDomainReq {
	...
    
    @NotNull(message = "예정일(시작일)은 필수값 입니다.")
    @JsonFormat(pattern = "[yyyy-MM-dd kk:mm:ss][yyMMdd kk:mm:ss]")
    private LocalDateTime startdate;
}


public class DomainApiTest extends ApiTestHelper {
    @Test
    void domainAddTest() throws JsonProcessingException {
        PostDomainReq request = PostDomainReq.builder()
        			...
                    .startDate(LocalDateTime.now())
                    .build();
        var response = apiCall(request);
        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
    }
}

 

즉 컨트롤러에서 파라미터를 전달 받을 때, @RequestBody PostDomainReq request를 통해서 받기 때문에  전달받는것도 동일해야 정상이어야 하고 전달 포맷 형식을 결정하는 JsonFormat도 동일하니까..

라고 생각을 했지만, 요청시 body에 데이터를 실어 서버에 전달하면 기본 생성자가 있어야 한다.

결론부터 말하자면 다른 도메인의 생성 DTO와는 다르게 이번에 테스트하는 DTO는 @NoArgsConstructor가 없었다. 이걸로 문제는 해결됐지만, 어떻게 이런일이 생긴건지 몰랐다.

해당 프로젝트는 TDD가 아닌 개발 후 테스트 코드 작성중이라 더 의문이 생겼다. 그럼 어떻게 실제 기능에서는 문제가 없었던걸까??

처음 생각했던 부분은 @JsonFormat 어노테이션이다. 해당 어노테이션을 통해 api를 json으로 요청시 아래와 같이 지정해준 형식으로 데이터가 변경된다.

	//아래와 같은 형식의 경우 api request
    @JsonFormat(pattern = "[yyyy-MM-dd kk:mm:ss][yyMMdd kk:mm:ss]")
    private LocalDateTime startdate;
    
    
    //요청시 디버깅시 보이는 데이터
    {
    	...
        "a":"a`",
        "startdate": "2023-05-19 16:51:32230519 16:51:32",
        ...
    }
    
    ========================================================================
    
    //JsonFormat을 지정하지 않은 경우
     private LocalDateTime startdate;
     
       
    //요청시 디버깅시 보이는 데이터
    {
    	...
        "a":"a`",
        "startdate": [
        2023,
        5,
        19,
        17,
        53,
        0,
        130240000
    ],
        ...
    }

위의 데이터를 통해서 @JsonFormat을 지정하면 ResponseBody를 통해서 전달받을때 파싱을 해주기도 하지만, 반대로 Json으로 요청할때도 똑같이 동작하게 된다. 그럼 여기서 해결 방법은

  1. 요청하는 startdate를 화면단에서 보낼때와 같이 "2023-05-19 17:53:00" 형식으로 변경해서 보내는 방법
  2. @NoArgsConstructor DTO에 추가하기(선택된 방법) -> 사실 휴먼 에러로 빈 생성자를 안넣어준 문제...
  3. @JsonFormat 제거하기 -> 해당 방법은 기존의 여러가지 형식의 데이터를 파싱해야 하기 때문에 불가능

방법은 이 외에도 더 있을것 같지만, 내가 생각한 방법은 위의 3가지이다.

하지만 어떻게 이런 일이 발생했는지, 왜 기본 생성자가 없으면 @JsonFormat이 동작하지 않는지가 궁금했다.

그걸 확인하기 위해서는 서블릿에서 요청받은 데이터를 Deserialize 해주는 BeanDeserializer를 디버깅했다.

기본 생성자가 없는 경우 deserializeFromObjectUsingNonDefault 메서드가 호출된다.(있을땐 createUsingDefault 호출)

이후 BeanDeserializerBase.java의 deserializeFromObjectUsingNonDefault 메서드를 통해 propertyBaseCreator가 존재하기 때문에(AllArgmentConstructor나 다른 생성자가 있는 경우) _deserializeUsingPropertyBased 함수가 호출된다.

 

이후 BeanDeserializer.java 내부 메서드인 _deserializeUsingPropertyBased가 호출되고, 내부적으로 토큰을 찾아 deserailize한다. 이때 _deserializeWithErrorWrapping 메서드를 호출하게 되는데 여기에서 문제를 확인할 수 있었다.

내부로 들어가 _deserializeWithErrorWrapping안에 있는 prop.deserialize 메서드를 호출하여, 하나씩 데이터들을 역직렬화 시킨다. 이후 SettableBeanProperty.java 내부 메서드인 serailize에서 아래와 같은 예외들을 지나 해당 메서드가 실행된다.

 이후 LocalDateTimeDeserializer.java의 deserailize 메서드에서 parser.hasTokenId(ID_STRING) 구문에서 _fromString 메서드가 실행된다. 아까 위의 요청시 보이는 타입에 따라 문자열이라서 내부에서 인식하게 되어 해당 구문이 실행됐다.

이후 내부 메서드인 _fromString을 통해 LocalDateTime으로 파싱시키는데 해당 문자열은 위의 로직을 통해서는 파싱이 불가능하다. 왜냐하면

@JsonFormat(pattern = "[yyyy-MM-dd kk:mm:ss][yyMMdd kk:mm:ss]")

Custom한 pattern을 통해 @JsonFormat을 지정해주기 때문에 기본적인 formatter로는 해당 문자열을 파싱할 수 없기 때문이다.

당연히 해당 문자열은 LocalDateTime으로 파싱될 수 없었고, 실제 요청시 발생하는 데이터 타입과 다르기 때문에 해당 문제가 발생했던 것 이었다.

 

그럼 해당 문제를 해결하려면 어떻게 해야 할까? Deserailize 하는 로직을 자세히 보자.

여기서 볼때 _formatter를 스프링에 등록해주면 해결하는 방법도 있을 것 같다.

해당 _formatter를 따라가보면 ContextualDeserializer를 implements한 JSR3100DateTimeDeserializerBase라는 추상 클래스가 나오는데 멤버 변수로 갖고 있는 _formatter를 스프링 변경할 수 있을것 같다.

해당 방법은 특정한 날짜 타입을 계속해서 사용하는 경우고, 특별하게도 빈 생성자를 두고싶지 않은 경우에 사용이 가능해보인다.

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유