[Java Spring] 시간의 흐름을 테스트 코드에 적용하자 : 로그인 토큰 만료 시나리오 테스트

2025. 5. 3. 15:47·Java

배경

현재 제가 진행 중인 토이 프로젝트에서는 로그인을 위해 액세스 토큰과 리프레시 토큰을 사용하고 있습니다.

 

이 중 액세스 토큰은 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`변수의 값으로 고정된 시간을 가져올 수 있도록 했습니다.

 

따라서 이제 만료 테스트의 흐름은 다음과 같이 변경되었습니다. 

  1. 토큰 생성 시 TTL을 10분으로 설정 (토큰 만료 일시는 0시 10분)
  2. 토큰 만료 시간 `expiredTime`을 10분 이후인 11분으로 설정
  3. clock이 `expiredTime`을 반환하도록 다시 스터빙
  4. 따라서 테스트는 내부에서 `clock.instant()` 동작 시, `expiredTime`을 반환하여 이것을 가지고 비교 실행 (만료 검증이 실행되는 시점은 0시 11분)
  5. `expiredTime` 시간에 토큰은 이미 만료되었으므로 정상적으로 예외 발생

마무리

이렇게 해서 최종적으로 `Instant.now()`대신, DI를 활용하여 외부에서 원하는 시계를 주입받아 사용하도록 코드를 개선해 보았습니다. 이를 통해 테스트에서 `Thread.sleep()`을 제거하고, 제어 가능한 환경을 만들어 안정적인 테스트 코드를 작성할 수 있었습니다.

 

테스트 코드 작성에도 정말 고려해야 할 것이 많다는 것을 요즘 계속해서 느끼고 있는데요. 앞으로도 더 나은 테스트를 위한 고민과 학습을 게을리하지 않아야겠다는 생각이 듭니다.

저작자표시 비영리 동일조건

'Java' 카테고리의 다른 글

[Java Spring] 초대 링크 가입 로직 트러블슈팅 (3) - Redis 트랜잭션 활성화 시, 값을 조회했을 때 null이 조회되는 이슈  (2) 2025.02.12
[Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (2) : 예외 발생 시 데이터 롤백을 위해 Redis 트랜잭션 적용 시도하기  (0) 2025.02.10
[Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (1) : Redis 동시성 문제 해결  (1) 2025.01.28
[Spring Data Redis] 불필요한 Redis 메모리 점유 현상 개선하기 : Spring Data Redis에서 TTL 미적용 문제 발생  (2) 2025.01.15
[Spring Data Redis] Redis repository에서 existBy로 올바르게 값이 검색되지 않는 문제 해결  (0) 2024.11.07
'Java' 카테고리의 다른 글
  • [Java Spring] 초대 링크 가입 로직 트러블슈팅 (3) - Redis 트랜잭션 활성화 시, 값을 조회했을 때 null이 조회되는 이슈
  • [Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (2) : 예외 발생 시 데이터 롤백을 위해 Redis 트랜잭션 적용 시도하기
  • [Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (1) : Redis 동시성 문제 해결
  • [Spring Data Redis] 불필요한 Redis 메모리 점유 현상 개선하기 : Spring Data Redis에서 TTL 미적용 문제 발생
seondays
seondays
  • seondays
    Maybe seondays
    seondays
  • 전체
    오늘
    어제
    • 분류 전체보기 (38)
      • python (5)
      • Java (16)
      • Dart (0)
      • 문제정리 (13)
      • etc (4)
  • 태그

    buddyguard
    Python
    트러블슈팅
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
seondays
[Java Spring] 시간의 흐름을 테스트 코드에 적용하자 : 로그인 토큰 만료 시나리오 테스트
상단으로

티스토리툴바