제목 끝의 「지지」는 판정이다. 증명 글은 실험 결과가 주장을 받쳐 주면 지지(supported), 주장을 뒤집으면 반증(refuted), 어느 쪽인지 가를 수 없으면 판정 불가(inconclusive)라고 적는다. 이 글은 지지다.


서울에서 저장한 시각을 UTC로 읽었더니 9시간 늦은 시각이 나올 수 있다

서울에 있는 서버가 「2026년 10월 20일 낮 12시」에 주문을 받았다. 같은 순간을 세계 표준시 UTC로 적으면 「같은 날 새벽 3시」다. 시계 글자는 다르지만 가리키는 순간은 하나다.

이 순간을 DB에 저장했다가 나중에 다시 읽는다. DB와 맺은 연결 하나하나에는 세션 시간대라는 설정이 붙어 있어서, 저장한 연결은 서울 시간대이고 읽는 연결은 UTC일 수 있다. 이때 읽은 값이 「새벽 3시 UTC」면 맞게 돌아온 것이다. 「낮 12시」라는 글자만 남아 그것을 UTC로 읽으면, 9시간 늦은 순간이 된다.

DB 시간 저장 표준은 사건 시점을 자바의 Instant(시간대와 상관없이 한 순간을 가리키는 값)로 들고, DB에 넣었다 꺼내도 같은 순간이 돌아오게 하려 한다. 이 표준은 원래 MariaDB를 전제로 쓰였는데, 2026-10-20에 표준 DB를 PostgreSQL로 옮기기로 했다. 그러면 PostgreSQL의 어느 타입이 그 약속을 지키는지 다시 확인해야 한다. 이 글이 그것을 잰다.


PostgreSQL에는 시각 타입이 둘 있고 시간대를 다루는 방식이 다르다

PostgreSQL에서 날짜와 시각을 함께 담는 타입은 둘이다.

  • timestamp(without time zone) — 넣은 날짜와 시각 글자를 그대로 저장한다. 값에 +09 같은 시간대 표시가 붙어 와도 버린다
  • timestamptz(timestamp with time zone) — 넣을 때 시간대 표시(없으면 세션 시간대)를 보고 UTC로 바꿔 저장한다. 읽을 때는 그 세션의 시간대로 바꿔 보여 준다

이름 때문에 timestamptz가 시간대를 같이 저장하는 것처럼 보이지만, 저장하는 것은 UTC로 바꾼 순간 하나뿐이다. 괄호 안 숫자 (6)은 소수점 아래 초를 몇 자리까지 둘지이고, 6이면 마이크로초(100만분의 1초)까지다.

실험 폴더의 claim.md가 PostgreSQL 17 문서 8.5에서 근거 문장을 옮겨 두었다(2026-10-20 확인). 두 타입 모두 범위는 4713 BC ~ 294276 AD, 정밀도는 1마이크로초다. timestamptz에 대해서는 이렇게 적는다.

In either case, the value is stored internally as UTC, and the originally stated or assumed time zone is not retained.

When a timestamp with time zone value is output, it is always converted from UTC to the current timezone zone, and displayed as local time in that zone.

timestamp(without time zone)에 대해서는 이렇게 적는다.

In a value that has been determined to be timestamp without time zone, PostgreSQL will silently ignore any time zone indication.

문서가 이렇게 말하니 결과는 거의 정해져 있다. 그래도 돌려 보는 이유는 표준을 증명하는 방식이 명세 사실(근거 종류 4)을 「공식 문서를 인용하고 최소 예제로 확인한다」로 정했기 때문이다. 문서를 잘못 읽었을 수 있고, 읽은 페이지에 적히지 않은 동작이 끼어들 수도 있다. 실제로 하나가 나왔다(뒤의 「예상과 달랐던 점」).


주장 — timestamptz(6)은 세션 시간대가 달라도 같은 순간 · 마이크로초 · 2106년 너머를 지킨다

PostgreSQL 17 의 timestamptz(6) 는
(1) 넣을 때와 읽을 때 세션 시간대가 달라도 같은 순간을 보존하고,
(2) 마이크로초 6자리를 그대로 돌려주며,
(3) 2106년을 넘는 시각(3000-01-01T00:00:00Z)을 저장한다.
같은 조건에서 timestamp(6)(without time zone)은 (1)에서 세션 시간대만큼 어긋난다.

