테스트를 리팩토링하는 과정에서 오류가 발생했다.
테스트에서 @Transactional을 제거했더니 또 오류가 발생했다. (@Transactional 를 제거하니 트러블 슈팅을 정말 많이하는구나....)
테스트 클래스 또는 메서드 레벨에서 트랜잭션이 시작하게 되면 트랜잭션과 JPA의 영속성 컨텍스트의 생명주기는 동일하다. 또한 엔티티를 저장할 때 flush 하고 clear 해주지 않으면 1차 캐시에서 엔티티를 조회해온다.
테스트에서는 1차 캐시에서 조회해올 때는 정상적으로 동작한다고 생각하지만, 실제 프로덕션 코드에서는 동작하지 않는 문제였다. False Negative 상황이다.
False Negative (거짓 부정) : 실제로 긍정적인 상태(오류가 있는 상태)인데도, 테스트나 시스템이 부정적(오류가 없는 상태)으로 잘못 판별한 경우이다.
문제 상황
@ParameterizedTest
@MethodSource("provideLocalDateTime1")
@DisplayName("같은 시간대에 카공을 여러개 만들 수 없다.")
void can_not_schedule_several_study_at_the_same_time(LocalDateTime testStartTime, LocalDateTime testEndTime) {
//given
// 현재시간에 영향을 받지 않도록 1년뒤로 설정
LocalDateTime standard = NOW.plusYears(1);
LocalDateTime start = standard.plusHours(4);
LocalDateTime end = start.plusHours(5);
...
Member leader = memberSaveHelper.saveMember(); // 스터디장이 존재한다.
Cafe cafe1 = cafeSaveHelper.saveCafeWith24For7(); // 카페1가 존재한다.
Cafe cafe2 = cafeSaveHelper.saveCafeWith24For7(); // 카페2가 존재한다.
//스터디장이 카페1에 카공 모임을 만든다.
studyOnceSaveHelper.saveStudyOnceWithTime(cafe1, leader, start, end);
StudyOnceCreateRequest request = makeStudyOnceCreateRequest(testStartTime, testEndTime,
cafe2.getId());
//then
// 카페1에서 카공 모임을 시작하는 스터디장이 같은 시간대에 카공을 만든다.
assertThatThrownBy(
() -> sut.createStudy(leader.getId(), request))
.isInstanceOf(CafegoryException.class)
.hasMessage(STUDY_ONCE_CONFLICT_TIME.getErrorMessage());
}
static Stream<Arguments> provideLocalDateTime1() {
LocalDateTime standard = NOW.plusYears(1);
LocalDateTime start = standard.plusHours(4);
return Stream.of(
// 카페2의 카공 모임의 시작시간과 종료시간은 카페1의 카공 모임의 시간과 동일하다.
Arguments.of(start, start.plusHours(5)),
// 카페2의 카공 모임의 시작시간은 카페1의 카공 모임의 시작시간-2시간
// 카페2의 카공 모임의 종료시간은 카페1의 카공 모임의 시작시간과 동일하다.
Arguments.of(start.minusHours(2), start),
// 카페2의 카공 모임의 시작시간은 카페1의 카공 모임의 종료시간과 동일하다.
// 카페2의 카공 모임의 종료시간은 카페1의 카공 모임의 시간에 속하지 않는다.
Arguments.of(start.plusHours(5), start.plusHours(9))
);
}
문제 해석
1. 스터디장과 카페1, 카페2가 존재한다.
2. 스터디장은 카페1에서 카공 모임을 만든다
3. 스터디장은 카페2에서 카공 모임을 만든다.
- 스터디장은 카공 모임을 만들 때, 동일한 날짜와 시간이 겹치지 말아야 한다.
오류 발생

테스트의 3번째 케이스인 카페2의 카공 모임의 시작시간은 카페1의 카공 모임의 종료시간과 동일하다. 에서 오류가 발생했다.
카페2의 카공 모임의 시작시간과 카페1의 카공 모임의 종료시간이 일치하기 때문에 동일한 날짜와 동일한 시간이 겹치는 상황이다.
예외가 발생해야하는 테스트 케이스에서 예외가 발생하지 않았다.
어느부분에서 문제가 발생했는지 디버깅을 해봤다.
디버깅


