본문 바로가기
Testing

테스트 코드 뜯어보기 - LocalDateTime.now()는 왜 테스트를 괴롭히는가

by Ramos 2026. 7. 5.

우리 API 서버의 테스트 코드를 하나하나 뜯어보며 "이 애노테이션이 백그라운드에서 정확히 무슨 일을 하는가"를 정리한 시리즈다. 첫 글은 시간이다. 서비스 코드가 LocalDateTime.now()를 직접 부르는 순간 테스트가 어떻게 무너지는지, 그리고 우리 팀이 그걸 두 가지 전략으로 어떻게 나눠서 다루는지 적었다.

Intro

  • LocalDateTime.now()는 매 실행마다 다른 값이라 테스트가 isEqualTo로 정확히 단언할 방법이 없다.
  • 근본 해법은 Clock을 주입받아 LocalDateTime.now(clock)을 쓰는 것. 테스트에선 Clock.fixed(...)로 시간을 얼려서 결정론적으로 만든다.
  • 하지만 우리 코드베이스엔 now() 직접 호출이 65개 넘는 파일에 퍼져 있어서, 전부 소급 리팩토링하는 건 리스크가 이득보다 크다.
  • 그래서 전략을 나눴다: 신규/수정 코드는 Clock 주입, 기존 코드는 isBetween(before, after) 근사 범위 단언.
  • isBetween은 게으른 게 아니라 의도된 트레이드오프다. "정확한 값" 대신 "합리적인 시간대 + 필드 간 동일성"을 검증한다.

처음 멈칫한 지점

테스트를 짜다 보면 반드시 한 번은 만나는 벽이 있다. 서비스 코드가 이렇게 생겼을 때다.

// 서비스 코드 (흔한 패턴)
public class ProjectConsoleServiceImpl {
    public ProjectCreateResponseDto createProject(ProjectCreateRequestDto request) {
        LocalDateTime now = LocalDateTime.now(); // 테스트가 손댈 수 없는 값
        ProjectEntity entity = ProjectEntity.builder()
            .activationDateTime(now)
            .createdDateTime(now)
            .updatedDateTime(now)
            .build();
        return toResponse(projectRepository.save(entity));
    }
}

activationDateTime이 제대로 세팅됐는지 검증하고 싶은데, 그 값이 무엇인지 테스트가 미리 알 수가 없다. assertThat(saved.getActivationDateTime()).isEqualTo(LocalDateTime.of(...)) 같은 정확한 단언은 원천적으로 불가능하다. 테스트가 할 수 있는 건 "실행 전후로 감싸서 그 사이 어딘가겠거니" 정도의 근사뿐이다.

LocalDateTime.now()가 나쁜가 (테스트 관점)

LocalDateTime.now()는 내부적으로 Clock.systemDefaultZone()을 사용해 System.currentTimeMillis() 기반 실제 시스템 시계를 읽는다. 이 호출은,

  • 매 실행마다 다른 값을 반환한다. 같은 테스트를 두 번 돌려도 값이 다르다.
  • 테스트가 그 값을 사전에 알 수 없으므로 isEqualTo로 정확히 단언할 방법이 없다.
  • 자정, 월말 같은 경계 시각에 실행되면 더 지저분해진다. createdDateTimeupdatedDateTime을 각각 now()로 따로 채우는 코드라면 두 값이 미세하게 어긋나서 "동일해야 하는 두 필드가 다르다"는 플레이키(flaky) 실패가 날 수 있다.

세 번째가 특히 고약하다. 평소엔 통과하다가 하필 자정 근처 CI 실행에서만 깨지는 테스트만큼 시간 잡아먹는 게 없다.

근본 해법: Clock 주입

Java 8부터 java.time 패키지 전체가 Clock을 선택적으로 받는 오버로드를 제공한다. LocalDateTime.now(Clock), Instant.now(Clock) 같은 것들이다. Clock은 "현재 시각을 얻는 방법"을 인터페이스로 추상화한 것이고, Clock.systemDefaultZone()이 프로덕션 기본값, Clock.fixed(Instant, ZoneId)가 테스트 전용 구현체다.

