DB와 서버의 ZonedDatetime 시간 불일치 이슈 - 보통
이번 시간에는 DB존과 서버존의 시간 불일치의 원인과 해결 방법에 대해 이야기 해보려고 한다.
우선 이번에 있었던 이슈에 대해서 말해보자면 DB서버 시간과 서버의 시간이 일치하지 않아 생긴 문제였다.
QA환경에선 디버깅이 힘들었는데 DEV와 PRODUCT 에선 문제가 없다가 QA 에서만 이슈가 나서 원인 파악에 나섰다.
일반적인 문제의 유형
Java의 경우 LocaDatetime 을 사용한다고 가정하면 가장 많이 나오는 문제는 아래와 같다.
LocalDateTime.now()를 사용하여 DB에 저장을 할 때, 서버 시간 기준 UTC로 생성 된 시간과 DB 저장되어 내가 생각한 NOW의 시간이 서로 다른 경우이다.
해당 문제는 인스턴스 서버의 운영체제 시간을 KST로 변경해주거나 DB의 시간을 UTC로 변경 후 서버에서 데이터를 조회할 때 KST로 변경해주는 방법 등 여러 방법으로 간단하게 해결이 가능하다.
여기서 우리가 알아야 하는 것은 LocalDateTime.now()은 서버 기준의 Now() 란 것이다.
즉 서버의 기준으로 현재 시간인 것이고 DB의 기준으로는 또 다를 수 있다.
왜냐하면 DB Now()도 존재하기 때문이다.
예를 들어 DB에서 Trigger로 Now()를 사용해 DB 시간 기준의 현재 시간을 넣을 수도 있고
JPQL을 사용해서 Native 키워드로 네이티브 쿼리를 만들어 Now()를 날리면 DB 기준으로 현재 시간이 저장 할 수 있는 것이다.
이처럼 VM의 설정이나 DB의 설정이 같은 환경이거나 설정 변경이 가능하다면 문제는 쉽게 해결 될 수 있다.
그러나 문제는 환경이 각각 다르거나 설정 변경이 불가능 할 때이다.
이번에 논의 할 문제의 유형
예를 들어보겠다.
아래의 예처럼 서버의 소스 코드는 같은데 개발 환경과 QA 환경의 DB나 서버의 운영체제 시간이
다른 경우이다. DB Now()로 생성한 데이터를 조회하였다고 생각해보자.
우리는 QA 환경에서 조회 결과가 UTC로 2024-09-11 00:00:00 로 받아지기를 예상하지만 값이 이상하게 온다.
그럼 QA의 서버의 운영체제나 JVM 시간을 변경할 것인가?
아니면 DB를? 이러한 경우에 QA 환경의 DB가 다른 팀도 같이 사용 중이라 설정을 바꿀 수 없다면?
위의 문제가 왜 문제가 되는지 구체적으로 이야기 해보겠다.
우선 DB의 datetime에 KST 09-11 09:00:00 이란 데이터가 들어가 있다고 가정하자.
이걸 서버에서 가져오면 우린 당연히 09-11 09:00:00으로 보여질 것이라고 생각한다. 그런데 해당 데이터는 2024-09-11 18:00:00으로 표시된다…?
잘못 나오면 2024-09-11 00:00:00으로 표시된다면 이해가 되지만 2024-09-11 18:00:00은 어디서 온 값인가?
해당 이슈를 이해하려면 우선 DB의 데이터 타입과 JAVA의 데이터 타입에 대해 이해할 필요가 있다.
DB의 datetime 과 timestamp
1. **DATETIME**의 특성
- 타임존에 무관: DATETIME은 타임존을 고려하지 않고 시간을 저장한다. 즉, 저장된 시간 자체가 변하지 않으며, 저장할 때와 조회할 때 동일한 값을 반환한다.
- 타임존 변환 없음: 타임존 변환 없이 저장된 그대로의 시간이 유지되기 때문에, 조회 시 타임존을 변환할 필요가 없다.
- 명시적 변환 필요: DATETIME을 사용하면 애플리케이션이나 쿼리에서 타임존을 직접 변환해줘야 한다. 예를 들어, KST로 저장된 데이터를 UTC로 변환하려면 쿼리에서 CONVERT_TZ() 같은 함수를 사용해야 한다.
**TIMESTAMP**의 특성
- 자동 타임존 변환: TIMESTAMP는 서버의 타임존에 맞게 저장되고, 조회할 때는 세션 타임존에 따라 자동으로 변환된다. 즉, 서버에서 UTC로 조회하려고 할 때 타임존 변환을 수동으로 할 필요 없이 자동으로 변환된 UTC 시간으로 반환된다.
- UTC로 저장, 타임존에 따라 변환: TIMESTAMP는 내부적으로 UTC로 저장되고, 조회 시 세션 타임존에 맞게 변환되므로, UTC 기반으로 일관된 시간 처리를 하고 싶다면 적합하다.
특징 | DATETIME | TIMESTAMP |
타임존 처리 | 타임존 변환 없음 (저장된 그대로 유지) | UTC로 저장, 조회 시 타임존에 맞게 자동 변환 |
타임존 자동 변환 | 없음 (명시적 변환 필요) | 있음 (자동 변환) |
사용 시기 | 타임존을 고려하지 않는 경우나 특정 시간 기록 시 | 글로벌 서비스, 타임존 변환이 필요한 경우 |
타임존 의존성 | 없음 (타임존을 무시하고 사용) | 타임존에 의존 (저장 시 UTC로 변환) |
적합한 경우 | 타임존 변환이 필요하지 않거나, 변환을 수동으로 처리 | 타임존 변환이 필요한 글로벌 애플리케이션 |
위의 내용을 비교하기 위해 DB의 데이터 데이터로 보여주려고 한다.
SELECT @@GLOBAL.time_zone, @@SESSION.time_zone
위 쿼리를 실행해 보면 아래와 같이 DB의 타임존이 System(KST:한국 시간)으로 설정 되어있는 것을 볼 수 있다.
아래는 테스트를 위한 데이터이다 Id7의 데이터를 보자.
now()로 datetime과 timestamp에 데이터를 생성하면 2024-09-11 16:49:02 가 저장됨을 볼 수 있다.
당연히 timestamp도 같은 값이 들어간다. 여기서
SET GLOBAL time_zone = 'UTC';
SET time_zone = 'UTC';
의 쿼리로 설정을 바꿔보면
로 설정이 바뀐걸 확인할 수 있다. datetime 데이터는 당연히 변동이 없는데
특이한 건 timestamp의 값이 2024-09-11 07:49:02 로 UTC 시간으로 바뀌었다는 것이다.
즉 timestamp는 utc로 저장되고 time zone 설정에 따라 컨버팅 되어 나온다는 것이다.
여기서 가장 중요한 건 타임존을 신경 쓴다 하더라도 DB에선 타임 존의 정보를 시간 정보와 같이 관리 하지
않는 다는 것이다.
DB는 저장된 데이터에 타임존 정보까지 같이 저장하지 않는 다는 것이다.
그저 시스템의 설정 된 타임존, 글로벌의 타임존에 따라 컨버팅을 할 수 있지만 결국 타임존에 대한 정보
서버에서 반영한다는 것이다!!
그럼 이번엔 JAVA로 가보자
JAVA의 LocalDateTime 과 ZoneDateTime
1. LocalDateTime: 타임존이 없는 날짜와 시간
LocalDateTime은 타임존을 고려하지 않은 날짜와 시간을 나타내는 클래스다. 즉, 타임존 정보 없이 단순히 연도, 월, 일, 시간, 분, 초를 기록한다.
- 타임존 독립적: LocalDateTime은 특정 타임존 정보 없이 시간 데이터를 처리한다.
- 저장된 값이 그대로 유지: 타임존에 따른 변환이 없으므로, 저장한 값은 그대로 유지된다.
- 주로 특정 날짜나 시간을 그대로 유지하고 싶을 때 사용된다. 예를 들어, 이벤트 시간이나 기념일과 같이 시간대와 상관없이 같은 시간을 기록하고 유지해야 하는 경우에 적합하다.
주요 특성:
- 타임존 없음: 저장된 시간에 타임존이 없으며, 타임존 변환이 불가능하다.
- 저장된 값 그대로 유지: 언제나 저장한 그대로의 값을 반환한다.
2. ZonedDateTime: 타임존이 포함된 날짜와 시간
ZonedDateTime은 타임존 정보를 포함한 날짜와 시간을 나타내는 클래스다. 즉, 특정 타임존에 맞춰 시간 데이터를 처리하고, 타임존 변환이 가능하다.
- 타임존 포함: ZonedDateTime은 타임존 정보를 포함하며, 다른 타임존으로 변환할 수 있다.
- 글로벌 시간 처리에 적합: ZonedDateTime은 서버와 클라이언트가 다른 시간대에 있는 환경에서 사용하기 적합하며, UTC 또는 특정 시간대를 기반으로 시간 데이터를 일관되게 관리할 수 있다.
- 타임존에 따른 시간 변환: 저장된 시간은 타임존에 따라 변환되어 표시됩니다. 예를 들어, KST 시간을 UTC로 변환하거나, 반대로 UTC를 특정 타임존으로 변환할 수 있다.
주요 특성:
- 타임존 정보 포함: 시간 데이터에 타임존을 포함하여 관리한다.
- 타임존 변환 가능: 다른 시간대로 변환할 수 있으며, 글로벌 시간 처리가 가능하다.
특성 | LocalDateTime | ZonedDateTime |
타임존 정보 | 타임존 정보 없음 | 타임존 정보 포함 |
타임존 변환 가능 여부 | 불가능 (저장된 값 그대로 사용) | 가능 (타임존에 맞게 변환 가능) |
사용 시기 | 특정 지역의 시간 정보를 그대로 유지할 때 (예: 이벤트 시간) | 글로벌 시간 처리가 필요한 경우 (예: 서버-클라이언트 간 시간 변환) |
유사한 MySQL 데이터 타입 | DATETIME | TIMESTAMP |
서버 타임존을 설정하는 기준
한 가지 더 알아야 할 것이 있다.
우리가 DB는 지역 정보를 전달해주지 않고 그저 날짜 시간 정보를 준다는 것은 알았다.
결국 서버에서 날짜 시간 데이터에 + 지역 정보를 추가해주는 것인데(ZonedDateTime을 쓴다면)
어떤 기준으로 설정이 되는 것일까?
서버의 시간을 정하는 건 기본적으로 운영체제의 시간이다.
그러나 우린 이것 외에 2가지를 더 고려 해야 한다.
- JDBC
- JVM 시간
여기서 가장 우선 되는 것은 JDBC의 명시적 타임존이다.
String url = "jdbc:mysql://localhost:3306/yourdb?serverTimezone=UTC";
JDBC url 맨 뒤에 serverTimezone 으로 설정을 해주는데 해당 DB의 연결 시 어떤 타임존을 사용 할 것인지 명시적으로 정해주는 것이다.
해당 내용이 없으면
JVM의 타임존에 따라 가는데 일반적으로는 JVM의 타임존은 별다른 설정이 없다면
운영체제를 따라간다고 한다.
그럼 실제 데이터를 여러 상황에 따라 조회해보고 분석해 보자
실제 데이터의 여러 상황 별 테스트
우선 데이터를 두 가지 데이터 타입으로 각각 나누고 각각의 환경에 따라 어떻게 받아지는지 확인해보겠다.
서버 | DB |
ZonedDateTime | datetime |
DateTime | timestamp |
또한 서버와 DB의 시간은 UTC와 KST 두 가지로 나누어 테스트를 해보겠다.
먼저 DB의 데이터이다.
4개의 컬럼과 2개의 로우가 존재한다.
zonedatetime : datetime (서버의 받는 데이터 타입은 ZonedDatetime)
localdatetime : datetime (서버의 받는 데이터 타입은 DateTime)
zonetimestamp : timestamp (서버의 받는 데이터 타입은 ZonedDatetime)
local_timestamp : timestamp (서버의 받는 데이터 타입은 DateTime)
id 1은 DB의 설정을 KST로 설정 한 후 현재 시간을 만든 데이터고
id 5는 DB의 설정을 UTC로 설정 한 후 현재 시간을 만든 데이터다.
DB의 time_zone 세팅은 아래와 같이 확인 가능하다.
SELECT @@GLOBAL.time_zone, @@SESSION.time_zone, @@system_time_zone;
system_time_zone은 DB의 로컬 서버를 기준으로 정해지기 때문에 현재 KST이고
Global과 Session 이 SYSTEM으로 정의되면 system_time_zone 을 따라 간다는 것이다.
d 1은 system으로 global 과 session이 설정 되어 있을 때 NOW() 로 생성한 날짜이고(KST)
id 5은 아래처럼 system을 utc로 설정했을 떄 NOW()로 생성한 날짜이다. (UTC)
테스트 1
서버 JVM(운영체제) 타임존 KST,
JDBC url 타임존 설정 x
DB global session 타임존 KST
출력 코드
//JVM 타임존 출력
System.out.println("JVM 타임존: " + java.util.TimeZone.getDefault().getID());
//ID1
ZoneEntity zoneEntity = zoneRepository.findAll().get(0);
System.out.println("zonedatetime: "+ zoneEntity.getZoneDateTime());
System.out.println("localdatetime: "+ zoneEntity.getLocalDateTime());
System.out.println("zonetimestamp: "+ zoneEntity.getZonetimestamp());
System.out.println("localtimestamp: "+ zoneEntity.getLocaltimestamp());
//ID5
ZoneEntity zoneEntity2 = zoneRepository.findAll().get(1);
System.out.println("zonedatetime: "+ zoneEntity2.getZoneDateTime());
System.out.println("localdatetime: "+ zoneEntity2.getLocalDateTime());
System.out.println("zonetimestamp: "+ zoneEntity2.getZonetimestamp());
System.out.println("localtimestamp: "+ zoneEntity2.getLocaltimestamp());
출력 결과
JVM 타임존: Asia/Seoul
//id1
zonedatetime: 2024-09-09T15:25:51+09:00[Asia/Seoul]
localdatetime: 2024-09-09T15:25:51
zonetimestamp: 2024-09-09T15:25:51+09:00[Asia/Seoul]
localtimestamp: 2024-09-09T15:25:51
//id5
zonedatetime: 2024-09-09T07:52:04+09:00[Asia/Seoul]
localdatetime: 2024-09-09T07:52:04
zonetimestamp: 2024-09-09T16:52:04+09:00[Asia/Seoul]
localtimestamp: 2024-09-09T16:52:04
id1은 DB에서 KST로 생성 된 시간이고 서버의 jdbc 설정은 안되어 있으나 JVM타임존은 KST 이기 때문에
id1의 출력은 DB와 차이가 없다.
하지만 id5부턴 문제가 존재한다.
UTC로 생성 된 데이터기 때문에 timestamp로 만들어진 zonetimestamp 과 localtimestamp 은 UTC 설정으로 저장 되었어도 DB 저장 당시에는 UTC로 저장되었지만 KST로 설정이 바뀌면 UTC->KST로 변경해주기
출력에 문제가 없다.
그러나 zonedatetime의 경우 UTC 시간 정보가 KST로 변환되어 +09:00가 붙어버린 걸 볼 수 있다.
즉 UTC 시간에 + 한국지역타임이 붙어버렸다.
해당 데이터는 UTC 지만 KST로 인지되고 이 데이터를 UTC로 타임존 변환을 하면 UTC 원래 데이터에서 또 한번 -9시간을 하는 이상한 데이터로 변하게 된다.(-9시간을 두번 하는 상황)
출력 코드
System.out.println("zonedatetime: "+ zoneEntity.getZoneDateTime().withZoneSameInstant(ZoneId.of("UTC")));
위 코드는 withZoneSameInstant()를 메서드 사용을 보여주는데 ZonedDatetime을 지역시간 기준을 바꿔 시간정보를 바꿔주는 메서드이다. 일반적으로 UTC 시간을 다시 withZoneSameInstant(ZoneId.of("UTC")) 로 하여도 같은 시간이 나와야 한다.
그러나 zonedatetime 은 UTC 원본 데이터를 KST로 서버에서 타임존을 붙여버렸기에 KST로 인식되고 withZoneSameInstant(ZoneId.of("UTC"))를 하게 되면 UTC 시간에 -9시간을 더하게 된다.
예상 결과랑 다른 실제 결과
zonedatetime: 2024-09-08T22:52:04Z[UTC]
테스트 2
서버 JVM(운영체제) 타임존 KST,
JDBC url 타임존 설정 UTC
DB global session 타임존 KST
출력 코드는 테스트1과 같기 때문에 생략하겠다.
JVM 타임존: Asia/Seoul
//id1
zonedatetime: 2024-09-10T00:25:51+09:00[Asia/Seoul]
localdatetime: 2024-09-10T00:25:51
zonetimestamp: 2024-09-10T00:25:51+09:00[Asia/Seoul]
localtimestamp: 2024-09-10T00:25:51
//id5
zonedatetime: 2024-09-09T16:52:04+09:00[Asia/Seoul]
localdatetime: 2024-09-09T16:52:04
zonetimestamp: 2024-09-10T01:52:04+09:00[Asia/Seoul]
localtimestamp: 2024-09-10T01:52:04
여기서 부터 본격적으로 이상해지기 시작한다.
분명 KST 데이터이지만 UTC로 받게 된 뒤 +9시간을 해 KST 타임존으로 보여주고 있다.
id5의 경우 UTC now로 생성되었던 zonedatetime과 localdatetime 만 문제가 없는 상황
결국 문제는 동일하다. DB에서 타임존을 알려주지 않으니 서버가 부여하다가 나는 문제이다.
해결 방법
서버 측에서 DB의 타임존과 같은 형식으로 명시적으로 선언해줘야 문제를 해결할 수 있다.
추가적으로 ZonedDateTime으로 한번 통일시켜 놓으면 UTC로 변환을 하나 KST로 변환을 하나 중복으로 변환해도 문제가 없다. 이는 서버에서의 withZoneSameInstant 뿐 아니라 Json으로 내려진 클라이언트 사이드의 데이터에도 동일하게 적용된다.
예를 들어 Json으로 직렬화 된 ZonedDateTime은 아래와 같다.
{
// UTC
"timestamp": "2024-09-11T03:00:00Z"
or
"timestamp": "2024-09-11T12:00:00+09:00"
}
{
//KST
"2024-09-11T12:00:00+09:00"
or
"2024-09-11T12:00:00[Asia/Seoul]"
}
ZonedDateTime으로 잘 변환이 되면 뒤에 z 혹은 +시간이 붙기 때문에 클라이언트에서 아래와 같이 타임존 변환을 해주면 중복으로 변환하더라도 문제 없다.
function responseDateFormatZone(dateStr) {
return moment(dateStr).tz('Asia/Seoul').format('YYYY-MM-DD HH:mm:ss');
}
혹여나 왜 JAVA에서 LocalDateTime을 안 쓰고 ZonedDateTime을 썼냐고 물어본다면 시간과 타임존을 함께 관리하면서 일관된 시간처리가 가능하기 때문이라고 말하겠다. 한번 타임존을 잘 맞춰 논다면 일관되게 시간 변경이 가능하다는 것이다.
결론적으로 아래와 같은 방법으로 서버의 타임존을 DB의 타임존과 일치만 시켜주면 해결할 수 있다.
- DB의 타임존과 JDBC or 운영체제의 타임존을 맞춰준다.
- ZonedDateTime으로 받는다
- DTO or View에서 원하는 타임존으로 변환한다.
이렇게 맞추더라도 문제가 해결이 안된다면 타임존을 이용한 시간 변경의 중복이 아니라
다른 곳에서 TimeZone의 세팅을 변경하는 Initializer가 있는지를 확인해 봐야 한다.
DB와 JAVA의 타입의 고민 없이 날짜를 받아오고 문제가 생기면 그 부분만 수정하여 해결하다
디버깅이 불가능하고 각 타임존 설정을 자유롭게 바꿀 수 없는 상황이 되니 문제지점이 명확하게 파악이 어려운 이슈가 있었다.
혹시라도 시간의 포멧의 문제가 난다면 고민 없이 포멧만 맞추려고 하지 말고 해당 원인을 명확하게 파악하고 변경의 장애점을 수정하여 해결하면 시간 리소스를 절약 할 수 있을 것으로 보인다.