본문 바로가기

진리는어디에/C++

[C++20] 날짜와 시간(Date and Time utilities)

들어가며

C++은 가장 유명한 프로그래밍 언어중 하나지만 명성에 걸맞지 않게 유틸리티에 대한 지원이 인색하다. 특히 다른 언어에서 지원하는 문자열과 캘린더 관련 기능들은 항상 C++말고 다른 언어를 이용해 개발하고 싶은 마음이 들게 한다. 하지만 C++11 부터 점점 개선되기 시작하더니 C++20에는 보다 꽤 쓸만한 기능들이 여럿 추가 되었다. 본 포스트에서는 C++20에 추가된 날짜와 시간에 관련된 유틸리티 기능의 집합인 chrono 라이브러리를 살펴 보도록 하겠다.

자주 쓰이는 용어

본격적으로 chrono 라이브러리를 살펴보기에 앞서 자주 사용되지만 우리에게 익숙하지 않은 용어 몇가지를 먼저 살펴 보도록하겠다. 이미 알고 있을 수도 있겠지만 개념 정리한다고 생각하고 가볍게 읽어 보자.

  • 에포크 타임(epoch time)
epoch 란 단어를 번역하자면, 중요한 사건이나 변화가 있었던 시대(era)를 의미하는데 Unix와 POSIX 등의 시스템에서 날짜와 시간의 흐름을 나타낼 때 기준을 삼는 시간, '1970년 1월 1일 0시'를 의미한다.
  • 단조 증가(monotone increasing)
어떤 수열이나 함수가 있을 때, 해당 수열이나 함수가 정의된 구간에서 감소하지 않는 경우를 단조 증가라고 한다. Monotonic clock은 시간 값이 단조 증가하는 시간이다. 예를 들어 시스템의 시간을 동기화 해서 시스템 시간이 1초 뒤로 돌아간다고 하더라도 monotonic clock의 시간은 안정적으로 증가한다.
  • 월 클락(wall clock)
Wall clock 시간은 벽 시계 또는 손목 시계와 같은 시간 측정기에 의해 결정되는 경과 시간을 나타낸다. 일반적으로 사람이 인지하기 쉬운 시 분 초와 같이 표현되며 시스템 시간의 변경에 의해 증가되었던 시간이 다시 감소할 수도 있다.

std::chrono 라이브러리

사전적 의미의 'chrono'는 '때', '시기'라는 뜻으로 일반적으로 시간을 나타낸다. C++에서는 'chorno'라는 단어를 시간과 날짜를 관리하는 라이브러리의 이름으로 채택했다. 앞으로 chrono 관련 기능들을 사용하기 위해서는 chrono 헤더std::chrono 네임스페이스를 이용해야 한다.

#include <chrono>
using namespace chrono;

 chrono 라이브러리는 아래 주요 세 가지 개념으로 구성되어 있다.

  • Durations
  • Time points
  • Clocks

Durations

사전적으로 '기간'을 의미하는 단어인 duration은 chrono 라이브러리에서 기간, 즉, 시간 간격을 나타낸다. 예를 들어 42초는 '1초 단위의 42틱' 동안의 '기간'을 의미한다. 

template<class Rep, class Period = std::ratio<1>>
class duration;

duration은 기간을 표현하기 위해 Rep 타입의 틱 카운트(count)와 틱의 주기(period)로 구성된다. 여기서 틱의 주기란 한 틱에서 다음 틱 까지의 시간을 '초' 단위로 표시한 것이다. 만일 밀리 초를 나타내야 하는 경우 1/1000초로 표시된다.

duration = 틱 카운트 + 틱 주기(초 단위)

C++ chrono 라이브러리에서는 편리한 duration 사용을 위해 아래와 같은 duration 템플릿 클래스의 alias 들을 제공한다.

using nanoseconds  = duration<long long, nano>;
using microseconds = duration<long long, micor>;
using milliseconds = duration<long long, milli>;
using seconds      = duration<long long>;
using minutes      = duration<long long, ratio<60>>;
using hours        = duration<long long, ratio<3600>>;

