Skip to Content

15장. 서버는 UTC, 표시만 로컬 — 경계에서 변환하기

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

시스템 안의 단일 UTC 흐름 → 경계 이전의 zone 정보 손실 방지 → 사용자에게 닿는 마지막 순간의 로컬 표시.

면접 실전 질문: ① 서비스 계층이나 DTO의 LocalDateTime이 경고 신호인 이유는? ② 사용자의 zone은 어떤 신호를 우선해 얻어야 하는가? ③ 미래의 벽시계 약속은 왜 UTC로 미리 확정하면 안 되는가?


연산과 저장은 처음부터 끝까지 UTC 하나로만 하고, 사람이 읽는 로컬 벽시계 시각으로 바꾸는 일은 값이 시스템을 떠나 사람 눈앞에 닿는 경계에서 딱 한 번만 일어나야 한다.

면접 실전 질문: ① 서비스 계층 메서드 시그니처나 계층을 넘나드는 DTO에 LocalDateTime이 등장하면 왜 경고 신호인가? ② 사용자의 타임존은 어떻게 알아내야 하며, 방법마다 신뢰도는 어떻게 다른가? ③ “저장은 UTC로 통일한다”는 이 장의 원칙을 미래의 벽시계 약속에도 그대로 적용해도 되는가?


배경 — 렌즈를 하나씩 고쳤으니, 이제 규칙 하나로 묶는다

13장은 컬럼 타입 하나를 고쳤습니다. DATETIMETIMESTAMP가 시간대를 다루는 방식이 다르다는 걸 실측으로 확인했습니다. 14장은 그 컬럼에 값이 닿기까지 통과하는 렌즈가 하나가 아니라 세 개라는 걸 보여줬습니다. JVM 기본 zone, JDBC 드라이버의 커넥션 타임존, DB 세션 time_zone. 셋 중 하나만 어긋나도 예외 없이 값이 조용히 밀렸고, 해법은 셋을 전부 UTC로 못 박는 것이었습니다.

두 장 다 파이프 하나를 고친 겁니다. 이 장은 그 파이프들을 관통하는 규칙을 문장 하나로 못박습니다. 시스템 안에서 일어나는 모든 비교·정렬·기간 계산·저장은 UTC라는 단 하나의 표현만 다룬다. 사람이 읽을 로컬 벽시계 시각으로 바꾸는 변환은, 값이 시스템 경계를 넘어 화면이나 알림처럼 사람 눈앞에 닿는 자리에서 딱 한 번만 일어난다. 이게 지켜지면 13·14장의 렌즈들은 각자 UTC라는 같은 목표를 향해 정렬됩니다. 이게 깨지면, 렌즈를 아무리 잘 맞춰도 어딘가에서 다시 어긋난 값이 새어 나옵니다.

왜 “중간”이 아니라 “경계”인지는 반례로 보는 게 빠릅니다. 서비스 계층에 이런 메서드가 있다고 합시다.

public LocalDateTime getOrderTime(Long orderId) { Instant storedAt = orderRepository.findInstant(orderId); return storedAt.atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime(); }

이 메서드가 돌려주는 LocalDateTime에는 이게 서울 시각이라는 정보가 남아 있지 않습니다(9장). 이 값을 호출한 컨트롤러가 그대로 JSON에 실어 보내거나, 다른 서비스 메서드의 인자로 넘기거나, 배치 잡이 다시 읽어 들이는 순간 — zone 정보는 이미 증발했고, 받는 쪽은 이게 서울 시각인지 다른 어딘가의 시각인지 알 길이 없습니다. 해석의 근거가 빠진 값 하나가 시스템 여기저기를 돌아다니기 시작합니다. 서비스 계층 메서드 시그니처나 계층을 넘나드는 DTO에 LocalDateTime이 등장하면, 변환이 경계가 아니라 중간 어딘가에서 이미 일어났다는 신호입니다. 변환은 값이 시스템을 떠나기 직전 — 컨트롤러가 응답을 만드는 자리, 혹은 화면을 그리는 프런트엔드 — 에서만 일어나야 합니다.

스토리 — API 응답 하나로 정책이 지켜졌는지가 드러난다

