C++20 날짜 파싱과 서식: std::format·chrono::parse·zoned_time 사용법
이 글의 핵심
C++20 <chrono>의 std::format과 std::chrono::parse로 날짜를 출력하고 파싱하는 방법을 %Y·%F·%T 같은 서식 지정자와 함께 정리합니다. 로케일에 따른 요일·월 이름, 형식이 맞지 않을 때의 파싱 실패 처리, UTC인 system_clock과 로컬 시간대(zoned_time), 초 이하 정밀도처럼 실무에서 걸리는 부분도 짚습니다.
날짜 파싱 및 서식 (C++20)
C++17까지는 날짜를 문자열로 바꾸려면 std::time_t를 std::localtime이나 gmtime으로 std::tm에 풀고, 다시 strftime이나 std::put_time에 넘기는 C 시대의 경로를 거쳐야 했습니다. localtime은 내부 정적 버퍼를 돌려주기 때문에 멀티스레드에서 안전하지 않고, std::tm은 월이 0부터, 연도가 1900부터 시작하는 등 실수를 부르는 설계였습니다. C++20의 <chrono> 확장은 year_month_day 같은 달력 타입, std::format용 chrono 서식, 그리고 그 반대 방향인 std::chrono::parse를 표준에 넣어서 이 경로를 한 번에 정리했습니다.
#include <chrono>
#include <format>
using namespace std::chrono;
// 서식
auto str = std::format("{:%Y-%m-%d}", 2026y/March/11);
// 파싱
std::istringstream iss{"2026-03-11"};
year_month_day ymd;
iss >> parse("%Y-%m-%d", ymd);
위 코드의 핵심은 대칭성입니다. 출력할 때 쓰는 %Y-%m-%d와 입력을 해석할 때 쓰는 %Y-%m-%d가 같은 문법이라서, 저장 형식을 한 곳에 상수로 두고 양쪽에서 재사용하기 좋습니다. 다만 파싱 쪽은 <sstream>의 스트림을 거쳐야 하므로 #include <sstream>도 필요합니다(위 예제는 짧게 줄이느라 생략했습니다).
컴파일러 지원은 반드시 먼저 확인해야 합니다. MSVC는 비교적 일찍 parse와 시간대 데이터베이스까지 구현했지만, libstdc++(GCC)는 chrono 서식·시간대·parse를 GCC 13~14 무렵에 걸쳐 채웠고, libc++(Clang)는 오랫동안 parse와 시간대 지원이 불완전했습니다. “C++20으로 컴파일했는데 parse가 없다”는 에러가 나면 코드보다 표준 라이브러리 버전을 먼저 의심하는 편이 빠릅니다. 지원이 부족한 환경이라면 Howard Hinnant의 date 라이브러리가 사실상 같은 API를 제공합니다(표준 chrono 확장이 이 라이브러리를 바탕으로 만들어졌습니다).
날짜 서식
using namespace std::chrono;
year_month_day ymd = 2026y / March / 11;
// std::format (C++20)
auto str1 = std::format("{:%Y-%m-%d}", ymd); // 2026-03-11
auto str2 = std::format("{:%Y년 %m월 %d일}", ymd);
auto str3 = std::format("{:%F}", ymd); // ISO 8601
2026y / March / 11은 연산자 오버로딩으로 만든 달력 리터럴입니다. 2026y는 std::chrono::year, March는 month 상수이고, /가 이들을 year_month_day로 조합합니다. 문자열 "2026-03-11"을 직접 들고 다니는 것과 달리, 이 값은 ymd.ok()로 유효성(예: 2월 30일인지)을 검사할 수 있고 sys_days{ymd}로 변환하면 날짜 산술도 할 수 있습니다.
서식 문자열의 % 지정자 사이에 있는 년, 월, 일 같은 일반 문자는 그대로 출력됩니다. {:%F}는 %Y-%m-%d의 축약이며, 저장·전송용 문자열은 되도록 이 ISO 8601 형식으로 통일하는 것이 좋습니다. 사전순 정렬이 곧 시간순 정렬이 되고, 거의 모든 언어의 파서가 이 형식을 받아들이기 때문입니다.
실전 예시
예시 1: 다양한 서식
using namespace std::chrono;
auto now = system_clock::now();
zoned_time seoul{"Asia/Seoul", now};
// ISO 8601
std::cout << std::format("{:%FT%T%z}", seoul) << std::endl;
// 2026-03-11T15:30:45.123456789+0900 (소수 초 자릿수는 구현의 system_clock 정밀도에 따름)
// 사람 친화적
std::cout << std::format("{:%Y년 %m월 %d일 %H시 %M분}", seoul) << std::endl;
// 2026년 03월 11일 15시 30분
// 요일 포함
std::cout << std::format("{:%A, %B %d, %Y}", seoul) << std::endl;
// Wednesday, March 11, 2026
zoned_time은 “어떤 시점(UTC 기준 sys_time)“과 “어느 시간대에서 볼 것인가”를 묶은 타입입니다. "Asia/Seoul" 같은 IANA 시간대 이름을 주면 표준 라이브러리가 내장(또는 OS) 시간대 데이터베이스에서 오프셋과 서머타임 규칙을 찾아 적용합니다. 이름이 틀리면 std::runtime_error가 던져지므로, 사용자 입력으로 시간대를 받는다면 locate_zone을 try로 감싸야 합니다.
주석에 적어 둔 것처럼 %T와 %S는 duration의 정밀도가 초보다 세밀하면 소수 초까지 출력합니다. system_clock::now()는 libstdc++에서 나노초, MSVC에서 100나노초 단위이므로 결과 자릿수가 플랫폼마다 다릅니다. 로그 포맷을 고정 폭으로 맞추고 싶다면 아래 “문제 4”처럼 먼저 floor로 정밀도를 잘라야 합니다.
예시 2: 파싱
using namespace std::chrono;
std::string dateStr = "2026-03-11";
std::istringstream iss{dateStr};
year_month_day ymd;
iss >> parse("%Y-%m-%d", ymd);
if (!iss.fail() && ymd.ok()) {
std::cout << "파싱 성공: " << ymd << std::endl;
} else {
std::cout << "파싱 실패" << std::endl;
}
파싱 성공 여부는 스트림 상태로 판단해야 합니다. parse는 실패하면 스트림에 failbit를 세우고 대상 변수를 건드리지 않습니다. 기본 생성된 year_month_day는 값이 초기화되지 않은 상태이므로, 실패한 뒤 ymd.ok()만 검사하면 쓰레기 값을 보고 판단하는 셈이 됩니다. 그래서 위 코드는 iss.fail()을 먼저 확인합니다.
year_month_day로 파싱할 때는 “2026-02-30”처럼 형식은 맞지만 존재하지 않는 날짜도 실패로 처리됩니다. 반대로 "2026-03-11abc"처럼 뒤에 쓰레기가 붙은 입력은 앞부분만 읽고 성공할 수 있습니다. 입력 전체가 날짜여야 한다면 파싱 후 iss.peek() == EOF인지 추가로 확인하는 것이 안전합니다.
예시 3: 시간 서식
using namespace std::chrono;
auto now = system_clock::now();
auto dp = floor<days>(now);
auto time = now - dp;
hh_mm_ss hms{time};
std::cout << std::format("{:%H:%M:%S}", hms) << std::endl;
// 15:30:45.123456789 (now의 정밀도 그대로 소수 초가 붙음)
floor<days>(now)는 오늘 자정(UTC)을 구하고, now - dp는 자정 이후 흐른 시간입니다. hh_mm_ss는 이 duration을 시·분·초로 쪼개 주는 도우미 타입입니다. 여기서 주의할 점은 system_clock이 UTC라는 것입니다. 서울에서 실행해도 이 코드는 UTC 시각(한국 시각보다 9시간 이른 값)을 출력합니다. 로컬 시각의 시·분·초가 필요하면 zoned_time{current_zone(), now}.get_local_time()에서 출발해야 합니다.
예시 4: 로그 타임스탬프
using namespace std::chrono;
class Logger {
public:
void log(const std::string& msg) {
auto now = system_clock::now();
zoned_time local{current_zone(), now};
std::cout << std::format("[{:%Y-%m-%d %H:%M:%S}] {}",
local, msg) << std::endl;
}
};
int main() {
Logger logger;
logger.log("애플리케이션 시작");
// [2026-03-11 15:30:45.123456789] 애플리케이션 시작
}
current_zone()은 OS 설정에서 현재 시간대를 읽습니다. 컨테이너 이미지처럼 /etc/localtime이나 TZ가 설정되지 않은 환경에서는 UTC로 잡히는 경우가 많아서, 개발 PC와 서버 로그의 시각이 9시간씩 어긋나 보이는 일이 흔합니다. 제가 여러 서버의 로그를 합쳐 볼 때 가장 먼저 겪은 문제도 바로 이것이었습니다. 그래서 서버 로그라면 로컬 시간대보다 UTC에 %z 오프셋을 붙이거나, 최소한 오프셋을 함께 남기는 방식을 권합니다. 오프셋이 없는 타임스탬프는 나중에 어느 시간대 기준이었는지 복원할 수 없습니다.
또한 current_zone()은 호출할 때마다 시간대 데이터베이스를 조회하므로, 로그가 많다면 생성자에서 한 번 const time_zone*로 받아 멤버로 들고 있는 편이 낫습니다.
서식 지정자
// 날짜
%Y // 년 (4자리)
%m // 월 (01-12)
%d // 일 (01-31)
%F // %Y-%m-%d
// 시간
%H // 시 (00-23)
%M // 분 (00-59)
%S // 초 (00-59, 정밀도가 초보다 세밀하면 소수 초 포함)
%T // %H:%M:%S
// 요일
%A // 요일 전체 (Monday)
%a // 요일 축약 (Mon)
// 월
%B // 월 전체 (January)
%b // 월 축약 (Jan)
// 시간대
%z // +0900
%Z // KST
지정자는 대부분 strftime과 같은 의미라서 기존 지식을 그대로 쓸 수 있습니다. 차이가 나는 부분은 두 가지입니다. 첫째, 앞에서 말한 소수 초 처리입니다. strftime은 std::tm에 초 이하 정보가 없으니 절대 소수점을 찍지 않지만, chrono 서식은 타입의 정밀도를 그대로 반영합니다. 둘째, 시간대 정보가 없는 타입에 %z나 %Z를 쓰면 컴파일 에러가 아니라 런타임에 std::format_error 예외가 납니다. 예를 들어 local_time에는 오프셋 정보가 없으므로 %z를 쓸 수 없습니다. sys_time에 %Z를 쓰면 “UTC”가 출력됩니다.
자주 발생하는 문제
문제 1: 로케일
// 기본 로케일 (영어)
std::cout << std::format("{:%A}", 2026y/March/11) << std::endl;
// Wednesday
// 로케일 변경 (C++20): 'L' 옵션이 있어야 로케일이 적용됨
std::cout << std::format(std::locale("ko_KR.UTF-8"), "{:L%A}", 2026y/March/11);
// 수요일
로케일을 넘겼는데 여전히 영어 요일이 나온다면 서식의 L 옵션이 빠진 경우입니다. chrono 서식은 L이 없으면 locale 인자를 무시하고 항상 “C” 로케일로 출력하도록 정의되어 있습니다. 이 설계 덕분에 로그나 파일명처럼 기계가 읽는 문자열은 사용자 로케일에 흔들리지 않습니다.
로케일 이름도 함정입니다. Linux에서는 보통 "ko_KR.UTF-8"처럼 인코딩까지 붙여야 하고, 해당 로케일이 시스템에 생성되어 있지 않으면 std::locale 생성자가 std::runtime_error를 던집니다. 최소 설치 Docker 이미지에는 한국어 로케일이 없는 경우가 대부분입니다. Windows는 "ko-KR" 같은 다른 이름 체계를 씁니다. 사용자에게 보이는 요일 이름 정도라면 로케일에 의존하기보다 weekday의 인덱스로 직접 “월, 화, 수…” 배열을 조회하는 쪽이 이식성이 좋습니다.
문제 2: 파싱 실패
std::string dateStr = "2026/03/11";
std::istringstream iss{dateStr};
year_month_day ymd;
iss >> parse("%Y-%m-%d", ymd); // 실패 (형식 불일치)
if (iss.fail()) {
std::cout << "파싱 실패" << std::endl;
}
// ✅ 올바른 형식
iss.clear();
iss.str("2026-03-11");
iss >> parse("%Y-%m-%d", ymd);
같은 스트림을 재사용할 때 clear()를 빼먹는 실수가 흔합니다. 한 번 failbit가 선 스트림은 이후 모든 입력 연산이 즉시 실패하므로, str()로 내용을 바꿔도 파싱이 계속 실패합니다. 위 코드처럼 clear()로 상태를 먼저 지워야 합니다. 여러 형식을 허용해야 한다면(예: 2026-03-11과 2026/03/11) 형식 목록을 돌면서 매번 새 istringstream을 만들어 시도하는 쪽이 상태 관리 실수를 줄입니다.
%d/%m/%Y와 %m/%d/%Y처럼 월·일 순서가 다른 형식은 “03/11”이 양쪽 모두에서 성공해 버려서, 파싱 실패로도 잡히지 않습니다. 외부 데이터의 형식이 확정되지 않았다면 파싱보다 먼저 데이터 제공자와 형식을 합의하는 것이 유일한 해결책입니다.
문제 3: 시간대
// system_clock은 UTC
auto now = system_clock::now();
// ✅ 로컬 시간
zoned_time local{current_zone(), now};
std::cout << std::format("{:%F %T}", local) << std::endl;
C++20부터 system_clock의 기준은 Unix epoch(1970-01-01 00:00:00 UTC)로 표준에 명시되었습니다. 즉 now를 그대로 서식하면 UTC 시각이 나옵니다. “서버에서 찍은 시간이 9시간 느리다”는 증상은 거의 항상 이 차이 때문이며, 여기에 수동으로 9시간을 더하는 식의 해결은 서머타임이 있는 시간대로 확장하는 순간 깨집니다. 오프셋 계산은 zoned_time에 맡기는 것이 원칙입니다.
문제 4: 정밀도
auto now = system_clock::now();
// 초 이하 정밀도 포함 (자릿수는 구현마다 다름)
std::cout << std::format("{:%T}", now) << std::endl;
// 15:30:45.123456789 (libstdc++) / 15:30:45.1234567 (MSVC)
// 초까지만
auto seconds = floor<std::chrono::seconds>(now);
std::cout << std::format("{:%T}", seconds) << std::endl;
// 15:30:45
// 밀리초까지만: floor<milliseconds>(now) → 15:30:45.123
자릿수를 제어하는 방법은 서식 문자열이 아니라 타입입니다. floor<milliseconds>로 자르면 소수 셋째 자리까지, floor<seconds>로 자르면 소수점 없이 출력됩니다. floor 대신 round를 쓰면 59.9995초가 다음 초로 올라가면서 분·시·날짜까지 바뀔 수 있으므로, 타임스탬프에는 보통 floor를 씁니다.
활용 패턴
// 1. 로그 타임스탬프
std::format("[{:%F %T}] {}", now, msg);
// 2. 파일명
std::format("backup_{:%Y%m%d_%H%M%S}.db", now);
// 3. 사용자 표시
std::format("{:%Y년 %m월 %d일}", ymd);
// 4. ISO 8601
std::format("{:%FT%T%z}", zoned_time);
2번 파일명 패턴은 그대로 쓰면 %S에 소수 초가 붙어 backup_20260311_153045.123456789.db 같은 이름이 됩니다. 파일명에는 floor<seconds>(now)를 넘기는 것이 맞습니다. 또 파일명에 %T처럼 콜론이 들어가는 지정자를 쓰면 Windows에서 파일 생성이 실패하므로, 파일명에는 구분자 없는 %H%M%S를 쓰는 습관이 좋습니다.
FAQ
Q1: 날짜 서식은?
A: C++20의 std::format에 chrono 서식({:%F} 등)을 씁니다. C++17 이하라면 std::put_time이나 date 라이브러리를 사용합니다.
Q2: 파싱은?
A: std::chrono::parse를 스트림과 함께 쓰고, 성공 여부는 스트림의 fail()로 판단합니다.
Q3: 지정자는?
A: 대부분 strftime과 호환됩니다. 다만 %S/%T가 소수 초를 출력할 수 있다는 점이 다릅니다.
Q4: 로케일?
A: std::format의 첫 인자로 std::locale을 넘기고, 서식에 L 옵션을 붙입니다({:L%A}).
Q5: 시간대?
A: zoned_time과 current_zone()/locate_zone()을 사용합니다.
같이 보면 좋은 글
- C++20 달력과 시간대: year_month_day와 zoned_time 사용법
- C++20 std::barrier와 std::latch: 스레드 동기화 지점 만들기
- C++20 ranges::subrange: 반복자 쌍을 범위로 묶기와 std::span과의 차이
- C++ Branch Prediction
- 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기
- C++ Concepts와 Constraints