TL;DR

Spring Boot + Jackson 환경에서 LocalDateTime을 JSON으로 직렬화할 때, 타임존 정보(Z)가 누락되어 프론트엔드에서 시간이 잘못 표시되는 문제를 커스텀 Serializer로 해결한 경험을 공유합니다.

// 문제: Z가 없어서 브라우저가 로컬 타임존으로 해석
"startTime": "2025-10-01T01:44:06"

// 해결: Z를 붙여서 UTC임을 명시
"startTime": "2025-10-01T01:44:06Z"

문제 상황

증상

학생 관리 시스템에서 공부 시작 시간이 9시간 과거로 기록되는 버그가 발생했습니다.

  • 실제 시작 시간: 오전 10:44
  • 화면 표시: 오전 01:44 (9시간 차이!)

원인 분석

  1. 서버 측:
    • JVM 타임존: UTC (TimeZoneConfig로 설정)
    • LocalDateTime.now() 호출 → UTC 시간 01:44 반환
    • DB에 UTC 01:44 저장 ✅
  2. API 응답:
    {
      "startTime": "2025-10-01T01:44:06"  // ❌ Z 없음!
    }
    
  3. 프론트엔드 (React):
    // Z가 없으면 브라우저가 로컬 타임존으로 해석
    new Date("2025-10-01T01:44:06")
      .toLocaleString('ko-KR')
    // → "2025. 10. 1. 오전 1:44:06" ❌ (한국 시간 01:44로 해석)
    
    // Z가 있으면 UTC로 인식 후 자동 변환
    new Date("2025-10-01T01:44:06Z")
      .toLocaleString('ko-KR')
    // → "2025. 10. 1. 오전 10:44:06" ✅ (UTC → 한국 시간 변환)
    

근본 원인

Jackson의 기본 LocalDateTimeSerializer가 타임존 표시자(Z)를 붙이지 않았습니다.


왜 LocalDateTime은 Z를 안 붙이나?

웹 검색 결과, 이는 설계상 의도된 동작이었습니다:

“LocalDateTime cannot use zone offset patterns (like XXXX) because it has no offset information, and ISO8601 discourages using Local Time as it’s ambiguous when communicating across different time zones.”

— Stack Overflow: How to serialize LocalDateTime with Jackson?

핵심:

  • LocalDateTime은 타임존 정보가 없는 “로컬 시간”
  • 따라서 Z(UTC)를 붙이는 것은 기술적으로 모순
  • ISO 8601 표준도 타임존 정보 없는 Local Time 사용을 권장하지 않음

권장 해결책:

  • ZonedDateTime 또는 OffsetDateTime 사용

하지만 이미 DB에 LocalDateTime을 사용 중이라면?


기존 해결 방법들을 찾아보니…

한국 블로그에서 찾은 방법들

타임존 문제를 검색하다 보니 여러 한국 블로그에서 비슷한 문제를 다루고 있었습니다:

  1. @JsonFormat 어노테이션 사용 (lejewk.github.io)
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
    private LocalDateTime startTime;
    
    • ❌ 모든 필드마다 어노테이션을 붙여야 함
    • ❌ 유지보수 어려움 (놓치기 쉬움)
  2. JavaTimeModule 등록 (velog.io/@sago_mungcci)
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
    mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    
    • ❌ 기본 설정으로는 Z를 안 붙여줌
  3. application.properties 설정
    spring.jackson.serialization.write-dates-as-timestamps=false
    spring.jackson.date-format=yyyy-MM-dd'T'HH:mm:ss'Z'
    
    • ❓ LocalDateTime에 적용되는지 불확실

해외 자료에서 찾은 힌트

결국 Stack Overflow와 Baeldung에서 답을 찾았습니다:

“LocalDateTime에 Z를 붙이는 건 기술적으로 모순이지만, 커스텀 Serializer를 만들면 가능하다”


우리의 해결 방법: 커스텀 Serializer

핵심 아이디어:

  • “DB의 모든 LocalDateTime은 UTC로 간주한다”는 Convention을 정하고
  • Jackson 직렬화 시 전역적으로 Z를 추가
  • 필드마다 어노테이션 붙이는 번거로움 제거

1. 커스텀 Serializer 작성

package saomath.checkusserver.common.config;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;

@Configuration
public class JacksonConfig {

    /**
     * LocalDateTime을 UTC 기준으로 직렬화하는 커스텀 Serializer
     * 출력 형식: yyyy-MM-ddTHH:mm:ssZ (예: 2025-10-01T01:44:06Z)
     */
    public static class UtcLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
        private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");