C++ reference 와 같은 사이트를 참고하다 보면 인자가 duration인 함수들은 복잡한 템플릿 설정이 된 인자를 받겠끔 설명되어 있는데 간단히 위의 alias를 이용해 duration을 선언할 수 있다.

#include <iostream>
#include <chrono>

int main()
{
    std::chrono::duration<int, ratio<3600>>     h1(10); // 10 시간
    std::chrono::hours                          h2(10);
    
    std::chrono::duration<long long, ratio<60>> m1(10); // 10 분
    std::chrono::minutes                        m2(10);
    
    std::chrono::duration<long long>            s1(10); // 10 초
    std::chrono::seconds                        s2(10);
    
    std::chrono::duration<long long, milli>     ms1(10); // 10 밀리세컨드
    std::chrono::milliseconds                   ms2(10);
        
    std::chrono::duration<long long, nano>      ns1(10); // 10 나노세컨드
    std::chrono::nanoseconds                    ns2(10);
}

Time points

time point란 말 그대로 특정 시점 - 예를 들어 '2022년 10월 8일' 또는 누군가의 생일과 같은 특정 시간 - 을 표현한다. chrono 라이브러리에서는 epoch(1970년 1월 1일 0시 0분 0초)를 기준으로 특정 기간(duration)이 지난 시간을 시점(time point)이라고 정의하고 있으며 해당 내용을 구현한 time_point 템플릿 클래스를 제공한다. time_point의 멤버들에 대해서는 [여기]를 참고 하도록 한다.

time_point = 기준 시간
#include <chrono>
#include <iostream>

int main()
{
    std::chrono::time_point tp = std::chrono::system_clock::now(); // 현재 시간 리턴
    
    // epoch 시간 이후 현재 까지 시간 단위 경과 시간
    std::chrono::hours h = std::chrono::duration_cast<std::chrono::hours>(tp.time_since_epoch()); 
    std::cout << h.count() << std::endl;

    // epoch 시간 이후 현재 까지 분 단위 경과 시간
    std::chrono::minutes m = std::chrono::duration_cast<std::chrono::minutes>(tp.time_since_epoch()); 
    std::cout << m.count() << std::endl;
}

Clocks

클록(clock)은 time point와 실제 물리 시간을 연결해주는 프레임워크로써 '시작 시점(예. epoch)'과 '틱 레이트(tick rate)'로 구성된다. 예를 들어 epoch 시간으로 잘 알려진 '1970년 1월 1일 부터 매 초마다 1틱'과 같이 말이다.

C++은 현재 시간을 time point로 표현할 수 있는 아래와 클록 타입을 제공한다. 우리는 아래 클록들을 이용해 우리가 원하는 시점, 즉, time_point 를 얻을 수 있다. 아래 클록들 외에도 utc_clock, tai_clock 와 같은 여러 클록 타입을 제공하고 있으니 보다 자세한 사항은 [여기]를 참고 하자.

클록 타입 설명 버전
system_clock 시스템 시간 기반 wall clock 타임. C++11
steady_clock 수정 되지 않는 단조 증가 시계(시스템 시간을 되돌려도 계속 증가 한다) C++11
high_resolution_clock 최대한 짧은 틱 주기를 가진 시계 C++11

이상 chrono 라이브러리를 사용하기 위한 간단한 개념을 살펴 보았다. 다음 섹션 부터는 예제 코드들을 통해 실제 chrono 라이브러리를 사용하는 방법과 아직 소개되지 않은 함수들에 대해 살펴 보도록하겠다.

문자열로 부터 time_point 구하기

이제 부터 chrono 라이브러리를 이용해 우리가 필요한 날짜 값을 얻어 오는 방법들에 대해 알아 보자. 가장 먼저 날짜를 표현하고 있는 문자열로 부터 time_point을 만드는 방법에 대해 살펴 보겠다. 문자열로 부터 time_point를 만들기 위해서는 from_stream 함수를 이용한다.

