개발을 하며 MySQL의 Timestamp가 이상하게 조회되는 상황에 맞닥뜨렸다.
발생한 이슈와 원인, 해결한 방법 순서대로 기록해둔다.
이슈 발생
간단하게 정리하면 데이터 조회 시 특정 일자의 하루 동안 저장된 데이터를 조회해야 했고, 이를 위해서 특정 일자의 시작 시간(yyyy-MM-dd 00:00:00)과 끝 시간(yyyy-MM-dd 23:59:59)을 Timestamp 타입으로 만들었다.
예를 들어 일자별 회원가입 현황을 데이터테이블로 산출하려고 한다.
조회 시에는 JPQL을 사용했고, 테스트 용으로 아주 간단하게만 구현해두었다.
/**
* 일자별 회원가입 현황
* @param
* @return
*/
public List<TestDto> getDailySignUp(Integer start, Integer length, String orderColumn, String orderDirection, String date){
switch(orderColumn){
case "idx" : orderColumn = "ad.id";
break;
default : throw new IllegalStateException("Unexpected value: " + orderColumn);
}
String orderBy = orderColumn + " " + orderDirection;
// date : yyyy-MM-dd
Timestamp startTimestamp = TimestampUtil.startOfDay(date);
Timestamp endTimestamp = TimestampUtil.endOfDay(date);
TypedQuery<TestDto> query;
query = em.createQuery("select new com.test.testProject.dto.TestDto" +
"(m.id, m.date, m.memberId)" +
" from Member m" +
" where 1=1" +
" and m.date between :startTimestamp and :endTimestamp" +
" order by " + orderBy, TestDto.class);
return query
.setParameter("startTimestamp", startTimestamp)
.setParameter("endTimestamp", endTimestamp)
.getResultList();
}
package com.test.testProject.util;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
public class TimestampUtil {
public static Timestamp startOfDay(String processDateTime){
// 입력 날짜를 LocalDate 객체로 변환
LocalDate dateObj = LocalDate.parse(processDateTime);
// 시작 시간(00:00:00)을 추가해 LocalDateTime 객체로 변환
LocalDateTime startOfDay = dateObj.atTime(LocalTime.MIN);
Timestamp startTimestamp = Timestamp.valueOf(startOfDay);
System.out.println("startTimestamp.toString() = " + startTimestamp.toString());
return startTimestamp;
}
public static Timestamp endOfDay(String processDateTime){
// 입력 날짜를 LocalDate 객체로 변환
LocalDate dateObj = LocalDate.parse(processDateTime);
// 끝 시간(23:59:59)을 추가해 LocalDateTime 객체로 변환
LocalDateTime endOfDay = dateObj.atTime(LocalTime.MAX);
Timestamp endTimestamp = Timestamp.valueOf(endOfDay);
System.out.println("endTimestamp.toString() = " + endTimestamp.toString());
return endTimestamp;
}
}
// startTimestamp.toString() = 2023-10-01 00:00:00.0
// endTimestamp.toString() = 2023-10-01 23:59:59.999999999
String 데이터 타입으로 “yyyy-MM-dd” 형식의 날짜(date)를 파마미터로 넣어 TimestampUtil의 startOfDay와 endOfDay 메서드를 호출하면 입력한 날짜의 가장 처음과 마지막 시간을 출력하도록 했다.
예를 들어 “2023-10-01”을 입력하면 각각 2023-10-01 00:00:00.0, 2023-10-01 23:59:59.999999999 이 출력된다.
여기까지는 아무런 문제가 없어보였다.
하지만 2023년 10월 28일의 회원 가입 현황을 조회하면, 2023년 10월 28일의 데이터만 조회되는 것이 아니라 Member 테이블의 date가 2023년 10월 29일 00:00:00인 데이터도 함께 조회가 되었다.
대체 왜..?
원인
결론부터 말하면 MySQL의 Datetime과 Timestamp와 같은 날짜 타입에서 반올림이 발생할 수도 있다고 한다.
1) Fractional Seconds
MySQL 공식 문서에서 아래와 같은 내용을 확인할 수 있다.
MySQL has fractional seconds support for TIME, DATETIME, and TIMESTAMP values, with up to microseconds (6 digits) precision
MySQL의 DATETIME, TIME, TIMESTAMP 타입은 초 단위를 최대 6자리 microseconds(ms) 정확도까지 저장할 수 있으며, ms부터의 시간을 Fractional Seconds라고 한다.
알아보니 좀 더 높은 시간 정밀도를 보장하기 위해 MySQL 5.6부터 지원하기 시작했다고 한다.
보장 범위는 위에서 언급한대로 6자리(ms)까지이며, DATETIME(6), TIMESTAMP(6) 등의 형식으로 Fractional Seconds를 지정할 수 있다.
하지만 Fractional Seconds는 내가 마주한 상황처럼 예상치 못한 결과를 가져올 수 있다는 점을 주의해야 한다.
예를 들어 아래와 같은 테이블을 생성했다고 하자.
CREATE TABLE `test_table` (
`a` datetime DEFAULT NULL,
`b` datetime(3) DEFAULT NULL,
`c` timestamp DEFAULT NULL,
`d` timestamp(6) DEFAULT NULL
)
그리고 아래와 같이 동일한 데이터를 각각의 컬럼에 추가한다.
INSERT INTO `test_table` VALUES (
'2023-10-28 23:59:59.999',
'2023-10-28 23:59:59.999',
'2023-10-28 23:59:59.999',
'2023-10-28 23:59:59.999'
);
이렇게 테이블을 조회하면 아래의 결과가 나온다.
SELECT * FROM test_table;
Fractional Seconds를 지정하지 않은 부분은 반올림이 되어 다음 날이 되었고, 지정한 부분은 지정한 수만큼 자릿수를 보장해 그대로 조회되었다.
컬럼 d처럼 저장할 Fractional Seconds의 자릿수가 6보다 더 작을 경우 0으로 채워주는 것도 확인할 수 있다.
이렇게 MySQL에서는 Fractional Seconds에 의해 날짜가 반올림될 수 있다는 것을 확인했다.
2) LocalTime.MAX
그렇다면 위의 내 코드에서는 무엇 때문에 날짜가 반올림되었을까?
원인은 LocalTime.MAX였다.
아래 이미지처럼 LocalTime class에 정의된 MAX는 Fractional Seconds를 9자리만큼 가지고 있다.
MySQL이 지원하는 Fractional Seconds의 최대 자릿수인 6자리를 초과했기 때문에 다음 날짜로 반올림되는 것이다.
public class TimestampUtil {
~~~~
public static Timestamp endOfDay(String processDateTime){
// 입력 날짜를 LocalDate 객체로 변환
LocalDate dateObj = LocalDate.parse(processDateTime);
// 끝 시간(23:59:59)을 추가해 LocalDateTime 객체로 변환
LocalDateTime endOfDay = dateObj.atTime(LocalTime.MAX);
Timestamp endTimestamp = Timestamp.valueOf(endOfDay);
System.out.println("endTimestamp.toString() = " + endTimestamp.toString());
return endTimestamp;
}
}
// endTimestamp.toString() = 2023-10-01 23:59:59.999999999
끝 시간(23:59:59)을 추가해 LocalDateTime 객체로 변환하고자 했던 부분에서 LocalTime.MAX를 이용해 하루의 마지막 시간인 LocalDateTime을 만들었고, 위와 같은 이유 때문에 당일의 데이터만 조회하려던 의도와 달리 다음날의 데이터까지 포함되는 문제가 발생한 것이었다.
해결 방법
의도하지 않은 LocalTime.MAX의 반올림에 대한 이슈에 대해 어떻게 해결할지 고민했다.
우선 요구사항 및 도출해야 할 결론은 명확했다.
1. 23시 59분 59초 999999에 저장된 데이터도 정상적으로 조회하기 위해 Fractional Seconds의 사용은 불가피하다!
2. 따라서 DB나 어플리케이션 단에서 ms 단위를 아예 절삭하는 것은 올바르지 않다!
이에 따라 해결 방법을 찾기 위해 접근한 방법은 다음과 같다.
MySQL의 DATETIME, TIME, TIMESTAMP 타입은 초 단위를 최대 6자리 microseconds(ms) 정확도까지 저장하니, Java의 LocalTime.MAX도 ms 6자리까지만 생성하도록 정의하자!
LocalDateTime 객체의 나노초를 변경하기 위해서는 withNano() 메서드를 사용하면 된다.
앞서 말한대로 ms 단위는 6자리까지만 표시되길 원하니 아래와 같이 코드를 수정하면 된다.
public class TimestampUtil {
...
public static Timestamp endOfDay(String processDateTime){
// 입력 날짜를 LocalDate 객체로 변환
LocalDate dateObj = LocalDate.parse(processDateTime);
// 수정 전 : 끝 시간(23:59:59)을 추가해 LocalDateTime 객체로 변환
// LocalDateTime endOfDay = dateObj.atTime(LocalTime.MAX)
// 수정 후 : 끝 시간(23:59:59.999999)을 추가해 LocalDateTime 객체로 변환
LocalDateTime endOfDay = dateObj.atTime(LocalTime.MAX).withNano(999999000); // dateObj.atTime(LocalTime.MAX) => 23:59:59.999999999가 반올림되어 다음날로 처리되기 때문에 withNano(999999000) => 23:59:59.999999
Timestamp endTimestamp = Timestamp.valueOf(endOfDay);
System.out.println("endTimestamp.toString() = " + endTimestamp.toString());
return endTimestamp;
}
// endTimestamp.toString() = 2023-10-01 23:59:59.999999
}
참고로 ms 단위를 아예 절삭하고자 한다면 아래와 같이 수정하면 된다.
LocalDateTime endOfDay = dateObj.atTime(LocalTime.MAX).withNano(0);
'Java' 카테고리의 다른 글
[Error/Exception] java.lang.IllegalStateException: Duplicate key ‘key로 저장하려는 값’ (attempted merging values ~~) 해결 방법 (0) | 2024.07.24 |
---|---|
[Java] Integer ArrayList을 int 배열로 변환하는 방법 (1) | 2023.09.24 |
[Java/DB] ORM이란? MyBatis와 JPA 비교 및 예제 (0) | 2023.08.25 |
[Java] JVM(Java Virtual Machine)이란? (0) | 2023.08.22 |
[Java] 코드 특정 구간의 실행 시간 구하기 (0) | 2023.06.14 |