이 정책이 실제로 지켜지는지, 깨지는지가 가장 뚜렷하게 드러나는 자리는 API 응답입니다. 서버가 내보내는 JSON 한 줄이 UTC 계약을 지키고 있는지, 아니면 조용히 zone 정보를 흘리고 있는지 — Jackson(jackson-databind · jackson-datatype-jsr310 2.22.1, jackson-annotations 2.22)으로 직접 확인했습니다. JDK 21(Temurin) 컨테이너에서 실행했습니다.

ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); Instant instant = Instant.parse("2026-07-31T09:00:00Z"); OffsetDateTime odt = instant.atOffset(ZoneOffset.of("+09:00")); ZonedDateTime zdt = instant.atZone(ZoneId.of("Asia/Seoul")); LocalDateTime ldt = zdt.toLocalDateTime(); System.out.println(mapper.writeValueAsString(instant)); System.out.println(mapper.writeValueAsString(odt)); System.out.println(mapper.writeValueAsString(zdt)); System.out.println(mapper.writeValueAsString(ldt));

JavaTimeModule만 등록하고 아무 옵션도 건드리지 않은 기본 ObjectMapper로 네 타입을 그대로 직렬화한 결과입니다.

WRITE_DATES_AS_TIMESTAMPS enabled? = true (실측 확인 — 기본값은 켜져 있다) Instant -> 1785488400.000000000 OffsetDateTime -> 1785488400.000000000 ZonedDateTime -> 1785488400.000000000 LocalDateTime -> [2026,7,31,18,0]

WRITE_DATES_AS_TIMESTAMPS는 기본으로 켜져 있습니다(실측 확인). Instant·OffsetDateTime·ZonedDateTime은 셋 다 초 단위 epoch에 나노초를 소수점으로 붙인 숫자로 나갑니다 — ISO 문자열이 아니라 숫자로요. 여기서 흥미로운 건 LocalDateTime입니다. epoch 숫자로 안 나가고 [2026,7,31,18,0]이라는 필드 배열로 나갑니다(실측 확인). 이유는 단순합니다. LocalDateTime은 자신이 어느 zone에 속하는지 모르니, epoch 순간 자체를 계산할 방법이 없습니다. 숫자로 바꿀 “순간”이 애초에 없는 겁니다.

같은 네 값을 WRITE_DATES_AS_TIMESTAMPS를 끈 ObjectMapper로 다시 직렬화하면 이렇게 나옵니다.

Instant -> "2026-07-31T09:00:00Z" OffsetDateTime -> "2026-07-31T18:00:00+09:00" ZonedDateTime -> "2026-07-31T18:00:00+09:00" LocalDateTime -> "2026-07-31T18:00:00"

여기서 이 장의 가장 설득력 있는 증거가 나옵니다. LocalDateTime이 만든 JSON엔 zone도 오프셋도 전혀 없습니다. "2026-07-31T18:00:00" — 이 문자열만 받은 클라이언트는 이게 서울 18시인지 뉴욕 18시인지 판단할 근거가 없습니다. 원래 값이 Instant.parse("2026-07-31T09:00:00Z")였다는 사실도, JSON만 보고는 복원할 수 없습니다. 서비스 계층 메서드가 LocalDateTime을 돌려주는 순간 이미 잃어버린 정보가, API 응답에서 그대로 증발한 채 드러난 것뿐입니다. 반면 InstantOffsetDateTimeZ+09:00을 문자열에 그대로 갖고 나갑니다 — 어느 시스템이 읽어도 같은 순간으로 복원됩니다(7장).

ZonedDateTime의 기본 출력에는 [Asia/Seoul]이 없습니다 — 오프셋만 실려 나갑니다. SerializationFeature.WRITE_DATES_WITH_ZONE_ID를 켜면 얘기가 달라집니다.

WRITE_DATES_WITH_ZONE_ID 활성화 ZonedDateTime -> "2026-07-31T18:00:00+09:00[Asia/Seoul]"

이 대괄호 표기는 7장에서 이미 본 그 함정입니다. java.time이 자기 생태계 안에서만 되읽을 수 있는 자체 확장이라, 표준 RFC 3339 파서는 대괄호 뒤에서 예외를 던집니다. API 경계로 내보낼 값이라면 WRITE_DATES_WITH_ZONE_ID는 꺼둔 채로 두는 편이 안전합니다.

