티스토리 뷰

백엔드 서비스를 개발하면서 LocalDateTime, LocalDate와 같은 타입을 통해 날짜 처리를 하고 해당 데이터를 Request, Response로 전달해야할 일이 있었습니다.

이때 타입 변환을 어떻게 하면 좋을지에 대해 고민한 내용을 정리해보려고 합니다. 또한 이후 클라이언트와의 논의에서 타임존을 처리한 방식까지 같이 정리해볼까 합니다.

 

먼저 상황은 다음과 같습니다.

 

  • 클라이언트에서는 요청, 응답 시 데이터 형태는 ```yyyy-MM-dd HH:mm:ss``` 포맷의 string으로 전달합니다.
  • 서버의 Request, Response 모델에서는 해당 데이터를 LocalDateTime 타입으로 전달받습니다.
  • 이때 별도 설정을 하지 않는 경우에는 JSON parse error: Cannot deserialize value of type `java.time.LocalDateTime` from String ... 가 발생합니다.

 

해결 방법 1. @JsonFormat

먼저 요청, 응답 모델에 @JsonFormat 애노테이션을 추가합니다. 이 방식으로 필요할 때마다 개별적으로 알맞은 포맷에 맞게 데이터를 요청하고 응답 받을 수 있습니다.

import com.fasterxml.jackson.annotation.JsonFormat;

...

@Getter
public class SampleResponse {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime localDateTime;
    
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate localDate;
    
    ...
}

 

위와 같이 작성한 뒤에 아래와 같이 postman을 통해 Sample 을 RequestBody 로 보내고 다시 받으면 정상적으로 원하는 포맷에 맞게 데이터를 주고 받을 수 있습니다.

 

 

해결 방법 2. Jackson2ObjectMapperBuilderCustomizer 

하지만 위의 방법의 경우 매번 필요할 때마다 모든 Request, Response에 애노테이션을 달아야 하기 때문에 번거로움이 있고 실수로 빼먹는 경우도 발생할 수 있다. 따라서 이를 한번에 설정하는 방법은 다음처럼 cofiguration 파일을 만들고 Jackson2ObjectMapperBuilderCustomizer 를 빈으로 등록하는 방식을 사용할 수 있습니다. 이렇게하면 미리 정해놓은 serializer, deserializer를 통해 원하는 포맷으로 데이터를 주고 받을 수 있습니다.

import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.time.format.DateTimeFormatter;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private static final String LOCAL_DATE_FORMAT = "yyyy-MM-dd";
    private static final String LOCAL_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> {
            builder.simpleDateFormat(LOCAL_DATE_TIME_FORMAT);
            builder.serializers(
                    new LocalDateSerializer(DateTimeFormatter.ofPattern(LOCAL_DATE_FORMAT)),
                    new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(LOCAL_DATE_TIME_FORMAT))
            );
            builder.deserializers(
                    new LocalDateDeserializer(DateTimeFormatter.ofPattern(LOCAL_DATE_FORMAT)),
                    new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(LOCAL_DATE_TIME_FORMAT))
            );
        };
    }
}

 

결과를 확인해보면 다음과 같습니다.

 

이렇게 하면 Sample Request, Response 모델에는 별도의 애노테이션을 달지 않아도 원하는 포맷으로 데이터를 주고 받을 수 있습니다. 하지만 특정 필드에는 다른 값을 추가해주고 싶다면 @JsonFormat으로 별도 지정을 해줄 수 있습니다. 두가지 경우가 같이 적용되어 있다면 @JsonFormat이 더 우선순위를 가지고 적용되기 때문입니다.

 

❗️이슈

@EnableWebMvc 가 설정되어 있다면 커스텀하게 적용한 Serializer, Deserializer가 동작하지 않습니다. 이는 @EnableWebMvc 가 있으면 WebMvcAutoConfiguration스프링 MVC 자동구성을 담당하게 되는데 이때 몇가지 스프링의 설정들은 무시되고 기본 세팅되기 때문입니다. (해당 내용에 대해서는 추가적으로 더 공부해보면 좋을 것 같습니다...ㅎㅎ)

 


 

