테스트 코드 에러
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 문제점을 해결하는 과정을 작성을 하겠습니다.
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의 역직렬화 문제를 해결하는 방법에 설명을 겠습니다.
기존의 문제의 코드
@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();
}
@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);
}
... 생략
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);
}
}
@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))
);
}
}
해당 오류는 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를 추가를 하지 않기 때문입니다.
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
이러한 문제를 해결하기 위해 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