Jackson SerializationException (LocalDateTime) + redis.serializer.SerializationException

Mugeon Kim·2023년 11월 13일
0
post-thumbnail

서론


  • 프로젝트를 진행을 하면서 직, 역직열화를 하는 과정에서 오류가 발생을 하였습니다. 주로 LocalDateTime
    프로젝트 링크
  • 주로 에러가 발생하는 과정을 총 2곳입니다. 테스트 코드를 진행을 하면서 HTTP 요청과 검증을 분리를 하였을 때 데이터 매핑과 Redis에 LocalDateTime을 캐싱 또는 적재를 하였을 때 발생을 하였습니다.

테스트 코드 에러

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
 at [Source: (String)"[{"id":1,"title":"제목1","content":"내용1","createdDate":"2023-09-28T14:30:00"},{"id":2,"title":"제목2","content":"내용2","createdDate":"2023-09-28T15:30:00"}]"; line: 1, column: 54] (through reference chain: java.util.ArrayList[0]->com.cstudy.modulecommon.dto.NoticeResponseDto["createdDate"])

Redis 코드 에러

exception is org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Unrecognized field "createdDate" (class com.cstudy.modulecommon.domain.member.Member), not marked as ignorable (13 known properties: "requests", "name", "questions", "memberCompetitions", "memberIpAddress", "version", "rankingPoint", "id", "email", "roles", "password", "countryIsoCode", "file"])
 at [Source: (byte[])"{"createdDate":"2023-11-13","lastModifiedDate":"2023-11-13","id":1,"email":"admin@admin.com","password":"$2a$10$fVwh2NdKoNbOrm0hoAfVVeviLGC8v5Is3NQy9F/emlAmG2xCmrlqy","name":"관리자","rankingPoint":0.0,"memberIpAddress":null,"countryIsoCode":null,"version":0,"file":[],"questions":[],"memberCompetitions":[],"requests":[],"roles":[{"roleId":2,"name":"ROLE_ADMIN"}]}"; line: 1, column: 17] (through reference chain: com.cstudy.modulecommon.domain.member.Member["createdDate"]); nested exception is com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "createdDate" (class com.cstudy.modulecommon.domain.member.Member), not marked as ignorable (13 known properties: "requests", "name", "questions", "memberCompetitions", "memberIpAddress", "version", "rankingPoint", "id", "email", "roles", "password", "countryIsoCode", "file"])
 at [Source: (byte[])"{"createdDate":"2023-11-13","lastModifiedDate":"2023-11-13","id":1,"email":"admin@admin.com","password":"$2a$10$fVwh2NdKoNbOrm0hoAfVVeviLGC8v5Is3NQy9F/emlAmG2xCmrlqy","name":"관리자","rankingPoint":0.0,"memberIpAddress":null,"countryIsoCode":null,"version":0,"file":[],"questions":[],"memberCompetitions":[],"requests":[],"roles":[{"roleId":2,"name":"ROLE_ADMIN"}]}"; line: 1, column: 17] (through reference chain: com.cstudy.modulecommon.domain.member.Member["createdDate"])] with root cause
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "createdDate" (class com.cstudy.modulecommon.domain.member.Member), not marked as ignorable (13 known properties: "requests", "name", "questions", "memberCompetitions", "memberIpAddress", "version", "rankingPoint", "id", "email", "roles", "password", "countryIsoCode", "file"])
 at [Source: (byte[])"{"createdDate":"2023-11-13","lastModifiedDate":"2023-11-13","id":1,"email":"admin@admin.com","password":"$2a$10$fVwh2NdKoNbOrm0hoAfVVeviLGC8v5Is3NQy9F/emlAmG2xCmrlqy","name":"관리자","rankingPoint":0.0,"memberIpAddress":null,"countryIsoCode":null,"version":0,"file":[],"questions":[],"memberCompetitions":[],"requests":[],"roles":[{"roleId":2,"name":"ROLE_ADMIN"}]}"; line: 1, column: 17] (through reference chain: com.cstudy.modulecommon.domain.member.Member["createdDate"]) 
  • 이 문제를 해결하기 위하여 검색을 하였을 때 Java 8의 LocalDateTime을 직렬, 역직렬화를 수행을 하였을 때 오류가 발생을 한다고 알게 되었습니다.

  • 이 게시글에서 Java의 LocalDateTime의 문제점과 Redis에서 SerializationException 문제점을 해결하는 과정을 작성을 하겠습니다.