#include <chrono>
using namespace std::chrono;

std::istringstream ss { "2022-06-19 23:24:39" };

std::chrono::system_clock::time_point tp;
std::chrono::from_stream(ss, "%F %T", tp);
std::cout << tp << std::endl;

ss.seekg(0); // 스트림의 리드 포인트를 다시 앞으로 옮긴다

std::chrono::time_point<system_clock, seconds> stp;
std::chrono::from_stream(ss, "%F %T", stp);
std::cout << stp << std::endl;

위 예제를 보면 system_clock::time_point와 chrono::time_point 템플릿 클래스를 사용한 것을 볼 수 있다. system_clock::time_point는 chrono::time_point<chrono::system_clock, chrono::milliseconds> 템플릿 클래스를 system_clock 클래스 내부에 typedef 로 미리 정의한 것으로써 결국 chrono::time_point 라고 생각하면 된다.

template<class Clock, class Duration = typename Clock::duration>
class time_point;

첫 번째 tp의 출력은 밀리세컨드 까지 출력되는 반면 두 번째 duration을 seconds로 정의한 stp의 출력은 초 단위 까지만 출력 되는 것을 볼 수 있다. 이렇게 Duration를 변경함으로써 time_point로 표현하는 시간의 정확도를 지정할 수 있다.

현재 시간 구하기

UTC(Universal Time Coordinated) 구하기

시스템 기반 현재 시간을 구하기 위해선 UTC 기반인 system_clock을 이용한다. system_clock은 wall clock 타입으로써 일반적으로 사람이 인지하기 쉬운 시 분 초로 구성된 시간이라고 이해하면 쉽다. 이는 시스템 기반 시간이며 시스템 시간(PC의 시계)이 조정됨에 따라 과거의 시간으로 돌아갈 수도 있다는 점을 기억하자. system_clock은 사용한다.

#include <chrono>
using namespace std::chrono;

// sytem_clock 이용해 현재 UTC 시간 얻기
const system_clock::time_point now = system_clock::now();
std::cout << "UTC now : " << now << std::endl; // 2022-06-18 15:45:36.0449508

Local Time 구하기

만일 여러분이 로컬 타임 기반 현재 시간을 구하고자 한다면 system_clock 기반의 time_point를 변환한 zoned_time을 이용하도록 한다. 아래 예제는 위 UTC 기반 현재 시간을 구하는 예제와 system_clock::time_point를 사용하는 대신 local_time을 사용하는것 외에는 동일하다.

#include <chrono>
using namespace std::chrono;

const local_time<system_clock::duration> now = zoned_time{ current_zone(), system_clock::now() }.get_local_time();
std::cout << "Local now : " << now << std::endl; // // 2022-06-19 00:45:36.0449508

만일 여러분이 한국 시간 기반 로컬 타임을 사용하고 있다면 앞의 UTC 기반 현재 시간과 9시간 차이의 시간을 출력하는 것을 확인할 수 있을 것이다.

epoch로 부터 경과 틱 구하기

앞에서 time_point을 이용하여 현재 시간을 구하는 방법에 알아 보았다. 그렇다면 기존 C++에서 사용하던 time함수와 처럼epoch 시간으로 부터 경과 초를 구하기 위해서는 어떻게 해야할까?

chrono에서는 time_point를 time_t로 변경하는 to_time_t 함수를 제공한다.

using namespace std::chrono;

std::cout << "original time_t : " << time(nullptr) << std::endl;

const system_clock::time_point utc_now = system_clock::now();

std::time_t t = std::chrono::system_clock::to_time_t(utc_now);
std::cout << "chrono time_t : " << t << std::endl;

출력되는 origianal time_t와 chrono time_t 의 값이 같은것을 확인 할 수 있다.

위와는 반대로 time_t로 부터 time_point를 구할수 도 있다.

