[MongoDB, Spring, Flask, JavaScript] LocalDateTime, datetime과 MongoDB Date에 대해

mrcocoball·2024년 4월 14일

Database

목록 보기
1/2

1. 개요

현재 회사 프로젝트에서 주력으로 사용하고 있는 데이터베이스가 MongoDB이고 MongoDB와 연결되어 있는 서버의 프레임워크는 Spring Boot와 Flask인데 MongoDB의 Date 및 DateTime 필드 매핑 과정에서 내용 정리가 필요하여 간단한 테스트 및 정리를 해보았다.

2. MongoDB와 UTC

https://www.mongodb.com/docs/manual/reference/method/Date/
일단 공식 문서에서 MongoDB는 시간 관련 필드로 Date를 지원하고 있으며, UTC 시간대에 맞게 날짜 데이터를 생성하며 ISO 형식으로 들어온 날짜 문자열도 UTC 시간대에 변환하여 매핑한다고 한다.

UTC와 ISO?

UTC는 '협정 세계시'로 시간에 대한 기준이며, ISO는 국제 표준화 기구가 정한 시간 표현 방식, 규격으로 '시간 표현 규격' 이다. ISO 시간은 UTC와 동일한 시간대를 가리키고 있다.

UTC와 MongoDB 시간대 이슈

기본적으로 대표적인 RDBMS인 MySQL, PostgreSQL의 경우 MongoDB와 동일하게 UTC 시간대로 시간대가 설정되어 있지만 DB 내부 설정으로 시간대를 변경해줄 수 있는 반면, MongoDB는 시간대를 변경할 수 있는 옵션이 없다.

즉, 외부에서 (이를테면 클라이언트 또는 데이터베이스와 연결된 서버) Date, LocalDateTime이나 datetime 객체를 생성하여 MongoDB에 전달했을 때 무조건 UTC 시간대로 저장이 된다는 것이다.

사실 위의 특성이 이번 기술 블로그 포스트를 시작하게 된 원인이었는데 다른 데이터베이스의 경우 배포나 설치 이전에 미리 시간대를 변경하면 굳이 서버나 클라이언트 측에서 시간대를 변경하지 않아도 되지만 MongoDB는 그렇지 않아 시간 데이터 -> 문자열 변경 시 Asia/Seoul와 같이 특정 지역의 시간대를 고정적으로 사용해야 하는 환경에서 번거로움이 발생할 수 있다.

3. Java + Spring Boot + Spring Data MongoDB의 경우

테스트 설정

테스트에 사용된 엔티티의 구조는 다음과 같다.

@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"));

    }
     */
}

테스트 내용

테스트 과정은 다음과 같다.

  1. 공통적인 테스트 과정
    ㄱ. 서버로 엔티티 생성 요청을 targetDateTime을 명시하지 않은 경우(=서버 내 LocalDateTime.now()로 생성), targetDateTime을 직접 명시하는 경우, UTC 기준으로 요청한 날짜가 아닌, 요청한 날짜의 하루 전의 23:59:59가 되도록 targetDateTime을 명시하는 경우 총 3회 요청한다.
    ㄴ. 생성 요청 3회 진행 후 서버에서 엔티티 목록 조회 요청을 보낸다.
    ㄷ. 실제로 저장된 MongoDB의 데이터를 확인하고 ㄴ의 데이터와 비교한다.
    ㄹ. Spring Data MongoDB의 MongoRepository에서의 between()을 활용한 쿼리 메서드를 통해, startDate, endDate를 파라미터로 전달하여 targetDateTime이 startDate, endDate 사이에 존재하는 엔티티 목록을 조회하는 요청을 보내고 결과를 확인한다.
  2. 1번의 과정을 다음과 같은 조건에서 각각 반복한다
    ㄱ. 한국 시간대의 MacOS에서 아무런 Timezone 설정 없이 진행
    ㄴ. 한국 시간대의 MacOS에서 Timezone을 한국 시간대로 설정한 후 진행
    ㄷ. 한국 시간대의 MacOS에서 Timezone을 로스엔젤레스 시간대로 설정한 후 진행

한국 시간대의 MacOS에서 아무런 Timezone 설정 없이 진행

한국 시간대의 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와 매칭시키는지 내부 동작은 잘 모르겠지만)

한국 시간대의 MacOS에서 Timezone을 한국 시간대로 설정

이번에는 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로 검색할 경우 노출이 되지 않는다.

한국 시간대의 MacOS에서 Timezone을 로스엔젤레스 시간대로 설정

이번에는 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의 LocalDateTime은 시스템, 환경의 시간대 설정에 영향을 받는다
  • LocalDateTime -> MongoDB의 Date로 저장될 때엔 UTC 시간대로 저장이 된다
  • MongoDB -> LocalDateTime 변환 시 시스템, 환경의 시간대 설정에 영향을 받는다
  • Spring Data MongoDB의 쿼리 메서드에서 Date에 대한 between 메서드는 LocalDateTime을 파라미터로 넘길 때 현재 시스템, 환경의 시간대 설정에 맞게 MongoDB의 Date 필드를 쿼리한다. (예를 들어 2024-04-20 09:00:00, 2024-04-21 00:00:00을 파라미터로 넘겼을 때 Timezone이 한국 시간대라면 2024-04-20 00:00:00, 2024-04-20 15:00:00 으로 변환하여 쿼리한다)

4. Python + Flask + MongoEngine의 경우

테스트 설정

엔티티의 설정은 다음과 같으며 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 시간대가 적용된다.

테스트 설정을 제외하면 테스트 내용은 동일하다.

한국 시간대의 MacOS에서 아무런 Timezone 설정 없이 진행

// 첫번째 요청
{
  	"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로 받아서 그대로 검색 조건에 반영하고 있는 셈이다.

  • Java + Spring Data MongoDB
    • 2024-04-21로 시간을 전달하면 OS 및 시스템 시간대의 2024-04-21로 변환 -> MongoDB로 저장 또는 검색 시 UTC로 변환하여 반영
  • Python + MongoEngine
    • 2024-04-21로 시간을 전달하면 UTC 시간대의 2024-04-21로 계산 -> MongoDB로 저장 또는 검색 시 그대로 반영

datetime의 시간대를 변경하려면

만약 현재의 시간을 생성해서 넣어야 한다면 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

5. JavaScript의 경우

클라이언트 측에서 Date()를 만들어서 요청을 보낼 때에도 시간대 정보가 적용이 되냐 안 되냐의 차이가 존재한다. JavaScript의 Date() 관련 아티클을 첨부한다.
https://yozm.wishket.com/magazine/detail/1695

결론

사실 단일 언어 / 프레임워크 / DB를 사용하는 환경이라면 한쪽 기준에 맞추면 되겠지만 그렇지 않은 경우 데이터를 주고 받고 다른 서비스에서 이를 활용하고 이런 과정에서 꼬일 수 밖에 없는 것 같다.

사실 그러한 이유로 UTC를 사용하는 것 같지만 비즈니스에 따라 지역별 시간대를 적용해야 하는 경우도 있다보니... 각자가 처한 환경 및 비즈니스의 특성에 따라 시간대 정책을 수립하고 처리해야할 것으로 보인다.

Reference

https://spoqa.github.io/2019/02/15/python-timezone.html
https://yozm.wishket.com/magazine/detail/1695/

profile
Backend Developer

0개의 댓글