Skip to Content

7장. 문자열로 시간을 적는 법 — ISO 8601과 RFC 3339

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

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

모호한 로컬 날짜 문자열 → 큰 단위부터 적는 ISO 8601 → 오프셋을 요구하는 RFC 3339로 전송 순간을 확정.

면접 실전 질문: ① 로컬 날짜 포맷을 API 경계에서 쓰면 왜 위험한가? ② ISO 8601과 RFC 3339가 다른 이유는? ③ 오프셋만으로 미래 약속을 저장하면 무엇이 부족한가?


오프셋을 문자열에 함께 적으면 그 순간은 어느 시스템이 읽어도 같은 순간이 된다 — 다만 개발자 대부분이 “ISO 8601”이라 부르는 건 실은 그보다 훨씬 엄격한 RFC 3339다.

면접 실전 질문: ① 03/04/2024처럼 로컬 날짜 포맷을 그대로 주고받으면 왜 데이터가 오염되는가? ② ISO 8601과 RFC 3339는 정확히 어떻게 다른가? ③ +09:00이라는 오프셋만 저장하고 타임존 자체는 저장하지 않으면 어떤 문제가 남는가?


배경 — 같은 숫자, 다른 나라, 다른 날짜

6장은 지역 시각 하나가 존재하지 않거나 두 번 존재할 수 있다는 걸 보여줬습니다. 그리고 마지막 질문을 이렇게 남겼죠. 애초에 그 지역 시각을 적은 문자열 자체가 표준이 하나가 아니라면 어떨까. 이 장은 그 질문에서 시작합니다.

한 팀 안에서 코드를 짤 땐 날짜 포맷이 문제가 안 됩니다. 다들 같은 관행을 씁니다. 문제는 그 값이 팀 밖으로, 회사 밖으로, 국경 밖으로 나갈 때 터집니다. 미국은 날짜를 03/04/2024처럼 월/일/연 순서로 적습니다. 한국을 포함한 세계 대부분은 일/월/연 순서로 같은 숫자를 적습니다. 그러니 03/04/2024라는 문자열 하나가 미국 개발자에겐 3월 4일이고, 그 밖의 거의 모든 나라 개발자에겐 4월 3일입니다. 파서는 어느 쪽을 넣어도 에러를 던지지 않습니다. 둘 다 유효한 날짜거든요. 그냥 조용히 틀린 값이 저장됩니다. 이건 예외적인 입력이 아니라 매일 로그·CSV·API 응답에 실려 오가는 흔한 데이터입니다.

4장에서 본 epoch 정수는 이 문제가 아예 없습니다. 정수 하나엔 순서 논쟁이 끼어들 여지가 없으니까요. 하지만 정수는 사람이 로그를 눈으로 읽을 때, 디버깅할 때, API 응답에 그대로 찍힐 때 불편합니다. 1710494100을 보고 그게 언제인지 바로 아는 사람은 없습니다. 필요한 건 사람도 읽고 기계도 한 가지로만 해석하는 문자열 포맷이었습니다.

스토리 — 위원회가 정한 순서, 그리고 그 순서가 산 대가

국제표준화기구(ISO)가 이 문제에 내놓은 답이 ISO 8601입니다. 1988년에 처음 공표됐고, 1991·2000·2004·2019년에 개정을 거쳐 지금 쓰는 버전(ISO 8601-1:2019)에 이릅니다. 핵심 설계는 단순합니다. 연 → 월 → 일 순서로 큰 단위부터 적고, 자릿수를 0으로 채워 폭을 고정합니다. 2024-03-05, 03이지 3이 아닙니다.

이 선택이 왜 영리한지는 코드로 확인하는 게 가장 빠릅니다. 자릿수가 고정된 큰 단위 우선 포맷은 문자열을 그냥 사전순으로 정렬해도 시간순 정렬과 같아집니다. JDK 21로 직접 확인했습니다.

List<String> iso = Arrays.asList( "2024-03-15T09:15:00Z", "2023-12-31T23:59:59Z", "2024-01-02T00:00:00Z", "2024-01-01T23:59:59Z"); List<String> isoSorted = new ArrayList<>(iso); Collections.sort(isoSorted);
ISO 8601 문자열 원본 = [2024-03-15T09:15:00Z, 2023-12-31T23:59:59Z, 2024-01-02T00:00:00Z, 2024-01-01T23:59:59Z] String.compareTo 정렬 = [2023-12-31T23:59:59Z, 2024-01-01T23:59:59Z, 2024-01-02T00:00:00Z, 2024-03-15T09:15:00Z] 연대순과 일치? = true

Instant도, LocalDate도 파싱하지 않고 문자열 그대로 정렬했는데 연대순과 완벽히 일치합니다. 같은 값들을 MM/DD/YYYY로 바꿔 같은 방식으로 정렬해 보면 얘기가 달라집니다.

