✅ 상황
현재 시간을 활용해야 하는 로직이 존재한다고 해보자.
예를 들면 예약을 수정할 때 “이미 시작된 예약 시간을 수정하려는지 검증”하려면 현재 시간을 알아야 한다.
직관적으로 머릿 속에 생각나는대로 구현한다면, 다음과 같이 구현할 수 있을 것이다.
@Transactional
public Reservation editDateTime(Long reservationId, LocalDate date, Long timeId, String guestName) {
Reservation reservation = getReservation(reservationId);
// 기타 검증 생략
validateAlreadyStarted(reservation);
ReservationTime changedTime = getReservationTime(timeId);
Reservation changedReservation = reservation.changeDateAndTime(date, changedTime);
updateReservation(changedReservation);
return changedReservation;
}
private void validateAlreadyStarted(Reservation reservation) {
if (reservation.isPassed(LocalDateTime.now(clock))) {
throw new DomainException(CANNOT_EDIT_ALREADY_STARTED_RESERVATION);
}
}
하지만 이는 좋은 방법이 아니다.
문제는 바로 테스트 상황에서 발생한다.
2026년 5월 19일 10시에서 2026년 5월 20일 12시로 수정하는 테스트 케이스이다.
@Test
@DisplayName("예약의 날짜 및 시간을 수정한다.")
public void editDateTime_success() {
// given
ReservationTime existTime = insertReservationTime(LocalTime.of(10, 0));
LocalDate existDate = LocalDate.of(2026, 5, 19);
Reservation reservation = insertReservation(existDate, existTime, "브라운");
LocalDate editedDate = LocalDate.of(2026, 5, 20);
ReservationTime editedTime = insertReservationTime(LocalTime.of(12, 0));
// when
Reservation editedReservation =
reservationService.editDateTime(reservation.getId(), editedDate, editedTime.getId(), reservation.getGuestName());
// then
assertThat(editedReservation)
.extracting(Reservation::getDate, r -> r.getTime().getId())
.containsExactly(editedDate, editedTime.getId());
}
현재 날짜는 2026년 5월 18일이기 때문에 `validateAlreadyStarted()`에 걸리지 않고 테스트과 통과한다.
하지만 만약 이 테스트를 내일(5월 19일) 10시 이후에 돌린다면? `validateAlreadyStarted()`에 걸려 테스트가 실패하게 된다.
이렇게 ‘현재 시간’과 같은 통제하기 힘든 외부 요인으로 인해 테스트가 실패할수도, 성공할수도 있는 건 바람직한 구조가 아니다.
❌ 해결방법1: 파라미터 인자로 현재 시간을 받자
가장 간단하게 해결할 수 있는 방법은 Service 메서드(`editDateTime()`)의 파라미터로 현재 시간값을 받는 것이다.
@Transactional
public Reservation editDateTime(
Long reservationId,
LocalDate date,
Long timeId,
String guestName,
LocalDateTime now // 추가✅
) {
Reservation reservation = getReservation(reservationId);
// 기타 검증 생략
validateAlreadyStarted(reservation, now);
ReservationTime changedTime = getReservationTime(timeId);
Reservation changedReservation = reservation.changeDateAndTime(date, changedTime);
updateReservation(changedReservation);
return changedReservation;
}
private void validateAlreadyStarted(Reservation reservation, LocalDateTime now) {
if (reservation.isPassed(now)) {
throw new DomainException(CANNOT_EDIT_ALREADY_STARTED_RESERVATION);
}
}
그리고 테스트에서 현재 시간을 직접 정의하여 서비스 메서드를 실행시킨다.
@Test
@DisplayName("예약의 날짜 및 시간을 수정한다.")
public void editDateTime_success() {
// given
LocalDateTime now = LocalDateTime.of(2026, 5, 18); // 현재 시간 고정
ReservationTime existTime = insertReservationTime(LocalTime.of(10, 0));
LocalDate existDate = LocalDate.of(2026, 5, 19);
Reservation reservation = insertReservation(existDate, existTime, "브라운");
LocalDate editedDate = LocalDate.of(2026, 5, 20);
ReservationTime editedTime = insertReservationTime(LocalTime.of(12, 0));
// when
Reservation editedReservation =
reservationService.editDateTime(
reservation.getId(),
editedDate,
editedTime.getId(),
reservation.getGuestName(),
now
);
// then
assertThat(editedReservation)
.extracting(Reservation::getDate, r -> r.getTime().getId())
.containsExactly(editedDate, editedTime.getId());
}
덕분에 시간이 지나도 테스트가 깨지지 않게 됐다.
LocalDateTime now = LocalDateTime.of(2026, 5, 18); // 현재 시간 고정
하지만 이게 정말 좋은 방법일까?
그렇지 않다. 이는 어디까지나 책임을 미루는 것일 뿐이다.
어쨋든 저 서비스를 호출하는 컨트롤러에서는 여전히 `LocalDateTime.now()`를 호출해야 한다.
@PatchMapping("/{id}")
public ResponseEntity<ReservationResponse> editDateTime(
@PathVariable("id") Long id,
@RequestBody @Valid ReservationEditRequest request,
@CurrentUser String guestName
) {
return ResponseEntity.ok(
ReservationResponse.from(
reservationService.editDateTime(
id, request.date(), request.timeId(), guestName, LocalDateTime.now())));
}
여기서도 책임을 미룬다? 그러면 클라이언트한테 현재 시간값을 입력받는건데 이건 보안상 좋지 않다.
💡 해결방법2: Clock 사용
Clock은 자바에서 제공하는 타임존을 기반으로 현재 시간, 날짜, 인스턴스에 접근하는 데 사용되는 추상 클래스이다. 이 Clock을 사용하면 운영체제 상의 시간이 아니라 내가 원하는 임의의 시간으로 변경할 수가 있게 된다.
`LocalDateTime.now()`에 clock 인스턴스를 인자로 넣을 수 있는데 그러면 해당 인스턴스에 설정된 시간이 반환된다. 이를 이용해서 현재 시간을 특정 시간으로 고정하여 테스트할 수 있다.
1️⃣ `ClockConfig` 정의
우선 서비스 상에서 사용될 타임존을 정의한다.
서비스 상에서는 `LocalDateTime.now(clock)`를 하면 실제 현재 시간이 나와야 하기 때문에 다음과 같이 정의했다.
@Configuration
public class ClockConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Seoul"));
}
}
2️⃣ 테스트 전용 Clock 정의
MutableClock 정의
나는 모든 테스트에서 시간을 고정하는 것이 아니라 시간이 필요한 특정 테스트에서 직접 시간을 정의해주고 싶었다. Clock 인스턴스는 한번 만들어지면 그 값을 변경할 수 없어서 시간을 변경하려면 새로운 Clock을 만들어 반환해야 한다.
그래서 다음과 같이 별도의 데코레이터 패턴을 활용한 수정가능한 Clock인 `MutableClock`을 만들었다.
public class MutableClock extends Clock {
private Clock delegate;
public MutableClock(Clock delegate) {
this.delegate = delegate;
}
public void setFixed(LocalDateTime now) {
this.delegate = Clock.fixed(
now.atZone(getZone()).toInstant(),
getZone()
);
}
public void setFixed(LocalDate now) {
this.delegate = Clock.fixed(
now.atStartOfDay(getZone()).toInstant(),
getZone()
);
}
// .. 생략
}
그리고 별도의 테스트 설정 파일에서 `MutableClock`을 주입해주었다.
@TestConfiguration
public class TestClockConfig {
@Bean
@Primary
public MutableClock mutableClock() {
return new MutableClock(Clock.system(ZoneId.of("Asia/Seoul")));
}
}
3️⃣ 서비스에 Clock 의존성 추가
그리고 이 의존성을 LocalDateTime.now() 인자에 넣어준다.
@Service
@RequiredArgsConstructor
public class ReservationService {
// 기타 의존성..
private final Clock clock;
@Transactional
public Reservation editDateTime(Long reservationId, LocalDate date, Long timeId, String guestName) {
Reservation reservation = getReservation(reservationId);
// 기타 검증 생략
validateAlreadyStarted(reservation);
ReservationTime changedTime = getReservationTime(timeId);
Reservation changedReservation = reservation.changeDateAndTime(date, changedTime);
updateReservation(changedReservation);
return changedReservation;
}
private void validateAlreadyStarted(Reservation reservation) {
if (reservation.isPassed(LocalDateTime.now(clock))) {
throw new DomainException(CANNOT_EDIT_ALREADY_STARTED_RESERVATION);
}
}
}
4️⃣ 테스트에 적용
@Autowired
MutableClock clock;
@Test
@DisplayName("예약의 날짜 및 시간을 수정한다.")
public void editDateTime_success() {
// given
ReservationTime existTime = insertReservationTime(LocalTime.of(10, 0));
LocalDate existDate = LocalDate.of(2023, 8, 5);
Reservation reservation = insertReservation(existDate, existTime, "브라운");
LocalDate editedDate = LocalDate.of(2023, 8, 10);
ReservationTime editedTime = insertReservationTime(LocalTime.of(12, 0));
clock.setFixed(LocalDate.of(2023, 7, 20)); // 직접 시간 설정
// when
Reservation editedReservation =
reservationService.editDateTime(reservation.getId(), editedDate, editedTime.getId(), reservation.getGuestName());
// then
assertThat(editedReservation)
.extracting(Reservation::getDate, r -> r.getTime().getId())
.containsExactly(editedDate, editedTime.getId());
}
이렇게 테스트 내에서 직접 시간을 지정해두면 “아 이 테스트는 2023-07-20 기준 상황을 검증하는구나”를 단번에 이해할 수 있어서 테스트의 가독성도 좋아지는 효과가 생긴다.
clock.setFixed(LocalDate.of(2023, 7, 20));
'우테코 8기 > 본과정 탐구 일지' 카테고리의 다른 글
| [레벨2 - 방탈출 사용자] 예약 정보 소프트 딜리트 도입기 (0) | 2026.05.31 |
|---|---|
| [레벨 2 - 방탈출 사용자] 유니크 제약 조건, 서비스에서 검증할까? 리포지토리에서 처리할까? (0) | 2026.05.10 |
| [레벨2 - 방탈출 사용자] 삭제할 리소스가 없을 때도 204 No Content를 내려도 될까? (0) | 2026.05.10 |
| [레벨2 - 방탈출 사용자] 201 Created 응답에서 Location 헤더는 꼭 필요할까? (0) | 2026.05.10 |
| [레벨 2 - 방탈출 관리자] 테이블 구조 변경으로 인한 코드 수정을 효율적으로 하기 (0) | 2026.05.01 |