14장. JPA와 시간 매핑 — 엔티티에서 컬럼까지

만화로 보는 요약 — 먼저 읽어보세요
JVM·JDBC·DB 세션의 타임존 렌즈 → 조용히 밀리는 엔티티 값 → 모든 변환 경로를 UTC로 정렬하는 설계.
면접 실전 질문: ① 엔티티 값이 컬럼에 닿기까지 어떤 타임존 경로를 통과하는가? ② connectionTimeZone과 DB 세션 time_zone을 맞춰야 하는 이유는? ③ 사실과 미래 약속은 각각 어떤 타입과 저장 전략을 써야 하는가?
엔티티 필드에 적은 값 하나가 컬럼에 닿기까지 JVM 기본 zone, JDBC 드라이버의 커넥션 타임존, DB 세션 time_zone이라는 세 렌즈를 통과한다 — 셋 중 하나만 어긋나도 예외 없이 정확히 고정된 시간만큼 밀린 값이 조용히 저장된다.
면접 실전 질문: ① Hibernate가
ZonedDateTime을 매핑할 때 왜 문제가 되는가? ②hibernate.jdbc.time_zone은 정확히 무엇을 바꾸며, 왜 UTC로 맞추는 게 흔한 해법인가? ③@CreationTimestamp와DEFAULT CURRENT_TIMESTAMP는 시각을 어느 쪽 시계로 찍는가, 그 차이가 왜 중요한가?
배경 — 값은 세 개의 렌즈를 통과한다
13장은 질문 하나를 남기고 끝났습니다. LocalDateTime 필드를 TIMESTAMP 컬럼에, Instant 필드를 DATETIME 컬럼에 잘못 매핑하면 무슨 일이 벌어지는가. 그리고 Hibernate는 이 매핑에서 어떤 시간대를 기준으로 변환을 수행하는가 — JVM 기본 zone인가, 커넥션의 세션 time_zone인가, 아니면 둘 다 무시하는 제3의 값인가.
답은 “셋 다입니다”에 가깝습니다. 엔티티 필드에 적힌 값이 실제 컬럼에 도달하기까지 통과하는 렌즈는 하나가 아니라 세 개입니다. 첫 번째는 애플리케이션이 도는 JVM의 기본 타임존(user.timezone, 8~9장에서 이미 여러 번 함정을 만든 그 값). 두 번째는 JDBC 드라이버가 값을 문자열로 변환할 때 쓰는 타임존 — MySQL Connector/J는 이걸 connectionTimeZone이라는 별도 속성으로 관리합니다. 세 번째는 13장에서 다룬 **DB 세션의 time_zone**입니다. 셋 다 값을 하나씩 가지고 있고, 셋이 같은 zone을 가리킬 필요가 없습니다. 그리고 셋 중 하나라도 서로 어긋나면 — 아무것도 예외를 던지지 않습니다. 행은 그냥 정확히 고정된 시간만큼(주로 UTC와 로컬 zone의 오프셋만큼) 밀린 채로 저장됩니다. “왜 DB에 9시간 밀려서 저장되죠?”라는 흔한 질문의 정체가 이겁니다.
9장은 이미 저장 전략의 원칙을 세워뒀습니다. 이미 일어난 사실은 Instant로, 아직 오지 않은 벽시계 약속은 LocalDateTime + ZoneId로. 10장은 ZonedDateTime과 OffsetDateTime이 무엇을 감싸는지 구분했습니다. 이 장은 그 타입들이 JPA 엔티티 필드에 앉았을 때 실제로 어떤 SQL 값으로 내려가는지, 그리고 그 변환 과정에서 세 렌즈가 어떻게 개입하는지를 실측으로 확인합니다.
스토리 — 실측: 같은 Instant, 다른 JVM zone, 다른 결과
Hibernate 없이, JDBC와 MySQL Connector/J만으로 이 함정의 정체부터 재현했습니다. 프레임워크가 무엇을 대신 해주기 전에, 변환이 실제로 일어나는 자리가 어디인지 보기 위해서입니다. MySQL 8.4.11 컨테이너 하나와, MySQL Connector/J 9.1.0을 얹은 JDK 21(Temurin) 컨테이너 하나를 붙여서 실험했습니다.
CREATE TABLE events14 (
id INT PRIMARY KEY,
label VARCHAR(40),
dt_col DATETIME,
ts_col TIMESTAMP NULL
);같은 Instant(2026-07-31T09:00:00Z) 하나를 DATETIME 컬럼과 TIMESTAMP 컬럼에 동시에 씁니다. DB 세션 time_zone은 커넥션을 열자마자 +00:00으로 고정해뒀습니다 — 13장이 보여준 세 번째 렌즈를 실험 중엔 상수로 묶어두고, 첫 번째·두 번째 렌즈만 움직여보기 위해서입니다.
String url = "jdbc:mysql://mysql-host:3306/testdb?connectionTimeZone=" + ctz
+ "&useSSL=false&allowPublicKeyRetrieval=true";
try (Connection conn = DriverManager.getConnection(url, "root", "")) {
try (Statement st = conn.createStatement()) {
st.execute("SET SESSION time_zone = '+00:00'"); // 세 번째 렌즈를 상수로 고정
}
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO events14 (id, label, dt_col, ts_col) VALUES (?, ?, ?, ?)");
Instant instant = Instant.parse("2026-07-31T09:00:00Z");
java.sql.Timestamp ts = java.sql.Timestamp.from(instant);
ps.setInt(1, id);
ps.setString(2, label);
ps.setTimestamp(3, ts); // dt_col
ps.setTimestamp(4, ts); // ts_col
ps.executeUpdate();
}이 코드를 JVM 기본 zone(-Duser.timezone)과 드라이버의 connectionTimeZone을 바꿔가며 네 번 실행했습니다.
| id | JVM 기본 zone | connectionTimeZone | 저장된 dt_col | 저장된 ts_col |
|---|---|---|---|---|
| 1 | Asia/Seoul | LOCAL(기본값) | 2026-07-31 18:00:00 | 2026-07-31 18:00:00 |
| 2 | UTC | LOCAL(기본값) | 2026-07-31 09:00:00 | 2026-07-31 09:00:00 |
| 3 | Asia/Seoul | UTC(고정) | 2026-07-31 09:00:00 | 2026-07-31 09:00:00 |
| 4 | UTC | UTC(고정) | 2026-07-31 09:00:00 | 2026-07-31 09:00:00 |
값을 넣은 Instant는 네 번 다 정확히 같습니다(2026-07-31T09:00:00Z). 그런데 1번 행은 두 컬럼 모두 18:00:00 — 9시간 밀려 저장됐습니다(실측 확인). 정확히 그 “9시간 밀려서 저장되죠?” 질문의 재현입니다. 2번은 우연히 맞았을 뿐입니다 — JVM 기본 zone이 마침 UTC였으니까요. 3번과 4번은 connectionTimeZone을 UTC로 못 박은 순간, JVM 기본 zone이 무엇이든 상관없이 정확한 값(09:00:00)이 저장됐습니다.
왜 이런 일이 생기는가
📄 문서 기반(미검증) — 아래 세 문단의 드라이버 내부 동작 설명(기본값 LOCAL의 의미, java.sql.Timestamp 변환 경로, forceConnectionTimeZoneToSession의 동작과 serverTimezone 별칭)은 MySQL Connector/J 공식 문서를 근거로 정리한 것입니다. 위 표에서 실측한 건 설정을 바꿨을 때의 결과값이지, 드라이버가 내부적으로 그 경로를 밟는지까지 코드로 확인하지는 않았습니다.
MySQL Connector/J 문서는 connectionTimeZone을 이렇게 정의합니다.
Configures the connection time zone which is used by Connector/J if conversion between the JVM default and a target time zone is needed when preserving instant temporal values.
기본값은 LOCAL — JVM 기본 타임존을 그대로 씁니다. java.sql.Timestamp는 내부적으로 epoch 이후 밀리초만 들고 있을 뿐, 자신이 어느 zone에 속하는지 모릅니다. 드라이버가 이 값을 서버로 보낼 문자열로 바꿀 때, connectionTimeZone=LOCAL이면 그 밀리초를 JVM 기본 zone의 벽시계 문자열로 바꿔서 보냅니다. 위 1번 행에서 JVM zone이 Asia/Seoul이었으니, 09:00Z(UTC)라는 순간은 18:00(KST)이라는 문자열이 되어 서버로 갑니다.
그 문자열이 DATETIME 컬럼엔 그대로 박힙니다(13장 — DATETIME은 변환하지 않으니까요). TIMESTAMP 컬럼엔 세션 time_zone(이 실험에선 +00:00으로 고정) 기준으로 다시 해석되어 저장됩니다 — 18:00을 UTC로 이미 취급하는 세션이니 그대로 18:00이 UTC로 저장됩니다. 결과적으로 두 컬럼 다 원래 순간보다 9시간 뒤의 값을 갖게 됩니다. connectionTimeZone=UTC로 고정하면 이 첫 번째 렌즈가 사라집니다 — JVM zone이 무엇이든 드라이버는 항상 UTC 벽시계 문자열을 만들어 보내고, 세션도 UTC로 그 문자열을 그대로 받아들이니 두 컬럼 다 정확한 값을 갖습니다.
forceConnectionTimeZoneToSession(기본값 false, Connector/J 8.0.23+)은 여기서 한 단계 더 나갑니다. 활성화하면 connectionTimeZone으로 정한 값을 커넥션이 열릴 때 서버의 세션 time_zone에 그대로 SET합니다 — 드라이버와 DB 세션이라는 두 번째·세 번째 렌즈를 아예 하나로 묶는 스위치입니다. 옛 버전 드라이버가 쓰던 serverTimezone 속성은 지금도 connectionTimeZone의 별칭으로 동작하지만, 공식 문서는 앞으로 폐기될 수 있다고 명시하니 새 코드에서는 connectionTimeZone을 씁니다.
핵심 — Hibernate가 그 위에 얹는 네 번째 변수
여기까지는 순수 JDBC 층입니다. Hibernate/JPA를 얹으면 여기에 hibernate.jdbc.time_zone이라는 자체 설정이 하나 더 끼어듭니다.
java.time 타입은 대부분 컨버터 없이 매핑된다
최신 JPA·Hibernate에서 Instant, LocalDateTime, OffsetDateTime, LocalDate, LocalTime은 별도의 AttributeConverter 없이 그대로 필드에 쓸 수 있습니다.
@Entity
public class OrderEvent {
@Id
@GeneratedValue
Long id;
Instant occurredAt; // → TIMESTAMP(-호환 컬럼), UTC 계열
LocalDateTime scheduledAt; // → DATETIME, 벽시계 값 그대로
LocalDate promoDate; // → DATE
LocalTime dailyAlarm; // → TIME
}문제는 이 목록에서 하나가 빠져 있다는 겁니다. ZonedDateTime. 10장에서 ZonedDateTime이 LocalDateTime + ZoneId(규칙 전체)를 들고 다닌다고 했는데, MySQL엔 그 ZoneId를 저장할 컬럼 타입이 아예 없습니다(13장). 그래서 ZonedDateTime 필드를 그대로 매핑하면, Hibernate는 값을 TIMESTAMP 호환 컬럼에 우겨넣으면서 ZoneId를 버립니다. 저장되는 건 그 순간의 UTC 값뿐이고, 필드에 적혀 있던 [Asia/Seoul] 같은 지역 정보는 컬럼 어디에도 남지 않습니다. 다시 읽어오면 JVM 기본 zone(또는 hibernate.jdbc.time_zone)을 기준으로 재구성된 ZonedDateTime이 나오는데, 그 zone이 원래 저장했던 zone과 같다는 보장이 없습니다. 📄 문서 기반(미검증) — Hibernate 6은 @TimeZoneStorage 애너테이션으로 이 동작을 NATIVE(DB가 zone-aware 타입을 지원할 때)·COLUMN(zone을 별도 컬럼에 저장) 방식으로 바꿀 여지를 열어뒀지만, MySQL은 zone-aware 타입 자체가 없으니 NATIVE는 애초에 선택지가 아닙니다. 결론은 10장에서 이미 낸 결론과 같습니다 — 미래의 벽시계 약속은 ZonedDateTime 필드 하나로 퉁치지 말고, LocalDateTime + zone 문자열 컬럼을 따로 둡니다.
hibernate.jdbc.time_zone — 네 번째 렌즈를 명시적으로 고정한다
📄 문서 기반(미검증) — hibernate.jdbc.time_zone을 설정하면 Hibernate는 타임스탬프를 바인딩·읽어올 때 JDBC의 Calendar 오버로드에 이 zone을 실어 보냅니다. JVM 기본 zone을 바꾸지 않고도, “데이터 계층은 이 zone으로 통일한다”를 애플리케이션 코드 레벨에서 못 박는 스위치입니다.
# application.properties
spring.jpa.properties.hibernate.jdbc.time_zone=UTC이 설정이 정말로 값을 바꾸는지, hb_events14라는 별도 테이블에 같은 실험을 Hibernate 6.6.4.Final로 재현해봤습니다. 엔티티는 dtCol(LocalDateTime)과 tsCol(Instant) 필드를 갖고, 둘 다 매번 같은 Instant(2026-07-31T09:00:00Z)에서 파생됩니다.
이 실험에서 connectionTimeZone은 건드리지 않고 드라이버 기본값(LOCAL)으로 뒀습니다. 앞 절에서 이미 “connectionTimeZone을 UTC로 못 박아라”고 해놓고 여기서는 왜 기본값이냐 싶겠지만, 그래야 hibernate.jdbc.time_zone 하나만의 효과가 보이기 때문입니다. 이 변수를 밝혀두는 게 중요합니다 — 아래 표는 connectionTimeZone이 기본값일 때의 결과이고, 이 값을 바꾸면 표가 달라집니다.
| id | JVM 기본 zone | hibernate.jdbc.time_zone | 저장된 dt_col | 저장된 ts_col |
|---|---|---|---|---|
| 1 | Asia/Seoul | (미설정) | 2026-07-31 09:00:00 | 2026-07-31 09:00:00 |
| 2 | UTC | (미설정) | 2026-07-31 09:00:00 | 2026-07-31 09:00:00 |
| 3 | Asia/Seoul | UTC | 2026-07-31 00:00:00 | 2026-07-31 09:00:00 |
| 4 | UTC | UTC | 2026-07-31 09:00:00 | 2026-07-31 09:00:00 |
ts_col(Instant 필드)은 네 조합 모두 정확했습니다(실측 확인) — Hibernate의 Instant 바인딩은 이 테스트 범위 안에서는 JVM zone에 영향을 받지 않았습니다. 반면 dt_col(LocalDateTime 필드)은 3번 조합에서 예상 밖의 값이 나왔습니다. JVM zone이 Asia/Seoul인데 hibernate.jdbc.time_zone=UTC를 함께 준 순간, 같은 09:00이 00:00으로 9시간 당겨져 저장됐습니다(실측 확인). hibernate.jdbc.time_zone을 지정하지 않았을 때(1·2번)는 JVM zone이 무엇이든 값이 그대로였습니다.
정확한 내부 동작까지 코드를 뜯어 확인하진 않았지만, 이 결과가 가리키는 그림은 이렇습니다. hibernate.jdbc.time_zone을 지정하면 Hibernate가 LocalDateTime도 Calendar 기반 바인딩 경로로 넘기는데, 그 경로의 첫 단계(LocalDateTime → java.sql.Timestamp)가 JVM 기본 zone을 기준으로 순간을 확정한 다음, 그 순간을 다시 지정된 hibernate.jdbc.time_zone 기준 벽시계로 재구성해서 보냅니다. JVM zone과 hibernate.jdbc.time_zone이 다르면 그 사이에서 LocalDateTime이 밀립니다. 결론은 단순합니다 — hibernate.jdbc.time_zone을 UTC로 맞췄다면, JVM 기본 zone도 함께 UTC로 맞춰야 합니다. 둘 중 하나만 맞추는 건 안전하지 않습니다(실측으로 확인된 함정입니다).
한 가지 더 정직하게 덧붙입니다. 이 표는 두 변수(JVM zone × hibernate.jdbc.time_zone)만 움직인 결과지만, 실제로는 connectionTimeZone까지 세 변수가 함께 얽힙니다. 검수 과정에서 connectionTimeZone=UTC를 준 채로 같은 실험을 돌리자, hibernate.jdbc.time_zone을 지정하지 않은 1번 조합에서도 9시간 어긋난 값이 나왔습니다. 즉 “hibernate.jdbc.time_zone을 안 주면 안전하다”는 건 이 표의 조건 안에서만 참입니다. 세 설정 중 어느 하나만 UTC로 바꾸는 조합은 어느 방향이든 위험하고, 안전한 상태는 결국 하나뿐입니다 — 셋 다 UTC로 맞추거나, 셋 다 건드리지 않거나.
이 장의 실측 수치는 MySQL 8.4.11 · MySQL Connector/J 9.1.0 · Hibernate ORM 6.6.4.Final · Temurin JDK 21 조합에서 나온 값입니다. 드라이버나 Hibernate 버전이 바뀌면 기본값이나 바인딩 경로 자체가 달라질 수 있습니다.
@CreationTimestamp/@UpdateTimestamp — 누구의 시계로 찍는가
created_at·updated_at을 채우는 방법은 두 갈래입니다. 하나는 애플리케이션 서버가 직접 찍는 방법, 하나는 DB 서버가 찍는 방법입니다.
@Entity
public class Article {
@Id @GeneratedValue Long id;
@CreationTimestamp // 기본: source = VM, insert 시점 JVM 시계로 1회 생성
Instant createdAt;
@UpdateTimestamp // 기본: source = VM, update마다 JVM 시계로 재생성
Instant updatedAt;
}📄 문서 기반(미검증) — @CreationTimestamp·@UpdateTimestamp는 기본적으로 source = VM입니다. 즉 애플리케이션 서버의 JVM 시계로 값을 메모리에서 만든 뒤, 평범한 컬럼 값처럼 INSERT/UPDATE 문에 실어 보냅니다. Hibernate 6부터는 @CreationTimestamp(source = SourceType.DB)처럼 source = DB를 지정할 수 있는데, 이건 13장에서 본 DEFAULT CURRENT_TIMESTAMP · ON UPDATE CURRENT_TIMESTAMP를 그대로 DDL에 위임하는 방식입니다 — 이때 시각을 찍는 건 DB 서버의 시계입니다.
두 방식 중 뭐가 맞고 틀리고는 없습니다. 다만 어느 시계로 찍었는지는 값의 의미를 바꿉니다. 애플리케이션 서버가 여러 대이고 그중 한 대의 NTP 동기화가 살짝 어긋나 있다면(17장에서 다룰 클록 드리프트), source = VM으로 찍은 created_at은 서버마다 조금씩 다른 기준으로 찍힙니다. 반대로 source = DB(또는 DEFAULT CURRENT_TIMESTAMP)로 찍으면 기준 시계가 DB 서버 하나로 통일되지만, 이번엔 애플리케이션이 INSERT 직전에 계산한 다른 시각 값들과 미묘하게 어긋날 수 있습니다 — 이를테면 요청을 받은 시각을 애플리케이션이 별도 필드에 Instant.now()로 남겼다면, 그 값과 DB가 찍은 created_at은 서로 다른 시계에서 나온 겁니다. 어느 쪽을 고르든, 이 행의 시각이 어느 서버의 어느 시계에서 나왔는지를 팀이 알고 있어야 합니다.
정리 — 컬럼 타입을 다시 고른다면
9장의 결정 표를 JPA 필드·컬럼 조합까지 내리면 이렇게 됩니다.
| 저장할 값 | 엔티티 필드 | 컬럼 | 주의점 |
|---|---|---|---|
| 이미 일어난 사실 | Instant | TIMESTAMP 또는 UTC로 통일한 DATETIME | 세 렌즈(JVM·드라이버·세션)를 전부 UTC로 맞춘다 |
| 아직 오지 않은 벽시계 약속 | LocalDateTime + String(zone id) | DATETIME + VARCHAR | ZonedDateTime 필드 하나로 합치지 않는다 — zone이 소리 없이 사라진다 |
MySQL도 JPA도 zone-aware 컬럼 타입을 갖고 있지 않다는 사실은 이 장 내내 바뀌지 않습니다. 그래서 실무의 안전한 기본값은 하나로 좁혀집니다. 사실은 Instant + UTC로 못 박은 컬럼에, 약속은 LocalDateTime + 별도 zone 문자열 컬럼에. 그리고 그 UTC를 못 박는 작업은 컬럼 타입 하나로 끝나지 않습니다 — JVM -Duser.timezone, connectionTimeZone(또는 hibernate.jdbc.time_zone), DB 세션 time_zone, 이 셋을 다 같이 UTC로 맞춰야 합니다. 하나라도 빠뜨리면, 이 장이 실측으로 보여준 정확히 그 자리에서 값이 조용히 밀립니다.
정리
- 엔티티 필드의 값은 컬럼에 닿기까지 JVM 기본 zone → JDBC 드라이버의 커넥션 타임존(
connectionTimeZone) → DB 세션time_zone, 세 렌즈를 통과한다. 셋이 어긋나도 예외는 없다 — 값이 고정된 시간만큼 조용히 밀릴 뿐이다. - 실측(순수 JDBC): JVM zone
Asia/Seoul+connectionTimeZone=LOCAL(기본값) 조합에서 같은Instant가DATETIME·TIMESTAMP두 컬럼 모두에 9시간 밀려 저장됐다.connectionTimeZone=UTC로 고정하면 JVM zone과 무관하게 정확한 값이 저장됐다. java.time의Instant·LocalDateTime·OffsetDateTime·LocalDate·LocalTime은 컨버터 없이 매핑되지만,ZonedDateTime은 문제아다. MySQL엔 zone-aware 컬럼 타입이 없어ZoneId가 저장 과정에서 사라진다(문서 기반) — 10·13장과 같은 결론으로, 미래 약속은LocalDateTime+ zone 문자열 컬럼으로 나눠 저장한다.hibernate.jdbc.time_zone은 데이터 계층의 타임존을 JVM 기본 zone과 별개로 못 박는 설정이다(문서 기반). 실측 결과,connectionTimeZone이 기본값인 상태에서 이 설정만 UTC로 주고 JVM zone은 그대로 두면Instant필드는 안전했지만LocalDateTime필드가 오히려 9시간 어긋나 저장됐다. 반대로connectionTimeZone=UTC를 준 채hibernate.jdbc.time_zone을 비워도 같은 크기로 어긋난다 — JVM zone·connectionTimeZone·hibernate.jdbc.time_zone셋 중 일부만 UTC로 바꾸는 조합이 위험하다. 셋 다 맞추거나, 셋 다 두거나.@CreationTimestamp/@UpdateTimestamp(기본source=VM)는 애플리케이션 서버 시계로,DEFAULT CURRENT_TIMESTAMP/ON UPDATE CURRENT_TIMESTAMP(또는source=DB)는 DB 서버 시계로 시각을 찍는다(문서 기반). 어느 쪽이든 이 값이 어느 서버의 시계에서 나왔는지를 팀이 알아야 한다.- 안전한 기본값: 사실은
Instant+ UTC로 통일한 컬럼, 약속은LocalDateTime+ zone 문자열 컬럼. UTC 통일은 컬럼 타입 하나가 아니라 JVM·드라이버·DB 세션 셋을 다 같이 맞추는 일이다.
생각해볼 질문: 이 장의 결론은 “저장은 UTC로 통일한다”였습니다. 그런데 사용자는 UTC로 시각을 보고 싶어하지 않습니다. 서울에 있는 사용자는 서울 시각을, 뉴욕에 있는 사용자는 뉴욕 시각을 보고 싶어합니다. 서버가 UTC만 들고 있다면, 그 값을 사용자의 zone으로 바꾸는 일은 어디서 일어나야 할까요 — DB 쿼리 안에서? 서비스 계층에서? 아니면 응답이 나가는 API 경계에서?