system_clock::time_point tp = std::chrono::system_clock::from_time_t(t);
std::cout << "reverse : " << tp << std::endl;

우리가 앞에서 살펴본 local_time을 이용해서는 to_time_t 함수를 사용할 수 없다. 이 경우에는 아래와 같이 time_since_epoch 함수를 이용하여 구할 수 있다. 

using namespace std::chrono;

const system_clock::time_point utc_now = system_clock::now();
std::cout << "UTC : " << floor<seconds>(utc_now.time_since_epoch()).count() << std::endl;

const local_time<system_clock::duration> local_now = zoned_time{ current_zone(), system_clock::now() }.get_local_time();
std::cout << "Local : " << floor<seconds>(utc_now.time_since_epoch()).count() << std::endl;

초단위 까지만 정확도를 가지기 위해 floor<seconds>를 이용하여 초단위 아래를 절삭한것에 주목하자. 그리고 floor로 부터 리턴된는 duration 객체의 count함수를 이용해 틱(여기서는 초)를 구하고 있다. floor함수의 자세한 사항은 [여기]를 참고한다.

날짜와 시간 구하기

필자의 프로그래밍 경험상 '2022-06-21 12:30:24'와 같은 날짜 포멧의 문자열을 프로그래밍에서 바로 이용할 수 있는 경우는 그리 많지 않았다. 대신 문자열을 파싱하여 년, 월, 일 값으로 각각 변환후 더하기를 하든 빼기를 하든 지지지고 볶는 일이 대부분 이었다. 이번 섹션에서는 time_point로 부터 년, 월, 일과 같이 Calendar 값들을 얻어오는 방법에 대해 살펴 보도록 한다.

{
    using namespace std::chrono;
    
    // UTC 기반 현재 시간 구하기
    const system_clock::time_point now = system_clock::now();
    // year_month_day를 위해 day까지 절삭
    const time_point<std::chrono::system_clock, std::chrono::days> dp = floor<std::chrono::days>(now);
    
    // 날짜를 얻기 위한 year_month_day
    std::chrono::year_month_day ymd{ dp };
    // 시간을 얻기 위한 hh_mm_ss
    std::chrono::hh_mm_ss time{ floor< std::chrono::milliseconds>(now - dp) };
    
    std::cout << "UTC now : " << now << std::endl;
    std::cout << "UTC year : " << ymd.year() << std::endl;
    std::cout << "UTC month : " << (unsigned int)ymd.month() << std::endl;
    std::cout << "UTC day : " << ymd.day() << std::endl;
    std::cout << "UTC hours : " << time.hours().count() << std::endl;
    std::cout << "UTC minutes : " << time.minutes().count() << std::endl;
    std::cout << "UTC seconds : " << time.seconds().count() << std::endl;
    std::cout << "UTC milliseconds : " << time.subseconds().count() << std::endl;
}

{
    using namespace std::chrono;

    // Local Time 기반 현재 시간 구하기
    const local_time<system_clock::duration> now = zoned_time{ current_zone(), system_clock::now() }.get_local_time();
    // year_month_day를 위해 day까지 절삭
    const time_point<std::chrono::local_t, std::chrono::days> dp = floor<std::chrono::days>(now);
    
    // 날짜를 얻기 위한 year_month_day
    std::chrono::year_month_day ymd{ dp };
    // 시간을 얻기 위한 hh_mm_ss
    std::chrono::hh_mm_ss time{ std::chrono::floor< std::chrono::milliseconds>(now - dp) };
    
    std::cout << "Local now : " << now << std::endl;
    std::cout << "Local year : " << ymd.year() << std::endl;
    std::cout << "Local month : " << (unsigned int)ymd.month() << std::endl;
    std::cout << "Local day : " << ymd.day() << std::endl;
    std::cout << "Local hours : " << time.hours().count() << std::endl;
    std::cout << "Local minutes : " << time.minutes().count() << std::endl;
    std::cout << "Local seconds : " << time.seconds().count() << std::endl;
    std::cout << "Local milliseconds : " << time.subseconds().count() << std::endl;
}

