Post

DATETIME? TIMESTAMP? 시간정보를 다루는 법

DATETIME? TIMESTAMP? 시간정보를 다루는 법

서론

안녕하세요! 오늘은 ‘탐식당’ 플랫폼을 운영하면서 겪은 TimeZone과 관련된 이슈와, 이를 해결한 과정에 대해서 정리해 보면서 Java와 MySQL, 그리고 ConnectorJ에서는 시간정보를 어떻게 처리하는지까지 알아보도록 하겠습니다.

언뜻 생각해보면 그냥 ‘날짜, 시간만 저장하면 되는거 아니야?’ 라고 생각할 수도 있을 것 같습니다. 그러나, 이는 모든 ‘순간’에 시간이 동일할 경우에만 해당이 됩니다.

전세계가 연결되어 있는 인터넷 환경에서는 TimeZone이라는 한 차원 더 높은 개념을 추가하여 시간을 저장해야 합니다.

그렇다면 TimeZone은 무엇일까요? 바로 지구가 자전하기 때문에 생기는 ‘시차’를 지역별로 구분한 개념입니다.

TimeZone
세계 각국 TimeZone

이렇게 지역에 따라서 약속된 TimeZone을 가지기 때문에 우리는 이를 고려해서 시간정보를 다루어야 합니다. 그렇다면 MySQL에선 시간정보를 어떻게 저장하고 처리할까요?

시간정보 저장형식

이번 문제상황을 겪게된 환경에서 사용했던 MySQL에서는 시간 정보를 크게 3가지로 저장합니다.

먼저 날짜 만을 저장하는 DATE
ex) 2024-09-03

그리고 날짜와 시간까지 저장하는 DATETIMETIMESTAMP
ex) 2024-09-03 00:00:00

그렇다면 DATETIME과 TIMESTAMP는 무슨 차이가 있는걸까요 🤔

공식 문서를 한번 봐볼까요?

The DATETIME type is used for values that contain both date and time parts. MySQL retrieves and displays DATETIME values in ‘YYYY-MM-DD hh:mm:ss’ format.

The TIMESTAMP data type is used for values that contain both date and time parts. TIMESTAMP has a range of ‘1970-01-01 00:00:01’ UTC to ‘2038-01-19 03:14:07’ UTC.

여기서 저장하는 포맷은 크게 다르지 않지만 TIMESTAMP는 항상 UTC로 저장한다는 것에 유의해야합니다. 항상 UTC로 저장한다면 어떤 지역에서 시간정보를 저장하던 정확한 ‘순간’을 저장할 수 있겠죠?

세 사람이 서로 다른 지역에 있고, 동시에 현재 시간을 저장한다고 예를 들어봅시다.

DATETIME
DATETIME으로 저장

UTC로 변환하지 않기 때문에 모두 다르게 저장되는 것을 볼 수 있습니다.

TIMESTAMP
TIMESTAMP로 저장

UTC로 변환하여 저장하기 때문에 모두 같은 순간임을 알 수 있습니다.

따라서 특정 로컬 시간을 저장하고 싶은 상황이나 시간대 정보가 아예 필요없는 날짜 정보를 기록할 때는 DATETIME
특정 순간을 기록하여 다른 지역에서도 같은 순간을 조회하게 하려면 TIMESTAMP를 사용하는 것이 적합합니다.

여기까지 본다면 ‘시간을 저장하고 조회할 때 TIMESTAMP는 데이터를 변환하고 DATETIME은 데이터를 변환시키는 과정이 일어나지 않구나’ 라고 생각이 들 수도 있지만 경우에 따라서 그러지 않은 경우도 있습니다. 문제상황을 통해서 어떤 경우가 그러한지 알아봅시다!


문제 상황

이 ‘탐식당’ 프로젝트의 기능 중 오전 10시 정각에 구독한 식당의 그 날의 중식 식단 정보를 알려주는 알림을 보내는 기능이 있습니다.

알림은 제 시간에 잘 보내졌지만 알림 메시지의 시간이 9시간 이르게 표시되는 현상을 발견하였습니다.😦