Java 공식 문서(java.time.Clock)는 fixed(...)를 이렇게 설명한다.

This clock simply returns the specified instant. As such, it is not a clock in the conventional sense. The main use case for this is in testing, where the fixed clock ensures tests are not dependent on the current clock.

즉 "고정된 시계"는 시계라기보다 테스트용 상수 주입 지점이다. 원리는 단순하다. 서비스가 LocalDateTime.now() 대신 주입받은 Clock 필드를 통해 LocalDateTime.now(this.clock)을 호출하게 바꾸면, 테스트는 아래처럼 시간을 완전히 얼려버릴 수 있다.

// Clock을 주입하는 신규/수정 코드 패턴
@RequiredArgsConstructor
public class SomeService {
    private final Clock clock; // 프로덕션: Clock.systemDefaultZone() 빈 주입

    public void doSomething() {
        LocalDateTime now = LocalDateTime.now(clock); // 테스트 가능
    }
}

// 테스트
Clock fixedClock = Clock.fixed(Instant.parse("2026-07-04T10:00:00Z"), ZoneOffset.UTC);
SomeService service = new SomeService(fixedClock);
// 이제 서비스 내부의 now(clock)는 항상 같은 값

Clock.fixed가 만드는 인스턴스는 불변 · 스레드-세이프 · Serializable이고, instant()를 몇 번 호출해도 항상 같은 Instant를 돌려준다. 이게 "테스트에서 시간이 멈춘 것처럼" 동작하는 이유다.

참고: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Clock.html

 

Clock (Java SE 17 & JDK 17)

All Implemented Interfaces: InstantSource A clock providing access to the current instant, date and time using a time-zone. Instances of this abstract class are used to access a pluggable representation of the current instant, which can be interpreted usin

docs.oracle.com

그런데 우리는 기존 코드를 리팩토링하지 않기로 했다

여기서 현실적인 판단이 들어간다. Clock 주입이 정답인 건 맞는데, 우리 코드베이스에는 LocalDateTime.now() 직접 호출이 65개 넘는 파일에 광범위하게 퍼져 있었다. 이걸 전부 Clock 주입으로 전환하려면,

  • 각 서비스의 생성자 시그니처를 바꿔야 하고 (빈 주입 방식 변경),
  • 그 서비스를 쓰는 모든 테스트의 목(mock) 설정을 바꿔야 하고,
  • 다수 도메인에 걸친 동시다발적 회귀 리스크를 떠안아야 한다.

"테스트를 더 엄밀하게 만들기 위한 리팩토링"이 오히려 "운영 코드 변경 범위 폭증 → 회귀 버그 유입"으로 이어질 수 있다는 얘기다. 정확한 시각 단언이라는 이득보다 65개 파일을 건드리는 리스크가 크다고 봤다. 그래서 우리는 비대칭 전략을 택했다: 신규/수정 코드에는 Clock 주입을 강제하되, 이미 잘 도는 기존 코드는 건드리지 않는다.

기존 코드는 이렇게 검증한다: isBetween 근사 범위 단언

기존 코드를 리팩토링하지 않기로 했으니, now()를 직접 부르는 코드도 어떻게든 검증은 해야 한다. 우리가 쓰는 방식은 근사 범위 단언이다.

void 활성화_생성_수정_시각은_동일한_현재_시각으로_세팅된다() {
    // given (LocalDateTime.now() 직접 호출 코드라 근사 범위 단언)
    ProjectCreateRequestDto request = ProjectFixture.createRequest();
    given(projectRepository.save(any(ProjectEntity.class)))
        .willAnswer(inv -> inv.getArgument(0));
    LocalDateTime before = LocalDateTime.now();

    // when
    projectConsoleService.createProject(request);

    // then
    ArgumentCaptor<ProjectEntity> captor = ArgumentCaptor.forClass(ProjectEntity.class);
    then(projectRepository).should().save(captor.capture());
    ProjectEntity saved = captor.getValue();
    assertThat(saved.getActivationDateTime()).isBetween(before, LocalDateTime.now());
    assertThat(saved.getCreatedDateTime()).isEqualTo(saved.getActivationDateTime());
    assertThat(saved.getUpdatedDateTime()).isEqualTo(saved.getActivationDateTime());
}

