티켓팅 서비스 프로젝트를 수행하며 시간에 관련된 코드가 자주 등장했다.
랭킹에서도 집계시간을 정할 때 사용되고, 예매대기라는 기능도 6시간동안 유효하다는 요구사항이 있었기에 시간 관련된 코드가 꽤 있었고, 자연스럽게 테스트 코드에도 녹아 있었다.
그래서 LocalDateTime.now() 이 코드가 여기저기 분포돼있었다.
💡 그리고 이 코드는 테스트에서 장애를 유발했다
우선 내가 겪은 포인트를 설명하기 전에, 알아야할 점은 내가 겪은 포인트가 아니더라도 테스트에서 LocalDateTime.now() 는 쓰면 안좋다.
현재 시간에 따라서 테스트의 결과가 달라질 수도 있기 때문에, 좋은 테스트 코드가 될 수 없다.
내가 겪은 에러 이름이다. 정확히는 Invalid value for NanoOfSecond (valid values 0 - 999999999): -91000000 이건데 처음에 예외를 맞았을 때 구글링을 좀 해봤는데, 전부 외국 글이었고 찾다보니
링크 해당 글을 봐서 좀 보니까.. 이 예외는 이름 그대로 LocalDateTime 에 올바르지 않은 NanoOfSecond 가 들어갈때 터지는 예외였다.
그래서 추적을 열심히 해보니 LocalDateTime.now() 요녀석이었다… 😡 그냥 이녀석을 사용하면 바로 빵빵 터졌다
웃긴게 어느 시간대에는 되다가 어느 시간대에는 터져서 처음에 테스트 코드를 짜고 돌릴땐 되다가 다른 팀원이 갑자기 예외가 터진다고 알려줘서 알게됐다.
정보가 많이없어서 정확한 이유는 모르겠지만 현재 서버 시간대를 명확하게 설정하지 않으면 발생하는 것 같다. 그런데 에러가 발생하는 경우가 좀 희박하긴 하다. 특정 시간대에서 실패하는건지… 다른 상황이 있었던건지 정확하게는 모르겠다 😢
프로덕트 코드라면 LocalDateTime.now().atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime() 이런식으로 타임존을 명시하거나 서버의 타임존을 잘 설정하면 된다.
테스트 코드도 마찬가진데 테스트 코드는 위에서 말했던 이유도 있기 때문에 LocalDateTime.now() 를 지양하는게 좋다. 그냥 LocalDateTime.of() 를 통해서 시간을 지정해서 사용하자.
위처럼 테스트 코드를 고치려고 하는데 문제가 하나 더 있다.
아래와 같은 상황이다
// 6시간동안 예약을 하지않아 만료된 예약대기를 처리
public void processExpiredWaitingBooking() {
<...>
waitingBookings.forEach(waitingBooking -> {
// 만료된 예약대기라면 만료리스트에 추가
if (waitingBooking.getExpiredAt().isBefore(LocalDateTime.now())) {
<...>
}
});
<...>
}
위 코드는 현재시간 기준으로 만료된 예약대기인지 if 문을 통해 확인하는 코드다
그래서 테스트를 작성하려면
LocalDateTime now = LocalDateTime.now();
LocalDateTime beforeNow = now.minusSeconds(100);
LocalDateTime afterNow = now.plusSeconds(100);
이렇게 데이터를 구성해야했다
그렇게 자연스럽게 LocalDateTime.now() 를 유발한다…
사실 프로덕트 코드도 now 를 기반으로 동작해서 상관없고 테스트환경 시간대만 잘 설정하면 문제가 없을거라 생각되지만 불안한 요소는 완전히 제거하는게 맞다고 생각했다
LocalDateTime.now() 는 시간의 영역이고 우리가 제어할 수 없는 영역이다. 즉 테스트가 불가능한 영역이다.
💡 이렇게 테스트가 불가한 부분이 있다는건 나는 좋은 코드가 아니라고 생각하며 테스트하기 좋은 코드가 좋은 코드라고 생각한다
그래서 이를 해결하기 위해 두 가지 방법이 있는데
나는 호출하기 적당한 레이어까지 끌어올리고 의존성 주입을 받아서 해결했다 👍
먼저 LocalDateTime.now() 에 사용될 인터페이스를 정의하자
라고 말했지만 이미 정의돼있는 함수형 인터페이스를 사용하겠다
@Configuration
public class TimeConfig {
@Bean("nowLocalDateTimeSupplier")
public Supplier<LocalDateTime> nowLocalDateTimeSupplier() {
return LocalDateTime::now;
}
}
이렇게 LocalDateTime.now() 을 응답하는 Supplier 를 빈에 등록한다
그리고 사용할 때는 실제 사용되는 곳에서는 파라미터를 사용해 한번 위 레이어로 올려서 테스트를 간단하게 만든 후에 호출하는 곳에서 정의한 Supplier 를 사용하면 된다
// 실제 사용하는 곳
public void processExpiredWaitingBooking(LocalDateTime now) {
<...>
waitingBookings.forEach(waitingBooking -> {
// 만료된 예약대기라면 만료리스트에 추가
if (waitingBooking.getExpiredAt().isBefore(now)) {
<...>
}
});
<...>
}
// Supplier 를 통해 파라미터로 now 정보를 넘겨서 호출
public class WaitingBookingScheduler {
private final WaitingBookingFacade waitingBookingFacade;
private final Supplier<LocalDateTime> nowLocalDateTIme;
@Scheduled(cron = "0/5 * * * * *")
@SchedulerLock(name = "wb_2", lockAtLeastFor = "4s", lockAtMostFor = "8s")
public void scheduleExpiredWaitingBookingProcess() {
waitingBookingFacade.processExpiredWaitingBooking(nowLocalDateTIme.get());
}
}
이런식으로 사용하면 되고 LocalDateTIme.now() 의 직접호출을 제거할 수 있다.
기존 테스트는 LocalDateTime.now() 를 사용해야만 했지만, 우리는 의존성 주입을 통해서 LocalDateTime.now() 역할을 하는 인터페이스를 주입해줬다.
그래서 테스트에서는 해당 인터페이스를 모킹해서 사용하거나 해당 인터페이스를 구현한 테스트용 스텁 클래스를 만들어서 사용하면 된다.
아래는 통합 테스트 환경에서 예시라 @MockBean 을 사용한다
@MockBean
protected Supplier<LocalDateTime> nowLocalDateTime;
@Test
@DisplayName("[만료된 활성화 상태인 예약대기를 처리한다]")
void scheduleExpiredWaitingBookingProcess_test() {
<...>
LocalDateTime now = LocalDateTime.of(2023, 11, 16, 12, 12, 12, 12);
LocalDateTime beforeNow = now.minusSeconds(100);
LocalDateTime afterNow = now.plusSeconds(100);
// 모킹하여 LocalDateTime.now() 대체
given(nowLocalDateTime.get()).willReturn(now);
<...>
}
이렇게 테스트에서도 LocalDateTime.now() 를 사용하지 않고 항상 동일한 환경에서 테스트할 수 있다.
제목이 조금 자극적인 부분이 있지만 핵심을 요약하자면 아래와 같다
💡 1. 테스트 코드에서는 LocalDateTime.now() 를 쓰지말자
2. 프로덕트 코드에서는 LocalDateTime.now() 를 직접 호출해서 사용하지말자(인터페이스 혹은 별도의 클래스를 주입받아서 사용하자)
이번 포스팅을 하며 LocalDateTime.now() 가 아니더라도 랜덤함수나 외부 API 연결같은 부분같이 테스트가 불가능한 영역을 사용할 때는 테스트가 가능한 코드인지 생각하면서 코드를 짜면 더 좋은 코드가 될 수 있는 것 같다고 느꼈다 👍
Time가져오면서 테스트할때 다양한 고민을 했는데 Bean으로 등록하는건 생각을 못했네요!
좋은 정보 감사드립니다!