알림 시간 표시 오류
알림 시간 표시 오류

알림 시간이 저장되고 조회하는 흐름에만 집중해보자면 대략 아래와 같습니다.

  1. 알림을 보낼 때 서버에서 알림 센터 메시지 생성(LocalDateTime으로 저장)
  2. DB에 메시지 저장(DATETIME 타입으로 저장)
  3. 유저의 알림 센터 메시지 조회

결과적으로 3번에서 10:00가 아닌 01:00로 생성시간을 표시하는 문제가 발생한 것입니다.

여기서 특이한 점은
같은 데이터를 조회할 때, 이런현상이 Local Server에는 나타나지 않았었고 Develop와 Release Server에서만 나타났던 것입니다.

DATETIME 형식의 데이터이기 때문에 저장한 데이터와 조회한 데이터가 달라지는 일은 일어나지 않아야 할 것 같지만
변환이 일어난 것입니다. 어떻게 된걸까요?


원인 분석

시간 정보 생성

먼저 어플리케이션에서는 어떻게 시간 정보를 생성하는지부터 알아봐야겠습니다.

시간정보 생성 코드
BaseEntity

LocalDateTime을 생성시간을 저장하고 있네요. LocalDateTime 역시도 TimeZone 정보는 저장하지 않습니다!

그리고 @CreatedDate가 붙은걸 볼 수 있는데 이 어노테이션이 붙은 속성은 JVM이 반환하는 현재 시간을 저장합니다. 당시 배포 서버의 JVM은 UTC TimeZone으로 세팅되어 있어서 한국시간으로 10:00에 알림을 생성했지만 UTC 기준인 01:00시로 저장됩니다.

시간정보 생성 코드
JVM에서 생성 시간 정보 저장

시간정보 변환

그 다음 JDBC의 구현체인 ConnectorJ에선 이 정보를 저장하기전에 변환시킵니다.(preserveInstant=true일 때, Default는 true임)

응?? DATETIME 타입으로 저장하려는데 왜 갑자기 변환을 시키는 걸까요? 그 이유는 TimeZone에 있습니다.

서로 다른 TimeZone

1
jdbc:mysql://${DB_URI}?serverTimezone=Asia/Seoul // 연결 URL

JVM은 UTC 타임존을 가지고 있지만 제 연결의 URL에선 KST 기준으로 세션이 만들어집니다.
DATETIME임에도 만약 이처럼 JVM과 DB간 서로 다른 TimeZone을 가지고 있다면 시간의 변환이 일어납니다. 조회할 때도 이와 마찬가지로 TimeZone이 다르다면 변환이 일어납니다.

(⚠️서로 다른 TimeZone을 가지고 있다고 해서 항상 시간의 변환이 일어나는 것은 아닙니다. 그 조건에 관한것은 뒤에서 다룰 예정입니다!)

따라서

시간정보 변환
시간정보 변환

위처럼 시간대 변환이 먼저 일어나고

변환된 정보 저장
변환된 시간정보 저장

변환이 일어난 채로 시간정보를 저장하게 됩니다.
따라서 로컬 서버처럼 JVM TimeZone이 KST일 때도 KST로 저장되고 배포서버 처럼 UTC일 때도 동일하게 저장이 됩니다.
(여기서는 DATETIME으로 저장했지만 사실 생성시간은 ‘순간’을 기록하기 위한 목적이 크기 때문에 TIMESTAMP가 더 적절합니다.)

조회할 때도 마찬가지로

데이터 조회
데이터 조회

TimeZone의 차이로 시간 데이터의 변환이 일어나고

조회한 데이터 변환
조회한 데이터 변환

그 결과를 프론트에게 전송하기 때문에 사용자에게 01:00으로 보였던 것입니다.

정리

결과적으로는 KST기준으로 10:00시에 알림을 생성하고 보냈으니 알림 전송시간 역시 KST기준으로 뜨길 바랐지만 배포된 서버의 기본 시간대가 UTC이므로 처음부터 생성 시간을 UTC 기준으로 생성했고, 세션 TimeZone이 KST라서 DB에는 KST 기준으로 변환되어 보여 변환이 일어나는 것을 몰랐을 때는 KST기준으로 잘 생성된 것으로 보여 문제가 없다고 판단했지만 다시 조회할 때는 UTC 시간으로 변환되어 01:00시로 표기된 것입니다.


