본문으로 건너뛰기
jsonbeautifiers
한국어

JSON에는 날짜 타입이 없다. 그러니 하나 정하고 적어 두자

JSON에는 여섯 가지 타입이 있고 그중 어느 것도 날짜가 아닙니다. 그래서 코드베이스마다 날짜를 발명합니다. 흔한 네 가지 답 중 안전한 것은 하나뿐입니다.

이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.

관리자 테이블의 모든 레코드가 1970년 1월 21일에 생성된 것으로 나온다는 제보가 들어옵니다. 페이로드는 멀쩡해 보입니다.

{ "created_at": 1735689600 }

에포크 초로 2025년 1월 1일입니다. 프런트엔드는 new Date(1735689600)을 호출했는데, 이건 밀리초를 받으므로 에포크로부터 1,735,689초 뒤로 읽어 1970년에 들어선 지 3주 되는 지점에 착지했습니다. 아무것도 던져지지 않았습니다. 숫자는 유효했고 타입도 옳았으며, 의미만 두 서비스 사이에서 사라졌습니다. JSON에는 그것을 실어 나를 방법이 없기 때문입니다.

RFC 8259가 주는 것은 객체, 배열, 문자열, 숫자, 불리언, null입니다. 날짜는 없습니다. 여러분이 보내는 것은 양쪽이 사적으로 해석을 합의한 문자열이나 숫자이고, 그 합의는 문서 안에 살거나 아무의 머릿속에도 없거나 둘 중 하나입니다.

마주치게 될 네 가지 관행

관행 무엇인가
RFC 3339 문자열 "2025-01-01T00:00:00Z" 자기 설명적이고, 정렬되며, 모호하지 않음
에포크 초 1735689600 지금은 10자리, 오프셋 없음, 단위 표시 없음
에포크 밀리초 1735689600000 지금은 13자리, JavaScript의 기본
ASP.NET AJAX "\/Date(1735689600000)\/" 문자열로 감싼 밀리초, 오래된 .NET API에 아직 남아 있음

뒤의 둘을 필드 수준에서 구별할 단서는 자릿수뿐입니다. 지금의 에포크 초는 10자리이고 2286년까지 10자리로 남습니다. 같은 순간을 밀리초로 쓰면 13자리입니다. 스키마 없는 피드를 물려받았다면 추측하기 전에 자릿수를 세세요. 그리고 초 값을 밀리초 API에 넣으면 언제나 1970년 초에 착지한다는 사실을 기억하세요. 바로 그래서 이 버그가 그토록 눈에 익습니다.

네 번째는 JSON의 의미에서도 진짜 문자열입니다. 백슬래시는 /에 대한 합법적인 이스케이프이므로, 파싱하고 나면 손에 남는 것은 문자 그대로의 텍스트 /Date(1735689600000)/이고 여기에 정규식을 돌려야 합니다. /Date(1735689600000-0800)/처럼 오프셋을 붙인 변종도 있지만 그 오프셋은 장식입니다. 숫자는 이미 UTC입니다.

RFC 3339는 ISO 8601과 완전히 같지 않다

사람들은 두 이름을 섞어 쓰고, 그러다 한쪽이 받아들이는 것을 다른 쪽이 거부합니다. RFC 3339는 ISO 8601의 프로파일입니다. 기계끼리 어긋날 수 없도록 고른, 더 작고 더 엄격한 문법이죠.

ISO 8601은 RFC 3339가 허용하지 않는 것들을 허용합니다.

  • 구분자 없는 기본 형식, 20250101T000000Z
  • 주 단위 날짜(2025-W01-3)와 서수 날짜(2025-001)
  • 2025-01이나 그냥 2025 같은 정밀도 축소
  • 초의 소수점 구분자로 쉼표를 쓰는 형태, 00:00:00,5
  • 오프셋이 전혀 없는 현지 시각

RFC 3339는 언제나 완전한 날짜, 완전한 시각, 그리고 오프셋을 요구합니다. 또한 ISO 8601이 금지하는 것 하나를 허용합니다. 오프셋 -00:00으로, 순간은 알지만 현지 오프셋은 모른다는 뜻입니다. 파서나 검증기를 만든다면, -00:00+00:00은 같은 순간이자 서로 다른 주장입니다.

