나그
[TroubleShooting] 스프링 부트 2에서 Instant 사용하기 2탄 - DB에 올바른 시간 입력하기
들어가며... & 이전 이야기
부크부크에서 LocalDateTime를 Instant로 변경하기로 한 나그!
여러 문서를 보고 Instant로 변경에 성공한다.
하지만 DB에 UTC 적용시가 아닌 이상한 시간(타임존이 적용된 시간)이 입력되는데...
일단 화면을 볼까...
이전의 코드에 더해 시간 조회 기능도 만들었고, data.sql을 통해 DB(Mysql)에 CURRENT_TIMESTAMP로 DB의 현재 시간도 찍었다. (코드는 여기에 있다. https://github.com/nag0124/laboratory)
그리고 테스트를 해보면...
DateTimeTest.java
@Test
void postNGet() {
// given
RestAssured.post("/date-time"); // 일단 시간을 찍고
// when & then
RestAssured.get("/date-time") // 조회하서 로그를 띄워주어라
.then().log().all();
}
빨간색 부분이 data.sql을 통해 DB의 현재 시간을 찍은 것이고, 노란색 부분이 스프링에서 찍은 시간이다.
여기에서 알 수 있는 점을 정리해보면, (참고로 스프링의 타임존은 KST(+09:00)고 DB의 타임존은 UTC이다.)
Spring에서
Instant를 찍어도 DB에는 Spring의 타임존이 적용되어 저장된다. (Spring 7시 입력 -> DB에 16시 저장)시간을 DB에서 찍고 Spring에서
Instant로 가져오면 Spring의 타임존이 역으로 적용된다. (DB 7시 저장 -> Spring에 전날 22시 입력)Spring에서 DB로 시간을 저장하고, 저장된 시간을 Spring에서 읽으면 값에 변동이 없다.(Spring 7시 입력 -> DB에 16시 저장 -> DB의 16시 읽음 -> Spring에 7시로 입력)
어...? 그러니까 쓰는데는 지장이 없는거 아닌가...?
위의 3번의 상황을 그림으로 그려보면 아래와 같다.

Spring에서
Instant.now()메서드를 통해 7시의Instant객체를 만든다.이 객체는 DB에`TIMESTAMP` 타입으로 16시의 데이터를 저장된다.
DB에서 16시의
TIMESTAMP데이터를 전달한다.Spring에서 이 데이터를 7시의
Instant객체로 변환한다.
한마디로 지금 쓰는 데는 지장이 없다.
이상한 값이 저장되기는 하지만, Spring에서는 값을 읽고 쓸 때 올바른 UTC값이 들어간다.
그리고 지금은 DB에서 시간을 찍지도 않는다.
하지만 안정적인 시스템이냐고 물어보면 그건 아닌 것 같다.
스프링의 타임존이 바뀐다면...? DB에서 시간을 찍게 된다면...? DB에 저장된 값들을 사용하기 위해선 따로 처리를 해야 할 것이다.
결국 나는 결정을 해야 했다.
양념이냐 후라이드냐, 짜장이냐 짬뽕이냐, 그냥 쓸 것인가 문제를 해결할 것인가...
나는 변동과 확장에 안정적인 시스템을 만들기 위해 나는 문제를 더 파기로 했다.
문제가 어디에서 발생할까?
디버깅을 해보고 로그를 확인해보니 Spring과 DB 사이에 이상한 점이 있었다.
Spring 로그
Hibernate:
insert
into
date_time
(created_time)
values
(?)
2024-05-22 17:13:12.070 TRACE 6378 --- [o-auto-1-exec-2] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [TIMESTAMP] - [2024-05-22T08:13:12.040609Z]DB 로그
2024-05-22T08:13:09.845061Z 229 Query SELECT @@session.transaction_read_only
2024-05-22T08:13:09.846066Z 229 Query INSERT INTO date_time (created_time) VALUES (CURRENT_TIMESTAMP)
2024-05-22T08:13:11.915183Z 229 Query SET autocommit=0
2024-05-22T08:13:12.088538Z 229 Query insert into date_time (created_time) values ('2024-05-22 17:13:12.040609')
2024-05-22T08:13:12.129760Z 229 Query commit
2024-05-22T08:13:12.133393Z 229 Query SET autocommit=1
2024-05-22T08:13:13.276707Z 229 QuitSpring에서는 로깅 옵션을 주어 쿼리에 바인딩되는 값을 출력하도록 했고, DB에서는 generl.log를 저장하도록 옵션을 주었다.
로그의 시간이 다른건 스프링은 OS 시간(KST)을 찍고 있고, DB는 Docker로 UTC 타임존을 셋팅 했기 때문이다.
DB 로그의 4번째 줄이 실제 INSERT 쿼리인데, 쿼리의 값이 Spring의 로그에서 보여주는 값과는 다르다.
(스프링: 2024-05-22T08:13:12, DB: 2024-05-22 17:13:12)
한마디로, Spring과 DB의 통신 과정에서 타임존이 적용되는 문제가 발생한다.
Instant 클래스는 타임존 정보를 가지고 있지 않지만, DB에 저장될 때는 타임존이 적용되어서 저장이 된다.
해결해보자.(1편)
이 문제에 대해서 검색을 하고 여러 글들을 참고해서 3가지의 해결책을 찾을 수 있었다.(셋 다 성공한다.)
spring.datasource.url에serverTimezone=UTC을 추가하여 서버 타임존 설정하기spring.jpa.hibernate.jdbc.time_zone=UTC을 추가하여 Hibernate의 JDBC의 타임존을 UTC로 설정하기Java 어플리케이션을 실행할 때
-Duser.timezone=UTC로 어플리케이션의 타임존 자체를 변경하기
이렇게 문제를 해결할 수는 있었지만, 찜찜했다.
어느 부분이, 어떻게 문제인지 모르고 "아~ 이렇게 하면 되는구나~" 이렇게 넘어가는 느낌이었다.
그래서 원인을 파악하고자 더 파보기로 했다.
문제를 해결한 지금 다시 생각해보면, 나는 여기서 멈췄어야 했다.

아니야... 그거 아니야... 하지마!!!!! 멈춰!!!!!!!!!!!
더 파보자...
검색하다 Mysql Connector/J의 개발자 가이드의 이 문서를 찾았다.
잠깐! Mysql Connector/J란?
자바는 DB 사이의 연결을 위해 JDBC(Java DataBase Connectivity) API를 사용한다.
이 중 Mysql을 쓰기 위한 JDBC 구현체가 Mysql Connector/J이다.
https://dev.mysql.com/doc/connector-j/en/connector-j-time-instants.html
이 글에서는 Mysql Connector/J를 사용하여 Mysql 특정 시점을 DB에 어떻게 보존할 수 있는지를 알려준다.(원제: Preserving Time Instant)
그리고 이 글에서 얻은 내용을 바탕으로 문제를 파악, 해결할 수 있었다. (아래 내용은 거의 이 문서의 번역본이다.)
문제 심층 해부
이 글을 읽고 정리해본 지금 상황은 아래와 같다.

7시의 Instant 객체가 DB에 저장하는 과정을 살펴보면,
Instant를 DB에 저장하기 위해 16시의Timestamp객체로 변환된다. (Instant는 UTC이고Timestamp는 KST로 설정되이기에 9시간이 더해진다.)Connector/J의 시간대가 Spring과 동일하기에 16시를 변환하지 않고 그대로 DB Session에 전달한다.
DB의 Timezone과 Session Timezone이 같기 때문에 변환하지 않고 UTC 16시가 저장된다.
DB에서 Spring으로 값을 보내주는 것은 이 과정의 역순이다.
이 과정에서 내가 파악한 문제의 핵심은 Connector/J(KST)에서 넘겨주는 시간 값을 DB Session(UTC)에서 변동하지 않고 값을 그대로 읽는다는 것이다.
문제 해결 방안 탐색
먼저 DateTime과 관련된 Mysql의 여러 옵션을 살펴봤다.
connectionTimezone: Connector/J의 타임존을 설정하는 속성LOCAL(default): 스프링(원문에서는 JVM이라고 지칭)의 타임존을 사용한다.
SERVER: 서버(DB)의 타임존을 사용한다.
User Defined Timezone: 서버, 로컬 타임존을 사용하지 않고 자유롭게 지정할 수 있다.(ex: connectionTimezone=Europe/Moscow)
preserveInstants: 시간을 보존하기 위해 Connector/J와 Spring(JVM)의 사이에서 값을 변환하는 속성TRUE(default): Connctor/J와 Spring의 타임존이 다르다면 값을 변환한다.
FALSE: Connctor/J와 Spring의 타임존이 다르더라도 값을 변환하지 않는다.
만약 connectionTimeZone=LOCAL 옵션을 사용하고 있다면 이 옵션이 적용되지 않는다.
forceConnectionTimeZoneToSession: Session Timezone을 Connection Timezone으로 변환하는 옵션FALSE(default): Session 타임존을 DB 타임존으로 설정한다.
TRUE: Session 타임존을 Connection 타임존으로 설정한다.
만약 connectionTimeZone=SERVER 옵션을 사용하고 있다면 이 옵션이 적용되지 않는다.
이 속성을 TRUE로 변경한다면, CURRENT_TIMESTAMP 같은 DB의 함수가 영향을 받을 수 있으니 주의해야 한다.
이 정보와 DB의 특성을 바탕으로 생각했을 때, 값을 변환할 수 있는 부분은 두 군데라고 생각했다.

Spring과 Connector/J 사이: preserveInstants 과 connectionTimeZone 속성을 설정해 변환할 수 있다.
DB Session과 Table 사이: DB session의 타임존이 DB 타임존과 다르면 DB에서 값을 변환하기에 이를 이용하면 된다.
안타깝게도 내가 찾아봤을 때 Connector/J에서 Session으로의 변환방법은 없었다.
해결해보자(2)
아래 방법들에서 사용한 테스트 코드는 아래와 같다.(전체 코드는 여기서 볼 수 있다. 링크)
@Test
void postNGet() {
// given
RestAssured.post("/date-time"); // 미리 만들어 놓은 함수로 Spring의 timestamp를 찍는다.
// when & then
RestAssured.get("/date-time") // 찍힌 모든 Timestamp를 가져온다.
.then().log().all();
}1. Spring과 Connector/J 사이에서 변환
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/laboratory?preserveInstants=TRUE&connectionTimeZone=SERVER // preserveInstants 속성은 default가 TRUE이기에 넣어주지 않아도 된다.흐름도

DB 쿼리

UTC로 변환된 값이 들어갔다.
테스트 결과

위의 창은 DB의 table을 조회한 것이고, 아래 json은 테스트 결과이다.
1번 레코드는 data.sql에서 CURRENT_TIMESTAMP로 찍은 값이고 2번 레코드가 Spring에서 생성해서 보낸 값이다.
시간이 잘 변환되고 잘 저장된다.
2. DB Session과 Table 사이에서 변환
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/laboratory?preserveInstants=FALSE&forceConnectionTimeZoneToSession=TRUE // connection time zone이 LOCAL이기에 preserveInstants 속성은 안 넣어도 된다.흐름도

DB 쿼리

Session을 Asia/Seoul로 설정했고, INSERT 쿼리도 KST시간으로 들어갔다.
테스트 결과

위의 창은 DB의 table을 조회한 것이고, 아래 json은 테스트 결과이다.
1번 레코드는 data.sql에서 CURRENT_TIMESTAMP로 찍은 값이고 2번 레코드가 Spring에서 생성해서 보낸 값이다.
시간이 잘 변환되고 잘 저장된다.
3.Spring과 Connector/J 사이에서 변환 + DB Session과 Table 사이에서 변환 (변환이 두개!)
Connection 타임존으로는 모스크바 타임존(Europe/Moscow)를 골랐다.
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/laboratory?preserveInstants=TRUE&connectionTimeZone=Europe/Moscow&forceConnectionTimeZoneToSession=TRUE // preserveInstants 속성은 생략해도 된다.흐름도

그림과 같이 Spring(KST)에서 Connector/J(MSK)로, DB session(MSK)에서 DB table(UTC)로 변환이 2번 일어난다.
DB 쿼리

Session 타임존이 Europe/Moscow로 설정되었고, DB에 모스크바 타임존이 적용된 값이 잘 전달됐다.
테스트 결과

잘 들어갔다.
결론
Connector/J, DB Session, DB의 타임존을 일치시키면 특정 시점을 보존할 수 있다.(DB, Spring 등 어떤 시간대가 변동되어도 시점은 영향 받지 않는다)
Connector/J와 DB Session 시간을 Spring(JVM)과 일치시키면 특정 시점을 보존할 수 있다.
변태같긴 하지만 3번 해결책과 같이 두 번 변환해서 시점을 보존할 수도 있다.
생각해보니까 Spring, DB의 타임존을 UTC말고 다른 타임존으로 설정하는 것 자체가 이상하다. (굳이...?)
아 참, 그리고
Instant외에도OffsetDateTime,ZonedDateTime같이 자바에서 타임존 정보가 있는 시간 클래스는 위의 해결책들을 사용하여 시점을 보존할 수 있다.하루만에 글을 번역하고 실험하고 글을 쓰는 짓은 하지 말자.

++조금만 더...
이전에 찾았던 3가지 해결방법을 생각해보면 이런 결론이 나온다.
1. spring.datasource.url에 serverTimezone=UTC을 추가하여 서버 타임존 설정하기
Mysql 8.0.23 버전 이상에서 serverTimezone과 connectionTimeZone 속성은 같은 속성이다.(링크: )
그렇지만 connectionTimeZone을 UTC로 하드코딩한다면 DB의 시간대가 UTC에서 다른 타임존으로 바뀌었을 때 시점을 보존할 수 없다.
만약 이걸 쓴다면
connectionTimeZone=SERVER로 변경해서 쓰겠다.
2. spring.jpa.hibernate.jdbc.time_zone=UTC을 추가하여 Hibernate의 JDBC의 타임존을 UTC로 설정하기
1번과 마찬가지의 이유로 UTC로 하드코딩은 위험할 수 있다.
하드코딩 외에 hibernate의 JDBC 타임존을 DB 타임존으로 설정하는 기능은 없기에 차라리 1번 옵션을 쓰겠다.
3. Java 어플리케이션을 실행할 때 -Duser.timezone=UTC로 어플리케이션의 타임존 자체를 변경하기
EC2의 타임존이 UTC가 아니라면 괜찮을 것 같은데, EC2가 UTC말고 다른 타임존을 쓸 이유가 있을까...?
Reference
- https://dev.mysql.com/doc/connector-j/en/connector-j-time-instants.html(Preserving Time Inststans)
- https://dev.mysql.com/doc/connector-j/en/connector-j-connp-props-datetime-types-processing.html#cj-conn-prop_connectionTimeZone (JDBC URL의 시간 관련 속성)
- https://dev.mysql.com/doc/refman/8.4/en/time-zone-support.html#time-zone-upgrades (Mysql에서 어떻게 Timezone 속성을 관리하는가)
- https://chung-develop.tistory.com/143 (해결 방법 소개글)






