Jackson Deserializer 커스텀을 통해 LocalDateTime에 타임존 반영하기!
문제상황
프로젝트에서 시간 관련 객체는 모두 타임존 정보가 없는 LocalDateTime
객체로 처리되고 있다.
사실 별 이유가 있었던 것은 아니고 잘 몰라서였다.(흑흑)
서버의 타임존은 KST로 설정되어있었기 때문에 요청값과 응답값이 모두 별도의 타임존 값 없이 암묵적으로 KST 시간대로 통일되어 돌아가고 있었다.
그런데 프로젝트에 여러 클라이언트들이 붙으며 작업을 할수록 문제가 생겼다.
서비스에서 사용자 개인이 직접 표시 시간대를 설정할 수 있고, 이 설정값에 따라서 KST로 설정되어있는 시간 데이터를 다시 적절히 계산하여 내려줘야하는데 이걸 서버 측에서 모두 하자니 누락되는 부분이 발생할 확률이 매우 높았고, 표시 시간대 데이터를 매번 조회해야하므로 성능상 좋지도 않았다.
결국 성능과 유지보수 측면에서 클라이언트 측에서 처리해주기로 합의가 되었는데, 기존 방식대로 KST로 내려주자니 클라이언트에서 이걸 다시 UTC로 바꿨다가 사용자 표시 시간대로 다시 바꿨다가.. 아주 난리법석이었다.
결국엔 클라이언트 - 서버 간 시간 데이터는 모두 UTC 형태로 통합하도록 합의를 했는데, 문제는 이미 실서비스에서 사용되고 있는 API였기 때문에 변환작업이 마무리될때까진 서버에서 UTC 형태와 타임존 없는(KST라고 가정되는) 시간 형태 모두 지원하도록 해야했다.
해결 방안 정의
결국 해결 방안은 클라이언트가 넘겨준 시간 데이터에 타임존 정보가 포함되어있다면 서버 내부에선 해당 시간을 KST로 변환해 사용하도록 하고, 그렇지 않다면 KST 타임존이라고 가정하고 그대로 사용하는 것이었다.
해당 작업을 위해선 클라이언트가 념겨주는 JSON 형태의 문자열을 직렬 / 역직렬화를 담당하는 Jackson
라이브러리의 objectMapper
를 커스터마이징 해주는 것이 필요했다.
우선 클라이언트가 보내는 정보에 대해서만 타임존 반영 작업이 필요하기 때문에, 역직렬화(Deserialize
) 시에만 해당 작업을 처리해주도록 작업하였다.
구현
1. 커스텀 Deserializer 등록하기
Deserializer
를 커스텀하기 위해선 objectMapper
객체에 커스텀 Deserializer
를 등록한 Module
상속 객체를 등록해주면 된다.
public class ObjectMapper extends ObjectCodec implements Versioned, Serializable {
...
public ObjectMapper registerModule(Module module) {
...
}
}
Jackson
에선 Module
객체를 구현한 SimpleModule
을 제공한다.
해당 객체를 사용하면 쉽게 등록이 가능하다.
SimpleModule module = new SimpleModule();
// 역직렬화 대상이 되는 객체에 커스텀한 Deserializer 추가
module.addDeserializer(LocalDateTime.class, new MyCustomDeserializer());
해당 모듈을 등록한 ObjectMapper
를 빈으로 띄워줘야하는데, 이 때 단순히 @Bean
을 사용하면 기본으로 뜨는 ObjectMapper
에 적용된 다른 설정값들이 모두 덮어씌워져버린다.
우리는 다른 설정 값들은 유지한채로 커스텀 역직렬화 모듈만 등록하길 원하기 때문에, @Autowired
어노테이션을 사용해 BeanFactory
가 빈을 등록하는 시점에 메서드를 실행시키도록 구현하였다.
@Configuration
public class LocalDateTimeDeserializerConfig {
@Autowired
public void configureDeserializer(final ObjectMapper objectMapper) {
SimpleModule module = new SimpleModule();
module.addDeserializer(LocalDateTime.class, new TimezoneAppliedLocalDateTimeDeserializer());
objectMapper.registerModule(module);
}
private static class TimezoneAppliedLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
// 타임존이 있으면 계산 후 KST 시간대로 조정된 LocalDateTIme으로 리턴
// 타임존이 없으면 그대로 리턴
}
}
}
2. 커스텀 Deserializer 구현 상세
이젠 직접 만든 TimezoneAppliedLocalDateTimeDeserializer
를 구현해줘야한다.
Jackson
에선 기본적으로 타임존이 포함된 ZonedDateTime
객체에 대하여 InstantDeserializer.ZONED_DATE_TIME
를, LocalDateTime
객체에 대해선 LocalDateTimeDeserializer
를 사용해 역직렬화를 진행한다.
따라서, 역직렬화 대상 문자열의 타임존 정보 포함 여부를 판단해 두 역직렬화 객체 중 적절한 것을 골라주기만 하면 됐다.
문제는 문자열을 보고 타임존 정보를 포함하는지 판단하는 것이 다소 애매했다.
일반적으로 클라이언트와 시간 데이터를 주고 받을 때는 yyyy-MM-dd'T'HH:mm:ss.SSSXXX
와 같은 ISO 8601 같은 형태를 따르는데,
타임존의 경우 Zero Offset일 때는 'Z'를, 그 외 Offset이 필요한 경우 +09:00 와 같은 형태로 입력되기 때문에 이러한 다양한 형태의 문자열을 직접 파싱하는 방법을 찾기는 불가해보였고, 안전하지도 않아보였다.
그런데 다행히도 InstantDeserializer.ZONED_DATE_TIME
에선 역직렬화 대상의 문자열에 타임존이 설정되어있지 않으면 예외를 발생시켜버렸다!
private static class UTCAppliedLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
return InstantDeserializer.ZONED_DATE_TIME.deserialize(jsonParser, deserializationContext)
// ZonedDateTime을 Asia/Seoul 타임존으로 변경
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime();
}
}
@SpringBootTest
class LocalDateTimeDeserializerConfigTest {
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("타임존이 없는 문자열을 역직렬화 하면 예외가 발생한다.")
void withoutTimezoneTest() throws JsonProcessingException {
String jsonString = "{\"date\": \"1996-09-12T00:00:00\"}";
assertThatThrownBy(() -> objectMapper.readValue(jsonString, RequestWithDate.class));
}
}
따라서, InstantDeserializer.ZONED_DATE_TIME
로 먼저 대상 문자열을 역직렬화 해보고 예외가 발생하면 타임존이 없다고 간주하고 LocalDateTimeDeserializer
를 사용해주면 됐다.
결과적으로 완성된 코드는 아래와 같았다.
private static class UTCAppliedLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
try {
return InstantDeserializer.ZONED_DATE_TIME.deserialize(jsonParser, deserializationContext)
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime();
} catch (InvalidFormatException e) {
return LocalDateTimeDeserializer.INSTANCE.deserialize(jsonParser, deserializationContext);
}
}
}
이후, 아래의 테스트 코드를 통해 최종적인 검증을 하였다.
@SpringBootTest
class LocalDateTimeDeserializerConfigTest {
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("타임존이 없으면 시간 조정 없이 그대로 반환된다.")
void withoutTimezoneTest() throws JsonProcessingException {
// given
String jsonString = "{\"date\": \"1996-09-12T00:00:00\"}";
// when
RequestWithDate requestWithDate = objectMapper.readValue(jsonString, RequestWithDate.class);
// then
assertThat(requestWithDate.getDate()).isEqualTo(LocalDateTime.of(1996, 9, 12, 00, 00, 00));
}
@Test
@DisplayName("타임존이 있으면 KST로 변환된 시간을 반환한다.")
void withTimezoneTest() throws JsonProcessingException {
// given
// Zero offset
String jsonString = "{\"date\": \"1996-09-12T00:00:00Z\"}";
// when
RequestWithDate requestWithDate = objectMapper.readValue(jsonString, RequestWithDate.class);
// then
// +09:00된 시간 반환
assertThat(requestWithDate.getDate()).isEqualTo(LocalDateTime.of(1996, 9, 12, 9, 00, 00));
}
}