(3)에 2106년이 나오는 이유는 MariaDB 시절의 상한 때문이다. 이 표준이 MariaDB를 전제로 했을 때 시각 칸의 상한이 2106-02-07T06:28:15Z였다. 그것도 MariaDB 11.5부터라, 옛 버전의 2038년 상한을 피하려고 DB 버전까지 묶었다. PostgreSQL에서 그 상한이 정말 사라지는지를 3000년으로 본다.

근거 종류는 4(명세 사실)다. 받치는 표준은 DB 시간 저장 표준 하나다.


반증 조건 — timestamptz 칸의 세 가지가 모두 맞아야 지지다

반증 조건은 「결과가 이렇게 나오면 주장이 틀린 것으로 친다」를 돌리기 전에 적어 둔 것이다. 결과를 보고 기준을 정하면 어떤 결과든 주장에 맞게 읽을 수 있어서다. 판정은 timestamptz 칸의 SQL 결과로만 가른다.

  • 지지 — 셋 다 성립한다
    • (1) UTC 세션에서 읽은 순간이 기대값과 같다
    • (2) UTC 세션에서 읽은 글자의 소수 부분이 .123456이다
    • (3) 3000-01-01 00:00:00+00이 오류 없이 들어가고, 읽은 순간이 기대값과 같다
  • 반증 — 셋 중 하나라도 성립하지 않는다. 넣다가 오류가 나는 것도 반증이다
  • 판정 불가 — DB가 뜨지 않는다, 넣는 세션의 시간대가 Asia/Seoul이 아니거나 읽는 세션의 시간대가 UTC가 아니다(시간대가 의도대로 안 걸렸다), 판정에 쓰는 값이 하나라도 기록되지 않았다

판정에 쓰지 않고 기록만 하는 것도 미리 정했다. timestamp(6) 칸의 결과(주장 뒷문장의 「어긋난다」 포함), 7자리 소수 .1234567의 처리, 자바 프로그램(JDBC)으로 넣고 읽은 결과다. 이것들은 결과가 어떻든 판정을 바꾸지 않는다.

「순간이 같다」는 글자가 아니라 숫자로 본다. PostgreSQL의 extract(epoch from ...)는 그 순간이 1970-01-01 00:00:00 UTC에서 몇 초 지났는지(epoch)를 돌려준다. 기대값은 PostgreSQL이 아니라 리눅스 date -u 명령으로 따로 계산했다. 재는 쪽과 맞추는 쪽이 같은 프로그램이면, 둘이 같이 틀려도 맞다고 나온다.

반복은 1회다. 같은 입력에 같은 결과가 나오는 명세 사실이라서다.


설계 — 서울 세션으로 넣고 UTC 세션으로 읽는다

bash run.sh 하나로 처음부터 끝까지 돈다. 실행마다 postgres:17 Docker 컨테이너를 새로 띄우고 끝나면 지운다. 테이블은 하나다.

CREATE TABLE t(id int PRIMARY KEY, tz timestamptz(6), ntz timestamp(6));

한 줄씩 따라가면, t라는 테이블에 칸이 셋 있다. id는 행 번호, tz는 timestamptz(6) 칸, ntz는 timestamp(6) 칸이다(ntz는 no time zone의 줄임이다). 같은 값을 두 칸에 나란히 넣어 두 타입을 바로 비교한다.

세션은 DB 명령 프로그램 psql을 따로 불러 가른다. psql을 한 번 부를 때마다 연결이 하나 새로 생기므로, 두 번 부르면 서로 다른 세션 둘이 된다.

세션 A(넣기) 는 시간대를 서울로 맞추고 값을 넣는다.

SET TIME ZONE 'Asia/Seoul';
SELECT 'A_TZ', current_setting('TimeZone');
INSERT INTO t(id, tz, ntz) VALUES (1, '2026-10-20 12:00:00.123456+09', '2026-10-20 12:00:00.123456+09');
INSERT INTO t(id, tz, ntz) VALUES (2, '2026-10-20 12:00:00.1234567+09', '2026-10-20 12:00:00.1234567+09');
INSERT INTO t(id, tz) VALUES (3, '3000-01-01 00:00:00+00');
SELECT 'A_ROW', id, tz::text, ntz::text FROM t ORDER BY id;

한 줄씩 따라가면, 첫 줄은 이 세션의 시간대를 서울로 바꾸고, 둘째 줄은 바뀐 시간대를 찍어 확인한다. 1번 행은 「서울 낮 12시, 소수 6자리」를 +09(UTC보다 9시간 빠르다)를 붙여 두 칸에 똑같이 넣는다 — (1)과 (2)를 보는 행이다. 2번 행은 소수를 7자리로 넣는다 — 기록만 하는 행이다. 3번 행은 3000년 1월 1일 UTC를 tz 칸에만 넣는다 — (3)을 보는 행이다. 마지막 줄은 넣은 행을 같은 서울 세션에서 바로 읽어 둔다. 실험자가 이 세션을 서울로 고른 것은 「UTC가 아닌 세션에서 넣는다」는 장면을 만들기 위해서다.

