작성하려던 API는 하루 방문자 수를 단순히 COUNT 하는 쿼리였다.
/**
* 오늘 하루 방문자 수 조회하는 메서드
* */
public long dailyVisitedCount() {
LocalDateTime startDate = LocalDate.now().atStartOfDay();
LocalDateTime endDate = LocalDate.now().atTime(23, 59, 59, 999999999);
log.info("startDate : {}", startDate);
log.info("endDate : {}", endDate);
return loginRepository.dailyVisitedCount(startDate, endDate);
}
메서드는 위와 같이 오늘 날짜에 해당하는 데이터를 구하도록 구성되어 있었다.
// 하루 방문자 수 조회 쿼리
@Query("SELECT COUNT(*) FROM LoginHistory WHERE accessAt BETWEEN :startDate AND :endDate")
long dailyVisitedCount(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
JPQL은 BETWEEN을 사용해서 오늘 날짜에만 포함되는 데이터의 COUNT를 구하도록 만들었다.
DB에는 총 3개의 데이터가 있었고 14일 2개, 15일 1개로 구성했다.
테스트 기준 날짜가 14일이었기 때문에 2개의 데이터가 리턴되는 것이 정상적인 로직의 흐름이다.
날짜 로그도 정상적으로 찍혔으나
반환 데이터의 개수가 3개가 나오는 문제가 발생했다.
여기서 무언가 이상함을 느끼고 MySQL 워크벤치로 직접 쿼리를 날려봤다.
SELECT COUNT(*)
FROM login_history
WHERE access_at BETWEEN '2024-12-14 00:00' AND '2024-12-14 23:59:59.999999999';
위와 같은 쿼리를 동일하게 날렸더니 결과는
동일하게 3개가 나오는 것을 확인했다.
뭔가 이상했다. 분명 데이터는 2개만 나와야 하는데 15일의 데이터까지 합산으로 나오는 것이다.
예상하기로는 nano로 설정한 999999999가 반올림이 돼서 15일의 데이터까지 계산이 들어가나?라고 의심했다.
그래서 쿼리의 nano 개수를 줄여가며 테스트를 진행했다.
JPQL
9의 개수 | COUNT 결과 |
9개 | 3 |
8개 | 2 |
7개 | 2 |
6개 | 2 |
... | |
1개 | 2 |
SQL(직접 쿼리 날림, MySQL)
9의 개수 | COUNT 결과 |
9개 | 3 |
8개 | 3 |
7개 | 3 |
6개 | 2 |
... | |
1개 | 2 |
JPQL에서는 9의 개수가 8개가 되면서부터 정상적으로 2개를 찾아오기 시작했고
SQL에서는 9의 개수가 6개가 되면서부터 정상적으로 2개를 찾아오기 시작했다.
대체 무슨 차이가 있길래 이런 상황이 발생하는 걸까?
우선 LocalDate의 atTime 메서드부터 내용을 읽어봤다.
nanoOfSecond에서 999,999,999까지 가능하다고 적혀있으니 메서드는 문제가 없다.
그럼 MySQL에서 허용하는 자릿수의 한계가 있는 건가?
그렇다.
MySQL에서는 Fractional Seconds라는 단어를 사용한다.
Fractional Seconds는 DB에 초 단위의 소수점 이하 값을 저장하고 조회할 수 있는지 나타내는 정밀도를 뜻한다.
https://dev.mysql.com/doc/refman/8.0/en/fractional-seconds.html
MySQL :: MySQL 8.0 Reference Manual :: 13.2.6 Fractional Seconds in Time Values
13.2.6 Fractional Seconds in Time Values MySQL has fractional seconds support for TIME, DATETIME, and TIMESTAMP values, with up to microseconds (6 digits) precision: To define a column that includes a fractional seconds part, use the syntax type_name(fsp)
dev.mysql.com
위 MySQL의 공식문서를 참고하면 Fractional Seconds는 0~6 사이로 설정이 가능하다고 설명되어있다.
그 말은 6자리까지 지원을 한다는 의미가 되는 것이다. 즉 7자리를 넘는 순간 999999는 반올림이 되어버려서 15일로 넘어가게 되는 것이었다.
그러면 JPA에서는 왜 8번째 자릿수부터 정상적인 결괏값이 도출되었던 걸까?
그 원인은 MySQL의 TimeUtil이라는 클래스에서 찾을 수 있었다.
그중에서 adjustNanoPrecision 메서드가 이번 문제의 핵심 실마리였다.
public static Timestamp adjustNanosPrecision(Timestamp ts, int fsp, boolean serverRoundFracSecs) {
if (fsp < 0 || fsp > 6) {
throw ExceptionFactory.createException(WrongArgumentException.class, "fsp value must be in 0 to 6 range.");
}
Timestamp res = (Timestamp) ts.clone();
double tail = Math.pow(10, 9 - fsp);
int nanos = serverRoundFracSecs ? (int) Math.round(res.getNanos() / tail) * (int) tail : (int) (res.getNanos() / tail) * (int) tail;
if (nanos > 999999999) { // if rounded up to the second then increment seconds
nanos %= 1000000000; // get last 9 digits
res.setTime(res.getTime() + 1000); // increment seconds
}
res.setNanos(nanos);
return res;
}
처음에 ts에 시간이 정확하게 들어오는 것을 확인할 수 있다. 여기서는 9가 9자리로 들어왔다.
fsp, 즉 Fractional Seconds가 6이기 때문에 if를 넘어가게 된다.
그다음 res 변수에 똑같이 매개변수로 받은 시간을 clone 한다.
(아마 매개변수로 넘어오는 Timestamp는 참조값이니 불변성을 유지하기 위해 clone을 하지 않았을까 예상한다.)
이후 tail이라는 값을 계산한다.
이 값은 MySQL의 fsp에 맞게 값을 조절하기 위해 사용된다고 한다.
위 계산 과정을 거쳤더니 nanos가 1000000000으로 반올림이 된 것을 알 수 있다.
여기서 문제가 발생하게 됐던 것이다.
모든 계산이 끝나면 res에 다시 nanos를 설정하고 return을 진행하게 된다.
여기서 '2024-12-14 23:59:59.999999999'가 '2024-12-15 00:00:00'으로 바뀌었던 것이다.
그러면 어떻게 사용해야 할까?
우선 MySQL에서 fsp를 최대 6자리까지 허용한다고 설명했으니 nano의 자릿수를 6자리까지 사용하기로 결정했다.
MySQL에서는 6자리까지 허용하지만 JPA를 사용했을 때 8자리부터 정상적인 값이 나왔던 이유는 결국 MySQL의 TimeUtil 클래스 때문이었다.
그렇지만 이렇게 6자리로 설정한다고 해서 앞으로 문제가 발생하지 않을 거라는 장담은 없다.
더 좋은 해결 방법이 있을지 고민하는 것도 좋은 방법일 것 같다.(근데 지금은 머리가 아프다)
'오류해결' 카테고리의 다른 글
Docker 컨테이너를 사용하면서 만난 네트워크 불일치 문제(nginx) (0) | 2025.01.05 |
---|---|
Input length must be multiple of 16 when decrypting with padded cipher 문제 해결 방법 (0) | 2024.10.31 |
mysql.cj.jdbc.Driver에 빨간글씨가 뜨는 문제 (1) | 2024.09.22 |
스프링 시큐리티에서 인증 후 CSRF 토큰 갱신 문제 (0) | 2024.08.07 |