마지막으로, 같은 프로그램을 -Duser.timezone=UTC-Duser.timezone=Asia/Seoul로 두 번 돌려 비교했습니다. Instant·OffsetDateTime·ZonedDateTime·LocalDateTime은 전부 명시적인 오프셋이나 zone을 이미 갖고 있는 값이라, 두 실행의 출력이 완전히 같았습니다. JVM 기본 zone이 끼어들 자리가 애초에 없었던 겁니다. 그런데 같은 프로그램에 instant.atZone(ZoneId.systemDefault())라는 한 줄 — “명시적 zone 없이 시스템 기본값에 기대는” 흔한 코드 — 을 끼워 넣으면 결과가 갈립니다.

[UTC 실행] atZone(systemDefault()) -> "2026-07-31T09:00:00Z" [Asia/Seoul 실행] atZone(systemDefault()) -> "2026-07-31T18:00:00+09:00"

명시적 zone을 쓴 값은 JVM 기본 zone을 바꿔도 그대로였고, ZoneId.systemDefault()에 기댄 값만 실행 환경에 따라 달라졌습니다(실측 확인). 이 대비가 이 장 뒤쪽에서 다룰 “테스트를 다른 zone에서 돌려본다”는 조언을 감으로 하는 말이 아니라 측정 가능한 사실로 만들어줍니다.

이 장의 실측 수치는 Jackson jackson-databind·jackson-datatype-jsr310 2.22.1, jackson-annotations 2.22, Temurin JDK 21 조합에서 나온 값입니다. Jackson 버전이 바뀌면 WRITE_DATES_AS_TIMESTAMPS의 기본값이나 배열 직렬화 방식이 달라질 수 있습니다.

핵심 — 어디서 바꾸고, zone은 어디서 얻고, 예외는 무엇인가

계층별 표현을 한 표로

시스템을 값이 지나가는 순서대로 늘어놓으면, 계층마다 “정답 표현”이 갈립니다.

계층표현
저장·연산 (DB, 서비스 로직, 비교·정렬·기간 계산)UTC Instant
API 응답오프셋 포함 문자열 (RFC 3339)
화면 표시사용자 zone으로 변환한 로컬 문자열
미래 약속 (회의·알림)LocalDateTime + zone id
로그UTC + 오프셋

표의 위 세 줄이 이 장의 뼈대입니다. 저장·연산은 UTC. API 응답은 오프셋을 실은 문자열. 로컬 변환은 화면을 그리는 마지막 순간. 이 순서를 거슬러, 화면에 뿌릴 로컬 문자열을 서비스 계층이나 DB 쿼리 안에서 미리 만들어두면 — 편해 보이지만, 그 값은 이제 두 번째 진실이 됩니다. 나중에 다른 화면이, 다른 팀이, 다른 클라이언트가 같은 데이터를 다른 zone으로 보여줘야 할 때, 이미 변환되어 저장된 로컬 값은 되돌릴 방법이 없습니다. 원본을 UTC로 계속 갖고 있어야만 변환을 다시 할 수 있습니다.

사용자의 zone은 어디서 얻는가 — 이게 진짜 어려운 부분이다

정책은 “경계에서 사용자 zone으로 바꾼다”고 말하지만, 그 zone을 어떻게 아는가는 이 장에서 가장 손에 안 잡히는 문제입니다. 후보는 넷이고, 신뢰도가 다 다릅니다.

방법신뢰도비고
사용자 프로필의 명시적 설정가장 높음온보딩·설정 화면에서 한 번 물어 저장. 여행 중에도 사용자가 의도한 zone을 유지한다
클라이언트가 보내는 Intl.DateTimeFormat().resolvedOptions().timeZone높음기기가 실제로 관측한 zone. 다만 매 요청마다 헤더나 쿼리로 실려 와야 하고, 기기 시계 설정에 좌우된다
IP 지오로케이션낮음회사 VPN, 클라우드 NAT, 모바일 통신사 프록시를 거치는 순간 실제 위치와 어긋난다
Accept-Language 헤더틀린 신호언어는 위치가 아니다. 뉴욕에 사는 한국어 사용자는 여전히 ko-KR을 보내지만 서울에 있지 않다

