Search
🔎

시간 변환의 미묘한 함정 - TimeZone 실수

가. 상황 파악

1) 배경

데이터를 집계하여 정해진 시간에 메신저로 전달하는 기능을 개발했습니다. 정해진 시간에 Scheduler가 동작하면 Spring JPA로 데이터를 집계하여 텔레그램 메시지로 전송하도록 작업했습니다.

2) 문제 발생

수동으로 집계한 데이터와 메신저로 전달한 데이터가 일치하지 않다는 클레임을 받았습니다.

3) 참고 - 클레임 자료

나. 원인 분석

1) DB 집계 로직 확인

배포 전에 직접 작성한 SQL Query와 JPA Repository를 통한 조회 결과가 일치하는 것을 확인했습니다. 다시 말해, 데이터를 집계하는 로직에는 문제가 없었습니다.

2) 시간 변환 실수

시간 변환 로직을 확인해 보니 데이터 집계에 사용되는 LocalDateTime 값이 UTC가 아니라 Asia/Seoul TimeZone을 기준으로 반환되고 있었습니다.
참고로 서버와 DB의 기본 TimeZoneUTC로 설정되어 있었습니다. 따라서 Asia/Seoul TimeZone의 시간 값은 DB 작업 전에 UTC로 변환되어야 합니다. 앞의 TimeZone 변환 작업이 생략되었기 때문에 시간차가 발생하여 데이터가 잘못 집계된 것입니다.

3) 참고 - System TimeZone 설정

# application.yml spring: datasource: jdbc-url: jdbc:mysql:aurora://hello-mj/mj?serverTimezone=UTC
YAML
복사
# Dockerfile FROM openjdk:17 COPY build/libs/mj-server-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT java -jar -Duser.timezone=UTC /app.jar
Docker
복사

4) 참고 - 예시 코드

@Component class TransmissionScheduler( private val service: ContentService, private val messenger: Messenger ) { @Scheduled(cron = "0 10 9 * * *", zone = "Asia/Seoul") fun schedule() { messenger.send(Msg(service.count())) } } @Service class ContentService( private val repository: ContentRepository ) { @Transactional(readOnly = true) fun count(): Long { val todayAt9Am = TimeConverter.toUtcFromKoreanAt(hours = 9, minutes = 0) return repository.countByRegisteredAtBetween( from = todayAt9Am.minusDays(1), to = todayAt9Am ) } } object TimeConverter { fun toUtcFromKoreanAt(hours: Int, minutes: Int): LocalDateTime = ZonedDateTime.ofInstant(Instant.now(), KOREA_ZONE) .withHour(hours).withMinute(minutes).withSecond(0).withNano(0) .toLocalDateTime() const val KOREA_ZONE = ZoneId.of("Asia/Seoul") }
Kotlin
복사

. 문제 해결

1) 시간 변환 로직 수정

시간 변환 로직을 수정하여 LocalDateTime 값을 반환할 때 TimeZoneUTC로 설정하였습니다.
TimeZone 변경에 따라 문제가 해결되었습니다.

2) 참고 - 수정한 예시 코드

object TimeConverter { fun toUtcFromKoreanAt(hours: Int, minutes: Int): LocalDateTime = ZonedDateTime.ofInstant(Instant.now(), KOREA_ZONE) .withHour(hours).withMinute(minutes).withSecond(0).withNano(0) .withZoneSameInstant(UTC_ZONE) .toLocalDateTime() const val KOREA_ZONE = ZoneId.of("Asia/Seoul") const val UTC_ZONE = ZoneId.of("UTC") }
Kotlin
복사

라. 대안

System Default TimeZone을 KST로 설정하고 시스템 간 통신에만 UTC를 활용하는 방법을 고려할 수 있습니다. 시스템 내부적으로 시간 변환 과정이 불필요하기 때문에 시간변환 실수를 방지할 수 있습니다.

1) KST 기반 Default TimeZone 설정

Server와 DB의 Default TimeZone을 KST로 설정합니다.
# application.yml spring: datasource: jdbc-url: jdbc:mysql:aurora://hello-mj/mj?serverTimezone=Asia/Seoul
YAML
복사
# Dockerfile FROM openjdk:17 COPY build/libs/mj-server-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT java -jar -Duser.timezone=Asia/Seoul /app.jar
Docker
복사

2) 시스템 간 통신에만 UTC 활용

HTTP API로 전달 받은 데이터(HttpRequestDto)를 내부적으로 사용하기 전에 System TimeZone으로 변경할 수 있습니다.
data class HttpRequestDto( val createdAt: ZonedDateTime ) { fun toServiceDto(): ServiceDto { return ServiceDto ( createdAt = createdAt.withZoneSameInstant(ZoneOffset.systemDefault()).toLocalDateTime() ) } data class ServiceDto( val createdAt: LocalDateTime )
Kotlin
복사

3) 참고 - 재수정된 예시 코드

System Default TimeZone을 KST로 설정한다면, 예시 코드는 아래와 같이 더욱 간결해질 수 있습니다.
TimeConverter 객체가 더이상 필요 없고, 시스템 내부적으로 LocalDateTime으로 시간 연산이 가능합니다.
@Component class TransmissionScheduler( private val service: ContentService, private val messenger: Messenger ) { @Scheduled(cron = "0 10 9 * * *", zone = "Asia/Seoul") fun schedule() { messenger.send(Msg(service.count())) } } @Service class ContentService( private val repository: ContentRepository ) { @Transactional(readOnly = true) fun count(): Long { val todayAt9Am = LocalDateTime.now() .withHour(9) .withMinute(0) .withSecond(0) .withNano(0) return repository.countByRegisteredAtBetween( from = todayAt9Am.minusDays(1), to = todayAt9Am ) } }
Kotlin
복사