프레임워크/Spring

Jackson Deserializer 커스텀을 통해 LocalDateTime에 타임존 반영하기!

SeongOnion 2024. 3. 16. 17:08
728x90

문제상황

프로젝트에서 시간 관련 객체는 모두 타임존 정보가 없는 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));
    }
}