UTC 기반과 로컬 타임 기반 두 가지 예제를 준비했다.

위에서 첫번째로 주목해서 보아야 할 부분은 UTC 타임이든 로컬 타임이든 둘 다 time_point를 사용한다는 것이다. 다만 차이는 UTC는 system_clock 기반이고 로컬 타임은 local_t 기반의 time_point를 사용한다는 것이다.

두 번째로 주목할 부분은 6라인과 28라인에서 현재 시간을 가리키는 time_point을 day 단위로 만들고 있다는 것이다. 날짜 정보를 표현할 수 있는 year_month_day 클래스의 생성자의 인자로 day단위 까지 끊어진 time_point가 필요하기 때문이다.

마지막으로 시간을 표현하기 위해 hh_mm_ss를 사용했다. 그 외 각 값을들을 추출하여 출력하는 방법은 위의 예제를 보면 충분히 이해할 수 있을것이라 생각한다. 키포인트는 로컬 시간을 저장하는 time_point를 위해 chrono::local_t 를 사용했고, 날짜를 위해 year_month_day, 시간을 위해 hh_mm_ss를 사용했다는 것이다.

특정 월의 마지막 날짜 얻기

year_month_day_last 클래스는 특정 연도 및 월의 마지막 날을 나타낸다. std::chrono::days의 해상도 가졌으며, 한 달의 마지막 날만 표현할 수 있는 제한이 있다. 날짜를 얻기 위해선 복잡한 계산 대신 C++20 부터 추가된 클래스를 이용해 쉽게 구할 수 있다. 보다 자세한 사항은 [여기]를 참고 한다.

using namespace std::chrono;

const system_clock::time_point now = system_clock::now();
time_point<std::chrono::system_clock, std::chrono::days> dp = floor<std::chrono::days>(now);
std::chrono::year_month_day ymd{ dp };

std::chrono::year_month_day_last last_day_of_month(ymd.year(), month_day_last(ymd.month()));

std::cout << last_day_of_month.year() << std::endl;
std::cout << last_day_of_month.month() << std::endl;
std::cout << last_day_of_month.day() << std::endl;

특정 일의 요일 얻기

year_month_weekday 클래스는 특정 연도 및 월의 n번째 요일을 나타내며 year_month_day_last 처럼 std::chrono::days의 해상도를 가지고 있다.

using namespace std::chrono;

const system_clock::time_point now = system_clock::now();
time_point<std::chrono::system_clock, std::chrono::days> dp = floor<std::chrono::days>(now);
std::chrono::year_month_weekday ymw{ dp };

std::cout << ymw.year() << std::endl; // 연도
std::cout << ymw.month() << std::endl; // 월
std::cout << ymw.weekday().c_encoding() << std::endl; // 일요일을 0으로 표현하는 c 스타일 요일 표현
std::cout << ymw.weekday().iso_encoding() << std::endl; // 일요일을 7로 표현하는 iso 스타일 요일 표현
std::cout << ymw.index() << std::endl; // 몇번째 주 요일인지 표현. 
                                       // 예) 오늘이 월요일이고 3이 리턴 된다면 3번째 월요일을 의미한다.
std::cout << ymw.weekday_indexed() << std::endl; // Mon[3] 형태로 출력

//            c  iso
// Sunday     0   7
// Monday     1   1
// Tuesday    2   2
// Wednesday  3   3
// Thursday   4   4
// Friday     5   5
// Saturday   6   6
// Sunday     0   7

날짜와 시간 증가 및 감소

날짜와 시간의 증가 및 감소 연산은 time_point에 duration을 더하거나 뺌으로써 가능하다. chrono 라이브러리는 minutes, hours 같은 미리 정의된 duration 타입을 제공하고 있다. 미리 제공되는 duration 타입들에 대한 추가 사항은 [여기]를 방문하거나 아래 예제를 통해 살펴 보자.

