시간? 그냥 LocalDateTime 사용하면 안되나요?
시간 데이터를 어떻게 저장하고 처리하느냐는 작은 문제 같지만, 서버 환경이 바뀌거나 배포 지역이 달라지면 예상치 못한 버그를 만들 수 있습니다.
프로젝트를 진행하면서 가장 어려웠던 부분도 이 부분인데요.
서버의 현재 시간을 그대로 저장하던 방식, 즉 LocalDateTime 기반의 방식 때문에 스케줄러 실행 시간과 DB에 저장된 시간이 다르게 기록되거나 환경에 따라 시간이 달라지는 문제가 발생했습니다. LocalDateTime은 타임존 정보를 포함하지 않기 때문에 발생한 문제이죠.
이 경험을 통해, 시간 데이터는 시스템 전체에서 일관된 기준으로 저장되어야 한다는 점을 깨달았습니다.
그래서 시간을 DB에는 UTC를 기준으로 저장하고 클라이언트에게 응답할 때만 KST(한국 표준시)로 변환하는 구조를 도입하였습니다.
시간 관련 프로젝트 내 표준 정의
저는 프로젝트 내에서 다음과 같이 시간 관련 표준을 정의하여 팀원들이 사용할 수 있도록 하였습니다.
| DB/서버 | 클라이언트 |
| UTC(표준) | KST(Asia/Seoul) |
1. DB에 저장 시 UTC로 모두 통일하여 저장한다.
- TimeStamp 칼럼에 대해서만 해당한다.
- DateTime, Date는 따로 TimeZone을 설정하지 않으므로 해당되지 않는다.
- DB 칼럼 타입이 TimeStamp인 경우 엔티티 속성 타입은 무조건 Instant로 통일한다.
- 현재 시간을 저장해야하는 경우 무조건 Instant.now()로 저장한다.
2. 클라이언트로 전송 시 KST로 모두 통일하여 전송한다.
- 전송 DTO에 Instant 타입으로 지정 후 @KstDateTime을 해당 필드에 붙여준다.
@KstDateTime
private Instant pnTime;
실제로 아래와 같이 관련 문서를 작성해 Jira에 공유하였습니다.