문제 해결

TimeZone 설정
탐식당 국가 별 활성 사용자 수

이를 해결하기 위한 방안으로 여러가지 방법이 있지만 ‘탐식당’ 프로젝트는 교내 식당을 위한 플랫폼이고 때문에 실제로 위치를 기반으로 국가 별 사용자 수를 나타내는 구글 애널리틱스 통계에 따르면 대부분의 유저가 한국 유저이기 때문에 해외 유저를 고려할 필요가 없다고 판단하여 서버의 JVM 시간대를 KST로 맞추어 주었습니다.(근데 저런 외국에서는 이 앱을 왜 다운로드 받는걸까요..?🤨)

TimeZone 설정
JVM TimeZone KST로 설정

그러면 처음 알림 정보를 생성할 때부터 KST 기준으로 생성할 것이고 DB Connection과 JVM의 시간대도 동일하니 KST 기준으로 저장되고 조회할 수 있겠죠?

데이터 저장
데이터 저장 시
데이터 조회
데이터 조회 시

실제로 개발서버에서 테스트를 해본 결과 정상적으로 잘 동작하는 것을 확인했습니다.

새로운 문제상황
개발 서버 API 정상적인 응답

해결!!…?


새로운 문제상황
프론트 개발자 박모씨의 제보

새로운 문제상황 발생

식단은 제공되는 날짜 정보를 저장하는데 이 역시도 TimeZone의 영향을 받게되어 JVM TimeZone을 바꾸면서 새로운 문제상황이 발생하였습니다.(훨씬 치명적인..)
식단 등록시에 등록한 식단 날짜 정보보다 1일 더 이르게 DB에 저장이 되는 것이었습니다😱

먼저 식단의 날짜 데이터 형식은 LocalDate이고 DB에는 DATE로 저장되는데 이 타입들도 TimeZone 정보를 따로 저장하지는 않습니다. 그런데 저장할 때 1일 이르게 저장이 된다는 것은 TimeZone의 영향을 받아 데이터가 변환이 된다는 것인데 또 어떤 이유로 변환이 되는걸까요..?

원인 분석 및 해결

hibernate의 데이터 변환 과정

LocalDate 형식의 날짜 정보를 저장하기 위해서는 Date형식으로(Java.sql.Date) 타입을 변환시켜야 합니다. 그 과정에서 hibernate에선 valueOf 메서드를 사용합니다.

hibernate-orm
hibernate-orm 중 unwrap 메서드

그리고 valueOf 메서드의 설명을 보면 다음과 같습니다.

public static Date valueOf(LocalDate date)
Obtains an instance of Date from a LocalDate object with the same year, month and day of month value as the given LocalDate.
The provided LocalDate is interpreted as the local date in the local time zone.

valueOf 메서드

마지막 문구에 주목할 필요가 있습니다.
변환 과정에서 local date와 time으로 해석된다고 합니다.
즉, 기본 JVM의 시간대를 따라 해석이 된다는 것이죠. 여기서 또 TimeZone정보가 개입되는 것입니다.

정확히는 java.sql.Date 타입은 java.util.Date를 상속받아 밀리초(epoch milliseconds)를 기반으로 동작하기 때문에 이 때 시스템의 기본 시간대의 하루의 시작을 기점으로 하는 것입니다. 이는 앞에서 생성시간을 저장할 때 사용했던 LocalDateTime/DateTime에도 똑같이 적용됩니다.

그런데 사실 세션의 TimeZone과 관계없이 Date형식의 데이터는 JVM의 TimeZone을 따라 저장됩니다.
DATETIME과는 다르게 만약 날짜가 바뀐채로 저장이 된다면 시간의 정보를 완전히 잃기 때문에 이후에 저장했던 시점과 동일한 TimeZone을 이용한다고 한들 복구가 불가능하기 때문이죠 (8.0.20에서 변경)