세션 B(읽기) 는 시간대를 UTC로 맞추고 읽는다.

SET TIME ZONE 'UTC';
SELECT 'B_TZ', current_setting('TimeZone');
SELECT id, tz::text, extract(epoch FROM tz), ntz::text, extract(epoch FROM ntz) FROM t ORDER BY id;

한 줄씩 따라가면, 첫 줄은 세션 시간대를 UTC로 바꾸고, 둘째 줄은 그것을 찍어 확인한다. 셋째 줄은 행마다 두 칸을 글자(::text)와 epoch 초로 함께 읽는다. 글자로는 소수 자리를, epoch로는 순간이 같은지를 본다. 실제 run.sh는 결과 줄을 골라내려고 'B_ROW' 같은 꼬리표를 붙이고 빈 칸을 -로 채운다. 위에서는 그 부분을 뺐다.

자바 프로그램(JDBC) — 기록만. JDBC는 자바가 DB와 이야기하는 표준 방법이다. 작은 자바 프로그램이 JVM(자바 프로그램을 실행하는 가상 머신) 기본 시간대를 Asia/Seoul로 두고 PostgreSQL 드라이버(JDBC 요청을 PostgreSQL의 말로 옮겨 주는 라이브러리) 42.7.5로 접속한다. 같은 세 순간(.123456 · .1234567 · 3000년)을 시간대가 붙은 자바 시각 OffsetDateTime으로 두 칸에 넣고 다시 읽는다. psql 세션과 달리 이 프로그램은 세션 시간대를 직접 고르지 않고, 열린 세션의 시간대를 찍기만 한다. 이렇게 넣은 행(100~102번)도 세션 B가 같이 읽는다.


결과 — timestamptz(6)은 세 가지를 모두 지켰다

판정은 지지다. result.md의 판정 값 표를 옮기면 이렇다.

항목 값 기대 성립
(1) tz epoch (B=UTC) 1792465200.123456 1792465200.123456 yes
(2) tz 텍스트 소수 (B=UTC) 2026-10-20 03:00:00.123456+00 .123456 yes
(3) 3000-01-01Z tz epoch (B=UTC) 32503680000.000000 (3000-01-01 00:00:00+00) 32503680000 yes

세션 시간대는 A가 Asia/Seoul, B가 UTC로 찍혀 의도대로 걸렸다. 서버는 PostgreSQL 17.10이었다.

시각 끝의 Z는 UTC 기준이라는 표시이고, 가운데 T는 날짜와 시각을 가르는 글자다. 2026-10-20T03:00:00Z는 「UTC 새벽 3시」, 곧 서울 낮 12시와 같은 순간이다.

  • (1) 서울 낮 12시로 넣은 값이 UTC 세션에서 epoch 1792465200.123456으로 읽혔다. date -u로 따로 계산한 2026-10-20T03:00:00Z의 초에 .123456을 붙인 값과 같다. 같은 순간이다
  • (2) UTC 세션에서 글자로 읽으니 2026-10-20 03:00:00.123456+00이었다. 넣은 「서울 12시」가 「UTC 3시」로 바뀌어 보이고, 소수 6자리는 그대로다
  • (3) 3000년 1월 1일이 오류 없이 들어갔고 epoch도 기대값과 같았다. MariaDB 시절의 2106년 상한 같은 것은 없었다

세션 A가 넣은 직후 같은 세션(서울)에서 읽은 줄도 있다. 1번 행의 tz는 2026-10-20 12:00:00.123456+09로 보였다. 같은 값이 서울 세션에서는 서울 시각으로, UTC 세션에서는 UTC 시각으로 보이는 글자만 바뀐다. 문서의 「읽을 때 세션 시간대로 바꿔 보여 준다」가 이것이다.


기록만 한 결과 — timestamp(6)은 9시간 어긋났고 JDBC 세션은 고르지 않은 시간대로 열렸다

판정에 쓰지 않은 것들이다.

항목 값
ntz 텍스트 · epoch (B=UTC) 2026-10-20 12:00:00.123456 · 1792497600.123456
ntz epoch - 기대 epoch +32400.000000초
7자리 .1234567 → tz 2026-10-20 03:00:00.123457+00
7자리 .1234567 → ntz 2026-10-20 12:00:00.123457