추가로 서버의 타임존이 UTC로 되어 있고 각 클라이언트에서 데이터를 전송할 때 만약 클라이언트가 다른 타임존에 있다고 한다면 해당 데이터는 어떻게 처리할지에 대해 논의한 내용을 정리하고자 합니다.

 

상황은 다음과 같습니다.

 

  • 클라이언트는 데이터를 저장하고 조회할 때 자신의 Timezone 정보를 Request Header에 추가해서 전달합니다.
  • 서버에서는 해당 Timezone 정보를 바탕으로 request, response 객체에 String으로 전달된 데이터를 UTC 기준의 LocalDateTime으로 직렬화, 역직렬화해서 DB에 저장합니다.
  • 예를 들어 클라이언트에서 "2023-11-16 19:50:00"라는 시간보낼 때 헤더의 Timezone이 Asia/Seoul 이라면 서버에서 받은 데이터는 LocalDateTime 타입의 "2023-11-16T10:50:00"으로 저장되어야 하는 것입니다. (UTC + 9시간 = Asia/Seoul 이기 때문입니다.)
  • 마찬가지로 LocalDateTime 타입의 "2023-11-16T10:50:00" 데이터는 응답 시 요청 헤더 Timezone이 Asia/Seoul이라면 "2023-11-16 19:50:00" 문자열로 반환되어야 합니다.

위와 같은 상황을 만족시키기 위해 먼저 ObjectMapper 를 새로운 빈으로 등록하였습니다. 이때 커스텀한 Serializer, Deserializer가 추가된 모듈을 등록하게 됩니다. 그리고 커스텀한 Serializer, Deserializer 내부에서 Request Header에 포함된 timezone 정보를 찾아 요청 시 받은 문자열을 LocalDateTime으로 변경하고 응답 시 LocalDateTime을 문자열로 변환할 수 있도록 하였습니다.

 

WebMvcConfig.class

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.hbdev.beapi.common.utils.serialize.CustomLocalDateTimeDeserializer;
import com.hbdev.beapi.common.utils.serialize.CustomLocalDateTimeSerializer;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(customLocalDateTimeModule());
        return objectMapper;
    }

    @Bean
    public SimpleModule customLocalDateTimeModule() {
        SimpleModule module = new SimpleModule();
        
        module.addSerializer(LocalDateTime.class, new CustomLocalDateTimeSerializer());
        module.addDeserializer(LocalDateTime.class, new CustomLocalDateTimeDeserializer());

        return module;
    }
}

 

CustomLocalDateTimeDeserializer.class

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.hbdev.beapi.common.utils.TimeZoneUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

@Slf4j
@Component
public class CustomLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {

    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        ZoneId zoneId = TimeZoneUtils.extractTimeZone();
        LocalDateTime parsedLocalDateTime = LocalDateTime.parse(p.getText(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        return ZonedDateTime.of(parsedLocalDateTime, zoneId).withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime();
    }
}

 

CustomLocalDateTimeSerializer.class

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.hbdev.beapi.common.utils.TimeZoneUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

@Slf4j
@Component
public class CustomLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        ZoneId zoneId = TimeZoneUtils.extractTimeZone();
        LocalDateTime localDateTime = ZonedDateTime.of(value, ZoneId.of("UTC")).withZoneSameInstant(zoneId).toLocalDateTime();
        gen.writeString(localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
}

 

TimeZoneUtils.class

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.time.ZoneId;
import java.util.Optional;

public class TimeZoneUtils {
    public static ZoneId extractTimeZone() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String zoneId = Optional.ofNullable(request.getHeader("Timezone")).orElse("UTC");
        return ZoneId.of(zoneId);
    }
}

 

이렇게 하고 테스트를 해보면 다음과 같습니다.

 

 