본론


레디스 역질렬화 해결법

문제 상황

  • JWT를 사용하면서 2곳의 회원의 인증을 받습니다. (1) 서비스 로직 (2) JWT 필터 위에 인증을 2개가 필요하지 않고 대용량 트래픽이 들어오면 문제가 발생할 수 있습니다.
  • 이러한 문제를 Redis 캐싱을 통하여 해결을 하였지만 다음과 같은 오류가 발생을 했습니다.
Resolved [org.springframework.data.redis.serializer.SerializationException:
Could not write JSON: Java 8 date/time type `java.time.LocalDateTime`
not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling

밑에서 LocalDateTime의 역직렬화 문제를 해결하는 방법에 설명을 겠습니다.

문제 해결하기 위하여 노력한 방법

1. jackson-datatype-jsr310 의존성을 추가한다.

  • 처음에 시도한 방식으로 LocalDatetime을 역직렬화 하기 위하여 다음과 같은 의존성을 추가를 하였지만 아직도 오류가 발생을 하였다.

2. Custom Serializer 사용하기

기존의 문제의 코드

StringRedisSerializer

  • StringRedisSerializer는 String 값을 그대로 저장을 한다. JSON 형태로 직접 인, 디코딩을 해줘야하는 단점이 있지만 클래스 타입을 지정할 필요가 없고 쓰레드간의 문제가 발생하지 않는다.

GenericJackson2JsonRedisSerializer

  • 객체의 클래스 지정 없이 모든 Class Type을 JSON 형태로 저장할 수 있는 Serializer이다.
  • 클래스 타입에 상관 없이 모든 객체를 직렬화 한다는 장점이 있지만 Object의 class 및 package까지 전부 함께 저장하게 되어 다른 프로젝트에서 redis에 저장되어 있는 값을 사용하려면 package까지 일치시켜줘야한다.
 @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext
                        .SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                        .SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));


        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
  • 기존의 코드는 LoacalDateTime을 처리하지 못하기 때문에 ObjectMapper에 JavaTimeModule()을 추가하여 GenericJackson2JsonRedisSerializer의 파라미터로 해당 ObjectMapper를 넘겨주게 만들었습니다.
    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext
                        .SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                        .SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));


        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }

역질렬화 문제가 발생한 테스트 코드

  • 현재 테스트 코드는 공지사항을 페이징을 처리하는 부분을 테스트를 하고 있습니다.

  • 테스트 코드의 이해를 위해 구조에 대해 설명을 하겠습니다.

  • 현재 테스트 코드에서는 Controller를 테스트 하는 구조입니다. 일단 mockMVC, ObjectMapper를 MockApiCaller에 추상 클래스로 분리를 하였습니다.

public abstract class MockApiCaller {

    protected final MockMvc mockMvc;

    protected final ObjectMapper objectMapper;

    public MockApiCaller(MockMvc mockMvc, ObjectMapper objectMapper) {
        this.mockMvc = mockMvc;
        this.objectMapper = objectMapper;
    }

