현재 회사 프로젝트에서 주력으로 사용하고 있는 데이터베이스가 MongoDB이고 MongoDB와 연결되어 있는 서버의 프레임워크는 Spring Boot와 Flask인데 MongoDB의 Date 및 DateTime 필드 매핑 과정에서 내용 정리가 필요하여 간단한 테스트 및 정리를 해보았다.
https://www.mongodb.com/docs/manual/reference/method/Date/
일단 공식 문서에서 MongoDB는 시간 관련 필드로 Date를 지원하고 있으며, UTC 시간대에 맞게 날짜 데이터를 생성하며 ISO 형식으로 들어온 날짜 문자열도 UTC 시간대에 변환하여 매핑한다고 한다.
UTC는 '협정 세계시'로 시간에 대한 기준이며, ISO는 국제 표준화 기구가 정한 시간 표현 방식, 규격으로 '시간 표현 규격' 이다. ISO 시간은 UTC와 동일한 시간대를 가리키고 있다.
기본적으로 대표적인 RDBMS인 MySQL, PostgreSQL의 경우 MongoDB와 동일하게 UTC 시간대로 시간대가 설정되어 있지만 DB 내부 설정으로 시간대를 변경해줄 수 있는 반면, MongoDB는 시간대를 변경할 수 있는 옵션이 없다.
즉, 외부에서 (이를테면 클라이언트 또는 데이터베이스와 연결된 서버) Date, LocalDateTime이나 datetime 객체를 생성하여 MongoDB에 전달했을 때 무조건 UTC 시간대로 저장이 된다는 것이다.
사실 위의 특성이 이번 기술 블로그 포스트를 시작하게 된 원인이었는데 다른 데이터베이스의 경우 배포나 설치 이전에 미리 시간대를 변경하면 굳이 서버나 클라이언트 측에서 시간대를 변경하지 않아도 되지만 MongoDB는 그렇지 않아 시간 데이터 -> 문자열 변경 시 Asia/Seoul와 같이 특정 지역의 시간대를 고정적으로 사용해야 하는 환경에서 번거로움이 발생할 수 있다.
테스트에 사용된 엔티티의 구조는 다음과 같다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Document(collection = "test_entity_java")
public class TestEntity {
@Id
private String id;
private String name;
private LocalDateTime targetDateTime;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime modifiedAt;
@Builder
private TestEntity(String name, LocalDateTime targetDateTime) {
this.name = name;
this.targetDateTime = targetDateTime;
}
}
createdAt, modifiedAt의 경우 @EnableMongoAuditing을 적용하여 자동으로 주입이 되고 있다.
간단하게 엔티티를 생성하는 API를 만들어 POST 요청으로 엔티티를 생성하게 되는데, 이 때 요청 바디에 작성해야 할 targetDateTime이 없을 경우엔 LocalDateTime.now()를 사용해서 엔티티 생성 시 주입하도록 설정하였다.
LocalDateTime.now()의 경우 UTC 기준이 아닌, 현재 해당 코드가 실행되고 있는 환경의 현재 시간을 계산한다. 예를 들어, 환경이 UTC 시간대라면 UTC 시간대 기준의 현재 시간을, Asia/Seoul 시간대라면 Asia/Seoul 기준의 현재 시간을 반환할 것이다.
환경의 시간대는 어플리케이션이 실행되고 있는 OS에서 설정을 하거나, 아니면 어플리케이션 내부에서도 설정이 가능하다. 어플리케이션 내부에서 설정하려면 Timezone.setDefault() 로 설정이 가능하다.
@EnableMongoAuditing
@SpringBootApplication
public class DatetimeSpringApplication {
public static void main(String[] args) {
SpringApplication.run(DatetimeSpringApplication.class, args);
}
/*
@PostConstruct
public void initTimeZone() {
// 한국 시간대로 설정
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
// 로스엔젤레스 시간대로 설정
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
}
*/
}
테스트 과정은 다음과 같다.
한국 시간대의 MacOS에서 실행시킨 어플리케이션에서 엔티티 생성을 요청하였다. 요청은 각각 다음과 같이 처리하였다.
// 첫번째 요청
{
"name": "test-default-none-target-date"
}
// 두번째 요청
{
"name": "test-default-target-date1",
// UTC와 Asia/Seoul 간에는 9시간 차이가 존재하여 UTC로는 00:00:00이 됨
"targetDateTime": "2024-04-17T09:00:00.000Z"
}
// 세번째 요청
{
"name": "test-default-target-date2",
// UTC와 Asia/Seoul 간에는 9시간 차이가 존재하여 UTC로는 전날의 23:59:59이 됨
"targetDateTime": "2024-04-17T08:59:59.000Z"
}
요청이 성공 후 MongoDB에 저장한 뒤, 이를 다시 불러왔을 때의 엔티티 데이터

