목차
같은 2025-11-02 01:30이라도 어떤 타입에 담느냐에 따라 되돌아오는 값이 달라진다. 이번 글은 그 갈림길에 선다.
팻말이 붙었나 안 붙었나
시각을 담는 타입은 크게 둘로 갈린다. 부르는 이름은 언어마다 다르지만 나뉘는 기준은 하나다. 팻말이 값 안에 들어 있느냐.
| 무엇을 들고 있나 | 답할 수 있는 질문 | |
|---|---|---|
| 팻말 없는 값 | 달력 숫자만. 2025-11-02 01:30 | ”달력에 뭐라고 적혀 있나” |
| 팻말 붙은 값 | 달력 숫자 + 지역 또는 오프셋 | ”그래서 어느 순간인가” |
팻말 없는 값은 어느 순간인지 답할 수 없다. 이건 결함이 아니라 그 타입이 하는 일이다. 문제는 답할 수 없는 값을 답할 수 있는 값처럼 쓸 때 생긴다.
숫자는 그대로 돌아온다. 대신 어느 순간인지 답할 수 없다.
순간은 지켜진다. 대신 어느 지역에서 왔는지가 사라진다.
아무것도 안 사라진다. 달력의 칸 자체가 뜻인 값에 맞는다.
빗금은 저장하면서 없어진 조각이다. 팻말이 사라지거나 지역이 사라지거나 아무것도 안 사라진다.
팻말 없는 값은 구간을 가리킨다
2025-11-02 01:30만 저장되어 있으면 이 값이 가리키는 순간은 하나가 아니다.
지구 어딘가에서는 이미 지난 시각이고 어딘가에서는 아직 안 왔다. 오프셋 범위를 다 훑으면 26시간 폭이다. 게다가 그날이 서머타임 되돌리는 날인 지역이라면 같은 지역 안에서도 두 순간을 가리킨다.
그래서 팻말 없는 값끼리 비교하는 건 달력 숫자를 비교하는 것이지 순간을 비교하는 게 아니다. 사용자가 서로 다른 지역에 있으면 “먼저 쓴 댓글”이 뒤에 오는 일이 벌어진다.
DB 컬럼을 고르는 순간 정해진다
가장 자주 걸리는 자리가 DB다. 이름이 비슷해서 더 헷갈린다.
PostgreSQL
timestamp(=timestamp without time zone) - 팻말 없음. 넣은 숫자가 그대로 나온다timestamptz(=timestamp with time zone) - 순간을 담는다
여기서 이름이 함정이다. timestamptz는 시간대를 저장하지 않는다. 넣을 때 세션의 시간대로 해석해 순간으로 바꿔 담고, 읽을 때 세션의 시간대로 다시 펼쳐 준다. 값 안에 남는 건 순간 하나뿐이고, 원래 어느 지역에서 들어왔는지는 사라진다.
“with time zone”이라는 이름 때문에 지역이 보존될 거라 기대하면 어긋난다. 저 이름은 “시간대를 고려해서 다룬다”는 뜻이지 “시간대를 담는다”가 아니다.
MySQL
DATETIME- 팻말 없음. 넣은 숫자가 그대로 나온다TIMESTAMP- 순간을 담는다. 쓸 때 세션 시간대에서 UTC로 바꾸고 읽을 때 되돌린다
TIMESTAMP에는 함정이 하나 더 있다. 담을 수 있는 범위가 1970년부터 2038년까지다. 32비트에 초를 담는 방식을 그대로 쓰기 때문이다. 만기가 먼 값을 여기 넣으면 못 넣거나 잘린다.
세션 설정이 값을 바꾼다
순간을 담는 타입에는 따라오는 성질이 있다. 읽는 쪽 설정에 따라 보이는 숫자가 달라진다.
같은 행을 서울에서 읽으면 15시, UTC로 설정된 서버에서 읽으면 6시로 보인다. 값은 안 바뀌었고 펼치는 팻말이 다를 뿐이지만, 이걸 모르면 “DB 값이 이상하다”는 결론으로 간다.
그래서 실무에서는 대개 애플리케이션과 DB 세션의 시간대를 UTC로 고정해 둔다. 어디서 읽든 같은 숫자가 나오게 만들어 두고, 지역 시각은 화면에 그릴 때만 만든다. 사람이 값을 눈으로 확인하는 일이 잦은데, 그때마다 숫자가 달라 보이면 판단이 흔들린다.
날짜만 필요한 값은 또 다르다
여기서 비유가 깨진다. 팻말을 붙이면 늘 나아지는 게 아니다. 오히려 붙이면 안 되는 값이 있다.
생일, 계약 기준일, 국경일 같은 값은 순간이 아니라 달력의 칸 자체가 뜻이다. 1996-03-15는 지구 어디서 보든 3월 15일이어야 한다.
여기에 시각과 팻말을 붙이면 어떻게 되냐면, 자정으로 채워진 값이 다른 지역에서 읽힐 때 하루가 밀린다. 3월 15일생이 3월 14일생으로 표시되는 사고가 이 꼴이다.
이런 값은 날짜만 담는 타입을 쓴다. 시각도 팻말도 붙이지 않는 것이 정확한 것이다. 이름도 birthDate처럼 시각이 아님이 드러나게 짓는다.
세 가지 중 하나를 고른다
정리하면 실무에서 고를 것은 셋이다.
| 값의 성격 | 무엇으로 담나 | 예 |
|---|---|---|
| 일어난 순간 | 순간 하나 (UTC) | 생성 시각, 결제 시각, 로그 |
| 앞으로 일어날 지역 시각 | 달력 시각 + 지역 이름 | 예약, 알림, 반복 일정 |
| 달력의 칸 자체 | 날짜만 | 생일, 정산 기준일 |
대부분의 컬럼은 첫째 줄이다. 그래서 “일단 UTC로 저장”이 좋은 기본값이 된다. 다만 이걸 규칙으로 외우면 둘째·셋째 줄에서 틀린다. 앞 글에서 본 대로 미래의 지역 시각을 순간으로 굳히면 규칙이 바뀔 때 어긋나고, 날짜를 순간으로 담으면 하루가 밀린다.
실무에서: 경계마다 무엇이 빠지는지 본다
값이 타입을 옮겨 다닐 때마다 정보가 조용히 떨어져 나간다. 확인할 자리는 정해져 있다.
// 순간 → 팻말 없는 값: 여기서 팻말이 버려진다
LocalDateTime local = instant.atZone(seoul).toLocalDateTime();
// 팻말 없는 값 → 순간: 여기서 팻말을 새로 만들어 붙인다
// 어느 지역으로 붙이냐를 코드가 정하지 않으면 서버 설정이 정한다
Instant back = local.atZone(ZoneId.systemDefault()).toInstant();systemDefault()류를 코드에서 찾아본다. 서버 설정에 값이 달린 자리다. 대부분 명시적인 지역으로 바꿀 수 있다- API 경계에서 무엇이 오가는지 문서로 못 박는다. 주고받는 값의 꼴이 애매하면 양쪽이 다르게 해석한다
- 마이그레이션이 가장 위험하다. 팻말 없는 컬럼을 순간 컬럼으로 바꿀 때, 기존 값을 어느 지역으로 해석할지 정해야 한다. 이걸 안 정하면 DB가 자기 설정으로 정해 버리고 전체 데이터가 몇 시간씩 밀린다. 되돌리기도 어렵다
정리
- 시각 타입은 팻말이 값 안에 있느냐로 갈린다. 팻말 없는 값은 순간을 답할 수 없다.
- 팻말 없는 값은 26시간 폭의 구간을 가리킨다. 그걸로 정렬하면 순서가 뒤집힌다.
timestamptz는 시간대를 저장하지 않는다. 순간만 담고 지역은 사라진다.- MySQL
TIMESTAMP는 2038년까지만 담는다. 만기가 먼 값은 못 넣는다. - 순간 타입은 읽는 쪽 설정에 따라 보이는 숫자가 달라진다. 그래서 UTC로 고정해 둔다.
- 날짜 자체가 뜻인 값에는 팻말을 붙이지 않는다. 붙이면 하루가 밀린다.
다음 글은 이 값을 글자로 적는 자리를 본다. 어떻게 적어야 받는 쪽이 똑같이 되돌리는지에는 이미 정해진 답이 있다.