[ 글의 목적: python datetime 구조, 다양한 시간대를 다루는 방법과 활용법을 제대로 알고 통일된 시간대 적용과 국제화 서비스 관점에서 바라보기 ]
프로젝트에서 "시간대"를 다루는 것은 굉장히 중요하다. 특히 batch 관점에서는 cron 성격의 "실행시간" 이 달라지는 것은 엄청나게 치명적일 수 있다. 그리고 timezone에 따라 다르게 보여주는 시간값, 절대 시간값 등은 data의 created, updated 시간에도 아주 지대한 영향을 준다. 이제 python 시간대를 매번 다르게 사용하지 말고, 정확하게 알고 사용하자!
🔥 python datetime module 공식 문서 기준으로 작성했습니다! 해당 페이지에서 original code를 많이 참조했습니다.
날짜와 시간은 생각보다 많은 세월동안 아주 다양한 형태로 수정되고, 표현되어 왔다. 당연히 해당 주제 자체가 빛으로 시작해 지구 과학까지 이어진다. digital time을 기억해야하는 컴퓨터는 기본적으로 날짜와 시간을 표현하는 방법이 정해져 있지 않았고 OS마다 다른 시간 표현 방법이 안착되었다.
시간 표현의 시작은, 윈도우는 1601-01-01 00:00:00 부터 현재 시간까지 몇 100ns 떨어져 있는지를 8바이트로 나타내기 시작했고, 유닉스와 리눅스는 1970-01-01 00:00:00부터 현재 시간까지의 초를 누적한 시간을 사용했다. 이렇게 표현되는 시간을 "timestamp" 라고 한다.
자세한 내용은 한빛미디어의 타임스탬프, 그리고 파이썬으로 날짜와 시간 다루기 칼럼을 추천한다. 그리고 OS별 시스템 시간 에서 OS별로 시간을 어떻게 표현하는지 살펴볼 수 있다.
UTC(Universal Time Coordinated)는 "협정 세계시" 정도로 해석이 된다. 1972년 1월 1일부터 시행된 국제 표준시이며, 1970년 1월 1일 자정을 0 밀리초로 설정하여 기준을 삼아 그 후로 시간의 흐름을 밀리초로 계산한다. UTC는 국제원자시와 윤초 보정을 기반으로 표준화되었다. (출처: wiki)
UTC는 그리니치 평균시(GMT)에 기반하므로 GMT로도 불리기도 하는데, UTC와 GMT는 초의 소숫점 단위에서만 차이가 나기 때문에 일상에서는 혼용된다. 기술적인 표기에서는 UTC가 사용된다. (출처: wiki)
UTC를 기준으로 시간이 빠르면 +시차, 시간이 느리면 -시차로 표시한다. (클릭) wiki에서 나라별 UTC기준 시간차를 확인해보자!
위에서 언급했듯이 유닉스 타임스탬프(Unix Timestamp)는 1970년 1월 1일 00:00:00 UTC부터 경과한 시간을 초 단위로 나타낸 정수 값이다. 이를 통해 전 세계 컴퓨터 시스템에서 일관된 시간을 표기할 수 있다.
대표적으로 DBMS에서 timestamp로 저장된 값은 UTC 기준이라 전세계 어디서든 timezone 기반으로 그에 맞는 시간대로 표현해줄 수 있다. 여담으로 일반적인 날짜와 시간 표현보다 작은 공간을 차지한다. 그래서 저장 공간을 절약할 수 있다.
컴퓨터 시스템 간 시간을 표현하고 처리하는 표준이 필요했고 개발자들은 1970년 1월 1일을 새로운 시간 표현의 시작점으로 선택해서 유닉스 타임스탬프의 기준이 1970년 1월 1일 00:00:00 UTC로 정해졌다고 알려져있다.
RTC는 메인보드에 붙어있어 전원을 끄더라도 계속 작동한다. 그러므로 컴퓨터 전원을 끄더라도 시간은 계속 흘러간다. RTC는 카운터 회로를 통해 클럭을 발생시킨다. 특정 시각(Epoch)을 기준으로 시스템 클럭의 틱을 세는 것으로 구현하는데 이를 시스템 시간이라 부른다.
python을 예시로 들자면, datetime.now()
는 (사용 가능하다면) C의 gettimeofday(...)
를 사용하며 gettimeofday는 커널로 부터 system time을 가져온다. (shell로 예시를 들자면, date
로 입력한 값) 즉, local time은 RTC로 계산된 시간이며, 해당 코드는 OS를 통해 해당 값을 가져오게 되는 것이다.
결국 UTC를 사용하지 않고, server의 timezone을 그대로 따라가며 날짜와 시간을 가져온다면, 코드가 어떤 OS에서 실행되냐에 따라 또 값이 달라진다. 그래서 우리는 이 시간을 "확실한 기준을 가지고 다룰" 필요가 있다.
물론 "네트워크 타임 프로토콜(NTP)"에 대한 얘기가 나올 수 있으나, 여기서는 하지 않겠다. 네트워크 시간 프로토콜 글을 추천한다.
datetime module은 날짜와 시간을 조작하는 아주 다양한 class를 내포하고 있다. 그리고 모듈과 이름이 같은 datetime class는 개인적으로 열받는다
대표적으로 date
, time
, datetime
, timedelta
, timezone
등이 있다.
나이브는 날짜와 시간 정보만 가지고 있다. 자신과 다른 날짜/시간 객체의 상대적인 위치를 파악할 수 있는 충분한 정보를 포함하지 않는다. 나이브 객체가 UTC(Coordinated Universal Time), 지역 시간 또는 다른 시간대의 시간 중 어느 것을 나타내는지는 "프로그램에 달려있다".
예를 들어 datetime.now()
는 컴퓨터 시간 그 자체 값만 가져오며, timezone에 대한 정보가 없어 어떤 시간인지 파악할 수 없다.
그에 반해 어웨어는 "자의적으로 해석할 여지 없는 특정 시간"을 나타낸다. 컴퓨터 시간으로 부터 날짜, 시간 정보 뿐 아니라 timezone에 대한 정보도 같이 있어서 "정확하게 어떤 시간" 인지 파악할 수 있다.
그에 따라서 다른 어웨어 객체와의 상대적인 위치를 파악할 수 있다. 즉, timezone info도 있기 때문에 KR timezone과 KR timezone 끼리 연산 뿐 아니라 다른 timezone의 경우는 utc 기준으로 연산을 하여 다시 적합한 timezone으로 표현이 가능하다.
어웨어 객체가 필요한 응용 프로그램을 위해, datetime 과 time 객체에는 추상 tzinfo 클래스의 서브 클래스 인스턴스로 설정할 수 있는 선택적 시간대 정보 어트리뷰트인 tzinfo 가 있다. 이러한 tzinfo 객체는 UTC 시간으로부터의 오프셋, 시간대 이름 및 일광 절약 시간이 적용되는지에 대한 정보를 보관한다.
>>> test = datetime.date(2023, 3, 3)
>>> test
datetime.date(2023, 3, 3)
>>> type(test)
<class 'datetime.date'>
기본적으로 year
, month
, day
attribute 모두 접근 가능하며 모두 int 이다.
그리고 date class instance는 "min", "max"라는 attribute가 있다. 그 외 datetime module이 가지는 상수값도 있다.
>>> test.min
datetime.date(1, 1, 1)
>>> test.max
datetime.date(9999, 12, 31)
>>> datetime.MINYEAR
1
>>> datetime.MAXYEAR
9999
weekday method로 datetime instance의 "요일"을 가져올 수 있다.
월요일 0 / 화요일 1 / 수요일 2 / ... / 일요일 6, isoweekday 의 경우 월요일은 1이고 일요일은 7. iso format은 국제 표준이다. ISO 8601
>>> test.weekday()
4 # 금요일이 맞다!
>>> test.isoweekday()
5 # 금요일이 맞다!
YYYY-MM-DD
. >>> test.isoformat()
'2023-03-03'
>>> result_replace = test.replace(year=2022, month=11)
>>> test
datetime.date(2023, 3, 3)
>>> result_replace
datetime.date(2022, 11, 3)
year
, month
, day
값을 필수로 입력받는다. 시, 분, 초 time부분은 다 0이 default 값 세팅으로 된다. >>> test = datetime.datetime(2023, 3, 3)
>>> type(test)
<class 'datetime.datetime'>
>>> test
datetime.datetime(2023, 3, 3, 0, 0)
import time as _time
...(생략)
@classmethod
def now(cls, tz=None):
"Construct a datetime from time.time() and optional time zone info."
t = _time.time()
return cls.fromtimestamp(t, tz)
여기가 시스템 시간을 가져오는 부분이다. (today도 동일하다)
time module의 time()
호출 -> cpython을 통해 C의 libc
library의 <time.h>
-> time_t
구조체 (여기서 너무 상세하게 접근하면 C자체에 대한 얘기가 되어버린다,, 가능하다면 c의 gettimeofday를 사용도 한다.) 를 통한 unix timestamp 값 을 가져온다.
date.today
역시 동일하다. 결국 위와 같은 과정을 거치면서 date부분만 가져와 return date(...)가 되는 것이다. 그리고 today method도 datetime class instance 역시 사용 가능하다. (class method)
fromtimestamp
는 _fromtimestamp
를 호출하고 utc unix timestamp 값으로 가져온 값을 적절한 형태들로 casting 한 뒤 return한다.
def timestamp(self):
"Return POSIX timestamp as float"
if self._tzinfo is None:
s = self._mktime()
return s + self.microsecond / 1e6
else:
return (self - _EPOCH).total_seconds()
>>> test
datetime.datetime(2023, 3, 3, 0, 0)
>>> type(test)
<class 'datetime.datetime'>
>>> test.timestamp
<built-in method timestamp of datetime.datetime object at 0x7fdcc09349c0>
>>> test.timestamp()
1677769200.0
>>> type(test.timestamp())
<class 'float'>
>>> date = datetime.date(year=2022, month=10, day=4)
>>> time = datetime.time(hour=5, minute=3, second=55)
>>> datetime2 = datetime.datetime.combine(date, time)
>>> datetime2
datetime.datetime(2022, 10, 4, 5, 3, 55)
>>> type(datetime2)
<class 'datetime.datetime'>
>>> datetime.datetime(2019, 5, 18, 15, 17, 12, 23435).isoformat()
'2019-05-18T15:17:12.023435'
>>> datetime.datetime(2019, 5, 18, 15, 17).isoformat()
'2019-05-18T15:17:00'
>>> datetime.datetime(2019, 5, 18, 15, 17, tzinfo=datetime.timezone.utc).isoformat()
'2019-05-18T15:17:00+00:00'
>>> datetime.datetime(2019, 5, 18, 15, 17, 12, 23435, tzinfo=datetime.timezone.utc).isoformat()
'2019-05-18T15:17:12.023435+00:00'
역시 timezone 여부에 따라 formatting이 조금 상이해진다. UTC timezone으로 세팅하면 조금 포멧팅이 달라진다.
추가로 date를 상속받으니 위에서 살펴보면 date method들은 다 사용이 가능하다.
예를 들어서 특정 날짜를 기준으로 20일 후, 3시간 전의 날짜 정보를 얻고 싶다고 가정해보자. 그럴때 timedelta
class instance를 활용하면 된다.
def __new__(cls, days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0)
가 timedelta class의 생성자다.
>>> test + datetime.timedelta(days=20)
datetime.datetime(2023, 3, 23, 0, 0)
>>> test - datetime.timedelta(days=20)
datetime.datetime(2023, 2, 11, 0, 0)
>>> test + datetime.timedelta(days=20, hours=3)
datetime.datetime(2023, 3, 23, 3, 0)
>>> test + datetime.timedelta(days=20, hours=3, weeks=1)
datetime.datetime(2023, 3, 30, 3, 0)
timezone은 tzinfo의 sub class이며, 생성자로 "offset" 값을 받는데, 해당 값의 instance는 timedelta class instance 여야 한다.
즉 이 부분을 활용해 우리는 자체 timezone을 세팅할 수 있다. 위 isoformat에 예시를 보자면 아래와 같다.
>>> datetime.datetime(2019, 5, 18, 15, 17, 12, 23435, tzinfo=datetime.timezone(datetime.timedelta(hours=9))).isoformat()
'2019-05-18T15:17:12.023435+09:00'
datetime.timezone(datetime.timedelta(hours=9))
를 통해 시차가 +9가 되는 timedelta를 만들어서 timezone 세팅을 하면 위와 같은 표현식이 된다. 즉 한국/도쿄 시간대와 같은 timezone임을 명시할 수 있다.__eq__
, __ge__
등의 연산자 dunder method가 다 정의가 되어 있기 때문에 기본적인 덧셈 뺄셈 동등 연산이 가능하다.>>> test_a = datetime.datetime(2023, 2, 10)
>>> test_b = datetime.datetime(2023, 3, 3, 12)
>>> test_a
datetime.datetime(2023, 2, 10, 0, 0)
>>> test_b
datetime.datetime(2023, 3, 3, 12, 0)
>>> test_a - test_b
datetime.timedelta(days=-22, seconds=43200)
datetime
module과 같이 쓰기 좋은 모듈인 pytz
를 사용하는 것이다. (설치를 해야한다)import datetime
import pytz
# 한국 시간대를 나타내는 `Asia/Seoul` 시간대 객체 생성
korea_tz = pytz.timezone("Asia/Seoul")
# 현재 날짜와 시간을 한국 시간대로 가져오기
now = datetime.datetime.now(korea_tz)
# 시간대 정보가 포함된 현재 날짜와 시간 출력
now
# datetime.datetime(2023, 6, 18, 1, 5, 41, 194961, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)
datetime class의 now
class method는 tz(timezone)을 받는다. 그리고 tz는 class tzinfo
의 instance여야 한다. 해당 class의 time값을 조절하게 되는 핵심 값이 "utcoffset" 값이다.
이런 부분을 활용해서 나만의 시간대를 만들 수 있다,, UTC기준 +9h35m 인 아찔한 나만의 시간대가 만들어진다.
>>> my_time_zone = datetime.timezone(datetime.timedelta(hours=9, minutes=35))
>>> datetime.datetime.now(my_time_zone)
datetime.datetime(2023, 6, 18, 1, 45, 21, 290185, tzinfo=datetime.timezone(datetime.timedelta(seconds=34500)))
>>> test = datetime.datetime(2023, 3, 3, tzinfo=my_time_zone)
>>> test
datetime.datetime(2023, 3, 3, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=34500)))
>>> print(test)
2023-03-03 00:00:00+09:35
datetime.now
는 "나이브" 하다. 하지만 kr timezone을 가지고 생성한 datetime.now(korea_tz)
은 "어웨어" 하다. 그 두개는 다를까? (초까지는 날리고 비교해보자)korea_tz = pytz.timezone("Asia/Seoul")
now_naive = datetime.datetime.now()
now_aware = datetime.datetime.now(korea_tz)
now_naive = now_naive.replace(microsecond=0)
now_aware = now_aware.replace(microsecond=0)
# runtime 에서 비교해보기
>>> now_naive
datetime.datetime(2023, 6, 18, 2, 20, 48)
>>> now_aware
datetime.datetime(2023, 6, 18, 2, 20, 48, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)
>>> now_naive == now_aware
False
pytz.all_timezones
를 통해 기본 제공하는 timezone을 다 가져올 수 있다. print를 찍으면 굉장히 많으니,, 조심,, 대표적으로 아래 10가지가 있다.Format | Explanation | Example |
---|---|---|
%y | Year without century as zero-padded decimal | 00, 01, ..., 99 |
%Y | Year with century as decimal number | 0001, 0002, ..., 2013, 2014, ..., 9998, 9999 |
%m | Month as zero-padded decimal | 01, 02, ..., 12 |
%d | Day of the month as zero-padded decimal | 01, 02, ..., 31 |
%H | Hour (24-hour clock) as zero-padded decimal | 00, 01, ..., 23 |
%M | Minute as zero-padded decimal | 00, 01, ..., 59 |
%S | Second as zero-padded decimal | 00, 01, ..., 59 |
>>> test = datetime.datetime(2023, 3, 3)
>>> test.strftime("%d/%m/%y")
'03/03/23'
>>> test.strftime("%A %d. %B %Y")
'Friday 03. March 2023'
>>> test.strftime("%Y, %m, %d, %H, %M, %S")
'2023, 03, 03, 00, 00, 00'
from datetime import datetime
date_string = "2023-06-17"
date_format = "%Y-%m-%d"
parsed_date = datetime.strptime(date_string, date_format)
# datetime.datetime(2023, 6, 17, 0, 0)
time_string = "10:30:45"
time_format = "%H:%M:%S"
parsed_time = datetime.strptime(time_string, time_format)
# datetime.datetime(1900, 1, 1, 10, 30, 45)
datetime_string = "2023-06-17 10:30:45"
datetime_format = "%Y-%m-%d %H:%M:%S"
parsed_datetime = datetime.strptime(datetime_string, datetime_format)
# datetime.datetime(2023, 6, 17, 10, 30, 45)
timezone_string = "2023-06-17 10:30:45 +0800"
timezone_format = "%Y-%m-%d %H:%M:%S %z"
parsed_timezone = datetime.strptime(timezone_string, timezone_format)
# datetime.datetime(2023, 6, 17, 10, 30, 45, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800)))
strptime 클래스 메서드를 사용할 때는 문자열에 맞는 형식 문자열을 사용자가 제공해야 한다. 그러나 dateutil 패키지의 parse 함수를 쓰면 자동으로 형식 문자열을 찾아 datetime 클래스 객체를 만들어 준다.
dateuitl은 여기 블로그 글 에서 사용 예시를 살펴보길 바란다.
일단 어떤 프로젝트에서든 시간에 대한 규칙을 정하는게 좋다. 무지성 datetime.datetime.now
는 무조건적으로 지양해야 한다. "기준을 꼭 잡아야한다." 이런식으로 사용하면, 이제 알 수 있듯이, 배포되는 서버마다(리전이 다르거나 설정이 다르다면) now가 달라지는 기적을 볼 수 있다.
즉, 일단 "보관되는 데이터"는 무조건 나이브객체는 피하고 "어웨어" 중심으로 가야한다. 그리고 시간이 중심이 되는 action의 경우도, 특히 batch, "어웨어" 해야한다.
다국적, 장기보관의 목적이 되는 경우는 당연히 "어웨어"하되 기준시를 UTC 자체로 잡는 것이 좋다. 그래야 다양하게 표현할 수 있다.
# settings.py
LANGUAGE_CODE = "ko-kr" # 언어 - 국가 설정
USE_TZ = True # 장고 시간대
TIME_ZONE = "Asia/Seoul" # 시간대
USE_I18N = True # 국제화 -> Internationalization
USE_L10N = True # 지역화 -> localization
django는 기본적으로 settings.py
에서 TIME_ZONE = "Asia/Seoul"
세팅값이 있을 것이다. 이 것이 project의 기본이되는 timezone이다.
그리고 "시간"을 사용할때 위 timezone을 사용하기 위해 from django.utils import timezone
를 가져와서 timezone.localdate()
또는 timezone.localtime()
를 사용하자.
전자는 timezone 기준 datetime.datetime
객체를 get_default_timezone
를 활용해 return하고, 후자는 전자를 date()
로 바꾼값을 준다.
>>> from django.utils import timezone
>>> timezone.localdate()
datetime.date(2023, 6, 18)
>>> timezone.localtime()
datetime.datetime(2023, 6, 18, 12, 55, 56, 305679, tzinfo=backports.zoneinfo.ZoneInfo(key='Asia/Seoul'))
>>> timezone.now()
datetime.datetime(2023, 6, 18, 3, 56, 40, 90003, tzinfo=datetime.timezone.utc)
다양한 형태로 접근 가능하다. UTC로 저장된 DB timestamp를 처리하기 위해 자체 middleware를 만들어서 response줄 때 timezone을 injection하기 위해 replace를 사용한다던가, 해당 timezone을 app 변수에 등록해서 활용한다던가 형태 정도가 된다.
사실 다른 프로젝트와 크게 다르지 않다. 가장 추천하는 방법은 그냥 django처럼 util함수를 만들어버리는 것이다. 그래서 어디서든 global하게 세팅해둔 상수 timezone을 기반으로 datetime을 핸들링하도록 하자
한국에서는 "pytimekr" 라는 모듈이 있다. 하지만 대체 공휴일이나 다양한 형태의 공휴일을 모두 대응하는데에는 한계가 있다.
그래서 "공공데이터포털" 을 활용하는 것이다. 많은 사람들이 OPEN API를 통해 "한국천문연구원_특일 정보" api를 사용하고 있다. 대체 공휴일을 대비해서 Weekly Batch 정도로 해당 open api가 제공하는 공휴일 정보를 DB에 update해서 사용하는게 가장 효율적이다.
https://www.data.go.kr/data/15012690/openapi.do 에서 확인할 수 있다.
Nageru.Date API (https://date.nager.at/): Nageru.Date API는 전 세계 다양한 국가의 공휴일 정보를 제공한다. RESTful API 형식으로 공휴일 데이터에 접근할 수 있다.
Calendarific API (https://calendarific.com/): Calendarific API는 전 세계 국가의 공휴일과 휴일 정보를 제공한다. RESTful API로 액세스할 수 있으며, 휴일 날짜, 국가별 공휴일 목록, 휴일 유형 등의 데이터를 얻을 수 있다.
Google Calendar API (https://developers.google.com/calendar): Google Calendar API는 Google 캘린더를 통해 전 세계 다양한 국가의 공휴일 정보를 얻을 수 있다. API를 사용하려면 Google 계정과 API 키를 생성해야 한다.
HolidayAPI (https://www.holidayapi.com/): HolidayAPI는 전 세계 국가의 공휴일 정보를 제공하는 상용 API다. RESTful API로 액세스할 수 있으며, 국가, 연도, 월 등의 매개변수를 사용하여 원하는 공휴일 데이터를 검색할 수 있다.
많이 언급했지만, 결국 "시간"을 다루는 것은 중요하다. 그리고 한 번 꼬인 시간은 되돌릴 수 없다. 혹시나 지금 "나이브"한 객체만 사용하고 있다면, 언젠간 시간대가 무너져 내리는 때가 올것이다. 그래서 시작부터 세팅을 잘 해두는것이 좋다.
다양한 서버에 배포, 다국적 서비스, 다양한 사람이 마음대로 사용하는 datetime 이런 것들이 project를 무조건 병들게 한다. django와 같이 "공통된 time setting을 기반으로 무조건적으로 "어웨어"한 datetime object를 사용하게끔" 꼭! 세팅하자.
공휴일 역시 중요하다. 우리의 배치가 공휴일에 따라 달라진다면, 또는 공휴일에 다른 정보를 제공해야 한다면, 해당 공휴일이 다국적 서비스일때라면? 이 경우를 대비하기 위해 Weekly 로 공휴일을 update하는 것을 추천하며, 웬만하면 필요할때마다 실시간 API (unstable, network output 트래픽) 대신 db에 저장해서 사용하자.