
프로젝트에서 JPA Auditing 덕분에 @CreatedDate, @LastModifiedDate 덕분에 엔티티 생성·수정 시점을 자동으로 관리할 수 있다!
하지만 이 신기방기 편리한 JPA Auditing 을 쓰다보면 불편한 점이 하나 존재했는데 그건 바로
테스트...
시간에 의존하는 로직이 늘어날수록 테스트가 점점 불안정해졌고 “이 테스트가 실패한 게 로직 때문인지, 실행 타이밍 때문인지” 헷갈리는 순간이 많아졌다. 결국 시간 자체를 테스트에서 통제할 수 있어야 한다는 결론에 도달했고 그 과정에서 TestClock 기반의 시간 제어 테스트 인프라를 만들게 되었습니다.
문제 상황: JPA Auditing으로 시간고정됨.
JPA Auditing을 사용하면 엔티티 저장 시점에 현재 시간이 자동으로 주입됩니다.
@Entity
public class PlayingHistory {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
운영 코드에서는 전혀 문제가 없었지만, 테스트에서는 다음과 같은 문제가 반복적으로 발생했습니다.
1. 정렬 순서 테스트가 불안정했습니다
“최근 재생 이력 조회”처럼 updatedAt DESC 기준으로 정렬하는 테스트에서 데이터 생성 순서와 실제 저장 시간이 밀리초 단위로 엇갈리면서 실행할 때마다 결과가 달라지는 경우가 발생했습니다.
2. 시간 기반 비즈니스 로직을 테스트할 수 없었습니다
- 최근 30일 이내 생성된 콘텐츠
- n일 이상 지난 데이터 필터링
- 신선도 점수 계산
이런 로직을 테스트하려면 과거 시점의 데이터가 필요한데 Auditing이 항상 현재 시간을 넣어버리니 의도한 테스트가 불가능했습니다.
3. 테스트가 비결정론적으로 변했습니다
같은 코드를 여러 번 실행해도 결과가 달라졌고 CI 환경에서는 간헐적으로 실패하는 테스트가 생기기 시작했습니다.
이 시점에서 문제를 이렇게 정리했습니다.
“시간이라는 외부 요인이 테스트 결과에 개입하는 구조가 문제다.”
접근 방향: 운영 코드는 건드리지 말자
처음에는 몇 가지 대안을 고민했습니다.
- 엔티티에 Clock을 주입할까?
- Reflection으로 Auditing 필드를 직접 바꿀까?
- 테스트에서 SQL로 시간을 직접 업데이트할까?
하지만 모두 마음에 들지 않았습니다.
- 운영 코드에 테스트용 의존성을 추가하고 싶지 않았고
- JPA Auditing의 동작 자체를 무시하는 방식도 피하고 싶었고
- “임시로 돌아가는 테스트”가 아니라 구조적으로 설명 가능한 해결책이 필요했습니다
그러다 Spring Data JPA가 제공하는 확장 포인트인 DateTimeProvider를 다시 보게 되었습니다.
핵심 아이디어: 시간을 직접 주입하는 대신, 시간을 “공급”하게 하자
JPA Auditing은 내부적으로 DateTimeProvider를 통해 현재 시간을 가져옵니다.
이 지점을 테스트에서만 교체할 수 있다면 운영 코드는 그대로 두고도 시간 제어가 가능하겠다고 판단했습니다.
그래서 만든 것이 TestClock입니다.
TestClock 구현
public class TestClock implements DateTimeProvider {
private static LocalDateTime fixedTime;
public static void freezeAt(LocalDateTime time) {
fixedTime = time;
}
public static void unfreeze() {
fixedTime = null;
}
@Override
public Optional<TemporalAccessor> getNow() {
LocalDateTime now =
(fixedTime == null) ? LocalDateTime.now() : fixedTime;
return Optional.of(now);
}
}
설계에서 중요하게 생각한 포인트는 다음과 같습니다.
- static 방식으로 테스트 어디서든 시간 제어 가능
- freezeAt()을 호출했을 때만 시간이 고정되고,
호출하지 않으면 실제 현재 시간을 그대로 사용 - Spring Data JPA가 제공하는 공식 인터페이스(DateTimeProvider)를 활용
즉, 프레임워크의 흐름을 거스르지 않고
“시간의 출처만 테스트에서 바꾸는” 방식입니다.
테스트 전용 JPA Auditing 설정
TestClock이 실제로 Auditing에 사용되도록 테스트 전용 설정을 추가했습니다.
@TestConfiguration
@EnableJpaAuditing(dateTimeProviderRef = "testDateTimeProvider")
public class TestJpaAuditingConfig {
@Bean(name = "testDateTimeProvider")
public DateTimeProvider testDateTimeProvider() {
return new TestClock();
}
}
- @TestConfiguration으로 테스트 컨텍스트에서만 로드
- 운영 환경에는 전혀 영향 없음
- Auditing이 시간을 가져올 때 TestClock을 참조하도록 명시
이렇게 해서 운영 코드에는 영향을 안 줄 수 있었습니다.
테스트 격리를 위한 DbHelper
시간을 고정했다가 되돌리지 않으면 다른 테스트에 영향을 줄 수 있기 때문에 시간 제어는 반드시 안전하게 감싸야 했습니다.
그래서 테스트 데이터 생성 로직을 헬퍼로 분리했습니다.
public PlayingHistory insertPlayingHistoryAt(
PlayingHistory history,
LocalDateTime time
) {
try {
TestClock.freezeAt(time);
return insertPlayingHistory(history);
} finally {
TestClock.unfreeze();
}
}
- try-finally로 시간 상태를 되돌림.
- 실제 저장 로직은 그대로 재사용
- 테스트 코드에서는 “이 데이터는 이 시점에 생성됐다”는 의도가 드러남
실제로 도움이 됐던 테스트 사례
최근 재생 이력 정렬 테스트
@Test
void 최근_재생_이력을_updatedAt_내림차순으로_조회한다() {
LocalDateTime baseTime = LocalDateTime.of(2025, 1, 1, 0, 0);
dbHelper.insertPlayingHistoryAt(
new PlayingHistory(member.getUuid(), hearit1, 10),
baseTime.minusMinutes(10)
);
dbHelper.insertPlayingHistoryAt(
new PlayingHistory(member.getUuid(), hearit2, 20),
baseTime.minusMinutes(1)
);
List<PlayingHistory> result =
repository.findRecentByMemberUuid(member.getUuid());
assertThat(result.get(0).getHearit()).isEqualTo(hearit2);
}
이 테스트는 더 이상 실행 타이밍에 따라 결과가 바뀌지 않았고 정렬 로직 자체만 검증할 수 있게 됐습니다.
시간 기반 신선도 점수 테스트
@Test
void 최근_콘텐츠에_높은_신선도_점수를_부여한다() {
LocalDateTime now = LocalDateTime.now();
Hearit fresh = dbHelper.insertHearitAt(createHearit(), now);
Hearit old = dbHelper.insertHearitAt(createHearit(), now.minusDays(4));
Hearit veryOld = dbHelper.insertHearitAt(createHearit(), now.minusDays(60));
List<ExploredHearit> result =
exploreService.getExploredHearits(memberInfo, request);
assertThat(result).extracting("hearitId")
.containsExactly(fresh.getId(), old.getId(), veryOld.getId());
}
시간 가중치가 들어간 탐색 로직도 Mock 없이 실제 구현체로 안정적으로 검증할 수 있었습니다.
정리하며
이 방식의 가장 큰 장점은 다음이라고 생각합니다.
- 테스트가 항상 같은 결과를 보장합니다
- 운영 코드를 전혀 오염시키지 않습니다
- JPA Auditing의 동작을 우회하지 않고 그대로 활용합니다
- 시간이라는 비결정적 요소를 테스트 인프라에서 격리했습니다
단순히 “테스트가 통과하게 만든다”가 아니라 왜 이 테스트가 항상 신뢰 가능한지 설명할 수 있는 구조를 만들었다는 점에서
개인적으로도 만족도가 높은 개선이었습니다.
'개발' 카테고리의 다른 글
| AI 도구를 활용해 백오피스 자동화하기 (3) | 2026.01.19 |
|---|---|
| 빈번한 쓰기 요청을 어떻게 저장할 것인가 (0) | 2026.01.11 |
| EC2 서버 설정 파일을 Git으로 관리하며 자동화하기 (0) | 2025.12.01 |
| 신뢰할 수 있는 API 문서를 만들기: REST Docs + Swagger UI 하이브리드 전략 (0) | 2025.11.21 |
| 개발에서의 추상화, 그리고 삶의 추상화 (2) | 2025.04.09 |