실제 MongoDB에 저장된 엔티티의 데이터

사용자(요청자)가 한국 시간대의 오후 1시에 저장을 했다면 DB에도 마찬가지로 동일하게 오후 1시로 저장되면 좋겠지만 UTC 시간대로 저장이 되기 때문에 9시간 전으로 저장이 된다.
물론, 저장된 값을 다시 불러와서 전달할 때엔 요청자의 시간대에 맞춰서 변환이 되기에 만약 사용자(한국 시간대의 MacOS)의 시점에선 사용자가 저장한 한국 시간대의 오후 1시로 표시가 될 것이다.
예상이 맞는지 확인하기 위해 해당 어플리케이션을 컨테이너화한 후, 기본 시간대가 UTC로 설정되어 있는 환경에서 실행시킨 뒤 동일한 절차를 수행해본 결과는 다음과 같다.
[사진][사진]
여기서 targetDateTime에서 UTC로 저장된 시간 중 요청일자보다 하루 전 23시 59분 59초인 엔티티가 하나가 있는데 startDate를 2024-04-16, endDate를 2024-04-17로 검색하면 어떻게 될까?

답은 조회되지 않는다. RequestParam을 ISO로 받아서 계산하고 있는데 단순히 MongoDB에 저장된 targetDateTime의 값만으로 계산을 했다면 test-default-target-date2 엔티티만 조회가 되었을 것인데 그렇지 않았다.
반면, 2024-04-17, 2024-04-18로 조회하면 3건 모두 조회된다.
즉, RequestParam으로 받아온 startDate, targetDate를 서버 어플리케이션의 시간대에 맞추고 between() 쿼리 메서드에 적용시키는 것 같다. (MongoDB에 UTC로 저장된 필드를 어떻게 startDate, targetDate와 매칭시키는지 내부 동작은 잘 모르겠지만)
이번에는 Timezone을 서버 어플리케이션 내에서 한국 시간대로 설정해보고 다음과 같이 요청을 해본다.
// 첫번째 요청
{
"name": "test-seoul-none-target-date"
}
// 두번째 요청
{
"name": "test-seoul-target-date1",
// UTC와 Asia/Seoul 간에는 9시간 차이가 존재하여 UTC로는 00:00:00이 됨
"targetDateTime": "2024-04-17T09:00:00.000Z"
}
// 세번째 요청
{
"name": "test-seoul-target-date2",
// UTC와 Asia/Seoul 간에는 9시간 차이가 존재하여 UTC로는 전날의 23:59:59이 됨
"targetDateTime": "2024-04-17T08:59:59.000Z"
}
이후의 결과를 살펴보면 예상대로 조회 결과와 기간 검색 결과 모두 맨 처음과 동일하게 나온다. 마찬가지로 2024-04-16 ~ 2024-04-17로 검색할 경우 노출이 되지 않는다.
이번에는 Timezone을 로스엔젤레스의 시간대로 설정해보고 다음과 같이 요청한다.
UTC와는 16시간 차이가 난다.
// 첫번째 요청
{
"name": "test-la-none-target-date"
}
// 두번째 요청
{
"name": "test-la-target-date1",
// UTC와 America/Los_Angeles 간에는 7시간 차이가 존재하여 UTC로는 16:00:00이 됨
"targetDateTime": "2024-04-17T09:00:00.000Z"
}
// 세번째 요청
{
"name": "test-la-target-date2",
// UTC와 America/Los_Angeles 간에는 7시간 차이가 존재하여 UTC로는 전날의 23:59:59이 됨
"targetDateTime": "2024-04-16T16:59:59.000Z"
}
요청이 성공 후 MongoDB에 저장한 뒤, 이를 다시 불러왔을 때의 엔티티 데이터

