[트러블 슈팅] Spring 3.1 이상, Hibernate(JPA) 사용시 발생할 수 있는 이슈 및 내부동작 파헤치기
하이버네이트 6.2 마이그레이션 가이드
https://docs.jboss.org/hibernate/orm/6.2/migration-guide/migration-guide.html#ddl-changes
문제 상황
스프링 부트 3.0 → 3.1 버전업을 하면서 하이버네이트도 덩달아 6.1 → 6.2로 업그레이드 되었다.
이 영향으로 timezone을 다루는 date 타입이 DB에 저장될 때 의도와는 다른 시간으로 저장될 수 있다.
각자 상황에 따라서 의도된대로 잘 동작할 수 있다.
(별 다른 설정이 없으면 하이버네이트에서 시간 변환을 UTC로 강제하기 때문에 DB의 타임존이 UTC로 쓰고 있으면 문제가 없을 수 있다 또는 MySQL 이외에 타임존을 지원해주는 DBMS 사용하면 UTC를 강제하진 않는다)
결론부터 말하면, application.yaml에서 아래 설정을 해주면 된다.
spring.jpa.properties.hibernate.timezone.default_storage: NORMALIZE
의도된대로 동작하더라도 기존 쓰던 방식과 호환하려면 하이버네이트 가이드에 맞춰 위의 설정을 해주는게 좋을듯싶다.
문제상황 예시)
기존 환경
- MySQL의 DB 타임존은 Asia/Seoul로 설정되어 있음
- 애플리케이션 서버(Spring을 사용하는 서버)도 타임존을 Asia/Seoul로 쓰고 있음
- 테이블의 컬럼은 타임존을 활용하는 컬럼 타입 사용: TIMESTAMP(참고로 MySQL의 TIMESTAMP는 내부적으로 UTC로 저장해뒀다가 클라이언트 타임존에 맞춰서 시간을 변환해서 반환해줌)
- MySQL converts TIMESTAMP values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval. (This does not occur for other types such as DATETIME.) By default, the current time zone for each connection is the server's time.
문제 발생 플로우
- 애플리케이션 코드에서 OffsetDateTime.now(ZoneId.of("Asia/Seoul"))를 통해 "2023-11-23 10:00:00+09:00" 시간 정보를 획득
- 엔티티 필드 변수(보통, created_at이나 updated_at 필드가 있을 듯)에 값을 할당
- 해당 엔티티 필드는 MySQL의 TIMESTAMP를 사용
- 기대 상황: "2023-11-23 01:00:00Z"로 저장
- 실제 결과: "2023-11-22 16:00:00Z"로 저장됨
- KST 타임존을 쓰는 MySQL이라 타임존 정보 없이 시간만 보내주면 한국 시간으로 인식해서 저장해버림
- 하이버네이트가 "2023-11-23 01:00:00"라는 String으로 요청함.
- DB에서 쿼리로 select 해보면 클라이언트 타임존이 KST라면, "2023-11-23 01:00:00Z"로 표기되어 헷갈릴 수 있음.
- unix_timestamp(타임스탬프 컬럼) 함수를 이용해서 변환해보면 잘못된 시간임을 알 수 있음.
내부 동작
이제부터 집중력이 필요하다.
org.hibernate.annotations.TimeZoneStorageType 클래스에서 DEFAULT를 보면,
Dialect.getTimeZoneSupport에서 NATIVE 값을 쓰지 않으면 자동으로 NORMALIZE_UTC를 사용한다.
MySQL은 없다.(아마 UTC로 통일해서 저장해서 지원하지 않는 것으로 보임) 그래서 자동적으로 하이버네이트는 NORMALIZE_UTC를 선택한다.
org.hibernate.boot.internal.MetadataBuilderImpl 클래스의 toTimeZoneStorageStrategy(TimeZoneSupport timeZoneSupport) 메서드에서 defaultTimezoneStorage가 'DEFAULT'면서 timeZoneSupport가 'NONE'이면 'NORMALIZE_UTC를 선택하는걸 볼 수 있다.
TimeZoneStorageType이 NORMALIZE_UTC로 선택되면, TIMESTAMP 타입은 org.hibernate.type.SqlTypes 클래스에 있는 TIMESTAMP_UTC라는 상수를 사용하게 된다.
org.hibernate.boot.model.process.spi.MetadataBuildingProcess 클래스에서 getTimestampWithTimeZoneOverride() 메서드에서 jdbcType을 TIMESTAMP_UTC를 선택하는걸 볼 수 있다.
TIMESTAMP_UTC를 핸들링하는 클래스는 org.hibernate.type.descriptor.jdbc.TimestampUtcAsJdbcTimestampJdbcType이다.
getBinder() 메서드에서 st.setTimeStamp를 따라가보면, com.zaxxer.hikari.pool.HikariProxyPreparedStatement를 통해 com.mysql.cj.jdbc.ClientPreparedStatement의 setTimeStamp 메서드를 탄다.
위 코드에서 ((PreparedQuery) this.query).getQueryBidnings().setTimestamp()가 동작하는 곳은 com.mysql.cj.NativeQueryBindings 클래스의 setTimestamp() 메서드이다.
binding.setBinding은 com.mysql.cj.NativeQueryBindValue 클래스의 setBinding을 탄다.
이때 valueEncoder로 받아오는 클래스가 TIMESTAMP 타입일 경우, com.mysql.cj.protocol.a.SqlTimestampValueEncoder를 받아온다.
여기서 문제 발생.
SqlTimestampValueEncoder에서 DB에 넣기 위해 값을 변환하는 과정에서 문제가 발생한다.
getString(BindValue binding)을 통해 쿼리문에 들어가는 값을 확인할 수 있다. 실제 들어갈 값을 만드는 함수는 같은 클래스 내에 encodeAsBinary 메서드로 추측된다.
따로 설정이 없을 경우, TimestampUtcAsJdbcTimestampJdbcType에서 보이듯이 UTC_CALENDAR를 넣어주었기 때문에 아래의 로직을 타게된다.
if (binding.getCalendar() != null) {
buf.append(TimeUtil.getSimpleDateFormat("''yyyy-MM-dd HH:mm:ss", binding.getCalendar()).format(x));
}
해결하기 위해 넣어줘야했다던 설정(spring.jpa.properties.hibernate.timezone.default_storage: NORMALIZE)을 넣게되면, TimestampUtcAsJdbcTimestampJdbcType가 아닌 org.hibernate.type.descriptor.jdbc.TimestampJdbcType을 사용한다.
이때 calendar가 null이기 때문에 else 로직을 타게된다.
else {
this.tsdf = TimeUtil.getSimpleDateFormat(this.tsdf, "''yyyy-MM-dd HH:mm:ss",
binding.getMysqlType() == MysqlType.TIMESTAMP && this.preserveInstants.getValue() ? this.serverSession.getSessionTimeZone()
: this.serverSession.getDefaultTimeZone());
buf.append(this.tsdf.format(x));
}
테스트 해보기
SqlTimestampValueEncoder에서 사용하는 if 로직에서 사용하는 TimeUtil.getSimpleDateFormat의 내부 구현은 아래와 같다.
SqlTimestampValueEncoder에서 사용하는 else 로직에서 사용하는 TimeUtil.getSimpleDateFormat의 내부 구현은 아래와 같다.
테스트 코드
internal class HibernateMigrationTest: BehaviorSpec({
Given("SqlTimestampValueEncoder에서 Timestamp 값으로 변환하는 결과를 확인한다") {
val offsetDateTime = OffsetDateTime.now(ZoneId.of("Asia/Seoul"))
println("현재시간: $offsetDateTime")
// 실제 코드의 변수명
val x = Timestamp.from(offsetDateTime.toInstant())
When("캘린더 정보가 존재해서 if 로직을 탔을 경우") {
val UTC_CALENDAR = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val result = getSimpleDateFormat(
"''yyyy-MM-dd HH:mm:ss",
UTC_CALENDAR
).format(x)
Then("캘린더 정보를 포함해서 시간을 반환한다") {
println("캘린더 정보가 존재해서 if 로직을 탔을 경우, 시간 $result")
}
}
When("캘린더 정보가 없어서 else 로직을 탔을 경우") {
val result = getSimpleDateFormat(
SimpleDateFormat("''yyyy-MM-dd HH:mm:ss"),
"''yyyy-MM-dd HH:mm:ss",
TimeZone.getTimeZone(ZoneId.of("Asia/Seoul"))
).format(x)
Then("입력된 시간 그대로 반환한다") {
println("캘린더 정보가 존재해서 else 로직을 탔을 경우, 시간 $result")
}
}
}
}) {
companion object {
// if 로직
fun getSimpleDateFormat(pattern: String?, cal: Calendar?): SimpleDateFormat {
var cal = cal
val sdf = SimpleDateFormat(pattern, Locale.US)
if (cal != null) {
cal = cal!!.clone() as Calendar
sdf.calendar = cal
}
return sdf
}
// else 로직
fun getSimpleDateFormat(
cachedSimpleDateFormat: SimpleDateFormat?,
pattern: String,
tz: TimeZone?
): SimpleDateFormat {
val sdf =
if (cachedSimpleDateFormat != null && cachedSimpleDateFormat.toPattern() == pattern) cachedSimpleDateFormat else SimpleDateFormat(
pattern,
Locale.US
)
if (tz != null) {
sdf.timeZone = tz
}
return sdf
}
}
}
결과 확인
결과에서 보았듯이 KST를 사용하는 DB에 계속 언급했던 설정을 하지 않고 if 로직을 탈 경우, DB는 '2023-11-23 07:28:56' 값을 한국 시간으로 취급해 저장해서 시간이 틀어진다.
DB는 UTC 시간으로 '2023-11-23 01:28:56Z'으로 저장하고 있다.