추가로 deserialize 할 때 여러가지 Pattern을 허용해 달라는 요청이 있었습니다. 그래서 다음과 같이 DateTimeFormmatterBuilder에 여러 Pattern을 등록하는 방식으로 수정하였습니다.

 

CustomLocalDateTimeDeserializer.class

@Slf4j
@Component
public class CustomLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
    private final String[] patterns = {
        "yyyy-MM-dd HH:mm:ss",
        "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
        "yyyy-MM-dd"
    };

    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        ZoneId zoneId = TimeZoneUtils.extractTimeZone();
        LocalDateTime parsedLocalDateTime = LocalDateTime.parse(p.getText(), makeDateTimeFormatter());
        return ZonedDateTime.of(parsedLocalDateTime, zoneId).withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime();
    }

    private DateTimeFormatter makeDateTimeFormatter() {
        StringBuilder pattern = new StringBuilder();

        for (String p : patterns) {
            pattern.append("[").append(p).append("]");
        }

        return new DateTimeFormatterBuilder()
                .appendPattern(pattern.toString())
                .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
                .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
                .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
                .toFormatter();
    }
}

 

LocalDateTime으로 parse 할 때 DateTimeFormat의 pattern이 "yyyy-MM-dd"와 같이 시간이 정의되지 않은 경우 에러가 발생할 수 있습니다. 이때는 위와 같이 parseDefaulting으로 시간에 대한 값들을 넣어주거나 다음과 같이 LocalDate로 parse한 뒤 다시 시간 정보를 추가해 LocalDateTime으로 parse할 수 있습니다.

LocalDateTime ldt = LocalDate.parse(p.getText(), DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();

 

💡(추가) ObjectMapper 를 새로 빈 등록하는 대신 기존에 사용했던 Jackson2ObjectMapperBuilderCustomizer 를 등록하는 방식으로 코드를 수정할 수 있습니다.

더보기

WebConfig.java

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> {
            builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");

            builder.serializers(
                    new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
                    new CustomLocalDateTimeSerializer()
            );

            builder.deserializers(
                    new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
                    new CustomLocalDateTimeDeserializer()
            );
        };
    }
}

 

CustomLocalDateTimeSerializer.java

@Slf4j
@Component
public class CustomLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
    @Override
    public Class<LocalDateTime> handledType() {
        return LocalDateTime.class;
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        ZoneId zoneId = TimeZoneUtils.extractTimeZone();
        LocalDateTime localDateTime = ZonedDateTime.of(value, ZoneId.of("UTC")).withZoneSameInstant(zoneId).toLocalDateTime();
        gen.writeString(localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
}

 

CustomLocalDateTimeDeserializer.java

@Slf4j
@Component
public class CustomLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
    private final String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd"};

    @Override
    public Class<?> handledType() {
        return LocalDateTime.class;
    }

    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        ZoneId zoneId = TimeZoneUtils.extractTimeZone();
        LocalDateTime parsedLocalDateTime = LocalDateTime.parse(p.getText(), makeDateTimeFormatter());
        return ZonedDateTime.of(parsedLocalDateTime, zoneId).withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime();
    }

    private DateTimeFormatter makeDateTimeFormatter() {
        StringBuilder pattern = new StringBuilder();

        for (String p : patterns) {
            pattern.append("[").append(p).append("]");
        }

        return new DateTimeFormatterBuilder()
                .appendPattern(pattern.toString())
                .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
                .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
                .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
                .toFormatter();
    }
}

 

이렇게 클라이언트와 데이터를 주고 받을 때 String을 LocalDateTime으로 Serialize, Deserialize 하는 방식에 대해 살펴보았습니다.

공통으로 작업을 수행해야 하는 부분이라 고민할 것이 많았던 것 같습니다. 다른 프로젝트에서는 이런 부분을 어떻게 처리하는지... 더 좋은 방식이 있을지 고민해보는 것이 좋을 것 같다고 생각했습니다. 또한 처음부터 서비스 단에서 ZonedDateTime을 더 적극적으로 활용하는 것도 좋은 방향이라고 생각해봅니다. 🧐

 

참고

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함