가. 상황 파악
1) 배경
•
데이터를 집계하여 정해진 시간에 메신저로 전달하는 기능을 개발했습니다. 정해진 시간에 Scheduler가 동작하면 Spring JPA로 데이터를 집계하여 텔레그램 메시지로 전송하도록 작업했습니다.
2) 문제 발생
•
수동으로 집계한 데이터와 메신저로 전달한 데이터가 일치하지 않다는 클레임을 받았습니다.
3) 참고 - 클레임 자료
나. 원인 분석
1) DB 집계 로직 확인
•
배포 전에 직접 작성한 SQL Query와 JPA Repository를 통한 조회 결과가 일치하는 것을 확인했습니다. 다시 말해, 데이터를 집계하는 로직에는 문제가 없었습니다.
2) 시간 변환 실수
•
시간 변환 로직을 확인해 보니 데이터 집계에 사용되는 LocalDateTime 값이 UTC가 아니라 Asia/Seoul TimeZone을 기준으로 반환되고 있었습니다.
•
참고로 서버와 DB의 기본 TimeZone은 UTC로 설정되어 있었습니다. 따라서 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 값을 반환할 때 TimeZone을 UTC로 설정하였습니다.
•
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
복사