9시간 더하는 것보다, 지금 어느 쪽인지 아는 게 어렵다

UTC와의 관계 자체는 아주 단순합니다. 한국 시간(KST)도 일본 시간(JST)도 UTC+9로 완전히 같고, 둘 다 서머타임이 없습니다. 그래서 한국과 일본 사이에는 시차가 없고, UTC와의 계산은 암산으로 끝납니다. 유럽이나 북미 타임존을 다룰 때의 번거로움과는 비교가 되지 않습니다.

그런데도 타임존 사고가 끊이지 않는 이유는 계산이 아니라, 그 시각이 지금 어느 쪽 기준인지 알 수 없게 되기 때문입니다. 로그는 UTC, DB는 로컬 시간, CI는 UTC, 개발 노트북은 KST인 상황에서 2026-08-06 09:00:00이라는 문자열만 봐서는 아무도 몇 시인지 알 수 없습니다.

이 글은 변환 대조표와, 실제로 사고가 나는 지점을 정리한 레퍼런스입니다.

시차 대조표

UTCKST / JST (같은 날)비고
00:0009:00UTC 자정은 오전 9시
03:0012:00정오
09:0018:00퇴근 시간
12:0021:00
15:00다음 날 00:00여기서 날짜가 바뀐다
18:00다음 날 03:00
21:00다음 날 06:00

반대 방향(KST/JST → UTC)은 9시간을 뺍니다. KST 00:00~08:59는 UTC로는 전날입니다.

날짜가 바뀌는 경계는 UTC 15:00입니다. 일 단위 배치나 집계의 대상 기간이 어긋나는 사고는 거의 이 선을 넘는 지점에서 발생합니다. “한국 시간 기준 하루치”를 집계하려면 UTC로는 전날 15:00 ~ 당일 15:00을 지정해야 합니다.

ISO 8601의 Z+09:00

표기의미
2026-08-06T00:00:00ZUTC 0시. Z는 UTC(Zulu time)를 뜻함
2026-08-06T09:00:00+09:00위와 완전히 같은 순간을 KST/JST로 쓴 것
2026-08-06T09:00:00오프셋 없음 = 어느 쪽인지 불명. 해석은 파서에 달림
2026-08-06날짜만. 시각과 타임존 해석이 구현마다 갈림

가운데 두 개는 같은 순간을 가리킵니다. 오프셋이 붙어 있는 한, 어느 표기로 써도 정보는 사라지지 않습니다.

문제는 세 번째입니다. 오프셋이 없는 일시 문자열은 그 자체로는 의미가 확정되지 않습니다. API 응답이나 CSV에서 이 형식을 만나면 먼저 스펙을 확인하세요.

JavaScript에서 날짜가 어긋나는 조건

가장 자주 만나는 함정입니다. 같은 날짜처럼 보이는 두 문자열이 서로 다른 순간으로 해석됩니다.

// 날짜만 있는 형식은 UTC로 해석된다
new Date('2026-08-06').toISOString();
// => '2026-08-06T00:00:00.000Z'   (KST로는 8/6 09:00)

// 시각이 있고 오프셋이 없는 형식은 '로컬 타임'으로 해석된다
new Date('2026-08-06T00:00:00').toISOString();
// => '2026-08-05T15:00:00.000Z'   (KST 환경에서 실행. 날짜가 전날로)

T00:00:00을 붙였을 뿐인데 결과가 9시간 어긋나고 날짜까지 바뀝니다. 이는 ES 사양대로의 동작이며 버그가 아닙니다. 날짜만 = UTC, 시각 있고 오프셋 없음 = 로컬이라고 외우는 수밖에 없는 부분입니다.

안전한 방법은 언제나 오프셋을 명시하는 것입니다.

new Date('2026-08-06T00:00:00+09:00').toISOString();
// => '2026-08-05T15:00:00.000Z'   (의도대로. KST 8/6 0시)

표시할 때 KST로 변환하려면 toLocaleString에 타임존을 넘깁니다. 실행 환경의 타임존 설정에 의존하지 않게 되어, 서버에서도 브라우저에서도 같은 결과가 나옵니다.

const d = new Date('2026-08-06T00:00:00Z');