 ///-> POST의 EXCEPTION을 검증하는 코드도 공통으로 사용하기 때문에 Htpp Method에 따라서 분리하여 상속을 통해 쉽게 사용할 
 ///    수 있게 관리
    public ApiResponse<ErrorResponse> sendPostRequest_WithAuthorization_ParseErrorResponse(String url, Object request) throws Exception {

        MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.post(url)
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer " + ADMIN_USER)
                .content(objectMapper.writeValueAsString(request));

        MockHttpServletResponse response = mockMvc.perform(builder)
                .andReturn()
                .getResponse();

        ErrorResponse errorResponse = ErrorResponse.builder()
                .code(JsonPath.read(response.getContentAsString(StandardCharsets.UTF_8), "$.code"))
                .message(JsonPath.read(response.getContentAsString(StandardCharsets.UTF_8), "$.message"))
                .validation(JsonPath.read(response.getContentAsString(StandardCharsets.UTF_8), "$.validation"))
                .build();

        return new ApiResponse<>(response.getStatus(), errorResponse);
    }
    
    ... 생략

NoticeMockApiCaller

  • 이렇게 분리한 이유는 테스트 코드는 데이터의 정합성을 검증하는 역할 이외에 상대방에게 코드에 대해 설명하는 기능을 한다고 생각합니다.
  • 이때 Controller에서 제일 중요한 부분은 Stub을 한 상태를 검증하는 부분이 제일 핵심 관심사라고 판단하여 Http 요청과 검증하는 로직을 분리를 하였습니다.
public class NoticeMockApiCaller extends MockApiCaller {

    public NoticeMockApiCaller(MockMvc mockMvc, ObjectMapper objectMapper) {
        super(mockMvc, objectMapper);
    }

    public ApiResponse<Page<NoticeResponseDto>> findNoticeWithPage(NoticeSearchRequestDto request) throws Exception {
        MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/api/notice")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request));

        MockHttpServletResponse response = mockMvc.perform(builder)
                .andReturn()
                .getResponse();

        String jsonResponse = response.getContentAsString(StandardCharsets.UTF_8);
        
        JsonNode jsonNode = objectMapper.readTree(jsonResponse);
        List<NoticeResponseDto> content = objectMapper.readValue(jsonNode.get("content").toString(), new TypeReference<>() {});
        int totalPages = jsonNode.get("totalPages").asInt();
        long totalElements = jsonNode.get("totalElements").asLong();

        Page<NoticeResponseDto> noticePage = new PageImpl<>(content, PageRequest.of(0, content.size()), totalElements);

        return ApiResponse.success(response.getStatus(), noticePage);
    }
}

NoticeControllerTest

  • HTTP 요청과 Response를 매핑하는 apiCaller 클래스를 요청하여 반환 값을 return을 받아 assertThat으로 검증을 했습니다.
    @DisplayName("/api/notice 공지사항 조회 페이징")
    @Nested
    class paging_findNotice{

        private final LocalDateTime localDateTime = LocalDateTime.of(2023, 9, 28, 14, 30);


        @BeforeEach
        void setUp(){
            NoticeResponseDto noticeResponse1 = createNoticeResponse(1L, "제목1", "내용1", localDateTime);
            NoticeResponseDto noticeResponse2 = createNoticeResponse(2L, "제목2", "내용2", localDateTime.plusHours(1));

            List<NoticeResponseDto> list = new ArrayList<>(Arrays.asList(noticeResponse1, noticeResponse2));


            Page<NoticeResponseDto> pagedResponse = new PageImpl<>(list);

            given(noticeService.findNoticePage(anyInt(), anyInt(), any(NoticeSearchRequestDto.class)))
                    .willReturn(pagedResponse);
        }

