날짜 파싱과 포맷: ISO 8601, 로캘, 음력 표시의 함정
날짜 강좌 3. 2월 30일을 3월 2일로 바꾸는 PHP, 시차로 하루 밀리는 JS Date, Java YYYY 연말 버그, '2026. 3. 4.'가 다시 안 읽히는 문제를 실행 결과로.
시리즈: 날짜·시간·타임존 강좌 3/6 (중급). 시각을 UTC 로 저장하는 법(1강)과 타임존이 무엇인지(2강)를 익힌 뒤, 사람과 다른 시스템이 보내는 날짜를 읽고 사용자의 언어로 날짜를 보여 줘야 하는 개발자를 위한 글입니다. 다 읽으면 불가능한 날짜를 조용히 "고쳐 버리는" 파서가 무엇인지, 같은 문자열이 나라마다 다른 날이 되는 이유, 월 이름이나 음력을 직접 만들지 않고 라이브러리로 출력하는 방법을 알게 됩니다.
Asia/Seoul 은 지금 서머타임이 없고 오프셋이 +09:00 하나라서, 국내 서비스만 만들 때는 날짜 버그가 잘 드러나지 않습니다. 문제는 해외 사용자가 생기거나, 서버가 UTC 로 돌거나, 화면에 찍힌 2026. 3. 4. 를 누군가 그대로 저장했을 때 터집니다. 이 글은 Apple Silicon Mac(M1 Pro)에서 Python 3.9.6(비교용 3.12.14), intl 이 들어간 PHP 8.5.6(ICU 78.3), macOS 에 들어 있는 JavaScriptCore 셸 jsc, Java 26, .NET 10.0.14, Swift 6.4.2 로 실제 실행한 결과입니다. 모든 명령은 TZ=… 로 타임존을 명시했고, 경로는 ~/demo/ 로 바꿨습니다.
내보낼 때는 ISO 8601 과 RFC 3339
ISO 8601 은 날짜와 시간을 쓰는 국제 표준입니다. 날짜는 2026-03-04, 한 순간은 2026-03-04T06:05:00+09:00, UTC 면 2026-03-04T06:05:00Z(Z = +00:00). RFC 3339 는 인터넷용으로 ISO 8601 을 좁힌 규격으로, 날짜·시간·오프셋이 항상 다 있습니다. 양쪽을 내가 정할 수 있다면 RFC 3339 로 내보내는 것만으로 이 글의 문제 대부분이 사라집니다.
- 연도가 앞이라 문자열 그대로 정렬된다. 로그 파일명에 쓰는 이유입니다.
- 오프셋도 데이터다. 오프셋 없는
2026-03-04T06:05:00은 "벽시계 읽은 값"이지 순간이 아닙니다. Java 의OffsetDateTime.parse("2026-03-04T06:05:00")는Text '2026-03-04T06:05:00' could not be parsed at index 19로, 정확히 오프셋이 와야 할 위치에서 멈췄습니다.
HTTP 헤더만은 예외로 Wed, 04 Mar 2026 06:05:00 GMT 같은 옛 형식을 항상 GMT 로 씁니다. Python email.utils.format_datetime(d, usegmt=True) 가 이대로 출력했습니다. PHP 8.5.6 에서 서울 시간 값에 DATE_RFC7231 을 쓰면 06:05 를 그대로 "06:05:00 GMT" 라고 써서 9시간 틀린 값이 나왔고, PHP 8.5 는 바로 그 이유("타임존을 무시하고 항상 GMT 를 쓴다")로 이 상수를 deprecated 처리했습니다. 먼저 UTC 로 바꾼 뒤 포맷하세요.
Python: strptime, strftime, fromisoformat
strptime 은 내가 준 형식으로 문자열을 읽고, strftime 은 그 반대입니다. TZ=UTC 에서 실행했습니다.
from datetime import datetime
for s, f in [("2026-02-30", "%Y-%m-%d"), ("2026-3-4", "%Y-%m-%d"),
("03/04/2026", "%m/%d/%Y"), ("03/04/2026", "%d/%m/%Y"),
("2026-03-04T06:05:00Z", "%Y-%m-%dT%H:%M:%S%z"),
("2026-03-04 extra", "%Y-%m-%d")]:
try: print(f"strptime({s!r}, {f!r}) -> {datetime.strptime(s, f)!r}")
except Exception as e: print(f"strptime({s!r}, {f!r}) -> {type(e).__name__}: {e}")
strptime('2026-02-30', '%Y-%m-%d') -> ValueError: day is out of range for month
strptime('2026-3-4', '%Y-%m-%d') -> datetime.datetime(2026, 3, 4, 0, 0)
strptime('03/04/2026', '%m/%d/%Y') -> datetime.datetime(2026, 3, 4, 0, 0)
strptime('03/04/2026', '%d/%m/%Y') -> datetime.datetime(2026, 4, 3, 0, 0)
strptime('2026-03-04T06:05:00Z', '%Y-%m-%dT%H:%M:%S%z') -> datetime.datetime(2026, 3, 4, 6, 5, tzinfo=datetime.timezone.utc)
strptime('2026-03-04 extra', '%Y-%m-%d') -> ValueError: unconverted data remains: extra
Python 은 2월 30일도, 뒤에 붙은 쓰레기 문자도 오류로 처리합니다. 같은 03/04/2026 이 형식에 따라 3월 4일도 4월 3일도 되므로, 형식 선택은 "사용자가 누구인가"에 대한 결정입니다.
ISO 문자열에는 datetime.fromisoformat 을 쓰는데, 3.9 에는 함정이 있습니다.
| 입력 | Python 3.9.6 | Python 3.12.14 |
|---|---|---|
2026-03-04T06:05:00+09:00 |
+09:00 오프셋 포함 | 같음 |
2026-03-04T06:05:00Z |
ValueError: Invalid isoformat string |
UTC 포함 |
20260304 |
ValueError: Invalid isoformat string |
2026-03-04 00:00 |
JSON 에서 가장 흔한 끝의 Z 가 3.9 에서는 실패하고 3.11 부터 됩니다. 3.9 라면 끝의 Z 를 +00:00 으로 바꾼 뒤 넘기세요. %Z(타임존 이름)는 UTC, GMT 와 그 기계 자신의 약어만 받습니다. TZ=UTC 에서 '2026-03-04 06:05 JST' 는 does not match format 으로 실패했고 '... UTC' 는 읽혔지만 결과는 타임존 없는(naive) 값이었습니다. "KST" 같은 약어를 입력 형식으로 믿지 말고 오프셋을 요구하세요.
화면용 한국어 날짜는 다시 읽히지 않는다
한국어 로캘의 짧은 날짜는 2026. 3. 4. 처럼 점과 공백이 섞인 독특한 형태입니다(jsc Intl, .NET ko-KR 의 "d" 출력). 이 문자열이 엑셀이나 화면 복사로 DB 에 들어오는 일이 생각보다 많습니다. 다시 읽어 봤습니다.
| 입력 | jsc new Date |
PHP strtotime |
Python strptime |
.NET TryParse(ko-KR) |
|---|---|---|---|---|
2026. 3. 4. |
Invalid Date | false |
%Y. %m. %d. 로 2026-03-04 |
2026-03-04 |
2026년 3월 4일 |
Invalid Date | false |
%Y년 %m월 %d일 로 2026-03-04 |
2026-03-04 |
2026-03-04 오후 3:05 |
— | — | — | 2026-03-04 15:05 |
.NET 은 한국어 문화권의 관습을 알고 있어서 "오후"까지 읽었지만, 그 결과는 서버 문화권 설정에 달려 있습니다. 화면용 문자열은 화면에만 쓰고, 저장·전송은 ISO 8601 로 하는 것이 유일하게 안전한 규칙입니다.
PHP: createFromFormat 은 2월 30일을 3월로 넘긴다
형식을 주는 DateTimeImmutable::createFromFormat() 은 엄격해 보이지만 그렇지 않습니다.
<?php
date_default_timezone_set('UTC');
foreach (['2026-02-30', '2026-13-01'] as $s) {
$d = DateTimeImmutable::createFromFormat('Y-m-d', $s);
echo $s, ' -> ', $d->format('Y-m-d H:i:s'), "\n";
print_r(DateTimeImmutable::getLastErrors()['warnings']);
}
2026-02-30 -> 2026-03-02 10:03:07
Array
(
[10] => The parsed date was invalid
)
2026-13-01 -> 2027-01-01 10:03:07
...
2월 30일은 3월 2일, 13월은 다음 해 1월이 됐습니다. 10:03:07 은 실행한 순간의 UTC 시각입니다. 읽지 않은 필드를 "지금"으로 채우기 때문입니다. 이상 신호는 getLastErrors() 의 경고 하나뿐이고, 예외도 false 도 없습니다. PHP 8.2 부터는 경고·오류가 없으면 getLastErrors() 가 false 를 돌려주므로 판정이 쉽습니다.
function strict(string $fmt, string $s): ?DateTimeImmutable {
$d = DateTimeImmutable::createFromFormat('!' . $fmt, $s); // ! = 안 읽은 필드는 0
if ($d === false || DateTimeImmutable::getLastErrors() !== false) return null;
return $d->format($fmt) === $s ? $d : null; // 왕복 확인
}
결과는 strict 2026-02-30 -> rejected, strict 2026-02-28 -> 2026-02-28T00:00:00+00:00, strict 2026-2-28 -> rejected. "다시 포맷해서 입력과 같은가"라는 왕복 확인은 어느 언어에서나 쓸 수 있는 가장 확실한 방어입니다.
추측해서 읽는 strtotime() 은 구분자에 따라 해석이 바뀝니다. 03/04/2026 은 2026-03-04(슬래시 = 미국식 월/일), 03-04-2026 과 03.04.2026 은 2026-04-03(일-월)이었습니다. 그리고 new DateTimeImmutable('2026-02-30x') 는 실패하지 않고 2026-03-02T00:00:00-11:00 을 돌려줬습니다. 끝의 x 를 군용 타임존 문자 X(UTC−11)로 읽은 것입니다.
JavaScript: 같은 문자열, 타임존마다 다른 날
JavaScript Date 는 날짜만 있는 ISO 문자열은 UTC 로, 오프셋 없는 날짜+시간은 로컬 시각으로 읽습니다. ISO 가 아닌 형식도 로컬로 읽었습니다. jsc 를 두 타임존에서 돌렸습니다.
const xs = ["2026-03-04", "2026-03-04T00:00", "2026-03-04T00:00Z", "03/04/2026", "02/30/2026", "2026-02-30"];
for (const s of xs) { const d = new Date(s);
print(JSON.stringify(s).padEnd(20), isNaN(d) ? "Invalid Date" : d.toISOString(), "getDate()=" + d.getDate()); }
| 입력 | TZ=UTC |
TZ=America/New_York |
|---|---|---|
2026-03-04 |
03-04T00:00Z, getDate 4 | 03-04T00:00Z, getDate 3 |
2026-03-04T00:00 |
03-04T00:00Z, 4 | 03-04T05:00Z, 4 |
2026-03-04T00:00Z |
03-04T00:00Z, 4 | 03-04T00:00Z, 3 |
03/04/2026 |
03-04T00:00Z (3월 4일) | 03-04T05:00Z (3월 4일) |
02/30/2026 |
2026-03-02T00:00Z | 2026-03-02T05:00Z |
2026-02-30 |
Invalid Date | Invalid Date |
서울은 UTC 보다 동쪽이라 "2026-03-04" 를 UTC 자정으로 읽어도 오전 9시, 날짜가 그대로입니다. 그래서 국내 테스트에서는 절대 안 보이고, 미국 사용자가 생긴 날 "생일이 하루 전"이라는 문의로 나타납니다. ISO 경로는 2월 30일을 거부했지만, 슬래시 형식 02/30/2026 은 PHP 처럼 3월 2일로 넘어갔습니다. Node.js 와 브라우저(V8 등)는 ISO 가 아닌 줄에서 결과가 다를 수 있으며 여기서는 실행하지 않았습니다. 이 jsc 에는 Temporal 이 없었고(typeof Temporal 이 undefined), timeZone 옵션이 있는 Intl.DateTimeFormat 은 있었습니다.
Java: yyyy 와 YYYY, 그리고 SMART 해석
Java DateTimeFormatter 에서 yyyy 는 연도, YYYY 는 주 기준 연도(그 날짜가 속한 주의 연도)입니다. 평소에는 같아서 테스트가 통과합니다. ofPattern("YYYY-MM-dd", locale) 로 연말연시를 출력했습니다(왼쪽이 실제 날짜).
ko-KR 12-26->2026-12-26 12-27->2027-12-27 12-28->2027-12-28 12-31->2027-12-31 01-01->2027-01-01
es-ES 12-26->2026-12-26 12-27->2026-12-27 12-28->2026-12-28 12-31->2026-12-31 01-01->2026-01-01
ko-KR, en-US, ja-JP 에서는 주가 일요일에 시작하고 1월 1일이 든 주가 1주차라서, 2026년 12월 27일부터 연도가 한 해 앞서 나갔습니다. 연말 정산 화면이나 영수증의 날짜가 "2027-12-28" 이 되는 셈입니다. es-ES, en-GB, de-DE(ISO 주)에서는 반대로 2027년 1월 1일이 2026-01-01 로 찍혔습니다. Swift DateFormatter 도 YYYY 2026-12-28: 2027-12-28 이었습니다.
또 하나는 기본 해석 방식 ResolverStyle.SMART 입니다. 2026-02-30 을 주면 STRICT 는 오류, SMART(기본값)는 조용히 2026-02-28, LENIENT 는 2026-03-02 를 돌려줬습니다. PHP 의 3월 2일, Python 의 오류에 이어 세 번째 답입니다. STRICT 와 yyyy 를 함께 쓰면 멀쩡한 2026-02-28 까지 Unable to obtain LocalDate from TemporalAccessor 로 거부됩니다(yyyy 는 "기원 연도"라 기원 정보가 필요). "uuuu + STRICT" 라는 조언이 나오는 이유입니다.
로캘 포맷과 음력: 라이브러리에 맡기기
로캘은 언어와 지역 관습의 묶음입니다(ko-KR, en-US, ja-JP). 요일·월 이름을 직접 배열로 만들지 마세요. 2026-03-04 06:05 UTC 를 jsc 의 Intl.DateTimeFormat(dateStyle: "full", timeZone: "UTC")으로 찍었습니다.
en-US | Wednesday, March 4, 2026 at 6:05 AM | 3/4/26
ko-KR | 2026년 3월 4일 수요일 오전 6:05 | 2026. 3. 4.
ja-JP | 2026年3月4日 水曜日 6:05 | 2026/03/04
es-MX | miércoles, 4 de marzo de 2026, 6:05 a.m. | 04/03/26
같은 데이터로 음력도 출력됩니다. ICU 의 dangi(단기, 한국식 음력) 달력은 jsc 에서 ko-KR-u-ca-dangi, PHP intl 에서 ko_KR@calendar=dangi 로 쓸 수 있었고, .NET 에는 KoreanLunisolarCalendar, Swift 에는 Calendar(identifier: .chinese) 가 있습니다. 세 가지 날짜로 확인했습니다.
| 양력 | jsc dangi |
PHP intl dangi (LONG) |
.NET KoreanLunisolarCalendar |
Swift .chinese + ko_KR |
|---|---|---|---|---|
| 2026-02-17 | 2026년(병오년) 1월 1일 | 병오년 1월 1일 | y2026 m1 d1 | 병오년 1월 1일 |
| 2026-03-04 | 병오년 1월 16일 수요일 | 병오년 1월 16일 수요일 | — | — |
| 2026-09-25 | — | 병오년 8월 15일 | y2026 m8 d15 | 병오년 8월 15일 |
2026년 설날(2월 17일)이 음력 1월 1일, 추석(9월 25일)이 8월 15일로 나와 널리 알려진 날짜와 일치했습니다. 다만 이는 각 런타임의 계산 결과이고, 공식 음력은 천문 기관이 발표하는 표가 기준입니다. 또 jsc 의 짧은 형식은 43. 1. 16. 처럼 60갑자 주기 안의 해 번호(43)를 써서, 그대로 화면에 내면 사용자가 알아볼 수 없습니다. 음력 생일을 매년 양력으로 바꾸는 계산은 4강에서 다룹니다.
흔한 실수 → 실제로 일어난 일 → 고치는 법
| 실수 | 실제 결과 | 고치는 법 |
|---|---|---|
createFromFormat('Y-m-d') 만으로 입력 검증 |
2026-02-30 → 2026-03-02 10:03:07, 경고뿐 |
!, getLastErrors() === false, 왕복 확인 |
생일을 new Date("2026-03-04") 로 표시 |
서울에선 정상, 뉴욕에선 3일 | 날짜는 날짜 타입·문자열로 유지 |
보고서 형식에 YYYY |
2026-12-28 이 2027-12-28 로 | yyyy 또는 uuuu |
화면 문자열 2026. 3. 4. 를 저장 |
jsc·PHP 가 다시 못 읽음 | 저장·전송은 ISO 8601 |
Python 3.9 에서 ...Z 를 fromisoformat |
ValueError |
3.11 이상, 또는 Z → +00:00 |
Swift ISO8601DateFormatter 에 밀리초 |
2026-03-04T06:05:00.123Z -> nil |
.withFractionalSeconds (단, 밀리초 없는 값은 nil 이 되니 둘 다 시도) |
실측: 파서 속도
로그나 CSV 를 대량으로 읽는다면 파서 선택이 시간을 크게 바꿉니다. 난수 시드 42 로 2020~2029년의 무작위 ISO 시각 10만 개를 만들고, 같은 데이터를 5번 파싱해 중앙값을 냈습니다(Apple Silicon Mac M1 Pro).
| 파서 | 3.9.6 | 3.12.14 |
|---|---|---|
datetime.fromisoformat |
5.2 ms (1건 52 ns) | 6.4 ms |
직접 자르기 + datetime(...) |
95.1 ms | 72.8 ms |
strptime(s, "%Y-%m-%dT%H:%M:%S%z") |
656.1 ms (1건 6,561 ns) | 451.2 ms |
3.9 에서 strptime 은 fromisoformat 보다 약 126배 느렸습니다. 100만 건이면 0.05초와 6.6초의 차이입니다. PHP 8.5.6 은 strtotime, new DateTimeImmutable, createFromFormat 모두 1건 399~536 ns, jsc 의 Date.parse 는 167 ns 였습니다.
솔직한 한계와 버전 메모
fromisoformat이Z와20260304를 받는 것은 Python 3.11 부터입니다. 3.9.6(실패)과 3.12.14(성공)로 확인했습니다.getLastErrors()가 아무 일 없으면false를 주는 것은 PHP 8.2 부터,DateMalformedStringException은 8.3 부터입니다. 8.5.6 에서만 확인했습니다.- JavaScript 는 macOS JavaScriptCore 결과입니다. 날짜만 있는 ISO 가 UTC 라는 것은 명세이지만 ISO 가 아닌 줄은 엔진마다 다르고, V8 이나 브라우저는 실행하지 않았습니다.
- 로캘·음력 출력은 런타임마다 들어 있는 ICU/CLDR 버전에 따라 달라집니다(PHP intl 은 ICU 78.3). .NET
KoreanLunisolarCalendar는 이 버전에서 918-02-19 ~ 2051-02-10 범위만 지원했습니다.
연습 문제
1. 협력사가 JSON 으로 "2026-11-01T01:30:00"(오프셋 없음)을 보냅니다. 서버는 TZ=UTC 의 Python 3.9 입니다. 이 값의 문제 두 가지를 들고, 대신 무엇을 보내 달라고 할지 답하세요.
2. 실행하지 말고 답하세요. LocalDate.of(2026, 12, 30).format(DateTimeFormatter.ofPattern("YYYY", Locale.KOREA)) 는 무엇을 출력할까요?
정답
1. (a) 오프셋이 없어 순간이 아니라 벽시계 값입니다. fromisoformat 은 타임존 없는 값을 돌려주고, UTC 라고 가정하는 코드에 들어가면 보낸 쪽이 서울일 때 9시간 어긋납니다. (b) 보낸 쪽이 뉴욕이라면 2026년 11월 1일 01:30 은 서머타임이 끝나며 두 번 있는 시각이라(2강), 타임존을 알아도 하나로 정해지지 않습니다. 2026-11-01T05:30:00Z 처럼 오프셋이나 Z 가 붙은 RFC 3339 를 요구합니다.
2. 2027. ko-KR 에서는 2027년 1월 1일이 든 주가 12월 27일(일요일)에 시작하므로 12월 27~31일의 YYYY 는 모두 2027 입니다. 본문 실행 결과와 같습니다.
다음 글
- 이전: 날짜 강좌 2/6: 타임존, 서머타임, IANA tz 데이터베이스
- 다음: 날짜 강좌 4/6: 월말, 윤년, 기간 계산
- 1강: 유닉스 타임스탬프와 UTC, 뒤의 강좌: 데이터베이스의 날짜, 스케줄링과 시계 오차
- HTTP 날짜와 캐시: ETag 와 Cache-Control
- 예제의 바탕이 되는 기초: Java 타입과 문자열, PHP 타입과 배열, JavaScript 타입과 동등 비교
- 엑셀로 여는 CSV 의 날짜와 인코딩: CSV 인코딩과 BOM
- 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
한국에서는 멀쩡한데 미국 사용자에게만 생일이 하루 전으로 보이는 이유는?
JavaScript 의 new Date("2026-03-04") 는 날짜만 있는 ISO 문자열을 UTC 자정으로 읽습니다. 서울(+09:00)에서는 오전 9시라 getDate() 가 4 지만, TZ=America/New_York 에서는 전날 저녁이 되어 3 을 반환했습니다. 날짜는 날짜 그대로 다루거나, 오프셋이 붙은 시각을 보내야 합니다.
화면에 보이는 '2026. 3. 4.' 를 다시 날짜로 읽을 수 있나요?
도구마다 다릅니다. jsc 의 new Date 와 PHP strtotime 은 실패했고, Python 은 strptime(s, '%Y. %m. %d.') 처럼 형식을 직접 주면 읽었고, .NET 은 ko-KR 문화권으로 TryParse 하면 읽었습니다. 화면용 문자열을 저장하거나 주고받지 말고, 저장과 전송은 ISO 8601 로 합니다.
PHP 에서 2026-02-30 을 오류로 처리하려면?
createFromFormat 은 3월 2일을 반환하고 getLastErrors() 에 경고 The parsed date was invalid 만 남겼습니다. 형식 앞에 ! 를 붙이고, getLastErrors() 가 false 인지, 결과를 다시 format() 한 문자열이 입력과 같은지 확인합니다.