실무 규칙: RFC 3339로 내보내되 T는 대문자, Z는 대문자, 초는 정수이거나 소수점 아래 정확히 세 자리. 어쩔 수 없다면 받을 때는 조금 더 너그러워도 되지만, 내보낼 때는 절대 그러지 마세요.

Z는 오프셋이지, 오프셋의 부재가 아니다

Z는 오프셋이 +00:00이라는 뜻입니다. 순간에 관한 사실입니다. “시간대 없음”을 말하는 방법이 아니고, “이 레코드의 시간대는 UTC다”를 말하는 방법도 아닙니다. 서로 다른 것들이고, 이 차이가 문제를 어렵게 만듭니다.

"2025-01-01T00:00:00Z""2025-01-01T09:00:00+09:00"은 같은 순간입니다. 들어오는 길에 전부 Z로 정규화한다면, 순간은 지키고 사용자가 어디에 있었는지는 버린 것입니다. created_at에는 대개 옳고, 캘린더 약속에는 대개 틀립니다. 약속에서 사용자가 신경 쓰는 것은 현지의 벽시계이고, 오프셋은 그날이 오기 전까지 알 수 없을 수도 있습니다. 그런 값은 현지 시각과 IANA 존 이름(+01:00이 아니라 Europe/Berlin)을 별도 필드에 저장하세요. 오프셋은 1년에 두 번 바뀌고 정부는 짧은 예고로 그것을 바꿉니다.

오프셋 없는 타임스탬프는 절대 내보내지 마세요. "2025-01-01T00:00:00"은 어느 기계가 읽느냐에 따라 의미가 달라지는 문자열이고, JavaScript와 파이썬은 이를 다르게 해석합니다.

달력 날짜는 타임스탬프가 아니다

생일, 청구서 만기일, 공휴일은 순간이 아닙니다. 시각도 오프셋도 없으며, 거기에 하나를 붙이는 것은 사용자의 절반에게 하루가 어긋나는 버그로 나타납니다.

new Date('1990-07-14').toLocaleDateString('ko-KR')
// UTC 서쪽 어디에서든 '1990. 7. 13.'

ECMAScript 명세는 날짜만 있는 형태를 UTC 자정으로 파싱하고, 그다음 현지 포매터가 그것을 뒤로 되돌립니다. "1990-07-14"는 평범한 문자열로 보내고, 문자열로 유지하고, Date를 아예 거치지 않고 서식화하세요. 어떤 값이 “몇 시간만큼 틀릴” 수 없는 종류라면, 애초에 시각을 지니고 다녀서는 안 됩니다.

JavaScript의 특수 사정

직렬화는 그냥 됩니다. Date.prototype.toJSONtoISOString을 부르기 때문입니다.

JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'

JSON.stringify({ at: new Date(NaN) })
// '{"at":null}'   유한하지 않은 날짜에는 toJSON이 null을 돌려준다. 던지지 않는다

파싱은 아예 되지 않습니다. JSON.parse는 어떤 문자열이 날짜인지 알 길이 없으므로, 왕복하면 문자열이 돌아오고, 버그는 나중에 무언가가 그것에 .getTime()을 부를 때 나타납니다. 흔한 땜질이 reviver입니다.

const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

JSON.parse(text, (key, value) =>
  typeof value === 'string' && RFC3339.test(value) ? new Date(value) : value
);

이 방식에는 경고가 둘 있습니다. 우선 휴리스틱입니다. 타임스탬프처럼 보이는 문자열은 사용자의 자유 서술 필드라도 날짜가 됩니다. 그리고 Date는 오프셋을 접어 버리므로 +09:00은 UTC 순간으로 돌아오고 원래 오프셋은 사라집니다. 키 경로 단위로 되살리거나, 아예 되살리지 않고 쓰는 지점에서 명시적으로 변환하는 편이 낫습니다.