timestamp(6) 칸은 주장 뒷문장대로 어긋났다. 같은 '2026-10-20 12:00:00.123456+09'를 넣었는데 +09가 버려지고 12:00:00.123456이라는 글자만 남았다. UTC 세션은 그 글자를 UTC 12시로 읽으므로, 순간이 기대보다 32400초(9시간) 늦었다. 문서의 「시간대 표시를 말없이 버린다」가 그대로 일어났다. 오류도 경고도 없었다.

JDBC 결과는 원본 출력에서 옮긴다.

JENV java=21.0.9 jvm_tz=Asia/Seoul driver=42.7.5 session_tz=Asia/Seoul
JCASE id=100 in=2026-10-20T03:00:00.123456Z tz_out=2026-10-20T03:00:00.123456Z ... tz_same=true ntz_out=2026-10-20T12:00:00.123456 ntz_as_utc_minus_in_sec=32400
JCASE id=101 in=2026-10-20T03:00:00.123456700Z tz_out=2026-10-20T03:00:00.123457Z ... tz_same=false ntz_out=2026-10-20T12:00:00.123457 ntz_as_utc_minus_in_sec=32400
JCASE id=102 in=3000-01-01T00:00:00Z tz_out=3000-01-01T00:00Z ... tz_same=true ntz_out=3000-01-01T09:00 ntz_as_utc_minus_in_sec=32400

한 줄씩 따라가면, 첫 줄은 환경이다. JVM 기본 시간대가 Asia/Seoul이고, 드라이버가 연 세션의 시간대도 Asia/Seoul이었다. 세션 시간대를 따로 맞추지 않았는데 JVM 기본값과 같아졌다. 드라이버가 접속할 때 JVM 시간대를 세션에 건 것으로 보이지만, 드라이버 소스로 확인하지 않았고 JVM 시간대도 서울 하나로만 돌렸다. 나머지 세 줄은 순간마다 하나다. tz_out은 tz 칸에서 읽은 값, tz_same은 넣은 순간과 같은지, ntz_out은 ntz 칸에서 읽은 값, ntz_as_utc_minus_in_sec은 ntz 글자를 UTC로 읽었을 때 넣은 순간과 몇 초 차이 나는지다. ...은 같은 값을 다르게 찍은 칸이라 줄였다. 101번의 in=...123456700Z는 자바가 소수를 나노초(10억분의 1초, 9자리)로 들고 있어서 .1234567 뒤에 00이 붙어 찍힌 것이다. 102번의 T00:00Z·T09:00은 초가 0이라 자바가 초 자리를 생략해 찍은 것이다.

  • tz 칸은 JDBC로도 같은 순간이었다(100번, 102번). 101번이 false인 것은 7자리 소수 때문이고, 아래에서 따로 본다
  • ntz 칸은 시간대가 붙은 OffsetDateTime을 넣어도 세 순간 모두 9시간 어긋났다. 그런데 psql 경우와 어긋난 길이 다르다. psql로 넣은 1번 행은 글자 12:00+09에서 +09를 버려 12:00이 됐다. JDBC로 넣은 100번 행은 03:00Z를 보냈는데 03:00이 아니라 서울 시각 12:00이 저장됐다 — 시간대 표시를 그냥 버린 것이 아니라 서울 세션 시간대로 바꾼 뒤에 시간대를 뗀 모양이다. 드라이버가 값을 어떤 모양으로 보냈는지는 확인하지 않았다. 확실한 것은 이 경로에서 timestamp 칸에 남은 글자를 정한 것이 세션 시간대였다는 것이다

이 결과가 DB 시간 저장 표준 원칙 3(모든 연결의 세션 시간대를 UTC로 맞춘다)의 근거가 된다. 이 실험에서 세션 시간대를 맞추지 않은 JDBC 연결은 프로그램이 고르지 않은 시간대(JVM 기본값과 같은 Asia/Seoul)로 열렸고, 그 값이 timestamp 칸의 결과를 바꿨다. 다른 JVM 시간대에서도 늘 그 값을 따라가는지는 재지 않았다.


예상과 달랐던 점 — 7자리 소수는 잘리지 않고 반올림됐다

.1234567을 넣으면 (6) 칸이 7번째 자리를 그냥 잘라 .123456이 될 거라고 막연히 생각했다. 결과는 .123457이었다 — 반올림이다. tz와 ntz 두 칸 모두, psql로 넣은 2번 행과 JDBC로 넣은 101번 행 모두 같았다.

