10월 1일부터 31일까지의 예약을 보고 싶은데 종료 경계를 10월 31일 00:00으로 만들었다고 하자. SQL이 그 시각보다 작은 값만 조회하면 31일의 예약은 빠진다. 서버 시간대를 서울로 맞춰도 이 경계는 그대로다.

한편 화면의 모든 게시일이 00:00으로 보이는 문제는 날짜만 가진 값을 시각처럼 표현해서 생길 수 있다. 둘을 같은 원인으로 묶기 전에 어떤 값을 어느 구간으로 해석하는가를 나눈다.

날짜와 사건 시각은 다른 입력이다

LocalDate는 업무 날짜, Instant는 타임라인의 한 순간, LocalDateTime은 지역이 정해지지 않은 날짜와 시각이다. 지역 예약 시각이라면 LocalDateTime에 ZoneId를 연결할 정책이 필요하다.

val value = LocalDateTime.of(2026, 10, 1, 9, 0)
val inSeoul = value.atZone(ZoneId.of("Asia/Seoul")).toInstant()
val inUtc = value.atZone(ZoneId.of("UTC")).toInstant()

같은 숫자 09:00을 두 지역 시각으로 해석하므로 두 순간은 달라진다. 시스템 기본 시간대에 맡기면 환경 설정이 입력의 의미를 결정하게 된다. LocalDateTime 문서의 지역 없는 표현과 사건 시각을 구분한다.

JVM·DB 세션·로그의 시간대 관리도 필요하지만 생성·승인 시각, 노출 날짜, 지점 예약 시각을 한 타입으로 합치는 문제를 대신 해결하지는 않는다.

종료일 다음 날의 시작을 제외 경계로 만든다

사용자가 양끝 날짜를 포함하는 조회를 선택했다면 업무 지역에서 다음 날의 시작까지 계산한다.

// startDate와 endDate는 LocalDate 입력이다.
val businessZone = ZoneId.of("Asia/Seoul")
val from = startDate.atStartOfDay(businessZone).toInstant()
val toExclusive = endDate.plusDays(1)
    .atStartOfDay(businessZone).toInstant()
SELECT id, status, occurred_at
FROM reservation
WHERE occurred_at >= :from
  AND occurred_at < :to_exclusive
ORDER BY occurred_at, id;

시작은 포함하고 다음 날 시작은 제외하는 [from, to)다. 종료일을 23:59:59.999로 만드는 방법과 달리 컬럼의 밀리초·마이크로초 정밀도에 기대지 않는다. atStartOfDay(zone)은 그 지역 규칙의 유효한 시작 시각을 고른다. LocalDate 문서를 참고한다.

사용자 날짜를 업무 지역의 달력 경계로 계산하고 Instant로 변환해 종료일 다음 날을 제외하는 조회 흐름

그림의 변환은 달력 계산 뒤에 위치한다. 하루를 항상 고정된 초 수로 더하기보다 해당 지역의 다음 날짜 시작을 계산한다. 시간대 규칙이 바뀌는 지역에서도 같은 경계 계약을 유지하기 위해서다.

범위 겹침을 DB에서 제한해야 한다면 tstzrange와 exclusion constraint도 선택지다. 단순 목록 조회에 반드시 필요한 구조는 아니므로 별도 요구가 있을 때 검토한다.

같은 순간도 DB 세션에 따라 다르게 보인다

PostgreSQL의 timestamptz는 순간을 저장하고 조회 세션 시간대에 맞춰 표현한다. 원래 입력의 Asia/Seoul 식별자를 보존하지 않는다. 날짜·시간 타입 문서에 따라 사건 시각과 지역 일정의 저장 의미를 구분한다.

SET TIME ZONE 'UTC';
SELECT timestamptz '2026-10-01 09:00:00 Asia/Seoul';

SET TIME ZONE 'Asia/Seoul';
SELECT timestamptz '2026-10-01 09:00:00 Asia/Seoul';

두 조회는 같은 순간을 다른 offset으로 표시하는 예시다. 화면 문자열만 비교해서 데이터가 바뀌었다고 판단하지 않는다. 실제 컬럼 타입, 바인딩 값과 세션 시간대를 같이 확인한다.

의미Kotlin/JVM 표현DB 표현 예시
생성·승인 사건Instanttimestamptz
노출 날짜LocalDatedate
지점의 지역 일정LocalDateTime + ZoneIdtimestamp + zone_id

표시 날짜의 빈 값을 다른 시각으로 덮지 않는다

{
  "displayDate": "2026-10-01",
  "publishedAt": "2026-10-01T03:17:42Z"
}

노출 날짜와 실제 공개 순간은 다른 질문에 답한다. 날짜에 자정을 붙이면 없던 시각을 가진 것처럼 보인다. 표시 날짜가 없을 때 승인일·등록일 중 무엇을 쓸지도 업무 정책으로 정한다.

// displayDate: LocalDate?, publishedAt: Instant?, createdAt: Instant
fun effectiveDisplayDate(zone: ZoneId): LocalDate {
    displayDate?.let { return it }
    publishedAt?.let {
        return it.atZone(zone).toLocalDate()
    }
    return createdAt.atZone(zone).toLocalDate()
}

이것은 공개일 다음 등록일을 사용하는 정책 예시다. 실제 정책을 확정한 뒤 목록과 상세에서 공유한다. 파생값을 계산할 뿐 생성·승인·공개 원본 시각을 수정하지 않는다.

같은 요청을 값의 형태별로 따라간다

종료일이 빠졌다면 요청 원문, controller 타입, repository 바인딩, 실제 SQL 연산자, DB 컬럼 타입, 응답 직렬화를 순서대로 비교한다. UTC라는 이름만으로 원인을 확정하지 않고 어느 계층에서 어떤 경계로 바뀌었는지 찾는다.

SHOW timezone;
SELECT pg_typeof(occurred_at), occurred_at,
       occurred_at AT TIME ZONE 'UTC' AS as_utc,
       occurred_at AT TIME ZONE 'Asia/Seoul' AS as_seoul
FROM reservation
WHERE id = :id;

컬럼 타입을 먼저 읽어 시간대 연산의 결과 타입을 해석한다. 경계 직전·정각·직후의 소수 레코드로 포함 여부를 비교하면 불필요한 대량 조회 없이 가설을 좁힐 수 있다.

시계에 의존한 정책은 Clock을 주입해 월말과 자정을 재현한다.

val clock = Clock.fixed(
    Instant.parse("2026-09-30T15:00:00Z"), ZoneId.of("Asia/Seoul")
)
val today = LocalDate.now(clock)
assertThat(today).isEqualTo(LocalDate.of(2026, 10, 1))

위 코드는 기대 날짜를 확인할 테스트 예시다. 시작 경계 포함, 종료 다음 날 제외, 정밀도가 높은 마지막 값, 월말·윤일, 서로 다른 세션 시간대를 추가한다. 다음 판단은 시간대를 일괄 변경하는 데 두지 않고 날짜 계약과 실제 조회 집합이 일치하는지에 둔다.