Date의 후계자인 Temporal에는 이런 구별을 제대로 모델링한 타입(Instant, PlainDate, ZonedDateTime)이 있고, PlainDate는 이 글이 계속 요구해 온 바로 그 달력 날짜 타입입니다. 이 글을 쓰는 시점에 브라우저 탑재가 시작됐습니다. 의존하기 전에 현재 지원 상황을 확인하고, 폴리필의 번들 크기가 받아들일 만한지도 확인하세요.

파이썬의 특수 사정

datetime은 JSON으로 직렬화되지 않고, 사람들이 처음 집어 드는 우회책은 미묘하게 틀렸습니다.

import json
from datetime import datetime, timezone

now = datetime.now(timezone.utc)

json.dumps({"at": now})
# TypeError: Object of type datetime is not JSON serializable

json.dumps({"at": now}, default=str)
# '{"at": "2025-01-01 00:00:00+00:00"}'   구분자가 공백. RFC 3339가 아니다

json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}'   올바르다

default=str이 주는 것은 str(datetime)이고, T 대신 공백을 씁니다. 읽기는 좋고, RFC 3339는 아닙니다. 엄격한 소비자는 거부합니다.

되읽을 때 datetime.fromisoformat은 파이썬 3.11부터 Z 접미사를 처리합니다. 3.10 이하에서는 ValueError: Invalid isoformat string을 던지고, 그래서 오래된 코드가 호출 앞에 .replace("Z", "+00:00")를 달고 다니는 것입니다. 또한 isoformat()Z가 아니라 +00:00을 내놓는다는 점도 유의하세요. 소비자가 Z를 고집한다면 내보내는 길에 치환하세요.

스키마는 기본값으로는 구해 주지 않는다

당연한 수순은 형태를 선언하는 것입니다.

{
  "type": "object",
  "properties": {
    "created_at": { "type": "string", "format": "date-time" },
    "due_on":     { "type": "string", "format": "date" }
  },
  "required": ["created_at"]
}

JSON Schema 2019-09와 2020-12에서 format은 기본적으로 **주석(annotation)**이지 단언(assertion)이 아닙니다. 손대지 않은 상태의 대부분 검증기는 이 스키마에 대해 "created_at": "어제"를 기꺼이 통과시킵니다. 문자열이고, format 키워드는 의도를 서술할 뿐이니까요. 단언으로 쓰려면 명시적으로 켜야 합니다(Ajv에서는 ajv-formats를 더하는 것). 주석과 단언 어휘가 어떻게 갈리는지는 JSON 스키마 설명을, 실제 페이로드에서 초안을 뽑으려면 스키마 생성기를 보세요.

무엇을 보내고 무엇을 받을 것인가

보낼 때는 순간에 대해 오프셋을 명시한 RFC 3339를, 읽는 쪽에 현지 오프셋이 의미가 있는 경우가 아니라면 Z로 정규화해서 보내세요. 달력 날짜에는 평범한 YYYY-MM-DD 문자열을 보냅니다. 타입이 분명해지도록 필드 이름을 지으세요. 순간이면 created_at, 날짜면 due_on, 정말로 에포크를 보내야 한다면 단위가 함께 다니도록 expires_at_ms라고 부르세요.

받을 때는 소수 초가 있든 없든 RFC 3339를, 오프셋은 +HH:MM 형태든 Z 형태든 받아들이고, 오프셋이 없는 것은 추측하지 말고 거부하세요. 무언가를 만들기 전에 문자열을 검증하세요. new Date("헛소리")는 Invalid Date를 주고, 그것은 조용히 번집니다.

실제 페이로드를 검증기에 붙여 넣어 구조가 온전한지 확인한 다음, 날짜 필드는 직접 눈으로 읽으세요. 모든 숫자의 자릿수를 세고, 모든 타임스탬프 문자열이 오프셋으로 끝나는지 확인하는 것. 이 두 가지 점검이 이 글에서 다룬 것의 대부분을 잡아냅니다. 나머지는 API 응답 설계가 다루며, 거기서는 필드 이름에 대한 결정을 한 번 내리고 다시 들추지 않습니다.