From 0da707f9dc592c4171d924bddd7fa2c2ac860f8f Mon Sep 17 00:00:00 2001 From: Khyojae Date: Sun, 8 Nov 2026 14:28:18 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20JVM=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?= =?UTF-8?q?=E2=89=A0=20JDBC=20=EC=97=B0=EA=B2=B0=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=EB=A9=B4=20=EA=B8=B0=EB=8F=99=20=EA=B1=B0=EB=B6=80=20?= =?UTF-8?q?+=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20JVM=20=EC=9D=84=20Asia/Seoul?= =?UTF-8?q?=20=EB=A1=9C=20(#846)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 시작 시각은 Hibernate 가 JVM→serverTimezone 으로 변환해 쓰고, pose_data.created_at 은 JdbcTemplate setObject(LocalDateTime) 로 변환 없이 쓴다. 두 시간대가 다르면 9시간 어긋나 완료 집계(findRepSummaries 의 created_at = 시작 시각)가 rep 을 못 찾고 통계·세트·리포트가 오류 없이 빈다. CI(JVM UTC)에서 RejectedAttemptRaceTest 가 처음부터 이 이유로만 실패했다(로컬 KST 는 통과). - build.gradle: 테스트 JVM -Duser.timezone=Asia/Seoul (운영 컨테이너 TZ 와 같게) - JvmTimezoneStartupCheck: URL 의 connectionTimeZone/serverTimezone 과 JVM 시간대 규칙이 다르면 기동 거부. 운영은 TZ=Asia/Seoul 로 이미 맞아 있어 동작 변화 없음 — 숨은 전제를 강제하는 것 검증: UTC 호스트에서 RejectedAttemptRaceTest 5/5(전엔 같은 조건에서 70.67≠42.0 재현) · 판정 단위 5/5 · H2 컨텍스트 · 실 MySQL 컨텍스트 통과. 로컬 compose: TZ=Asia/Seoul 이면 «JVM 시간대 확인» 후 UP, TZ=UTC 로 띄우면 BeanCreationException 으로 기동 중단. Co-Authored-By: Claude Opus 5.5 --- backend/build.gradle | 7 ++ .../config/JvmTimezoneStartupCheck.java | 84 +++++++++++++++++++ .../config/JvmTimezoneStartupCheckTest.java | 52 ++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 backend/src/main/java/com/shadowfit/global/config/JvmTimezoneStartupCheck.java create mode 100644 backend/src/test/java/com/shadowfit/global/config/JvmTimezoneStartupCheckTest.java diff --git a/backend/build.gradle b/backend/build.gradle index d7e960e7..b30fb548 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -162,6 +162,13 @@ sourceSets { tasks.named('test') { useJUnitPlatform() + // 테스트 JVM 의 시간대를 운영과 같게 둔다 (#846). 운영·dev 백엔드 컨테이너는 TZ=Asia/Seoul 이고 + // JDBC URL 의 serverTimezone 도 Asia/Seoul 이다. 둘이 다르면 Hibernate 로 쓴 세션 시작 시각과 + // JdbcTemplate 로 쓴 pose_data.created_at 이 9시간 어긋나 완료 집계가 rep 을 못 찾는다. + // CI 러너는 UTC 라 RejectedAttemptRaceTest 가 이 차이로만 실패했다. 운영에서는 + // JvmTimezoneStartupCheck 가 같은 전제를 기동 때 막는다. + jvmArgs '-Duser.timezone=Asia/Seoul' + // 아래 셋은 같은 문제의 서로 다른 사례다 — gradlew 에 준 -D 는 Gradle 자신의 JVM 에만 // 붙고 포크된 테스트 JVM 으로 자동 전달되지 않아, 그대로 두면 @EnabledIfSystemProperty 가 // 테스트 쪽에서 못 보고 **조용히 스킵**된다. 셋 다 기본값이 없으므로 평소에는 프로퍼티 diff --git a/backend/src/main/java/com/shadowfit/global/config/JvmTimezoneStartupCheck.java b/backend/src/main/java/com/shadowfit/global/config/JvmTimezoneStartupCheck.java new file mode 100644 index 00000000..32cfc020 --- /dev/null +++ b/backend/src/main/java/com/shadowfit/global/config/JvmTimezoneStartupCheck.java @@ -0,0 +1,84 @@ +package com.shadowfit.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 기동 시 JVM 시간대와 JDBC 연결 시간대가 같은지 확인한다 — 다르면 기동을 거부한다 (#846). + * + *

왜 필요한가. 같은 «세션 시작 시각» 을 두 경로가 쓴다. {@code exercise_sessions.start_time} 은 + * Hibernate 가 쓰면서 JVM 시간대 → URL 의 {@code serverTimezone} 으로 변환하고, + * {@code pose_data.created_at} 은 {@code PoseDataService} 가 JdbcTemplate {@code setObject(LocalDateTime)} 로 + * 변환 없이 쓴다. 두 시간대가 같으면 변환이 없어 값이 같고, 다르면 시차만큼 어긋난다. 완료 집계 + * ({@code PoseDataRepository.findRepSummaries})는 {@code created_at = 세션 시작 시각} 으로 rep 을 찾으므로, + * 어긋나면 오류 없이 싱크 통계·세트·리포트가 빈다. CI(JVM UTC)에서 이 일이 실제로 났다. + * + *

운영·dev 는 컨테이너 {@code TZ=Asia/Seoul} 로 맞아 있지만, 그 전제가 코드 어디에도 강제돼 있지 + * 않았다. 이 검사가 그 전제를 기동 시점에 강제한다. + * + *

판정 — URL 에 {@code connectionTimeZone}(8.0.23+) 또는 {@code serverTimezone} 이 있으면 그 값과 + * JVM 기본 시간대의 규칙이 같아야 한다. 둘 다 없으면 드라이버가 JVM 시간대를 그대로 써 변환이 없으므로 통과. + * MySQL 이 아니면 건너뛴다. + */ +@Slf4j +@Component +public class JvmTimezoneStartupCheck implements InitializingBean { + + private static final Pattern ZONE_PARAM = + Pattern.compile("[?&](connectionTimeZone|serverTimezone)=([^&]+)", Pattern.CASE_INSENSITIVE); + + private final DataSource dataSource; + + public JvmTimezoneStartupCheck(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public void afterPropertiesSet() throws SQLException { + String url; + try (Connection c = dataSource.getConnection()) { + if (!"MySQL".equalsIgnoreCase(c.getMetaData().getDatabaseProductName())) { + return; + } + url = c.getMetaData().getURL(); + } + ZoneId jvm = ZoneId.systemDefault(); + check(jvm, url); + log.info("JVM 시간대 확인 — {} (JDBC 연결 시간대와 같다)", jvm); + } + + /** 판정만 따로 — JVM 시간대를 바꾸지 않고 거부 경로를 시험할 수 있게. */ + static void check(ZoneId jvm, String jdbcUrl) { + Matcher m = ZONE_PARAM.matcher(jdbcUrl == null ? "" : jdbcUrl); + String connection = null; + while (m.find()) { + // connectionTimeZone 이 serverTimezone 보다 우선한다(Connector/J 8.0.23+). + if (connection == null || m.group(1).equalsIgnoreCase("connectionTimeZone")) { + connection = m.group(2); + } + } + if (connection == null || connection.equalsIgnoreCase("LOCAL")) { + return; + } + ZoneId db; + try { + db = ZoneId.of(connection); + } catch (DateTimeException e) { + throw new IllegalStateException("JDBC URL 의 시간대를 해석하지 못했다: " + connection, e); + } + if (!db.getRules().equals(jvm.getRules())) { + throw new IllegalStateException( + "JVM 시간대(" + jvm + ")가 JDBC 연결 시간대(" + db + ")와 다르다 — 세션 시작 시각과 pose_data.created_at 이 " + + "어긋나 완료 집계가 rep 을 못 찾는다 (#846). 컨테이너에 TZ=" + db + " 를 주거나 -Duser.timezone=" + db + " 로 띄울 것"); + } + } +} diff --git a/backend/src/test/java/com/shadowfit/global/config/JvmTimezoneStartupCheckTest.java b/backend/src/test/java/com/shadowfit/global/config/JvmTimezoneStartupCheckTest.java new file mode 100644 index 00000000..19528816 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/global/config/JvmTimezoneStartupCheckTest.java @@ -0,0 +1,52 @@ +package com.shadowfit.global.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("JVM 시간대 기동 검사 — 판정 (#846)") +class JvmTimezoneStartupCheckTest { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final ZoneId UTC = ZoneId.of("UTC"); + private static final String URL = "jdbc:mysql://db:3306/shadowfit?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8"; + + @Test + @DisplayName("JVM 과 serverTimezone 이 같으면 통과한다") + void sameZonePasses() { + assertThatCode(() -> JvmTimezoneStartupCheck.check(SEOUL, URL)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("JVM 이 UTC 인데 serverTimezone 이 Asia/Seoul 이면 거부한다 — CI 에서 난 조합") + void differentZoneIsRejected() { + assertThatThrownBy(() -> JvmTimezoneStartupCheck.check(UTC, URL)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("#846"); + } + + @Test + @DisplayName("이름이 달라도 규칙이 같은 시간대면 통과한다") + void sameRulesPass() { + assertThatCode(() -> JvmTimezoneStartupCheck.check(ZoneId.of("ROK"), URL)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("connectionTimeZone 이 serverTimezone 보다 우선한다") + void connectionTimeZoneWins() { + String url = "jdbc:mysql://db/x?serverTimezone=Asia/Seoul&connectionTimeZone=UTC"; + assertThatCode(() -> JvmTimezoneStartupCheck.check(UTC, url)).doesNotThrowAnyException(); + assertThatThrownBy(() -> JvmTimezoneStartupCheck.check(SEOUL, url)).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("URL 에 시간대가 없거나 LOCAL 이면 드라이버가 JVM 시간대를 써서 통과한다") + void noZoneOrLocalPasses() { + assertThatCode(() -> JvmTimezoneStartupCheck.check(UTC, "jdbc:mysql://db/x?useSSL=false")).doesNotThrowAnyException(); + assertThatCode(() -> JvmTimezoneStartupCheck.check(UTC, "jdbc:mysql://db/x?connectionTimeZone=LOCAL")).doesNotThrowAnyException(); + } +}