매개변수로 들어온 start와 end는 카페2의 카공 모임의 시작시간과 종료시간이다.
studyStartDateTime과 studyEndDateTime은 카페1의 카공 모임의 시작시간과 종료시간이다.
위 테스트 케이스에서 시간의 충돌이 나는 부분은 카페2의 카공 모임의 시작시간과 카페1의 카공 모임의 종료시간이다.
두 시간이 일치하게 되면 메서드는 true를 반환하게 된다.
카페2의 카공 모임의 시작시간 : "2025-07-08T06:36:10.679376400"
카페1의 카공 모임의 종료시간 : "2025-07-08T06:36:10:679376"
두 시간을 비교해보니까 자릿수가 다르다.
카페2의 시간은 LocalDateTime.now() 메서드를 통해 만들어진 시간이다.
카페1의 시간은 LocalDateTime.now() 메서드를 통해 만들어지고 데이터 베이스에 저장이 되고난 뒤, 호출된 값이다.
여기서 문제의 원인을 추론해볼 수 있는데, 자바에서 만들어진 시간은 nano자릿수(소수점 아래 9자리)까지 생성해주고 DB에 저장이 될때는 micro자릿수(소숫점 아래 6자리)까지 저장해주는 것 같다.
JPA를 통해 엔티티와 테이블을 매핑할 때, LocalTime의 길이는 0, LocalDateTime의 길이는 6으로 저장된다.
위 내용은 MariaDB의 DATETIME 과 TIME 의 길이가 6으로 설정되어 있을 경우이다.
추론이 맞는지 확인해보자.


자바에서 LocalDateTime MAX는 LocalDate.MAX와 LocalTime.MAX로 이루어져있다.
LocalTime의 MAX는 23:59:59.999999999 로 정의되어 있다.
Maria DB의 문서도 확인해보자.

Maria DB에서는 최대 6자리까지 지원해주는 것을 확인할 수 있다.
참고: MySQL 5.6부터 Microseconds를 지원한다.
(참고) JPA(Hibernate 구현체) 를 사용하여 LocalDateTime 과 LocalTime을 MariaDB에 매핑할 때 주의
JPA의 ddl-auto 설정이 create 일때,
- JPA의 엔티티의 필드로 LocalDateTime 을 가지고 DB 테이블을 생성하면 기본 정밀도는 6자리 소수 초이다(micro second)
- JPA의 엔티티의 필드로 LocalTime을 가지고 DB 테이블을 생성하면 기본 정밀도는 소수 초가 없다.




해결
데이터 정합성을 만족하기 위해서는 애플리케이션과 DB의 시간 데이터의 형식이 일치해야한다.
이것은 팀마다 다르게 정할 수 있는데, 우리는 MariaDB(MySQL)에서 지원하는 마이크로 초 단위의 형식을 사용하기로 했고, 애플리케이션과 DB에서 돌아다니는 모든 시간은 마이크로 초 단위여야 한다.
시간에서 나노초 까지 다루는 시간은 MicroTimeUtils 객체를 이용해서 마이크로 초로 바꾸기로 결정했다.
단 규칙이 있다.
- 무조건적인 것은 아니지만, MicroTimeUtils를 이용하는 것은 public 메서드에서 변환해서 넘겨준다. private 메서드에서는 변경하지 않는다. public 메서드에서 이미 변환했기때문에 이중으로 변경할 필요가 없다.
public class MicroTimeUtils {
public static final LocalTime MAX_LOCAL_TIME = LocalTime.of(23, 59, 59, 999_999_000);
public static final LocalDateTime MICRO_LOCAL_DATE_TIME_NOW = LocalDateTime.now().truncatedTo(ChronoUnit.MICROS);
public static LocalTime toMicroTime(LocalTime time) {
return time == null ? null : time.withNano((time.getNano() / 1000) * 1000);
}
public static LocalDateTime toMicroDateTime(LocalDateTime dateTime) {
return dateTime == null ? null : dateTime.withNano((dateTime.getNano() / 1000) * 1000);
}
}
MicroTimeUtils를 사용해 나노초를 마이크로초로 바꾼다.
validateStudyScheduleConflict(
MicroTimeUtils.toMicroDateTime(request.getStartDateTime()),
MicroTimeUtils.toMicroDateTime(request.getEndDateTime())
);
시간을 다루는 코드에서 위와 같이 변경함으로써 데이터 정합성을 만족한다.
프로젝트에서 위와 같이 마이크로초까지 변경했었으나, 한번더 리팩토링을 통해 마이크로초 단위를 절삭하고 초단위로 변경하였다.
그 이유는 다음과 같다.
진행하고 있는 프로젝트에서는 마이크로초까지 정확도를 요구하는 기능이 존재하지 않는다.
이는 비용적인 관점에서 생각해봤을 때, 손해이다.
마이크로초 단위를 검증하는 것은 초단위를 검증하는 것보다 복잡하다.
참고
https://lenditkr.github.io/MySQL/fractional-seconds-rouding-problem/
https://docs.w3cub.com/mariadb/date-and-time-literals/index
https://docs.w3cub.com/mariadb/microseconds-in-mariadb/index
'테스트' 카테고리의 다른 글
| 테스트 코드 개선기 - 테스트 코드에서 얻을 수 있는 인사이트 (0) | 2024.12.23 |
|---|---|
| 테스트 코드 개선기 - given 절을 쉽게 구성해보기 (0) | 2024.12.07 |
| Jpa의 영속성 전이와 테스트의 스프링 트랜잭션 전파 (0) | 2024.06.21 |
| [단위 테스트] 단위 테스트의 세 가지 스타일 (0) | 2024.06.18 |
| [단위 테스트] 리팩토링 내성 (좋은 단위 테스트의 요소) (0) | 2024.06.17 |