        @Override
        public void serialize(LocalDateTime value, JsonGenerator gen,
                            SerializerProvider serializers) throws IOException {
            if (value == null) {
                gen.writeNull();
            } else {
                gen.writeString(value.format(FORMATTER));
            }
        }
    }

    @Bean
    @Primary
    public ObjectMapper objectMapper() {
        JavaTimeModule module = new JavaTimeModule();

        // LocalDateTime 직렬화: UTC 기준으로 Z 포함
        module.addSerializer(LocalDateTime.class, new UtcLocalDateTimeSerializer());

        return new ObjectMapper()
                .registerModule(module)
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .setTimeZone(TimeZone.getTimeZone("UTC"));
    }
}

2. 핵심 포인트

DateTimeFormatter 패턴:

DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
//                                                 ^^^
//                           작은따옴표로 감싼 'Z'는 문자 그대로 출력

JsonGenerator 사용:

gen.writeString(value.format(FORMATTER));
// JsonGenerator를 직접 사용하여 문자열 출력
// 이렇게 해야 Z가 확실히 포함됨

3. 왜 기본 LocalDateTimeSerializer는 안 됐을까?

처음에는 이렇게 시도했지만 실패했습니다:

// ❌ 이렇게 하면 Z가 안 붙음
private static final DateTimeFormatter UTC_FORMATTER =
    DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");

module.addSerializer(LocalDateTime.class,
    new LocalDateTimeSerializer(UTC_FORMATTER));

원인:

  • Spring Boot의 Auto-configuration과 충돌
  • LocalDateTimeSerializer의 내부 구현이 포맷터를 무시하는 경우가 있음
  • 따라서 JsonSerializer를 직접 상속받아 gen.writeString() 사용

결과

Before

{
  "startTime": "2025-10-01T01:44:06",
  "endTime": "2025-10-01T01:53:08"
}

브라우저 표시: 오전 01:44 ❌ (9시간 과거)

After

{
  "startTime": "2025-10-01T01:44:06Z",
  "endTime": "2025-10-01T01:53:08Z"
}

브라우저 표시: 오전 10:44 ✅ (정상)


전체 흐름 정리

┌─────────────────┐
│  Server (UTC)   │
│  10:44 KST =    │
│  01:44 UTC      │
└────────┬────────┘
         │
         │ LocalDateTime.now()
         │ → 01:44 (UTC)
         │
         ▼
┌─────────────────┐
│   Database      │
│   01:44 저장    │
└────────┬────────┘
         │
         │ API Response
         │
         ▼
┌─────────────────────────────┐
│  Jackson Serializer         │
│  UtcLocalDateTimeSerializer │
│  → "2025-10-01T01:44:06Z"   │
└────────┬────────────────────┘
         │
         │ JSON
         │
         ▼
┌──────────────────────────────┐
│  Frontend (Browser)          │
│  new Date("...Z")            │
│  → UTC 01:44 인식            │
│  → 한국 시간 10:44로 변환    │
│  → 화면에 10:44 표시 ✅      │
└──────────────────────────────┘

대안: ZonedDateTime 사용

더 나은 장기적 해결책은 ZonedDateTime 또는 OffsetDateTime을 사용하는 것입니다:

// Entity
@Column(name = "start_time")
private ZonedDateTime startTime;

// Service
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));

장점:

  • 타임존 정보가 객체에 포함됨
  • Jackson이 자동으로 올바른 형식으로 직렬화
  • 타입 안정성 증가

단점:

  • 기존 DB 마이그레이션 필요
  • 코드 전체 수정 필요

참고 자료

한국어 자료

특징: 대부분 역직렬화(Z 파싱) 문제를 다루거나, 필드별 어노테이션 방식 소개. 커스텀 Serializer를 통한 전역 Z 추가 방법은 다루지 않음.

영어 자료


마치며

LocalDateTime의 타임존 문제는 Spring Boot + React 조합에서 흔히 발생하는 이슈입니다.

핵심은:

  1. 서버는 UTC로 저장 (글로벌 표준)
  2. API 응답에 명시적으로 Z 포함 (타임존 정보 제공)
  3. 프론트엔드는 브라우저의 Date 객체가 자동 변환하도록 위임

이 방법으로 타임존 문제를 깔끔하게 해결할 수 있습니다!


작성일: 2025-10-01 기술 스택: Spring Boot 3.4.5, Jackson 2.x, React, TypeScript 프로젝트: CheckUS (학생 관리 시스템)