MM/DD/YYYY 문자열 원본 = [03/15/2024, 12/31/2023, 01/02/2024, 01/01/2024] String.compareTo 정렬 = [01/01/2024, 01/02/2024, 03/15/2024, 12/31/2023] (연대순: 2023-12-31, 2024-01-01, 2024-01-02, 2024-03-15 이어야 함)

2023년 12월 31일이 맨 뒤로 밀려납니다. 월이 앞에 오는 순간, 문자열 정렬은 연대와 아무 상관 없는 순서를 만듭니다. 로그 파일을 ls로 훑거나, 파일명을 정렬하거나, DB에서 문자열 컬럼으로 ORDER BY를 걸 때 ISO 8601이 실무에서 이기는 이유가 이겁니다. 별도 파싱 없이 정렬이 그냥 맞기 때문입니다.

날짜와 시각을 합칠 땐 T로 구분합니다(2024-03-15T09:15:00). 오프셋이 없는 UTC는 Z로 표기하는데, 이 Z는 임의로 고른 글자가 아닙니다. 항해·항공·군에서 협정세계시를 가리킬 때 쓰던 음성 부호 “Zulu”(Z를 소리 나는 대로 읽은 것, 자오선 0도를 뜻함)에서 왔습니다. 지역 오프셋은 +09:00, -05:00처럼 부호와 시:분으로 적습니다.

문제는 ISO 8601이 여기서 멈추지 않았다는 겁니다. 이 표준은 2024-W15-3(2024년 15주차 수요일) 같은 주(week) 날짜, 2024-105(2024년의 105번째 날) 같은 순서(ordinal) 날짜, P3Y6M4D(3년 6개월 4일) 같은 기간(duration), 구분자를 뺀 basic 포맷(20240315T091500Z), 그리고 소수점 자리에 마침표 대신 쉼표(13:45:30,250)를 쓰는 것까지 전부 허용합니다. 표준 하나가 이렇게 넓다 보니, “ISO 8601을 따른다”는 두 시스템이 서로 다른 부분집합만 지원하고도 둘 다 “표준 준수”라고 주장할 수 있습니다. 게다가 ISO 8601 원문 자체가 무료가 아닙니다. 최신판(ISO 8601-1:2019)은 ISO 공식 스토어에서 유료로 판매됩니다(확인 시점 기준 CHF 181 — 가격과 환율은 시점에 따라 달라집니다). 개발자 대부분이 원문을 사서 읽는 대신 블로그 요약과 각자의 파서 구현에 의존해온 배경이 여기 있습니다.

핵심 — RFC 3339는 ISO 8601보다 좁지만, 완전한 부분집합은 아니다

인터넷 프로토콜은 이 애매함을 감당할 수 없었습니다. 그래서 IETF가 2002년 RFC 3339를 내놓습니다. 표현은 “ISO 8601의 프로파일”이지만, 실제로는 더 좁고 더 엄격한 규칙입니다. 주 날짜, 순서 날짜, 기간, 쉼표 소수점 같은 넓은 표현들을 아예 빼버리고, YYYY-MM-DDThh:mm:ss(.s)+hh:mm 딱 하나의 형태만 인정합니다. 그리고 결정적으로 오프셋을 항상 요구합니다. ISO 8601은 오프셋 없는 지역 시각(2024-03-15T09:15:00)도 유효한 값으로 허용하지만 — 이게 바로 6장에서 본, 저장하는 순간 모호해지는 그 값입니다 — RFC 3339엔 그런 값이 존재하지 않습니다. 개발자가 흔히 “ISO 8601 포맷으로 저장한다”고 말할 때 실제로 기대하는 동작은 거의 항상 RFC 3339 쪽입니다.

두 표준이 갈라지는 지점은 하나 더 있습니다. -00:00이라는 표기입니다. RFC 3339는 이 표기에 특별한 뜻을 부여합니다. **“UTC 시각 자체는 알지만, 그걸 만든 지역 오프셋은 모른다”**는 뜻으로, Z+00:00(UTC가 곧 기준이라는 확정적 의미)과는 다른 신호입니다. 그런데 ISO 8601은 정반대입니다. 0 또는 양수 오프셋엔 반드시 +를 쓰라고 못 박아, -00:00 자체를 금지된 표기로 취급합니다. RFC 3339가 “쓸모 있다”며 새로 만든 표기를, ISO 8601 원문은 애초에 허용하지 않는 겁니다. “ISO 8601을 따른다”는 두 시스템이 여기서 진짜로 어긋날 수 있는 지점입니다.

