[Java] 타임존 버그 정리: LocalDateTime vs Instant, UTC 저장, 날짜 경계 함정
![[Java] 타임존 버그 정리: LocalDateTime vs Instant, UTC 저장, 날짜 경계 함정](https://blog.kakaocdn.net/dna/vhTA5/dJMcaaldUXc/AAAAAAAAAAAAAAAAAAAAADGjkHaNKNf_3nOrRh09ogkhK_462nKRqo44BV5g2jwb/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=QauCFmETekUkQ258CoAd6HcTMYk%3D)
[Java] 타임존 버그 정리: LocalDateTime vs Instant, UTC 저장, 날짜 경계 함정
"로컬에선 멀쩡했는데 배포하니 시간이 9시간 밀려요." 타임존 버그는 늘 이렇게 환경을 갈아탈 때, 그리고 자정 근처에서 터져요. 원인의 대부분은 하나로 모여요 — 타임존 정보가 없는 시각(LocalDateTime)을 "절대 시각"처럼 쓴 것.
이 글은 LocalDateTime이 왜 위험한지부터, "UTC로 저장하고 표시할 때 변환"이라는 표준 규율, 타입 선택 기준(Instant vs ZonedDateTime vs LocalDateTime), 그리고 "오늘 매출" 집계가 어긋나는 날짜 경계 함정까지 차례로 풀어요. 함정 시리즈 2편이에요.
01. LocalDateTime은 "언제"가 아니에요

LocalDateTime의 "2026-07-27 09:00"은 그냥 숫자 묶음이에요. 어느 타임존의 9시인지 정보가 없어요. 그래서 이 타입의 의미는 실행 환경의 타임존에 따라 달라져요.
LocalDateTime.now();
// Mac 로컬(KST): 한국시간 기준 지금
// 운영 컨테이너(기본 UTC): UTC 기준 지금 — 한국보다 9시간 전의 "숫자"
도커 컨테이너의 기본 타임존은 UTC인 경우가 많아서 로컬(KST)에서 잘 돌던 코드가 배포 직후 9시간 어긋나요. 쿠폰 만료가 9시간 일찍 되고, 새벽 배치가 오후에 돌고요. -Duser.timezone이나 TZ 환경변수로 맞추는 응급처치도 있지만 환경 설정에 의존하는 시각 코드 자체가 폭탄이에요. 근본 해법은 타입이에요.
02. 규율: 절대 시각으로 저장, 표시할 때만 변환

국제 표준이자 사실상 업계 합의예요 — 안에서는 UTC 절대 시각, 밖(사용자 눈앞)에서만 지역 시각.
- 저장 —
Instant(UTC 기준 타임라인 위의 한 점)로 다루고 DB도 UTC 기준으로 통일해요. JPA·JDBC라면 <code>hibernate.jdbc.time_zone=UTC</code>(또는 드라이버 connectionTimeZone)를 명시해야 JVM 타임존에 끌려가지 않아요. 서버가 어디 있든, 컨테이너 타임존이 뭐든 같은 값이에요. - 전송 — API는 ISO-8601에 오프셋을 명시해요:
2026-07-27T00:00:00Z또는+09:00. 오프셋 없는 시각 문자열을 주고받는 순간 양쪽 해석이 갈려요. - 표시 — 맨 마지막에 사용자 타임존으로 변환해요:
instant.atZone(ZoneId.of("Asia/Seoul")).
타입 선택은 질문 하나로 갈려요 — "이건 사건의 순간인가, 지역의 벽시계인가?"
- Instant — 결제 시각, 로그, created_at... "그 일이 일어난 순간". 백엔드 시각의 대부분이에요.
- ZonedDateTime — "매일 한국시간 09:00에 알림" 같은 지역 벽시계 기반 스케줄. 타임존 규칙(DST 포함)을 알아야 하는 경우예요.
- LocalDateTime — 생일, 영업일처럼 애초에 타임존 개념이 없는 값에만. "지금"을 LocalDateTime으로 만드는 코드는 거의 항상 잘못이에요.
03. "오늘 매출"의 함정, 날짜 경계

UTC로 저장하면 새로운 함정이 하나 생겨요. "날짜"의 경계가 타임존마다 다르다는 거예요. 한국시간 7/27 새벽 2시의 주문은 UTC로는 아직 7/26이에요. UTC 날짜로 GROUP BY를 하면 한국 비즈니스 입장의 일별 매출이 매일 새벽 9시간어치만큼 어긋나요. "어제 매출이 시스템마다 달라요"의 단골 원인이죠.
// "KST 7/27 하루"를 UTC 범위로 환산해서 자른다
ZoneId seoul = ZoneId.of("Asia/Seoul");
Instant start = LocalDate.of(2026, 7, 27).atStartOfDay(seoul).toInstant(); // = 7/26 15:00Z
Instant end = start.plus(1, ChronoUnit.DAYS);
// WHERE created_at >= :start AND created_at < :end
원칙은 분리예요 — 저장은 UTC, "오늘·이달" 같은 경계는 비즈니스 타임존(KST)으로 환산해서 자르기. 그리고 그 기준 타임존을 코드 여기저기가 아니라 한 곳에 상수로 박아둬요.
04. 자잘하지만 아픈 함정들
- 오프셋 고정의 함정 —
+09:00을 하드코딩하는 것과Asia/Seoul은 달라요. 지역 ID는 서머타임·정책 변경의 역사를 알지만 고정 오프셋은 몰라요. 한국은 지금 DST가 없지만 글로벌 사용자·해외 거래소 연동이 있으면 차이가 바로 드러나요. 지역은 항상 ZoneId로요. - SimpleDateFormat — 스레드 세이프하지 않아서 동시 요청에서 가끔 엉뚱한 날짜를 만들어요.
DateTimeFormatter(불변)로요. 레거시의 간헐 버그 단골이에요. - JSON 직렬화 — Jackson이 시각을 어떤 형식·타임존으로 내리는지 기본값에 맡기지 말고 명시해요(
JavaTimeModule+ ISO-8601). Redis 직렬화 편에서 본 LocalDateTime 직렬화 예외도 같은 뿌리예요. - DB 컬럼 타입 — MySQL의 TIMESTAMP는 세션 타임존에 따라 변환되고 DATETIME은 안 돼요. 게다가 TIMESTAMP는 저장 범위가 2038-01-19(UTC)까지라는 한계도 있어요. 이 차이를 모르고 섞으면 마이그레이션 때 시각이 밀려요. 팀이 하나를 정해 통일하는 게 중요해요.
05. 자주 만나는 문제
배포했더니 시간이 9시간 밀려요
컨테이너 UTC + LocalDateTime 조합이에요. 응급으로는 TZ를 맞추고 근본적으로는 Instant 기반으로 바꿔요.
일별 집계가 시스템마다 달라요
날짜 경계 기준이 서로 다른 거예요(UTC 자정 vs KST 자정). 집계 기준 타임존을 명시하고 환산해서 잘라요.
드물게 날짜 파싱이 이상한 값을 만들어요
SimpleDateFormat 공유를 의심해요. DateTimeFormatter로 교체해요.
자정 직전·직후에만 버그가 나요
거의 항상 경계 처리예요 — "오늘" 계산, 만료일 비교에서 타임존이 갈리는 지점을 봐요. 테스트를 자정 경계 시각으로 고정해서(Clock 주입) 재현하는 게 빨라요.
정리
타임존은 환경 설정이 아니라 타입으로 다루는 영역이에요. 사건의 시각은 Instant(UTC)로 저장하고 지역 시각으로의 변환은 화면에 보여주는 맨 끝에서만 해요. "오늘·이달" 같은 날짜 경계는 비즈니스 타임존(KST)으로 환산해서 자르고, LocalDateTime은 타임존이 애초에 없는 개념에만 남겨둬요. 여기에 시각 코드엔 Clock을 주입해두면 자정·경계 테스트까지 손에 들어와요.
다음은 또 다른 단골 함정이에요 — 10만 번째 페이지에서 죽는 쿼리, 깊은 페이징 이야기로 이어갈게요.
출처: Java — java.time package · MySQL — DATETIME vs TIMESTAMP
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Java] 돈 계산에 double 쓰면 안 되는 이유: BigDecimal 사용법과 함정 (0) | 2026.08.13 |
|---|---|
| [Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드 (0) | 2026.08.05 |
| [Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점 (1) | 2026.08.04 |
| [Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파 (0) | 2026.08.03 |
| [Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍 (0) | 2026.07.31 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."