d.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
// => '2026. 8. 6. 오전 9:00:00'  (표기 형식은 실행 환경의 ICU 버전에 따라 조금 다를 수 있음)

// 다만 '어느 시각을 가리키는가'는 실행 환경이 UTC든 KST든 동일

참고로 toISOString()항상 UTC로 출력합니다. 로컬 시각을 그대로 ISO 문자열로 만들었다고 생각하고 쓰면 9시간 어긋난 값이 저장됩니다.

서버 사이드에서의 변환

Python

from datetime import datetime, timezone
from zoneinfo import ZoneInfo  # Python 3.9+

now_utc = datetime.now(timezone.utc)
now_kst = now_utc.astimezone(ZoneInfo('Asia/Seoul'))   # 일본은 'Asia/Tokyo'

# naive(타임존 정보 없는) datetime은 시스템에 들이지 않는 것이 원칙

MySQL

SELECT CONVERT_TZ('2026-08-06 00:00:00', '+00:00', '+09:00');
-- => 2026-08-06 09:00:00

MySQL에서 주의할 점은 TIMESTAMP 타입은 세션 타임존으로 자동 변환되는 반면 DATETIME 타입은 전혀 변환되지 않는다는 것입니다. 한 테이블에 두 타입이 섞여 있으면, 같은 ‘시각’이라도 의미가 다른 컬럼이 나란히 놓이게 됩니다. 이름 있는 타임존('Asia/Seoul')을 쓰려면 타임존 테이블 로드가 필요하므로 오프셋 지정이 더 확실합니다.

PostgreSQL

SELECT TIMESTAMPTZ '2026-08-06 00:00:00+00' AT TIME ZONE 'Asia/Seoul';
-- => 2026-08-06 09:00:00

PostgreSQL에서는 TIMESTAMPTZ(타임존 포함)를 기본으로 저장하고 표시할 때 AT TIME ZONE으로 변환하는 것이 정석입니다. 타입 선택은 CREATE TABLE 레퍼런스 에도 정리해 두었습니다.

사고가 나는 네 곳

1. cron은 서버의 타임존으로 돈다

GitHub Actions의 schedule은 항상 UTC입니다. “매일 아침 9시에 실행”할 생각으로 0 9 * * *라고 쓰면 한국 시간 오후 6시에 돕니다.

on:
  schedule:
    # KST 오전 9시 = UTC 0시
    - cron: '0 0 * * *'

cron 식 자체를 읽는 법은 cron 식 작성법, 워크플로 의존 관계는 GitHub Actions needs 설계 패턴 에 정리했습니다.

2. Docker 컨테이너의 기본값은 UTC

대부분의 베이스 이미지는 타임존 설정을 갖지 않고 UTC로 동작합니다. 로컬 개발(KST)과 컨테이너 안(UTC)에서 로그 시각이 9시간 어긋나는 것은 이 때문입니다. 애플리케이션이 시각을 ‘로컬 타임’으로 다루고 있으면 환경에 따라 결과가 달라집니다.

3. 로그의 타임존이 뒤섞인다

액세스 로그는 서버 로컬 타임, 애플리케이션 로그는 UTC, 모니터링 서비스의 표시는 브라우저 타임존인 상태는 드물지 않습니다. 장애 조사에서 여러 로그를 대조할 때는 먼저 전부 UTC나 유닉스 시간으로 맞춘 뒤 나열하는 것이 확실합니다. 유닉스 타임스탬프는 타임존 개념이 없는 절대적인 초 수이므로 대조의 기준으로 쓸 수 있습니다(자세히는 유닉스 시간이란).

4. ‘날짜만’ 저장한다

2026-08-06 같은 날짜만의 값은 타임존에 따라 가리키는 범위가 달라집니다. 마감일이나 집계일 같은 업무상의 날짜는 일시로 바꾸지 말고 날짜 그대로(DATE 타입) 다루는 편이 안전합니다. 일시로 바꾸는 순간 “어느 타임존의 0시인가”라는 문제가 생깁니다.

원칙: 저장은 UTC, 표시할 때 변환