그래서 원래는 TimeZone과는 관게없이 생성한 날짜 그대로가 저장되어야 하는게 맞습니다..만!

cacheDefaultTimezone

stackoverflow에서 동일한 사례 질문

바로 DB Connection을 관리하는 hikariCP에서 JVM의 TimeZone 정보를 캐싱을 하는 cacheDefaultTimezone 옵션이 있는데 default 값이 true이기 떄문에 이 캐싱된 TimeZone이 적용되어 런타임 중 바뀐 TimeZone 정보를 제대로 읽어오지 못했던 것입니다.

위 두 가지 원인이 콜라보를 이루면서 날짜 데이터가 변환되어 하루 더 이르게 저장되는 결과를 낳았던 것이었습니다!

위 상황을 다시 그림으로 정리해보겠습니다.

TimeZone 캐싱
Local TimeZone 캐싱

먼저 서버가 구동될 당시에는 JVM의 TimeZone이 UTC였기 때문에 hikariCP에선 이를 캐싱하여 저장하고 후에 런타임 중에 JVM의 TimeZone이 KST로 바뀝니다.

데이터 변환
데이터 타입 변환

그리고 날짜 정보를 생성할고 저장할 때 LocalDate 정보가 Date로 바뀌면서 Time과 TimeZone이 추가된 정보로 바뀝니다.(valueOf의 영향)

캐싱된 TimeZone으로 잘못 해석하여 저장
캐싱된 TimeZone으로 잘못 해석하여 저장

캐싱된 Local TimeZone인 UTC를 기준으로 시간 데이터를 저장하여 하루 이르게 저장됩니다.

따라서 TimeZone과 무관한 정보를 생성했지만 타입 변환과정에서 Local TimeZone정보가 개입되고 동시에 캐싱 정보의 불일치 문제로 UTC로 날짜를 해석하여 하루 이르게 저장이 되는 것 입니다.

새로운 문제상황
cacheDefaultTimezone false 설정

HikariCP가 TimeZone을 cache하지 않도록 설정을 변경한다면 문제를 해결할 수 있습니다.
또는 JVM을 구동할 때부터 KST로 맞추고 캐싱 기능을 살리는 것도 방법이 될 수 있겠죠?

사실 해결방법은 간단하지만 그 원인을 알기 위해서 Java와 MYSQL의 시간 데이터를 어떻게 저장하는지 그리고 그 변환과정과 연결을 하는 Hibernate와 ConnectorJ에 의 동작에 대해서도 알아볼 수 있는 경험이었습니다.🙂


다양한 시간 정보 Types을 위한 옵션들

문제 해결을 위해서 문서들을 찾아보면서 MySQL에서는 시간 정보를 유연하게 다루기 위해서 여러 옵션들을 제공함을 알게 됐습니다! 이를 알아두면 좋을 것 같아서 대표적인 3가지만 소개해보려고 합니다. 참고로 각 옵션들은 서로 영향을 미치며 특정 옵션이 활성화될 때 같이 활성화 해야하기도 하고, 특정 옵션은 무효화되기도 하며 복합적으로 동작합니다.

preserveInstant

preserveInstant = preserve + instant, 즉 순간의 데이터를 보존하기 위한 옵션입니다.
TIMESTAMP의 경우 항상 UTC로 변환되어 저장되기 때문에 ‘순간’을 보존할 수 있지만 DATETIME은 그렇지 않습니다. 하지만 preserveInstant옵션이 어떻게 ‘순간’을 보존하는데 도움을 주는 것일까요?

JVM TimeZone이 UTC+3와 UTC+2인 클라이언트가 각각 존재할 때 세션 TimeZone은 UTC이고 preseveInstant=false라고 해봅시다.
UTC+3인 클라이언트가 2024-10-10 10:00:00인 인스턴스 정보를 DATETIME으로 저장하면 변환없이 그대로 저장하고 UTC+2인 클라이언트가 이를 그대로 읽어오니 서로다른 시간대임에도 10:00시를 그대로 읽어오기 때문에 시점보존이 되지 않습니다.

