Django Web Server on Ubuntu 20.04에 이어 오랜만에 Django 관련 정확히는 Django Timezone 에 대한 내용입니다.

Python datetime을 사용할 때 Django Timezone 문제를 어떻게 쉽게 해결할 수 있을까요? Django Model은 DateTimeField라는 기본 필드를 제공하고 있습니다. Django를 사용하기만 하면 DateTimeField를 이용하여 날짜 또는 시간을 이용하여 Filtering, Aggregation 과 같은 DB의 편리하고 강력한 기능을 사용할 수 있습니다.

그런데 서비스의 사용자들이 서로 다른 Timezone 지역에서 서비스를 이용하는 경우 시간대가 다름으로 인해 DateTimeField 와 python의 datetime 구조에 기인하는 문제점이 발생하게 됩니다.

많은 서비스가 글로벌 타겟으로 개발이 되고 있는 만큼 시간과 시간대를 다루는 방법도 중요한 부분입니다.

경험이 있는 소프트웨어 엔지니어들은 자신만의 노하우를 가지고 관련 문제들을 잘 다루고 있을겁니다. (아쉽게도 저는 아니었습니다…) Django Timezone 문제로 인해 어려움을 겪었는데, 혹시 비슷한 문제를 가진 분에게 아래 글이 작은 도움이나마 되었으면 합니다.

Django Timezone 뭐가 문제인데?

예를 들어서 24시간 물품이 입고되는 공장이 있다고 가정해 보겠습니다. 이 공장은 런던에 있지만 본사는 서울에 있습니다. 서울 본사 직원과 런던 공장의 관리 직원이 이용하는 서비스를 제작하려고 합니다. 서울 본사 직원과 런던 관리 직원은 서비스에서 일일 입고 물품의 수량을 확인하고 싶어 합니다.

본사 직원은 서울의 기준시(GMT +9) 00:00~24:00 사이의 입고량을 산출해야 하고, 런던의 공장 관리 직원은 런던의 기준시(UTC) 00:00~24:00 사이의 입고량을 1일 입고량으로 산출합니다.

본사 직원과 런던 공장 직원은 API를 호출할 때 둘다 [202208010000, 202209010000] 파라미터를 던집니다. 8월 한달간 일별 입고량을 알고 싶은 것이지요. API 핸들러는 이걸 가지고 런던 직원이 원하는 데이터와 서울 직원이 원하는 데이터를 만들어 주어야 합니다.

대략 문제가 뭔지 감이 오시나요?

Timezone에 따라 UTC 기준으로 런던 직원은 파라미터 그대로 2022년 8월 1일 00:00 ~ 2022년 9월 1일 00:00이지만, 서울 직원의 데이터는 2022년 8월 1일 09:00 ~ 2022년 9월 1일 09:00 까지의 데이터를 aggregation해 주어야 합니다. Aggregation 기준도 물론 UTC 기준 매일 오전 9시라야 문제가 없겠습니다.

Django Timezone

이걸 어떻게 처리하는 것이 좋을까요?

Django Timezone 시간대 문제 다루기

사실 Django Timezone 과 같은 문제를 해결하는 데에 정답이 있는 것은 아닙니다. 아래 제안하는 방법도 경험적으로 문제를 해결했던 한 가지 방법에 불과할 수 있습니다.

하지만 대체로 어지간한 경우에는 큰 무리 없이 시간대 문제를 처리할 수 있을 것 같아 문서로 공유하고자 합니다. 대체로 아래의 원칙으로 하면 좋습니다.

  • 서버 내에서 연산하는 모든 시간은 UTC 기반으로 통일한다
  • 사용자별 데이터로 시간대 자체를 설정하도록 한다
  • 서버에서 연산한 시간을 지역 시간으로 변환하는 작업은 사용자에게 데이터를 전달할 때만
  • 사용자 입력 시간을 서버에서 사용하기 위해 UTC 시간으로 변환하는 작업은 사용자로부터 파라미터를 받았을 때만

서버 내에서 연산하는 모든 시간은 UTC 기반으로 통일