가장 안전한 조합은 명시적 프로필 설정을 기본값으로 쓰고, 아직 설정하지 않은 사용자에겐 클라이언트가 보낸 Intl.DateTimeFormat 값으로 온보딩 시 채워 넣는 것입니다. IP 지오로케이션과 Accept-Language는 최후의 fallback으로만 남겨두고, 정확도를 보장하는 신호로는 쓰지 않습니다. 어떤 방법을 고르든, 서버가 사용자의 zone을 “안다”는 착각은 위험합니다 — 사용자가 여행 중이거나, 브라우저 설정이 기기 실제 위치와 다르거나, 애초에 아무 신호도 보내지 않은 첫 요청일 수 있습니다. 이 불확실성을 인정하고 명시적으로 다루는 게, 알아서 맞춰줄 거라 가정하는 것보다 안전합니다.

예외 — 미래의 벽시계 약속은 UTC로 미리 확정하지 않는다

9장이 이미 이 예외를 세워뒀습니다. 이미 일어난 일은 Instant로, 아직 오지 않은 벽시계 약속은 LocalDateTime + ZoneId로 저장한다. 이 장의 “저장은 UTC로”라는 규칙은 그 원칙을 뒤집지 않습니다 — 오히려 그 원칙이 적용되는 범위를 분명히 그어줍니다.

내년 3월 서울에서 열릴 회의 14시를 지금 UTC Instant로 확정해 저장한다고 합시다. 그 순간부터 이 값은 “서울의 오후 2시”가 아니라 “지금 시점 서울의 오프셋(+09:00)을 적용한 어떤 순간”이 됩니다. 그런데 그 사이 한국이 표준 오프셋을 바꾸는 결정을 내리면(5장이 보여준 대로, 타임존 규칙은 언제든 바뀔 수 있는 입법 기록입니다) — 저장된 UTC 값은 더 이상 “오후 2시”를 가리키지 않게 됩니다. 사람이 실제로 약속한 건 순간이 아니라 벽시계 눈금이었습니다. 이 경우엔 LocalDateTime(2027-03-15T14:00)과 ZoneId(Asia/Seoul)를 그대로 저장해두고, 실제 순간으로의 변환은 그 약속이 다가왔을 때 — 그때 유효한 규칙으로 — 미룹니다. “UTC로 통일한다”는 이 장의 정책은 이미 확정된 사실에 적용되는 규칙이지, 아직 확정되지 않은 미래 약속에 UTC를 강제로 씌우라는 뜻이 아닙니다.

로그와 서버 간 통신은 UTC로 — 사람 친화적인 로그가 오히려 함정이다

API 응답과 화면 표시는 사람이 읽으니 로컬 변환이 필요합니다. 그런데 로그와 서버 간(machine-to-machine) 페이로드는 다릅니다. 이 값을 읽는 건 대부분 다른 서버거나, 여러 리전에 흩어진 로그를 한 줄로 훑는 운영자입니다.

로그 타임스탬프를 “친절하게” 로컬 시각으로 찍으면, 서버가 한 리전에만 있을 땐 아무 문제가 없습니다. 하지만 서비스가 여러 리전에 걸쳐 있는 순간, 서울 서버의 로그는 KST로, 프랑크푸르트 서버의 로그는 CET로 찍힙니다. 장애가 여러 서버에 걸쳐 발생했을 때 로그를 시간순으로 이어 붙이려면, 그 자리에서 각 줄이 몇 시간 오프셋인지부터 계산해야 합니다. 사고 대응 중에 하고 싶은 계산이 아닙니다. 로그와 M2M 페이로드는 UTC로, 오프셋을 명시(Z)해서 남깁니다. 사람이 읽을 땐 로컬로 다시 바꾸면 되지만, 그 변환도 로그를 보는 도구(대시보드, grep 스크립트) 쪽 경계에서 하는 일이지 로그를 쓰는 쪽이 미리 해줄 일이 아닙니다.

테스트는 UTC가 아닌 zone에서 한 번 돌려본다

