배경
현재 제가 진행 중인 토이 프로젝트에서는 로그인을 위해 액세스 토큰과 리프레시 토큰을 사용하고 있습니다.
이 중 액세스 토큰은 security가 직접 유효성과 만료 여부를 확인해주고 있지만, 리프레시 토큰은 제가 직접 작성한 코드에서 유효성을 검증하고 있는데요.
따라서 검증 기능의 테스트를 위해 `만료된 리프레시 토큰을 사용하면 예외가 발생하는 시나리오`를 가정하여 테스트 코드를 작성하게 되었고, 그 과정에 대해 정리해보려고 합니다.
첫 번째 아이디어 : Thread.sleep()
유효한 리프레시 토큰을 생성한 후, TTL이 지나서 만료되는 것을 확인해야 하기 때문에 시간의 흐름을 반영하는 테스트가 필요합니다.
첫 번째로 생각해볼 수 있는 방법은 토큰 생성 후 `Thread.sleep()`을 이용하는 것인데, 다음과 같이 작성했습니다.
@Test
@DisplayName("만료된 리프레시 토큰으로 액세스 토큰 발급 시 예외가 발생한다")
void createAccessTokenWithExpiredToken() throws InterruptedException {
//given
Long userId = 1L;
UserRole role = UserRole.ROLE_USER;
Duration duration = Duration.ofSeconds(5);
RefreshToken refreshToken = tokenFactory.createRefreshToken(userId, role, duration);
tokenRepository.save(refreshToken);
Cookie[] cookies = {new Cookie("refresh", refreshToken.getToken())};
Thread.sleep(7);
//when //then
assertThatThrownBy(() -> tokenService.reissueAccessToken(cookies)).isInstanceOf(
BadCredentialsException.class);
}
위 코드를 실행하면 TTL이 5초인 토큰을 생성하고, 7초를 대기한 후 토큰의 유효성을 확인하면 예외가 발생해서 테스트가 통과될 것입니다. 그런데 아무리 봐도 `테스트 코드에 sleep()을 사용하는 것이 적절한가?`라는 의문이 떠나지 않았는데요 🤔
실제로 해당 테스트에 대해 다음과 같은 문제점을 생각해 볼 수 있었습니다.
- `sleep()`만큼의 시간이 실제로 지연되어 테스트의 실행 시간이 길어지게 된다
- 코드 실행 환경의 실제 시스템 시간의 흐름에 의존하기 때문에 실행 환경에 따라 테스트의 결과가 달라질 수 있다
따라서 이러한 문제를 해결하고 여러 환경에서 일관된 결과를 도출하는 신뢰할 수 있는 테스트, 신속한 테스트를 위해, `sleep()`의 사용 대신 우리가 제어할 수 있는 상황을 기반으로 동작할 수 있도록 코드를 개선하고자 했습니다.
개선 방안
테스트 상황을 원하는 대로 제어한다고 하면 익숙하게 떠오르는 개념이 있습니다. 바로 `Mockito`를 사용한 Mock 객체의 사용입니다. 일반적으로 외부 의존성을 가지는 객체를 Mock 객체로 대체하여 테스트 격리성을 높이는 데 활용되는데요.
이번에도 시간을 설정하는 부분만 `Mockito`를 이용해 스터빙하여, 우리가 원하는 시간대로 동작을 제어할 수 있게 코드를 변경해 보았습니다.
우선 테스트 코드를 수정하기에 앞서 프로덕션 코드가 어떻게 작성되어 있는지를 살펴봅시다. 기존 코드는 토큰의 생성과 유효성 검증 로직에서 `Instant.now()`를 통해 현재 시간을 가져오고 있습니다.
private String createJwt(Duration expiresIn) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuedAt(now)
.expiresAt(now.plus(expiresIn))
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
`Instant.now()`는 현재 시스템 시간의 타임스탬프 값인 `Instant`객체를 반환하는데, 이것이 바로 테스트를 힘들게 하는 근본적인 원인이었습니다. 이렇게 실제 시스템 시간과 바로 결합하고 있는 상황에서는 `Mockito`를 사용하더라도 `Instant.now()` 자체의 동작을 제어하기가 어려워지기 때문이죠.
따라서 이 시간 의존성을 분리하는 코드 리펙토링이 필요한데, 여기에 시간대와 관련된 기능들을 제공하는 인터페이스인 `Clock`을 활용해 보겠습니다.
`Instant.now()`대신 `Instant`를 생성하는 역할을 할 `Clock`을 외부에서 주입받도록 변경해 줍니다.
@Configuration
public class Config {
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}
먼저 `Clock` bean을 설정 클래스에 등록합니다. `systemDefaultZone()`를 통해 실행 환경에서는 실제 서버의 시간에 맞게 작동하도록 설정했습니다.
@Component
@RequiredArgsConstructor
public class TokenFactory {
private final Clock clock;
private String createJwt(Duration expiresIn) {
Instant now = clock.instant(); // 변경
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuedAt(now)
.expiresAt(now.plus(expiresIn))
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
private void checkExpiration(Jwt jwt) {
Instant expiresAt = jwt.getExpiresAt();
if (expiresAt.isBefore(clock.instant())) { // 변경
throw new BadCredentialsException("리프레시 토큰이 만료되었습니다.");
}
}
}
다음으로 필요한 클래스가 `Clock` 의존성을 주입받도록 한 후, 기존 코드인 `Instant.now()`부분을 주입받은 `Clock`의 메서드인 `clock.instant()`로 변경합니다.
이제 이를 통해 테스트 코드에서 `Clock`을 스터빙하여 원하는 대로 시간 조정이 가능해졌습니다!
@MockitoBean
private Clock clock;
private Instant testSystemTimeInstant;
@BeforeEach
void setUp() {
testSystemTimeInstant = Instant.parse("2025-01-01T00:00:00Z");
when(clock.instant()).thenReturn(testSystemTimeInstant);
}
@Test
@DisplayName("만료된 리프레시 토큰으로 액세스 토큰 발급 시 예외가 발생한다")
void createAccessTokenWithExpiredToken() {
//given
Long userId = 1L;
UserRole role = UserRole.ROLE_USER;
Duration duration = Duration.ofMinutes(10); // 1
RefreshToken refreshToken = tokenFactory.createRefreshToken(userId, role, duration);
tokenRepository.save(refreshToken);
Instant expiredTime = testSystemTimeInstant.plus(Duration.ofMinutes(11)); // 2
when(clock.instant()).thenReturn(expiredTime); //3
Cookie[] cookies = {new Cookie("refresh", refreshToken.getToken())};
//when //then
assertThatThrownBy(() -> tokenService.reissueAccessToken(cookies)).isInstanceOf(
BadCredentialsException.class); // 4, 5
}
테스트 전에 테스트 클래스 전체에서 사용할 시간을 먼저 설정해줘야 합니다. 따라서 setUp 작업으로 `testSystemTimeInstant`변수의 값으로 고정된 시간을 가져올 수 있도록 했습니다.
따라서 이제 만료 테스트의 흐름은 다음과 같이 변경되었습니다.
- 토큰 생성 시 TTL을 10분으로 설정 (토큰 만료 일시는 0시 10분)
- 토큰 만료 시간 `expiredTime`을 10분 이후인 11분으로 설정
- clock이 `expiredTime`을 반환하도록 다시 스터빙
- 따라서 테스트는 내부에서 `clock.instant()` 동작 시, `expiredTime`을 반환하여 이것을 가지고 비교 실행 (만료 검증이 실행되는 시점은 0시 11분)
- `expiredTime` 시간에 토큰은 이미 만료되었으므로 정상적으로 예외 발생
마무리
이렇게 해서 최종적으로 `Instant.now()`대신, DI를 활용하여 외부에서 원하는 시계를 주입받아 사용하도록 코드를 개선해 보았습니다. 이를 통해 테스트에서 `Thread.sleep()`을 제거하고, 제어 가능한 환경을 만들어 안정적인 테스트 코드를 작성할 수 있었습니다.
테스트 코드 작성에도 정말 고려해야 할 것이 많다는 것을 요즘 계속해서 느끼고 있는데요. 앞으로도 더 나은 테스트를 위한 고민과 학습을 게을리하지 않아야겠다는 생각이 듭니다.