문제 상황
진행하는 프로젝트에서, 특정 날짜와 범위가 주어지면, 해당 범위의 스케줄을 모두 조회해오는 쿼리가 있었다.
예를 들면, 요청 중앙값이 2025-09-15이고 요청 범위가 7이라면, 2025-09-12 ~ 2025-09-18 기간의 스케줄을 조회해오는 쿼리였다.
Service Layer
@Transactional(readOnly = true)
public List<DayScheduleResponse> getSchedule(ScheduleRequestParam request) {
LocalDate median = Optional.ofNullable(request.median()).orElse(LocalDate.now()); // 요청 중앙값
Integer range = request.range(); // 요청 범위
LocalDate startDate = median.minusDays(range / 2); // 쿼리의 시작일 지정
LocalDate endDate = median.plusDays(range / 2); // 쿼리의 종료일 지정
LocalDateTime startDateTime = median.minusDays(range / 2).atTime(LocalTime.MIN); // 쿼리의 시작시간 지정
LocalDateTime endDateTime = median.plusDays(range / 2).atTime(LocalTime.MAX); // 쿼리의 종료시간 지정
Map<LocalDate, DayScheduleResponse> scheduleMap = new HashMap<>(); // 시작일부터 종료일까지를 key로 하는 Map 생성
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
scheduleMap.put(date, new DayScheduleResponse(date, new ArrayList<>()));
}
// 시작 시간부터 종료 시간 사이의 스케줄 조회
List<Announcement> announcements =
announcementRepository.findPublicScheduleByDateTimeBetween(startDateTime, endDateTime);
for (Announcement announcement : announcements) {
// 해당 스케줄에서 key 값 추출
LocalDate key = announcement.getScheduleStartAt() != null
? announcement.getScheduleStartAt().toLocalDate()
: announcement.getCreatedAt().toLocalDate();
DayScheduleResponse response = scheduleMap.get(key); // Map에서 key에 대한 value
response.schedules().add(ScheduleDto.from(announcement)); // NullPointerException
}
Repository Layer
@Query(
"""
SELECT a FROM Announcement a
LEFT JOIN a.study s
WHERE (
(a.scheduleStartAt BETWEEN :startDateTime AND :endDateTime)
OR (a.scheduleStartAt IS NULL AND a.createdAt BETWEEN :startDateTime AND :endDateTime)
)
AND a.isPublic = true
AND (a.announcementType != 'STUDY' OR (a.announcementType = 'STUDY' AND s.isPublic = true))
ORDER BY COALESCE(a.scheduleStartAt, a.createdAt)
""")
List<Announcement> findPublicScheduleByDateTimeBetween(
@Param("startDateTime") LocalDateTime startDateTime, @Param("endDateTime") LocalDateTime endDateTime);
해당 코드가 제대로 실행될거라 기대했지만, FE에서 테스트 중 특정 날짜에서 500 에러가 반환된다는 보고를 해 주셔서 로그를 확인해보았다. 확인 결과, 특정 요청들에 한해서 NullPointerException이 발생하고 있었다.
일단 NullPointerException이 발생하는 위치를 확인해보았다. DB에서 조회해오는 스케줄에서, 기존에 만들었던 Map의 Key를 포함하지 않는 스케줄이 조회되는 것 같았다. 머릿속으로 생각해보았을 때, 전혀 문제있을만한 코드라고 생각이 들지 않았다. startDate의 첫 시간부터, endDate의 끝 시간 사이의 스케줄을 조회해오는 코드였고, 실제로 그렇게 작성되어 있었기 때문이다.
원인
결국 애플리케이션 코드 단에서 확인되는 문제가 없어서, DB를 확인했다. 레코드를 살펴보던 중, 오류가 나는 요청들의 공통점을 발견했다. 오류가 나는 요청들에 대해서, DB에 요청의 endDate + 1의 Date 값을 가지고, 00:00:00.000000의 Time을 가지는 레코드가 존재했다.
그래서 내 생각에는, 이 값이 어떤 이유를 통해 쿼리로 넘어오기 때문에, endDate + 1 라는 Key에 접근해서 NullPointerException이 발생한다고 추측했다. 문제는 대체 왜 이게 넘어올까이다. 우리 쿼리에서 endDateTime 조건에 잘못된 부분이 있었을까?
일단 LocalTime 내부 구현에서, MAX 값이 어떻게 구현되어 있는지 살펴보았다.
/**
* The maximum supported {@code LocalTime}, '23:59:59.999999999'.
* This is the time just before midnight at the end of the day.
*/
public static final LocalTime MAX;
소수점 9자리까지의 정밀도를 지원하고 있었다. 우리 DB(MySQL 사용 중)는 00:00:00.000000 에서 알 수 있듯, 시간을 나타내는 컬럼 타입이 DATETIME(6) 이었다. 즉, 소수점 6자리까지의 정밀도를 나타낼 수 있었다.
그렇다면, Java에서 소수점 9자리 정밀도로 나타내던 LocalTime.MAX 때문에, endDateTime이 자동으로 반올림되어 다음날 0시를 나타내버린 것이 아닐까?
MySQL 공식 문서에 따르면, '타입에 지정된 소수점 자릿수보다' 더 낮은 자릿수까지 표현된 값을 삽입할 경우, 오류 없이 정상적으로 반올림하는 것을 알 수 있다. 나의 추측처럼, DB단에서 반올림이 일어나고 있던 것이었다.
해결방법
Java의 LocalTime.MAX는 나노초까지 표현해주지만, MySQL에서는 마이크로초까지 표현 가능하다. 따라서 자동 반올림이 일어나지 않도록 애플리케이션 코드에서 마이크로초까지만 나타내야 할 필요성이 생겼다.
// 기존
LocalDateTime endDateTime = median.plusDays(range / 2).atTime(LocalTime.MAX);
// 수정
LocalDateTime endDateTime = median.plusDays(range / 2).atTime(LocalTime.of(23, 59, 59, 999_999_000));
조금 가독성이 떨어질 수 있겠으나, 이렇게 표현해 준 결과 의도하지 않은 날짜가 쿼리 결과로 포함되어 오는 것을 막을 수 있었다.