서버에서 DB에 저장하는 시간, 연산하는 시간 등 다루는 모든 시간은 UTC로 통일하는 것이 좋습니다. 서버에서 시간을 다룰 때 일반적으로 어떤 사건의 시간을 지정하는 경우가 많지만 대부분의 경우 해당 시간은 추후 어떤 연산의 소스가 되는 경우가 대부분일 것입니다.

앞서 예를 들었던 시간별, 일별, 주별, 월별 등 다양한 형태의 Aggregation이 흔히 볼수 있는 예입니다. 그 외에도 1일전, 12시간전, 이틀후 등과 같은 기준 시간의 offset 형태의 연산도 있을 것입니다.

그런데 만약 사용자별로 다양한 시간대에 따라 어떤 사용자는 UTC, 어떤 사용자는 GMT+9 등과 같이 시간을 저장하는 경우 분명히 추후 더 큰 문제가 되어 시간을 허비하게 됩니다.

사용자별 데이터로 시간대 자체를 설정

그러나 결국 사용자 각각은 다른 시간대에서 생활하고 있기 때문에 각각이 살고 있는 시간대 기준의 서비스를 제공해야만 합니다. 이를 위해 사용자별로 어떤 시간대에 위치하고 있는지 정보를 설정으로 추가해 주는 것입니다.

사용자에게는 아래와 같은 방식으로 시간대 목록을 제공하고 선택하도록 할 수 있습니다. 접속 정보를 통해 클라이언트에서 시간대 정보를 보내주고 기본값을 설정하는 것도 좋습니다.

import logging, pytz
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated


class TimezonesView(APIView):
    def get(self, request):
        timezones = pytz.common_timezones
        if not search:
            return Response(timezones, status=status.HTTP_200_OK)

        timezones = list(filter(lambda x: search in x, timezones))
        return Response(timezones, status=status.HTTP_200_OK)

API를 호출해 보면 아래와 같은 목록을 얻을 수 있습니다. 사용자에게는 옵션으로 제공하고 본인의 타임존을 선택할 수 있는 UI가 필요할 것입니다.

["Africa/Abidjan","Africa/Accra","Africa/Addis_Ababa", ..., "UTC" ]

사용자의 시간값 입력시 Timezone 설정

사용자 인터페이스에서는 필터 옵션 지정시 시간을 지정할 수 있을겁니다. 예를들어 2022년 8월의 데이터를 원하는 경우 20220801000000, 20220901000000 와 같은 시간 범위를 지정할 수 있을 것입니다.

사용자 인터페이스를 통해 입수된 연월일시분초 형식의 시간을 설정에 지정된 Django Timezone 시간대로 변환하여 연산하는 작업이 필요할 것입니다.

이 경우 사용자의 입력값은 Timezone 지정이 되어 있지 않지만 Localtime 인 시간값을 이용해 Django ORM 에서 사용할 수 있는 시간값으로 변환을 해 주어야 합니다. 즉, 시간 정보에 타임존 정보를 넣어주는 과정이 필요합니다.

import pytz
from datetime import datetime

# 사용자의 timezone 설정 정보를 이용해 pytz 객체 생성
tz_setting = 'Asia/Seoul'
user_tz = pytz.timezone(tz_setting)

# 사용자 입력값을 이용해 Unawared Datetime 생성
t_from = '20220801000000'
t_to = '20220901000000'
from_dt = datetime(year=int(t_from[:4]), month=int(t_from[4:6]), day=int(t_from[6:8]), hour=int(t_from[8:10]), minute=int(t_from[10:12]), second=int(t_from[12:14]))
to_dt = datetime(year=int(t_to[:4]), month=int(t_to[4:6]), day=int(t_to[6:8]), hour=int(t_to[8:10]), minute=int(t_to[10:12]), second=int(t_to[12:14]))

# Datetime 객체를 pytz 객체를 이용해 localtime으로 변경
from_dt_local = user_tz.localize(from_dt)
to_dt_local = user_tz.localize(to_dt)

Python interpreter에서 실행해 보면 아래와 같은 값이 표시됩니다.