이 장 스토리의 마지막 실험이 이 조언을 감이 아니라 측정치로 만들어줍니다. 명시적 zone을 쓴 코드는 JVM 기본 zone을 바꿔도 출력이 그대로였고, ZoneId.systemDefault()에 기댄 코드만 실행 환경에 따라 값이 달라졌습니다. CI 서버가 우연히 UTC로 맞춰져 있으면, 코드 어딘가에 숨어 있는 ZoneId.systemDefault()LocalDateTime.now()(9장) 같은 암묵적 기본 zone 의존성이 테스트를 통과한 채로 묻혀 있습니다. 배포 서버의 zone이 CI와 다르거나, 다른 개발자의 로컬 머신이 Asia/Seoul이면, 그제서야 실패가 드러납니다.

해법은 값싸고 효과가 큽니다. 테스트 스위트를 -Duser.timezone=Asia/Seoul이나 -Duser.timezone=America/New_York처럼 UTC가 아닌 zone으로 한 번 돌려봅니다. CI 파이프라인에 이 한 줄을 추가 잡으로 끼워 넣기만 해도, 위 실험이 보여준 것과 똑같은 종류의 버그 — 명시적 zone 없이 시스템 기본값에 기댄 코드 — 가 그 자리에서 드러납니다.

정리

  • 이 장의 정책: 연산과 저장은 UTC 하나로만, 로컬 변환은 값이 시스템 경계를 넘는 자리에서 딱 한 번만. 13·14장이 고친 개별 렌즈들을 이 규칙 하나가 묶는다.
  • 서비스 계층 메서드 시그니처나 계층을 넘나드는 DTO에 LocalDateTime이 등장하면 경고 신호다. 변환이 경계가 아니라 중간에서 이미 일어났다는 뜻이다.
  • Jackson 실측(2.22.1): 기본 ObjectMapperWRITE_DATES_AS_TIMESTAMPS가 켜져 있어 Instant·OffsetDateTime·ZonedDateTime을 epoch 숫자로 직렬화하고, LocalDateTime은 epoch를 계산할 수 없어 필드 배열로 직렬화한다. 옵션을 끄면 전부 문자열이 되는데, LocalDateTime만 zone·오프셋 정보 없이 나간다 — 이 값을 받은 쪽은 원래 순간을 복원할 수 없다.
  • ZoneId.systemDefault()에 기댄 코드는 JVM 기본 zone에 따라 출력이 실제로 달라졌다(실측 확인). 명시적 zone을 쓴 코드는 어떤 JVM zone에서 돌려도 값이 그대로였다.
  • 사용자의 zone은 명시적 프로필 설정 > 클라이언트의 Intl.DateTimeFormat > IP 지오로케이션 > Accept-Language 순으로 신뢰한다. Accept-Language는 언어이지 위치가 아니다.
  • 예외는 9장 그대로다. 미래의 벽시계 약속은 지금 UTC로 확정하지 않고 LocalDateTime + ZoneId로 저장해, 변환을 약속이 다가올 때로 미룬다.
  • 로그와 서버 간 페이로드는 UTC + 명시적 오프셋으로. 여러 리전에 흩어진 로그를 사람이 친절하게 읽으라고 로컬 시각으로 찍으면, 리전이 늘어나는 순간 사고 대응 중에 오프셋 계산부터 해야 하는 함정이 된다.
  • 테스트 스위트를 UTC가 아닌 zone에서 한 번 돌려본다. 값싸게, 숨어 있는 암묵적 기본 zone 의존성을 통째로 드러낸다.

생각해볼 질문: 서버를 UTC로 통일했으니, 배치 잡을 “매일 새벽 2시에 실행”으로 예약했다고 합시다. cron 표현식은 서버의 UTC 시계를 기준으로 정확히 같은 시각에 돌아갑니다. 그런데 이 배치가 실제로 맞춰야 하는 건 서울의 “새벽 2시”라는 사용자의 감각입니다. DST가 있는 지역이라면, 서버의 UTC 새벽 2시와 사용자가 상상하는 로컬 새벽 2시 사이의 오프셋은 1년에 두 번 바뀝니다. 게다가 그 cron이 도는 서버가 재시작되거나 여러 인스턴스로 늘어나면, “정확히 한 번” 실행된다는 보장은 어디서 오는 걸까요?

16장 · 배치와 스케줄러가 무너지는 자리 — cron·DST·중복 실행

Last updated on