개발 환경의 시간대 차이가 만든 버그: LocalDateTime 이슈 해결기
1. 문제 사항
중국 음원 사이트에서 데이터를 수집해 차트 정보를 생성하는 스크래핑 로직이 있었는데 로컬에선 정상 작동하지만 서버 환경에서는 예상과 다르게 동작하는 이슈가 있었습니다.
스크래핑 대상 사이트는 요청 시 현재 시간을 기준으로 ±15분 범위 내의 요청만 허용하는 정책을 갖고 있었고 이 로직에서는 해당 기준에 맞는 현재 시간을 생성해 요청 값에 포함시켜야 했습니다.
함께 작업하던 동료가 시간 생성 방식에 문제가 있을 수도 있다는 점을 짚어주었고 함께 해당 부분을 확인해 보았습니다. 실제로 시간을 생성하는 로직에서 LocalDateTime.now()를 사용하고 있었고 이로 인해 로컬(UTC+9)과 서버(UTC)의 시간대 차이로 서버에선 조건을 만족하지 못하는 요청이 만들어지고 있었습니다.
이 문제를 해결하기 위해, 요청 시간 생성 시 서버의 시간대가 아닌 대상 사이트에서 요구하는 시간대 "Asia/Shanghai"를 명시적으로 설정하도록 수정하였습니다. 이 과정에서 ZonedDateTime.now(zone) 사용 여부도 논의했으며 시간대를 포함해 명확한 기준 시각을 사용할 수 있도록 보완했습니다.
2. LocalDateTime, 무엇이 문제였나?
LocalDateTime은 시간대 정보를 포함하지 않는 시간 객체입니다. 예를 들어 "2025-05-31 02:00:00"이라는 값을 가진다고 해도 이 시각이 UTC 기준인지, KST 기준인지, 혹은 CST ("Asia/Shanghai") 기준인지 알 수 있는 정보는 포함되어 있지 않습니다.
이 객체는 내부적으로 LocalDate와 LocalTime을 조합해 만들어지며, now() 메서드를 호출하면 JVM의 기본 시간대를 기준으로 현재 시각을 계산합니다.
public static LocalDateTime now() {
return now(Clock.systemDefaultZone());
}
동일한 시점에 LocalDateTime.now()를 호출한 경우
• 서버 시간대가 UTC인 경우 → "2025-05-31 02:00:00"
• 로컬 시간대가 KST인 경우 → "2025-05-31 11:00:00"
문제는 서버에서 생성된 LocalDateTime 객체에 시간대를 부여하려고 할 때 발생합니다. Java는 이 값을 CST ("Asia/Shanghai") 시간대 기준의 시각으로 간주하고 거기서 UTC 기준 시각(epochMillis)을 계산합니다. 하지만 실제 이 값은 UTC에서 생성된 시각이었기 때문에 실제보다 8시간 늦은 시간으로 잘못 계산되는 문제가 생깁니다.
LocalDateTime.now().minusHours(1L).atZone(ZoneId.of("Asia/Shanghai")).toInstant();
UTC "2025-05-31 02:00:00" | JVM 시간대 KST 가정
[로컬]
LocalDateTime.now() → 2025-05-31 02:00:00 + offset(9) → 2025-05-31 11:00:00
minusHours(1L) → 2025-05-31 11:00:00 - 01:00:00 → 2025-05-31 10:00:00
atZone(ZoneId.of("Asia/Shanghai"))
toInstant() → 2025-05-31 10:00:00 - offset(8) → 2025-05-31 02:00:00 (epochMilli)
[서버]
LocalDateTime.now() → 2025-05-31 02:00:00 + offset(0) → 2025-05-31 02:00:00
minusHours(1L) → 2025-05-31 02:00:00 - 01:00:00 → 2025-05-31 01:00:00
atZone(ZoneId.of("Asia/Shanghai"))
toInstant() → 2025-05-31 02:00:00 - offset(8) → 2025-05-30 18:00:00 (epochMilli)
즉, 시간대 없이 생성된 LocalDateTime에 잘못된 시간대를 나중에 붙이면 그 시간대에서의 “그 시각”이 실제 존재했다고 가정해 버리기 때문에 시차 계산이 어긋납니다.
3. 어떻게 해결했는가?
실제 문제의 원인을 확인한 뒤 우리는 시간 값을 생성하는 로직에서 서버의 JVM 기본 시간대에 의존하지 않고 스크래핑 대상인 음원 사이트에서 사용하는 시간대(CST, Asia/Shanghai)를 명시적으로 지정하기로 했습니다.
LocalDateTime 대신 ZonedDateTime을 사용하였고 다음과 같이 요청에 필요한 정확한 Instant 값을 생성하도록 수정했습니다.
ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).toInstant();
이 방식은 시간대가 명확하게 포함된 시각을 기준으로 UTC timestamp(epochMillis)를 계산하기 때문에 운영 환경의 JVM 기본 시간대가 UTC이든 KST이든 상관없이 항상 동일한 기준 시각을 만들 수 있습니다.
이후 요청에서 시간 오차로 인해 스크래핑이 실패하는 일은 더 이상 발생하지 않았습니다. ZonedDateTime을 활용하면 시간 비교나 범위 검증이 더욱 명확해지기 때문에 로직의 안정성과 가독성 모두를 높일 수 있었습니다.
4. 이 경험을 통해 얻은 교훈
이번 이슈를 통해, 시간을 다룰 때 단순히 날짜와 시각을 표현하는 것만으로는 충분하지 않다는 사실을 다시 한번 확인할 수 있었습니다.
특히 LocalDateTime처럼 시간대 정보를 포함하지 않는 객체는 사용 목적에 따라 적절하게 선택해야 한다는 점이 중요합니다.
✅ LocalDateTime을 사용해도 괜찮은 경우
• 시간대와 무관한 로컬 이벤트를 표현할 때 (예: 생일, 정기 알림, UI 표시용 시간)
• DB에 단순한 날짜와 시간 정보만 저장하고, 별도로 시간대를 다루지 않는 경우
• 동일한 시스템 내에서만 동작하며 시간대 차이에 영향을 받지 않는 로직
단, 이 경우에도 시간대가 필요한 상황으로 확장될 가능성이 있다면 별도로 ZoneId, ZoneOffset, 혹은 원본 시간대 정보를 함께 저장해 두는 것이 좋습니다.
✅ 피해야 하는 경우
• 외부 시스템과의 연동, 특히 타임스탬프나 범위 검증이 필요한 경우
• 시간 간 비교, epochMillis 계산, 요청 유효성 검증 등 시간 계산이 중요한 로직
• 서버 환경이 다양한 시간대에서 운영될 가능성이 있는 경우
이번 경험을 통해 단순히 동작하는 코드를 넘어서 운영 환경과 시간의 의미까지 함께 고려한 설계가 필요하다는 것을 다시금 느꼈습니다. LocalDateTime은 편리하지만 언제 사용하는 것이 맞는지 판단하는 기준을 갖추는 것이 더 중요한 일입니다.