>>> from_dt_local
datetime.datetime(2022, 8, 1, 0, 0, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)
>>> to_dt_local
datetime.datetime(2022, 8, 31, 23, 59, 59, tzinfo=<DstTzInfo 'Asia/Seoul' KST+9:00:00 STD>)

DateTimeField 에 필터 적용 예

이제 from_dt_local, to_dt_local 을 이용해 Django timezone 이 적용된 ORM 쿼리를 실행할 수 있습니다. 예를 들어 MyRecords 테이블에서 utc_from 이라는 DateTimeField가 있다고 할 때 필터를 적용해 본다면 아래와 같이 실행할 수 있습니다.

records = MyRecords.objects.filter(utc_from__gte=from_dt_local, utc_to__lt=to_dt_local)

DateTimeField Aggregation(Group By) 적용 시간별 합계 및 평균 예제

Aggregation을 1시간 단위로 적용하여 my_number 필드의 시간별 합계와 시간별 평균값을 가져오려고 하는 경우 아래와 같이 적용할 수 있습니다. MyRecords 테이블에 my_number 라는 IntegerField가 있다고 할 때 aggregation을 적용해 본다면 아래와 같이 실행할 수 있습니다.

사실 이 경우 특별히 Django Timezone 과는 관계가 없을 것입니다. 1시간 단위는 timezone에 의해 다르게 적용되지 않을 것이기 때문입니다.

from django.db.models import Sum, Avg
from django.db.models.functions import TruncHour

# 시간 단위의 시간값을 가진 새로운 필드 annotation
records = MyRecords.objects.annotate(agg_time=TruncHour("utc_from"))
# TruncHour
##  utc_from 필드의 시간값에서 분, 초, 마이크로초 정보가 0으로 설정된
##  새로운 필드를 annotation하고 해당 필드 이름은 agg_time 으로 설정함

# 시간별 aggregation(group by)
records = records.values("agg_time")

# Aggregation된 QuerySet에 my_number 합계 및 평균값 annotation
records = records.annotate(my_sum=Sum("my_number"), my_avg("my_number"))
records = records.order_by("agg_time")

DateTimeField Aggregation(Group By) 적용 일별 합계 및 평균 예제

일별 Aggregation 데이터를 산출하기 위해서는 반드시 사용자 타임존을 고려해야만 합니다. 이 경우 유용한 Django Timezone 도구인 TruncDay 를 사용할 수 있습니다.

import pytz
from datetime import datetime
from django.db.models import Sum, Avg
from django.db.models.functions import TruncDay

# 사용자의 timezone 설정 정보를 이용해 pytz 객체 생성
tz_setting = 'Asia/Seoul'
user_tz = pytz.timezone(tz_setting)

# Timezone 적용된 일단위의 시간값을 가진 새로운 필드 annotation
records = MyRecords.objects.annotate(agg_time=TruncDay("utc_from"), tzinfo=pytz.timezone(tz))
# TruncDay
##  utc_from 필드의 시간값에서 시, 분, 초, 마이크로초 정보가 0으로 설정된
##  새로운 필드를 annotation하고 해당 필드 이름은 agg_time 으로 설정함

# 일별 aggregation(group by)
records = records.values("agg_time")

# Aggregation된 QuerySet에 my_number 합계 및 평균값 annotation
records = records.annotate(my_sum=Sum("my_number"), my_avg("my_number"))
records = records.order_by("agg_time")

맺음말

Global Service 런칭을 하기 위해 필요한 사용자 타임존을 다루는 방법에 대해 나름대로 고민을 해서 적용했던 내용입니다. 유사한 고민을 하고 있는 분은 참고해 보시면 좋겠습니다. 그리고 혹시 더 좋은 방법이나 문제점이 있다면 이런 부분에 대해서 함께 토론해 보았으면 합니다. 부담없이 댓글로 알려주시기 바랍니다. 스팸이 많아서 댓글은 승인 후 표시되는 점 넓은 양해 바랍니다.

참고 문헌


Jay

Jay

S/W Engineer!!

0개의 댓글

답글 남기기

Avatar placeholder