정리하면 실무 지침은 다음 세 줄입니다.

  1. 저장과 내부 처리는 UTC(또는 유닉스 시간)로 통일한다
  2. 표시할 때만 KST/JST로 변환한다
  3. 문자열로 주고받을 때는 반드시 오프셋을 붙인다(Z 또는 +09:00)

“국내 서비스니까 로컬 시간으로 저장한다”는 언뜻 합리적이지만, CI와 컨테이너가 UTC로 도는 이상 어딘가에 경계가 생깁니다. 경계는 적을수록 사고가 줄어듭니다.

직접 변환해 보기

로그에 있던 UTC 시각을 지금 바로 KST/JST로 확인하고 싶다면 UTC/JST 변환 도구 에 붙여 넣으면 상호 변환됩니다. KST와 JST는 같은 UTC+9이므로 그대로 한국 시간으로 읽을 수 있습니다. ISO 8601 형식도 지원하고 현재 시각 가져오기와 복사도 그 자리에서 됩니다.

유닉스 타임스탬프(1785974400 같은 숫자)에서 일시로 되돌리려면 유닉스 시간 변환, 타임아웃이나 TTL 설정값을 초·분·시로 바꿔 읽으려면 시간 단위 변환 을 쓸 수 있습니다. 모두 브라우저 안에서 처리되며 입력한 일시가 외부로 전송되지 않습니다.

정리

  • KST도 JST도 UTC+9로 고정. 둘 다 서머타임이 없어 한일 간 시차는 0
  • 날짜가 바뀌는 경계는 UTC 15:00. “한국 시간 하루”는 UTC로 전날 15시~당일 15시
  • ISO 8601에서 오프셋(Z / +09:00)이 없는 문자열은 그것만으로는 의미가 확정되지 않는다
  • JavaScript는 날짜만 = UTC, 시각 있고 오프셋 없음 = 로컬로 해석이 갈린다
  • MySQL은 TIMESTAMP만 자동 변환되고 DATETIME은 변환되지 않는다
  • GitHub Actions의 cron은 항상 UTC. KST 9시는 0 0 * * *
  • 저장은 UTC, 표시할 때 변환, 문자열에는 반드시 오프셋

자주 묻는 질문

한국 시간과 일본 시간 사이에는 시차가 있나요?

없습니다. KST(한국 표준시)와 JST(일본 표준시)는 모두 UTC+9이며 양국 모두 서머타임을 시행하지 않으므로, 연중 언제나 같은 시각입니다. 따라서 UTC/JST 변환표는 그대로 한국 시간으로 읽어도 됩니다.

2026-08-06T09:00:00+09:002026-08-06T00:00:00Z는 다른 시각인가요?

같은 순간입니다. 표기한 타임존이 다를 뿐 가리키는 시점은 완전히 일치합니다. 어느 형식으로 저장해도 정보는 사라지지 않지만, 시스템 안에서는 한쪽으로 통일해 두면 비교와 정렬에서 헷갈리지 않습니다.

JavaScript에서 날짜가 하루 어긋나는 이유는 무엇인가요?

날짜만 있는 문자열('2026-08-06')은 UTC로 해석되는 반면, 시각이 있고 오프셋이 없는 문자열('2026-08-06T00:00:00')은 로컬 타임으로 해석되기 때문입니다. KST 환경에서는 후자가 9시간 전(전날 15:00 UTC)을 가리키게 되어, UTC 기준으로 날짜를 출력하면 하루 어긋나 보입니다. 문자열에는 항상 오프셋을 붙이세요.

GitHub Actions에서 매일 아침 9시(한국 시간)에 실행하려면?

schedule의 cron은 UTC로 해석되므로 - cron: '0 0 * * *' 라고 씁니다. KST 9시는 UTC 0시입니다. 다만 GitHub Actions의 예약 실행은 부하 상황에 따라 수 분에서 십수 분 지연될 수 있으므로, 분 단위 정확성이 필요한 처리에는 적합하지 않습니다.

변환하려는 일시를 붙여 넣으면 데이터가 전송되나요?

아니요. UTC/JST 변환 도구유닉스 시간 변환 은 모두 브라우저 안에서 처리가 끝나며, 입력한 일시가 서버로 전송되는 일은 없습니다.