하지만 preseveInstant=true이라면
2024-10-10 10:00:00을 UTC로 변환하여 저장하여 2024-10-10 07:00:00으로 저장할 것이고

한마디로 TIMESTAMP처럼 TimeZone의 기준점을 제시함으로서 ‘시점’을 유지할 수 있는 것입니다. 물론 이 역시도 세션마다 TimeZone이 다르다면 올바르지 않게 변환되어 시점이 왜곡될 수 있습니다.

참고로 클라이언트에서도 인스턴스 정보를 가지는 경우에 한하여 변환이 일어나는 것입니다. 만약 LocalDateTime을 그대로 저장한다면 변환은 일어나지 않습니다.

하지만 문서에서 지속적으로 말하듯 사실 그런 ‘순간’을 저장하는 인스턴스 정보는 TIMESTAMP로 저장하는 것이 바람직하기 때문에 처음부터 이 시간 정보가 ‘순간’을 저장하기 위한 정보인지 ‘시간’을 저장하기 위한 정보인지를 잘 판단하여 설계하는 것이 중요할 것 같습니다!

connectionTimeZone

세션의 시간대를 명시적으로 지정하는 옵션입니다. 문제상황에서는 Asia/Seoul로 직접 지역을 설정하여 지정했지만 이외에도 LOCAL, SERVER으로 지정하여 JVM의 시간대로 정하거나 DB의 time_zone 값을 기준으로 지정할 수도 있습니다. 만약 SERVER이외의 옵션을 사용한다면 forceConnectionTimeZoneToSession 옵션을 같이 true로 설정하는 것이 좋습니다.

forceConnectionTimeZoneToSession

MySQL session time_zone 변수를 세션 시간대로 변경하는 옵션입니다. 이 옵션을 활성화하지 않아도 클라이언트 측에서는 지정한 세션 시간대로 인지하여 문제가 없지만 서버측에서 session time_zone을 사용하는 NOW(), CURTIME(), CURDATE() 함수들이 동작할 때 다른 시간대로 동작할 수 있기 때문에 connectionTimeZone=SERVER이거나 DB와 항상 같은 시간대임이 확실한 경우가 아니라면 활성화하는 것이 좋아보입니다.

총정리

  1. 기본적으로 Java의 LocalDate, LocalDateTime 타입과 MySQL에서 Date, DateTime 타입은 TimeZone 정보를 포함하지 않는다.
  2. MySQL의 TIMESTAMP 타입은 시간 정보를 항상 UTC로 변환하여 저장하여 시점을 유지한다.
  3. Hibernate에서는 LocalDate, LocalDateTime 속성이 있는 엔티티를 저장할 시에 java.sql.Date, DateTime으로 변환시키기 때문에 간접적으로 TimeZone 정보가 개입되므로 preserveInstant 옵션에 의해서 시간정보가 변환될 수 있음에 주의하자
  4. preserveInstant옵션으로 ‘시점’정보를 갖는 데이터는 저장할 때는 JVM TimeZone에서 Session TimeZone으로, 조회할 때는 Session TimeZone에서 JVM TimeZone으로 변환될 수 있다.(간접적으로 시점정보를 갖는 epoch millisecond도 포함)
  5. HikariCP에서 JVM TimeZone 정보를 캐싱하는 것이 주의하자(캐싱이 디폴트, 정보 불일치 주의, 특히 JVM을 기준으로 저장하는 LocalDate의 경우)
  6. (제일중요👑) DB 설계 시에 처음부터 시간을 저장할지, 시점을 저장할 지를 유의해서 적절한 타입을 선택하고 ConnectorJ에 시간을 다루는 다양한 옵션이 있으니 필요시에 이를 적극 활용하자

참고자료

mysql - The DATE, DATETIME, and TIMESTAMP Types
mysql - Preserving Time Instants
mysql - Datetime types processing mysql - 8.0.20 패치노트
mysql - bug #98436
Github - hibernate-orm
Java docs - LocalDate
Bealdung - Converting Between LocalDate and SQL Date

This post is licensed under CC BY 4.0 by the author.