반응형
최근 운영 중인 플랫폼에서 JPA의 LocalDate 필드(환자 생일, 결제 요청일)가 하루씩 내려가는 문제가 발생했습니다. 이 문제의 원인과 해결 과정을 공유합니다.
문제 상황
문제 증상
- LocalDate 필드(환자 생일, 결제 요청일)의 날짜가 하루씩 내려갑니다.
- 예: 1997-03-25 → 1997-03-24 → 1997-03-23...
로그 확인
- 환자 생일을 수정한 API 호출 이력이 없습니다.
- 결제 요청일은 클라이언트에서 수정할 방법이 제공되지 않았습니다.
- LocalDate 필드만 문제가 발생하며, LocalDateTime은 정상적으로 동작하고 있었습니다.
타임존 확인
- 백엔드 서버의 JVM 타임존과 DB의 타임존을 확인한 결과 모두 한국(Asia/Seoul)으로 설정되어 있었습니다.
@SpringBootApplication
public class SerafinApplication {
public static void main(String[] args) {
SpringApplication.run(SerafinApplication.class, args);
}
@PostConstruct
public void init() {
// 타임존 설정
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
}
}
해결 과정
문제 원인 분석
- 클라이언트의 수정이 아닌 JPA의 변경 감지로 인해 데이터베이스 정보가 변경된다고 판단했습니다.
- MySQL Connector/J 관련 공식 문서를 확인한 결과, cacheDefaultTimeZone 설정에 대한 이슈를 발견했습니다.
- cacheDefaultTimeZone의 기본값은 true이며, 프로젝트 실행 시 처음 설정된 타임존을 캐시로 사용합니다.
- LocalDate만 타임존 캐시를 사용하고, LocalDateTime은 캐시를 사용하지 않기 때문에 LocalDateTime에는 문제가 발생하지 않았습니다.
디버깅
- Connector/J 라이브러리 내부의 com.mysql.cj.protocol.a.NativeProtocol 클래스의 configureTimeZone() 메서드를 디버그 모드에서 확인한 결과, @PostConstruct 메서드 호출 이전에 NativeServerSession에서 JVM 타임존 기본값을 설정하는 것을 확인했습니다.
//com.mysql.cj.protocol.a.NativeServerSession 클래스 내부
public NativeServerSession(PropertySet propertySet) {
this.propertySet = propertySet;
this.cacheDefaultTimeZone = this.propertySet.getBooleanProperty(PropertyKey.cacheDefaultTimeZone);
this.serverSessionStateController = new NativeServerSessionStateController();
}
- cacheDefaultTimezone의 기본값이 true라서 위의 캐시가 활성화되어 getDefaultTimeZone() 호출 시 처음 캐시된 값(UTC)을 사용하게 됩니다.
public TimeZone getDefaultTimeZone() {
if (this.cacheDefaultTimeZone.getValue()) {
return this.defaultTimeZone;
}
return TimeZone.getDefault();
}
- com.mysql.cj.protocol.ServerSession 클래스의 주석을 통해 JVM 기본 시간대를 반환하는 것을 확인했습니다.
/**
* The default time zone used to marshal date/time values to/from the server. This is used when methods like getDate() are called without a calendar argument.
*
* @return The default JVM time zone
*/
TimeZone getDefaultTimeZone();
Actuator 엔드포인트 추가
- 서버의 타임존 설정을 확인하기 위해 Actuator 엔드포인트를 추가했습니다.
// 로그 수집용 엔드포인트 정의
@Component
@Endpoint(id = "connectj-timezone")
public class ActuatorConfig {
private final DataSource dataSource;
@ReadOperation
public String mysqlTimeZone() {
StringBuilder response = new StringBuilder();
// 기본 JVM 시간대 확인
TimeZone defaultTimeZone = TimeZone.getDefault();
response.append("Default JVM Time Zone: ").append(defaultTimeZone.getID()).append("\n");
// DataSource를 통해 MySQL 서버 시간대 확인
try (Connection connection = dataSource.getConnection()) {
response.append(getCachedServerTimezone(connection, "DataSource"));
} catch (SQLException e) {
response.append("Error fetching MySQL time zone from DataSource: ").append(e.getMessage()).append("\n");
}
return response.toString();
}
private String getCachedServerTimezone(Connection connection, String source) {
StringBuilder response = new StringBuilder();
try {
JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class);
NativeSession nativeSession = (NativeSession) jdbcConnection.getSession();
ServerSession serverSession = nativeSession.getServerSession();
// 공개 메서드를 통해 시간대 가져오기
TimeZone sessionTimeZone = serverSession.getSessionTimeZone();
TimeZone defaultTimeZone = serverSession.getDefaultTimeZone();
response.append(source).append(" MySQL Connector/J Default Time Zone (via method): ").append(defaultTimeZone.getID()).append("\n");
response.append(source).append(" MySQL Connector/J Session Time Zone (via method): ").append(sessionTimeZone.getID()).append("\n");
} catch (Exception e) {
response.append("Error accessing cached MySQL time zone from ").append(source).append(": ").append(e.getMessage()).append("\n");
}
return response.toString();
}
}
- 엔드포인트 출력 결과
- JVM 타임존은 Asia/Seoul로 바뀌었으나 MySQL Connector의 Default Time이 UTC로 설정되어 있음을 확인했습니다.
Default JVM Time Zone: Asia/Seoul
DataSource MySQL Connector/J Default Time Zone (via method): UTC
DataSource MySQL Connector/J Session Time Zone (via method): Asia/Seoul
- LocalDate만 문제가 발생하고 LocalDateTime은 문제가 없는 이유는 LocalDate는 기본 시간대를 사용하고 LocalDateTime은 세션 시간대를 사용하기 때문입니다.
// ResultSetImpl
// LocalDate 는 DefaultTimeZone을 사용
this.defaultTimeValueFactory = new SqlTimeValueFactory(pset, null, this.session.getServerSession().getDefaultTimeZone(), this);
// LocalDateTime 은 Session Time Zone을 사용
this.defaultTimestampValueFactory = new SqlTimestampValueFactory(pset, null, this.session.getServerSession().getDefaultTimeZone(),this.session.getServerSession().getSessionTimeZone());
public LocalDate getLocalDate(int columnIndex) throws SQLException {
checkRowPos();
checkColumnBounds(columnIndex);
return this.thisRow.getValue(columnIndex - 1, this.defaultLocalDateValueFactory);
}
public LocalDateTime getLocalDateTime(int columnIndex) throws SQLException {
checkRowPos();
checkColumnBounds(columnIndex);
return this.thisRow.getValue(columnIndex - 1, this.defaultLocalDateTimeValueFactory);
}
문제 해결
- DB URL에 cacheDefaultTimezone=false 옵션을 추가한 결과, 문제를 해결할 수 있었습니다.
spring.datasource.url=jdbc:mysql://localhost:3306/yourDatabase?useLegacyDatetimeCode=false&serverTimezone=Asia/Seoul&cacheDefaultTimezone=false
- Docker 컨테이너 내부에서 타임존이 UTC로 설정된 것도 문제의 원인이었습니다.
//docker-compose
environment:
- TZ=Asia/Seoul // 오타 수정: TZ=Asia/Seoul 라고 해야함
- PROFILE=dev
- 컨테이너 내부 출력 결과
$ ls -l /etc/localtime
lrwxrwxrwx 1 root root 25 Jul 14 2023 /etc/localtime -> ../usr/share/zoneinfo/Asia/Seoul
결론
해결 방법
- SpringApplication.run() 이전에 TimeZone 설정하기
@SpringBootApplication
public class SerafinApplication {
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
SpringApplication.run(SerafinApplication.class, args);
}
}
- Docker 컨테이너 실행 시점에 환경 변수 TZ=Asia/Seoul 추가하기.
- DB URL에 cacheDefaultTimezone=false 옵션 추가하기.
위의 세 가지 방법중 하나를 선택해 해결하면 됩니다.
최종적으로, 문제는 JVM과 MySQL Connector 간의 시간대 설정 불일치로 인한 것이었으며, 설정을 일치시킴으로써 문제를 해결할 수 있었습니다.
반응형