동작 원리는 간단하다. 서비스 호출 직전before를 찍고, 호출 직후에 다시 LocalDateTime.now()를 찍어 [before, after] 구간을 만든다. 서비스 내부의 now() 호출은 반드시 이 구간 안에서 일어났을 것이므로(같은 스레드, 같은 프로세스, 테스트 실행 시간이 시스템 시계 정밀도보다 훨씬 짧다), isBetween(before, after)는 사실상 항상 참이면서도 "값이 세팅되긴 했다" 이상을 검증한다. 정확한 시각 대신 합리적인 시간대를 검증하는 트레이드오프다.

그리고 하나 더. saved.getCreatedDateTime()).isEqualTo(saved.getActivationDateTime())처럼 같은 트랜잭션 안의 여러 필드가 서로 같은 값을 참조하는지isEqualTo로 정확히 검증할 수 있다. 이건 시스템 시계에 의존하는 게 아니라 캡처된 두 필드를 서로 비교하는 것이기 때문이다. 즉 "값이 뭔지"는 근사로, "필드들이 같은지"는 정확하게, 두 축을 나눠서 본다.

Mermaid로 보는 두 갈래

흔한 오해들

오해 실제
isBetween은 게으른 테스트다 아니다. 문서화된 의도적 전략이다. Clock 주입 없이 정확한 값을 단언하는 대안은 리플렉션 필드 조작이나 정적 메서드 목킹인데, 둘 다 isBetween보다 유지보수 비용과 위험이 크다.
before/after를 테스트 시작 시각 한 번만 찍어도 된다 아니다. before는 호출 직전, after는 호출 직후에 찍어야 구간이 타이트해진다. 클래스 최상단에서 미리 찍은 시각을 재사용하면 다른 테스트 실행 시간까지 섞여 의미가 희석된다.
Clock.fixed는 무조건 좋으니 다 바꾸자 신규 코드엔 맞지만, 기존 코드 소급 적용은 우리 컨벤션상 권장하지 않는다. "왜 안 하는가"를 모르면 무작정 리팩토링 PR을 올리게 된다.
LocalDateTime.now()가 매번 새 객체라 ==가 실패하는 것과 같은 문제다 별개다. 이 글의 문제는 "값 자체가 예측 불가능"한 것이고, LocalDateTime은 값 객체라 isEqualTo로 비교하면 == 문제는 없다.

언제 무엇을 쓰나?

  • 신규 서비스/메서드: Clock을 생성자로 주입받고 LocalDateTime.now(clock). 테스트에서 Clock.fixed(...)로 정확한 값 단언.
  • 기존 서비스 수정 (시간 로직 자체는 안 건드림): 리팩토링하지 않는다. isBetween(before, after) 또는 isNotNull()로 근사 검증.
  • 기존 서비스의 시간 로직 자체를 고치는 PR: 그 김에 Clock 주입으로 전환할지는 팀 판단. 최소한 해당 서비스 관련 테스트 전체를 다시 점검해야 하니 별도 PR로 분리하는 걸 권한다.

한 가지 남는 숙제: Clock 빈이 프로젝트에 실제로 등록돼 있는지(@Bean Clock clock() { return Clock.systemDefaultZone(); } 같은 설정)는 신규 코드 작성 전에 config 패키지를 먼저 확인하는 게 좋다. 주입 지점을 만들어놓고 정작 프로덕션 빈이 없으면 앱이 안 뜬다.

다음 글에서는 컨트롤러 슬라이스 테스트로 넘어간다. @WebMvcTest만 붙였는데 컨트롤러가 500으로 떨어지는, 그리고 @Import가 그걸 어떻게 살려내는지에 대한 이야기다.