        @Test
        public void 공지사항_조회_페이징_기본_PageRequest_Default() throws Exception{
            //given
            NoticeSearchRequestDto request = NoticeSearchRequestDto.builder().build();
            //when
            ApiResponse<Page<NoticeResponseDto>> response = noticeMockApiCaller.findNoticeWithPage(request);

            //Then
            assertAll(
                    ()->assertThat(response.getStatus()).isEqualTo(200),

                    ()->assertThat(response.getBody().getTotalPages()).isEqualTo(1),
                    ()->assertThat(response.getBody().getNumber()).isEqualTo(0),
                    ()->assertThat(response.getBody().getSize()).isEqualTo(2),


                    ()->assertThat(response.getBody().getContent().get(0).getId()).isEqualTo(1L),
                    ()->assertThat(response.getBody().getContent().get(0).getTitle()).isEqualTo("제목1"),
                    ()->assertThat(response.getBody().getContent().get(0).getContent()).isEqualTo("내용1"),
                    ()->assertThat(response.getBody().getContent().get(0).getCreatedDate()).isEqualTo(localDateTime),

                    ()->assertThat(response.getBody().getContent().get(1).getId()).isEqualTo(2L),
                    ()->assertThat(response.getBody().getContent().get(1).getTitle()).isEqualTo("제목2"),
                    ()->assertThat(response.getBody().getContent().get(1).getContent()).isEqualTo("내용2"),
                    ()->assertThat(response.getBody().getContent().get(1).getCreatedDate()).isEqualTo(localDateTime.plusHours(1))
            );
        }
    }
  • MockHttpServletResponse는 정상적으로 처리가 되었지만 다음과 같은 문제가 발생을 했다.

문제

  • 해당 오류는 Java 8에 추가된 날짜/시간 타입인 LocalDate, LocalTime, LocalDateTime이 기본적으로 Jackson 라이브러리에 의해 지원되지 않기 때문에 발생하는 것입니다.

  • 이를 해결하려면 com.fastxml.jackson.datatype:jackson-datatype-jsr310 모듈 추가가 필요합니다. 해당 모듈은 Java 8의 날짜/시간 타입은 Jackson에서 처리할 수 있도록 지원해 줍니다.

  • 해당 jsr310을 따로 의존성을 추가를 할 필요는 없습니다. spring-boot-starter-json모듈에 jackson-datatype-jsr310을 가져오지만 ObjectMapper에 jsr310를 추가를 하지 않기 때문입니다.

해결

  • 문제가 발생하면 가장 도움을 많이 받으는 baeldung에서 도움을 받았다.
 ObjectMapper objectMapper = new ObjectMapper();
 objectMapper.registerModule(new JavaTimeModule());

JavaTimeModule

  • JavaTimeModule은 Java 8에 도입된 새로운 날짜 및 시간 API(LocalDate, LocalTime, LocalDateTime)를 Jackson 라이브러리에서 적절하게 처리할 수 있게 해주는 모듈입니다. 기본적으로 Jackson 라이브러리는 Java 8의 새로운 날짜 및 시간 타입들을 인식하지 못하기 때문에 해당 타입들을 JSON으로 직렬화하거나 JSON에서 역직렬화할 때 문제가 발생할 수 있습니다.

이러한 문제를 해결하기 위해 JavaTimeModule을 ObjectMapper에 등록하면 날짜/시간 타입들을 적절하게 직렬화하고 역직렬화할 수 있게 됩니다.

 public ApiResponse<Page<NoticeResponseDto>> findNoticeWithPage(String url, NoticeSearchRequestDto request) throws Exception {
        MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(url)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request));

        MockHttpServletResponse response = mockMvc.perform(builder)
                .andReturn()
                .getResponse();

        String jsonResponse = response.getContentAsString(StandardCharsets.UTF_8);
	========================변경===================================
        objectMapper.registerModule(new JavaTimeModule());
	=================================================================

        JsonNode jsonNode = objectMapper.readTree(jsonResponse);
        List<NoticeResponseDto> content = objectMapper.readValue(jsonNode.get("content").toString(), new TypeReference<>() {});
        int totalPages = jsonNode.get("totalPages").asInt();
        long totalElements = jsonNode.get("totalElements").asLong();

        Page<NoticeResponseDto> noticePage = new PageImpl<>(content, PageRequest.of(0, content.size()), totalElements);

        return ApiResponse.success(response.getStatus(), noticePage);
    }

참고


https://woo-chang.tistory.com/75
https://www.baeldung.com/jackson-serialize-dates

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글