그래서 “RFC 3339는 ISO 8601의 부분집합”이라는 흔한 요약은 정확하지 않습니다. 대부분의 방향에서는 좁히지만, -00:00 한 곳에서는 오히려 원본이 금지한 표기를 더합니다. 부분집합이 아니라 대체로 좁고 한 군데가 삐져나온 프로파일로 기억해두는 편이 안전합니다.

java.time이 이 표기를 실제로 어떻게 다루는지 확인했습니다.

OffsetDateTime fromNegZero = OffsetDateTime.parse("2024-03-15T09:15:00-00:00"); OffsetDateTime fromZ = OffsetDateTime.parse("2024-03-15T09:15:00Z");
-00:00 파싱 결과 오프셋 = Z Z 파싱 결과 오프셋 = Z 두 오프셋 equals? = true 두 OffsetDateTime equals? = true

java.time-00:00을 파싱은 해주지만, RFC 3339가 부여한 “오프셋을 모른다”는 의미는 그대로 증발합니다. +00:00과 완전히 같은 값으로 합쳐져 버려요. 표준 문서가 말하는 의미와 라이브러리가 실제로 보존하는 값이 다를 수 있다는 걸, 코드 세 줄이 증명합니다.

이번엔 파서 세 개(Instant.parse, OffsetDateTime.parse, ZonedDateTime.parse)에 ISO 8601이 허용하는 여러 표기를 넣어 실제 동작을 확인했습니다.

입력: 2024-03-15T09:15:00Z -> 셋 다 성공 입력: 2024-03-15t09:15:00z -> 셋 다 성공 (소문자 t/z 허용) 입력: 20240315T091500Z -> 셋 다 실패: DateTimeParseException 입력: 2024-105 (순서 날짜) -> 셋 다 실패: DateTimeParseException 입력: 2024-W15-3 (주 날짜) -> 셋 다 실패: DateTimeParseException

RFC 3339가 소문자 t/z를 허용한다는 대목은 세 파서 모두 그대로 받아들입니다. 그런데 ISO 8601이 분명히 유효하다고 규정한 basic 포맷(구분자 없는 20240315T091500Z), 순서 날짜, 주 날짜는 셋 다 DateTimeParseException을 던지며 거부합니다. java.time의 표준 파서들은 애초에 RFC 3339 쪽 좁은 부분집합만 받도록 만들어져 있는 겁니다. “ISO 8601이라 적힌 문자열이니 당연히 파싱되겠지”라는 가정이 가장 자주 깨지는 지점이 여기입니다.

포맷터도 마찬가지로 갈립니다. DateTimeFormatterISO_INSTANT, ISO_OFFSET_DATE_TIME, ISO_ZONED_DATE_TIME 세 가지가 있고, 셋은 서로 다른 걸 찍습니다.

Instant instant = Instant.parse("2024-03-15T09:15:00Z"); ZonedDateTime zdt = instant.atZone(ZoneId.of("Asia/Seoul"));
ISO_INSTANT.format(instant) = 2024-03-15T09:15:00Z ISO_OFFSET_DATE_TIME.format(zdt) = 2024-03-15T18:15:00+09:00 ISO_ZONED_DATE_TIME.format(zdt) = 2024-03-15T18:15:00+09:00[Asia/Seoul] zdt.toString() = 2024-03-15T18:15+09:00[Asia/Seoul]

ISO_ZONED_DATE_TIMEzdt.toString()이 그대로 찍는 [Asia/Seoul]이라는 대괄호 부분에 주목해야 합니다. 이건 ISO 8601도, RFC 3339도 아닙니다. java.time이 “이 오프셋을 만든 타임존이 무엇인지”까지 문자열에 남기려고 붙인 자체 확장입니다. 실제로 이 문자열을 표준 파서에 되돌려 넣어 보면 그 경계가 바로 드러납니다.

OffsetDateTime.parse("2024-03-15T18:15+09:00[Asia/Seoul]") -> 실패: DateTimeParseException: unparsed text found at index 22 ISO_OFFSET_DATE_TIME.parse(같은 문자열) -> 실패: DateTimeParseException: unparsed text found at index 22 ZonedDateTime.parse(같은 문자열) -> 성공: 2024-03-15T18:15+09:00[Asia/Seoul]

ZonedDateTime은 자기가 찍은 문자열을 자기가 다시 읽을 수 있습니다. 하지만 OffsetDateTime은 물론이고, 표준 이름이 붙은 ISO_OFFSET_DATE_TIME 포맷터조차 대괄호 뒤에서 “파싱되지 않은 텍스트가 남았다”며 예외를 던집니다. [Asia/Seoul]java.time 생태계 밖으로 나가는 순간 잉여 문자에 불과합니다. JSON API 응답에 ZonedDateTime.toString()을 그대로 실어 보내면, Java로 만들지 않은 클라이언트나 다른 언어의 RFC 3339 파서는 이 문자열을 거부합니다. 흔한 인터롭(interop) 함정이 바로 이겁니다.