실제 MongoDB에 저장된 엔티티의 데이터

조회 API 상에서 나온 데이터는 요청 시 넣었던 시간과 동일하게 출력이 되고 있다.
MongoDB에 저장되는 데이터의 경우 동일한 UTC 시간대로 저장이 되며, 사용자가 직접 입력하는 부분에 있어서 차이가 존재한다.
(한국, 로스엔젤레스의 2번째 요청을 살펴보면 둘 다 동일하게 2024년 4월 17일 9시이지만 MongoDB에 저장된 값은 각각 2024년 4월 17일 00시, 2024년 4월 17일 16시이다)
한편, 기간 검색을 2024-04-16, 2024-04-17로 검색할 경우 단순 UTC 값으로만 계산한다면 test-la-target-date2가 검색되어야 하겠지만

오히려 화면상에 표시되는 targetDateTime이 검색 조건에 포함되는 test-la-target-date2가 조회된다.
즉, 한국 시간대에서의 처리와 마찬가지로 RequestParam으로 받아온 startDate, targetDate를 서버 어플리케이션의 시간대에 맞추고 between() 쿼리 메서드에 적용시키는 것 같다.
3번의 테스트를 통해 확인한 내용을 요약하자면 다음과 같다.
엔티티의 설정은 다음과 같으며 Java + Spring 테스트에 활용되었던 도큐먼트와 겹치지 않도록 컬렉션을 분리하였다.
class TestEntity(Document):
mongoengine.connect('TimeTest')
meta = {
'collection': 'test_entity_python'
}
name = StringField()
target_datetime = DateTimeField()
created_at = DateTimeField()
modified_at = DateTimeField()
@classmethod
def pre_save(cls, sender, document, **kwargs):
if document.created_at is None:
document.created_at = datetime.now()
document.modified_at = datetime.now()
signals.pre_save.connect(TestEntity.pre_save, sender=TestEntity)
추가적으로 MongoEngine의 경우 @EnableMongoAuditing, @CreatedDate, @LastModifiedDate와 같은 데이터베이스 INSERT / UPDATE에 대한 이벤트 처리를 하기 위해선 mongoengine.signals를 도큐먼트 클래스마다 선언해줘야 한다.
Python의 경우 Java의 Timezone.setDefault()와 같은 전역적인 시스템의 시간대 변경 기능은 없는 것으로 보이며 보통 시간대를 pytz.timezone()으로 지정한 후에 datetime에 적용하는 편이다. 시간대를 지정하지 않는 경우 UTC 시간대가 적용된다.
테스트 설정을 제외하면 테스트 내용은 동일하다.
// 첫번째 요청
{
"name": "test-default-none-target-date"
}
// 두번째 요청
{
"name": "test-default-target-date1",
// UTC와 Asia/Seoul 간에는 9시간 차이가 존재하여 UTC로는 00:00:00이 됨
"target_datetime": "2024-04-20T09:00:00.000Z"
}
// 세번째 요청
{
"name": "test-default-target-date2",
// UTC와 Asia/Seoul 간에는 9시간 차이가 존재하여 UTC로는 전날의 23:59:59이 됨
"target_datetime": "2024-04-20T08:59:59.000Z"
}
요청이 성공 후 MongoDB에 저장한 뒤, 이를 다시 불러왔을 때의 엔티티 데이터

실제 MongoDB에 저장된 엔티티의 데이터

