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시간 차이!)
원인 분석
- 서버 측:
- JVM 타임존: UTC (TimeZoneConfig로 설정)
LocalDateTime.now()호출 → UTC 시간 01:44 반환- DB에 UTC 01:44 저장 ✅
- API 응답:
{ "startTime": "2025-10-01T01:44:06" // ❌ Z 없음! } - 프론트엔드 (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을 사용 중이라면?
기존 해결 방법들을 찾아보니…
한국 블로그에서 찾은 방법들
타임존 문제를 검색하다 보니 여러 한국 블로그에서 비슷한 문제를 다루고 있었습니다:
- @JsonFormat 어노테이션 사용 (lejewk.github.io)
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC") private LocalDateTime startTime;- ❌ 모든 필드마다 어노테이션을 붙여야 함
- ❌ 유지보수 어려움 (놓치기 쉬움)
- JavaTimeModule 등록 (velog.io/@sago_mungcci)
ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);- ❌ 기본 설정으로는 Z를 안 붙여줌
- 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 마이그레이션 필요
- 코드 전체 수정 필요
참고 자료
한국어 자료
- 스프링 Java 8 LocalDateTime 직렬화 역직렬화 오류 - @JsonFormat 사용법
- JPA LocalDateTime의 JSON format 처리 - 필드별 어노테이션 방식
- HomoEfficio - Java8 LocalDateTime Jackson 직렬화 문제 - JavaTimeModule 설정
특징: 대부분 역직렬화(Z 파싱) 문제를 다루거나, 필드별 어노테이션 방식 소개. 커스텀 Serializer를 통한 전역 Z 추가 방법은 다루지 않음.
영어 자료
- Stack Overflow - How to serialize LocalDateTime with Jackson? - 커스텀 Serializer 힌트
- Baeldung - Customize Jackson ObjectMapper in Spring Boot - ObjectMapper 커스터마이징
- Mkyong - Jackson Custom Serializer Examples - Serializer 구현 예제
마치며
LocalDateTime의 타임존 문제는 Spring Boot + React 조합에서 흔히 발생하는 이슈입니다.
핵심은:
- 서버는 UTC로 저장 (글로벌 표준)
- API 응답에 명시적으로
Z포함 (타임존 정보 제공) - 프론트엔드는 브라우저의
Date객체가 자동 변환하도록 위임
이 방법으로 타임존 문제를 깔끔하게 해결할 수 있습니다!
작성일: 2025-10-01 기술 스택: Spring Boot 3.4.5, Jackson 2.x, React, TypeScript 프로젝트: CheckUS (학생 관리 시스템)
Comments