시간 관련 데이터를 DB에 저장하는 경우
위에서 계속해서 말했듯이 Instant 타입을 활용하면 됩니다. Instant는 항상 UTC 기준으로 시간을 저장하기 때문에 타임존이 변경되더라도 일관된 시각을 유지할 수 있습니다,
즉, LocalDateTime처럼 서버의 시스템 시간에 영향을 받지 않으며, 해외 서버나 멀티 리전 환경에서도 동일한 값으로 관리할 수 있습니다.
실제로 저는 프로젝트에서 다음과 같이 활용하였습니다.
SharedAccount account = SharedAccount.builder()
.createdAt(Instant.now()).build();
시간 데이터를 단순히 조회해서 클라이언트에게 반환하는 경우
위에서 언급한 대로 전송할 DTO에 Instant 타입으로 지정 후 @KstDateTime 커스텀 어노테이션을 해당 필드에 붙여줍니다.
@KstDateTime
private Instant pnTime;
@KstDateTime은 다음과 같이 정의해주었습니다.
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "Asia/Seoul")
public @interface KstDateTime {}
시간 데이터를 조회 후 가공하는 경우
시간 데이터를 단순히 DB에서 조회만 하는 것이 아니라, 조회한 값을 기준으로 가공하거나 비교해야 하는 경우에는 조금 더 신경 쓸 점이 많습니다.
예를 들어, DB에 저장된 시간(UTC 기준)을 가져온 뒤 한국 시간으로 "오늘인지 아닌지”, “이번 주에 포함되는지”, “여행 기간에 속하는지” 등을 판단하려면, 무조건 KST 기준으로 변환한 뒤 비교해야만 올바른 결과가 나옵니다.
따라서 저는 ZoneTimeUtil을 구현하여 활용하였습니다.
ZoneTimeUtil
해당 클래스에서는 한국 시간을 기준으로 UTC 시간을 반환합니다. static 클래스이기 때문에 private final로 선언하지 않고 바로 ZoneTimeUtil.함수명으로 작성해도 사용 가능합니다.반환 타입은 ZonedTimeRange입니다.
하루 단위
ex) 한국 기준으로 2025년 9월 1일의 시작 시간, 끝 시간을 UTC로 받고 싶은 경우 사용
[9월 1일 0시, 9월 2일 0시) → [KST 기준 9월 1일 0시의 UTC 시간, KST 기준 9월 2일 0시의 UTC 시간)
/**
* 하루 단위
* N년 N월 N일 0시 - N년 N일 24시
* @param day
* @return
*/
public static ZonedTimeRange day(LocalDate day) {
ZonedDateTime start = day.atStartOfDay(ZONE_ID);
ZonedDateTime end = start.plusDays(1);
return new ZonedTimeRange(start.toInstant(), end.toInstant());
}
주 단위
ex) 한국 기준으로 2025년 9월 1일 0시 - 9월 8일 0시를 UTC로 받고 싶은 경우 사용
[9월 1일 0시, 9월 8일 0시) → [KST 기준 9월 1일 0시의 UTC 시간, KST 기준 9월 8일 0시의 UTC 시간)
/**
* 주 단위, 월요일이 주의 시작이라고 고정
* N년 N월 N일 월요일 0시 - N년 N월 N+7일 월요일 0시
* @param day
* @return
*/
public static ZonedTimeRange week(LocalDate day) {
ZonedDateTime start = day.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.atStartOfDay(ZONE_ID);
ZonedDateTime end = start.plusWeeks(1);
return new ZonedTimeRange(start.toInstant(), end.toInstant());
}
월 단위
ex) 한국 기준으로 2025년 9월 1일 0시 - 10월 1일 0시를 UTC로 받고 싶은 경우 사용
[9월 1일 0시, 10월 1일 0시) → [KST 기준 9월 1일 0시의 UTC 시간, KST 기준 10월 1일 0시의 UTC 시간)
/**
* 월 단위
* N년 N월 1일 0시 - N년 N+1월 1일 0시
* @param ym
* @return
*/
public static ZonedTimeRange month(YearMonth ym) {
ZonedDateTime start = ym.atDay(1).atStartOfDay(ZONE_ID);
ZonedDateTime end = start.plusMonths(1);
return new ZonedTimeRange(start.toInstant(), end.toInstant());
}
커스텀
ex) 한국 기준으로 2025년 9월 1일 3시 - 9월 13일 0시를 UTC로 받고 싶은 경우 사용
[9월 1일 3시, 9월 13일 0시) → [KST 기준 9월 1일 3시의 UTC 시간, KST 기준 9월 13일 0시의 UTC 시간)
/**
* 사용자 지정 기간
* @param start
* @param end
* @return
*/
public static ZonedTimeRange custom(LocalDate start, LocalDate end) {
ZonedDateTime startZdt = start.atStartOfDay(ZONE_ID);
ZonedDateTime endZdt = end.plusDays(1).atStartOfDay(ZONE_ID);
return new ZonedTimeRange(startZdt.toInstant(), endZdt.toInstant());
}
최종 코드는 다음과 같습니다.
@Component
public final class ZonedTimeUtil {
private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul");
private ZonedTimeUtil() {}
/**
* 하루 단위
* N년 N월 N일 0시 - N년 N일 24시
* @param day
* @return
*/
public static ZonedTimeRange day(LocalDate day) {
ZonedDateTime start = day.atStartOfDay(ZONE_ID);
ZonedDateTime end = start.plusDays(1);
return new ZonedTimeRange(start.toInstant(), end.toInstant());
}
/**
* 주 단위, 월요일이 주의 시작이라고 고정
* N년 N월 N일 월요일 0시 - N년 N월 N+7일 월요일 0시
* @param day
* @return
*/
public static ZonedTimeRange week(LocalDate day) {
ZonedDateTime start = day.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.atStartOfDay(ZONE_ID);
ZonedDateTime end = start.plusWeeks(1);
return new ZonedTimeRange(start.toInstant(), end.toInstant());
}
/**
* 월 단위
* N년 N월 1일 0시 - N년 N+1월 1일 0시
* @param ym
* @return
*/
public static ZonedTimeRange month(YearMonth ym) {
ZonedDateTime start = ym.atDay(1).atStartOfDay(ZONE_ID);
ZonedDateTime end = start.plusMonths(1);
return new ZonedTimeRange(start.toInstant(), end.toInstant());
}
/**
* 사용자 지정 기간
* @param start
* @param end
* @return
*/
public static ZonedTimeRange custom(LocalDate start, LocalDate end) {
ZonedDateTime startZdt = start.atStartOfDay(ZONE_ID);
ZonedDateTime endZdt = end.plusDays(1).atStartOfDay(ZONE_ID);
return new ZonedTimeRange(startZdt.toInstant(), endZdt.toInstant());
}
/**
* 현재 날짜
*/
public static LocalDate now() {
return LocalDate.now(ZONE_ID);
}
}
참고로 ZoneTimeRange는 다음과 같습니다.
/**
* [start, end)
* @param start
* @param end
*/
public record ZonedTimeRange(Instant start, Instant end) {
}
사용 예시
1. 9월 10일 하루를 알고 싶은 경우
LocalDate day = LocalDate.of(2025, 9, 10);
ZonedTimeRange r = ZonedTimeUtil.day(day);
2. 9월 10일부터 일주일을 알고 싶은 경우
LocalDate anyDay = LocalDate.of(2025, 9, 10);
ZonedTimeRange r = ZonedTimeUtil.week(anyDay);
3. 2025년 8월의 시작, 끝을 알고 싶은 경우
YearMonth ym = YearMonth.of(2025, 9);
ZonedTimeRange r = ZonedTimeUtil.month(ym);
4. 여행 기간을 UTC로 얻고 싶은 경우
ZonedTimeRange tripRangeUtc = ZonedTimeUtil.custom(t.getStartDate(), t.getEndDate());
마무리하면서
저는 위와 같이 시간 데이터를 관리하였습니다. 진행하면서 아쉬웠던 점은 시간이 부족하여 오직 KST 기준으로만 작동하도록 구현했다는 점인데, 이후 클라이언트의 타임존을 기준으로 시간 데이터를 가공하는 로직도 구현해보고 싶다는 생각이 들었습니다.
읽어주셔서 감사합니다.

'자바 > 스프링부트' 카테고리의 다른 글
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 2 (0) | 2025.10.21 |
|---|---|
| [JAVA / 스프링 부트] 커스텀 필터 & AuthorizationManager로 그룹별 API 접근 제어하기 (1) | 2025.10.21 |
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 1 (0) | 2025.10.14 |
| [JAVA / 스프링부트] API 공통 응답 형식 지정 + 공통 에러 처리 - 2 (0) | 2025.10.13 |
| [JAVA / 스프링부트] API 공통 응답 형식 지정 + 공통 에러 처리 - 1 (0) | 2025.10.13 |