using namespace std::chrono;

std::istringstream ss{ "1970-01-31 23:59:59" };
std::chrono::system_clock::time_point tp;
std::chrono::from_stream(ss, "%F %T", tp);

std::cout << tp << std::endl;                               // 1970-01-31 23:59:59.0000000
std::cout << tp + milliseconds(5) << " +5 ms" << std::endl; // 1970-01-31 23:59:59.0050000 +5 ms
std::cout << tp + seconds(1) << " +1 sec" << std::endl;     // 1970-02-01 00:00:00.0000000 +1 sec
std::cout << tp + minutes(1) << " +1 min" << std::endl;     // 1970-02-01 00:00:59.0000000 +1 min
std::cout << tp + hours(1) << " +1 hour" << std::endl;      // 1970-02-01 00:59:59.0000000 +1 hour
std::cout << tp + days(1) << " +1 day" << std::endl;        // 1970-02-01 23:59:59.0000000 +1 day
std::cout << tp + weeks(1) << " +1 week" << std::endl;      // 1970-02-07 23:59:59.0000000 +1 week

위 예제에서 날짜에 대한 증감 연산은 주단위 까지 밖에 사용하지 않았다. 실제 chrono 라이브러리에서는 month와 year에 대해 미리 정의된 타입이 있지만 이를 날짜의 증감 연산에 사용한다면 우리가 기대하는 결과를 출려해주진 않을 것이다.

달을 증가 시킨다는 것든 단순히 1월을 2월로 증가 시킨다고 끝나는 간단한 문제가 아니다. 예를 들어 1월 31일을 한 달 증가시킨다고 하면 2월 31일이 되어야 하는 것인가 아니면 1월의 마지막 날이었으니 2월의 마지막 날짜 2월 28일이 되어야 하는것인가 그것도 아니면 30일 또는 31일을 증가 시켜 3월이 되었어야 하는가와 같은 복잡한 문제가 따른다.

그래서 chrono 에 미리 정의된 months와 years는 적당한 평균값을 duration 클래스의 period 템플릿 인자로 넘기고 있다. 

위와 같은 이유로 tp + months(1) 과 같은 연산을 하면 우리가 원하는(?) 결과 값이 나오지 않는다. 달과 년도를 증가 시킬 때는 days나 weeks 처럼 명확한 기간을 가지고 있는 단위까지만 이용하도록 하자.

두 시점의 경과 시간 구하기

프로그램을 만들다 보면 종종 두 시간 사이의 간격을 구해야 할 경우가 있다. 예를 들어 코드의 실행 시간을 측정하기 위해 시작 부분에서 시작 시간을 얻어 저장하고 종료 시점에 시간을 구해 두 시점 사이의 경과 시간을 알아낸다.

이럴때 유용하게 사용할 수 있는 것이 앞에서 언급된 steady_clock이다. steady_clock은 단조 증가 클락으로써 시스템 타임이 과거로 돌아간다고 하더라도(PC의 시간을 과거로 변경한다는 이야기) 그것과 상관 없이 변함 없이 증가한다.

아래 예제는 steady_clock을 이용해 두 시점간의 차이를 구해 출력하고 있다.

#include <iostream>
#include <thread>
#include <chrono>

using namespace std::chrono_literals;
const std::chrono::time_point<std::chrono::steady_clock> start = std::chrono::steady_clock::now();

std::this_thread::sleep_for(std::chrono::seconds(5));

const std::chrono::time_point<std::chrono::steady_clock> end = std::chrono::steady_clock::now();

std::cout << std::chrono::duration_cast<std::chrono::seconds>(end - start).count() << std::endl;
std::cout << std::chrono::duration<double>(end - start).count() << std::endl;

마치며

이상 C++11 부터 현재 20 버전까지 추가되어 온 chrono 라이브러리의 사용법을 살펴 보았다.

부록 1. 같이 읽으면 좋은 글

 

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!