Java + Spring과 다르게 한국 시간대 -> UTC로 변환되어 저장된 것이 아니라 datetime.now() 및 직접 target_datetime으로 입력한 시간 모두 UTC 시간대 기준으로(입력한 시간이 한국에서의 오전 9시가 아니라 UTC 기준 9시로) 그대로 저장이 되었다.
print()를 찍었을 때엔 한국 시간대와 동일한 시간이 출력이 되고 있었는데(이건 Java도 마찬가지) 사실 이게 Java의 LocalDateTime과 동일한 환경임에도 MongoDB에 저장된 결과가 달라서 혼란스러운 감이 있지만, MongoDB 내부 동작은 어쨌든 외부에서 들어온 시간 데이터를 Date로 변환할 때 UTC 기준으로 변환하는 것이기 때문에 Java의 LocalDateTime과 달리 어쨌든 입력받은 시간과 동일한 시간으로 저장되었다는 것은, 입력받은 시간이 Flask를 거치면서 시간대가 변환되지 않았다는 것(즉 애초에 UTC로 받았음)을 추측할 수 있었다.
between()과 동일한 역할을 하는 objects(필드명__gte, 필드명__lte)의 경우 파라미터를 UTC 기준으로 받아서 (2024-04-21로 받는다면 UTC 기준의 2024-04-21) 별다른 변환 없이 검색 조건에 반영한다.
즉, 앞단에서 코드가 실행되고 있는 Timezone에 맞게 파라미터를 UTC 시간대로 변환하여 도큐먼트 검색 조건에 반영했던 Java + Spring Data MongoDB와는 다르게 UTC로 받아서 그대로 검색 조건에 반영하고 있는 셈이다.
만약 현재의 시간을 생성해서 넣어야 한다면 datetime.datetime.now()를 사용하는 대신, 전역적으로 시간대를 지정하여 해당 시간대의 현재 시간을 반환해주는 유틸 기능을 활용해본다.
# 유틸
timeutil_now = datetime.now(pytz.timezone('Asia/Seoul'))
# 도큐먼트
class TestEntity(Document):
...
@classmethod
def pre_save(cls, sender, document, **kwargs):
# 유틸 활용
if document.created_at is None:
document.created_at = timeutil.timeutil_now
document.modified_at = timeutil.timeutil_now
외부에서 입력 받은 시간을 특정 시간대 기준의 시간으로 받고 싶다면 (예를 들어 2024-04-10 09:00:00을 그냥 받으면 UTC 기준의 2024-04-10 09:00:00 으로 받게 되는데 이를 한국 시간대 기준의 2024-04-10 09:00:00 으로 받고 싶다면) 저장 전에 UTC와 특정 시간대의 시차만큼 추가 계산을 해서 처리해야 한다. (한국 시간대 기준의 2024-04-10 09:00:00은 UTC 기준으로 2024-04-10 00:00:00 이므로 9시간을 빼서 저장을 한다던지)
아래의 예시는 Request를 Marshmallow를 활용하여 DTO로 매핑하는 과정에서 시간대에 맞게 시차만큼 값을 추가하는 예시이다.
class TestEntityRequestDTO(Schema):
name = fields.Str()
target_datetime = fields.DateTime()
@post_load
def convert_datetime(self, data, **kwargs):
if data.get('target_datetime') is not None:
target_datetime = data.get('target_datetime')
target_datetime_seoul = target_datetime - timedelta(hours=9)
data['target_datetime'] = target_datetime_seoul
return data
그리고 MongoDB에 저장된 UTC 기준의 Date를 DateTimeField로 변환할 때엔 특정 시간대의 시차만큼 추가 계산을 처리하던지 해야 한다.
추가적으로 Python 공식 문서에 datetime에 대한 내용이 나와 있으며 시간대가 분명히 명시된 것과 명시되지 않은, 나이브한 datetime에 대한 동작이 다르게 작동하는 것으로 보인다.
이에 대해 괜찮은 아티클이 나와 있어서 첨부한다.
https://spoqa.github.io/2019/02/15/python-timezone.html
클라이언트 측에서 Date()를 만들어서 요청을 보낼 때에도 시간대 정보가 적용이 되냐 안 되냐의 차이가 존재한다. JavaScript의 Date() 관련 아티클을 첨부한다.
https://yozm.wishket.com/magazine/detail/1695
사실 단일 언어 / 프레임워크 / DB를 사용하는 환경이라면 한쪽 기준에 맞추면 되겠지만 그렇지 않은 경우 데이터를 주고 받고 다른 서비스에서 이를 활용하고 이런 과정에서 꼬일 수 밖에 없는 것 같다.
사실 그러한 이유로 UTC를 사용하는 것 같지만 비즈니스에 따라 지역별 시간대를 적용해야 하는 경우도 있다보니... 각자가 처한 환경 및 비즈니스의 특성에 따라 시간대 정책을 수립하고 처리해야할 것으로 보인다.
https://spoqa.github.io/2019/02/15/python-timezone.html
https://yozm.wishket.com/magazine/detail/1695/