claim.md를 쓸 때 문서 8.5 페이지에서 7자리 처리를 찾았지만, 반올림인지 절삭인지는 그 페이지에 적혀 있지 않았다. 그래서 판정에서 빼고 기록만 하기로 했던 항목이다.

이것이 DB 시간 저장 표준 원칙 4의 근거를 하나 늘린다. 원칙 4는 자바의 나노초 시각을 DB에 넣기 전에 애플리케이션이 직접 마이크로초로 잘라 두게 한다. 원래 이유는 「DB에 맡기면 저장 전과 조회 후의 값이 달라진다」였다. 이 이유는 그대로이고, 근거가 하나 더 붙었다. DB가 넘치는 자리를 자르는 것이 아니라 반올림한다는 것, 그리고 그 규칙이 문서 8.5 페이지에 없다는 것이다. 예컨대 .1234567을 들고 있던 자바 객체는 저장 뒤 .123457로 돌아온다 — 101번의 tz_same=false가 그것이다. 애플리케이션이 먼저 .123456으로 잘라 두면 이 차이가 생길 자리가 없다.


이 결과가 표준에 남기는 것 — 사건 시점 타입은 timestamptz(6)이다

이 실험이 정한 것은 사실이다. PostgreSQL 17의 timestamptz(6)은 세션 시간대가 달라도 같은 순간을 돌려주고, 마이크로초를 지키고, 3000년도 담는다. timestamp(6)은 마이크로초와 3000년은 지켰지만(1번·102번 행), 같은 순간은 지키지 못했다.

DB 시간 저장 표준은 이것을 받아 다음을 정했다(2026-10-20).

  • 원칙 1 — MariaDB 시절의 최소 버전과 2106년 상한을 지웠다. (3)이 그 근거다
  • 원칙 2 — 사건 시점은 timestamptz(6)에 저장한다. timestamp(6)은 MariaDB 시절 버린 DATETIME(시간대 없이 날짜·시각 글자만 담는 타입)과 같은 뜻이다. 컬럼에 UTC라는 뜻이 없어 다른 곳에서 지역 시각을 넣어도 막지 못한다는 같은 이유로 버린다. 이 실험의 요약 줄이 그 원칙에 인용돼 있다
  • 원칙 3 — 세션을 UTC로 맞추는 이유에 JDBC 결과(맞추지 않은 세션이 고르지 않은 시간대로 열려 timestamp 칸이 9시간 어긋남)가 더해졌다
  • 원칙 4 — 앱에서 마이크로초로 자르는 이유에 「DB는 문서 8.5 페이지에 없는 반올림을 한다」가 더해졌다

표준을 쓰는 프로젝트 아리맘(산후 돌봄 서비스)은 지금 사건 시점 칸이 timestamp(6)이다. 이 실험의 ntz 칸과 같은 타입이라, 표준에 맞추려면 timestamptz(6)으로 옮겨야 한다. 그 일은 아리맘 쪽에서 한다.

재지 않은 것도 적는다. 반복은 1회였다. PostgreSQL 17 말고 다른 메이저 버전은 재지 않았다. JPA·Hibernate와 MyBatis를 거친 왕복은 재지 않았다 — 이 실험의 자바 쪽은 JDBC를 직접 썼다. SET TIME ZONE 'UTC'를 연결마다 건 JDBC 세션에서의 결과도 재지 않았다. 범위 밖의 연도(예컨대 294277년)를 넣었을 때 어떤 오류가 나는지도 재지 않았다. 반올림 규칙이 문서의 다른 페이지에 적혀 있는지는 찾지 않았다.


정리

  • timestamptz(6)은 서울 세션으로 넣고 UTC 세션으로 읽어도 같은 순간이었다. epoch가 date -u로 따로 계산한 기대값과 같았다
  • 소수 6자리 .123456이 그대로 돌아왔고, 3000년 1월 1일도 오류 없이 들어갔다
  • 같은 조건의 timestamp(6)은 시간대 표시를 버려 9시간(+32400초) 어긋났다. 오류도 경고도 없었다
  • 세션 시간대를 맞추지 않은 JDBC 세션은 JVM 기본값과 같은 Asia/Seoul로 열렸고, 그 세션에서 timestamp(6) 칸은 시간대가 붙은 값을 넣어도 9시간 어긋났다
  • 7자리 소수 .1234567은 .123457로 반올림됐다. 문서 8.5 페이지에는 그 규칙이 없다. 앱에서 미리 마이크로초로 잘라 두는 이유가 하나 늘었다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기