여기까지 정리하면 이렇습니다. 오프셋을 문자열에 적으면 그 순간이 무엇이었는지는 확정됩니다. +09:00이라는 오프셋 자체엔 모호함이 없어요. 하지만 오프셋과 타임존은 다른 정보입니다. +09:00은 “그 순간 그 지역이 UTC보다 9시간 앞서 있었다”는 사실만 기록할 뿐, “어느 나라의 어느 규칙이 그 오프셋을 만들었는가”는 기록하지 않습니다. 과거에 이미 확정된 순간이라면 오프셋만으로 충분합니다. 문제는 미래의 약속입니다. 6개월 뒤 서울에서 열릴 회의를 2027-01-15T14:00:00+09:00으로 저장했다고 합시다. 그런데 그사이 한국이 서머타임을 다시 도입하거나 표준 오프셋 자체를 바꾼다면 — 5장에서 본 것처럼 이런 규칙은 입법 기록이라 언제든 바뀔 수 있습니다 — 저장된 +09:00은 더 이상 그 회의가 열릴 실제 오프셋이 아닐 수 있습니다. 오프셋은 “그 순간”을 기록하지만, 규칙이 바뀔 수 있는 미래를 안전하게 기록하려면 오프셋이 아니라 Asia/Seoul이라는 타임존 자체가 필요합니다. 이 구분은 여기서 답을 내리지 않고 9장과 15장으로 넘깁니다.

정리

  • 로컬 날짜 포맷(MM/DD/YYYY 등)은 국경을 넘는 순간 조용히 틀린 데이터가 된다. ISO 8601은 연→월→일 큰 단위 우선 순서와 고정 자릿수로 이 문제를 없앤다.
  • 그 설계의 실질적 승리는 문자열 사전순 정렬이 곧 시간순 정렬이 된다는 것이다(실측 확인). 같은 값을 MM/DD/YYYY로 적으면 정렬이 깨진다(실측 확인).
  • ISO 8601은 표준 하나치고 지나치게 넓다 — 주 날짜, 순서 날짜, 기간, basic 포맷, 쉼표 소수점까지 전부 유효하다. 게다가 원문은 유료(CHF 181)다. 그래서 인터넷은 2002년 RFC 3339라는 더 좁고 무료인 부분집합을 사실상의 표준으로 굳혔다 — 오프셋을 항상 요구하고, 형태를 하나로 고정한다.
  • 두 표준은 실제로 어긋난다. -00:00을 RFC 3339는 “오프셋 모름”이라는 뜻으로 허용하지만 ISO 8601 원문은 이 표기 자체를 금지한다. java.time은 파싱은 해주되 그 의미를 보존하지 않고 +00:00과 합쳐버린다(실측 확인).
  • java.time의 표준 파서는 RFC 3339 부분집합만 받는다 — basic 포맷·순서 날짜·주 날짜는 전부 DateTimeParseException(실측 확인). ZonedDateTime.toString()[Asia/Seoul] 대괄호는 Java 자체 확장이라 OffsetDateTime.parse조차 되돌려 읽지 못한다(실측 확인) — API 경계 밖으로 내보내면 안 되는 값이다.
  • 오프셋은 순간을 확정하지만 타임존을 대신하지 못한다. +09:00은 그 순간의 사실을 기록할 뿐, 그 오프셋을 만든 규칙이 바뀔 미래를 지켜주지 않는다. 이 간극은 9장·15장에서 다시 다룬다.
  • 제2부를 관통한 순서를 다시 보면 — 4장의 정수는 폭에 천장이 있었고, 5장의 타임존 규칙은 사실 언제든 바뀌는 데이터 파일이었고, 6장의 지역 시각은 존재하지 않거나 두 번 존재할 수 있었다. 7장은 그 마지막 문제에 실무적 답을 낸다 — 오프셋을 문자열에 함께 적으면, 경계를 넘는 순간의 모호함 대부분이 사라진다.

생각해볼 질문: 이 장에서 확정한 건 전송·저장 경계를 오갈 때 쓸 문자열 규격입니다. 그런데 그 문자열이 시스템 안으로 들어온 다음엔 어떻게 될까요? 자바는 이 문제를 처음부터 잘 풀지 못했습니다. DateCalendar라는 두 개의 API를 이미 만들었다가, 훗날 둘 다 사실상 폐기하고 java.time을 처음부터 다시 설계했습니다. 잘 정리된 문자열 표준이 있는데도, 왜 언어 내부의 시간 표현은 두 번이나 실패해야 했을까요?

8장 · Date와 Calendar는 왜 실패했나 — java.time 이전의 세계

Last updated on