Spring Boot TDD로 Refresh Token 갱신 테스트 완성기

양갱·2025년 10월 29일
post-thumbnail

Spring Boot TDD로 Refresh Token 갱신 테스트를 안정화하기까지

테스트가 모두 통과했는데, 왜 이렇게 오래 걸렸을까?
이번 글에서는 Spring Boot 환경에서 JWT Refresh Token 갱신(refresh renewal) 로직을 TDD로 구현하며 마주친 문제와, 이를 해결한 과정을 정리한다.


배경: 헤더 기반 토큰 갱신 로직

프로젝트의 클라이언트는 웹이 아니라 네이티브 앱(Android/iOS) 이었다.
따라서 Refresh Token을 HttpOnly 쿠키 대신 헤더(X-Refresh-Token) 로 주고받는 방식을 선택했다.

컨트롤러의 초기 형태는 다음과 같았다.

@PostMapping("/refresh")
public ResponseEntity<RefreshResponse> refresh(
        @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
        @RequestHeader(value = "X-Refresh-Token", required = false) String xRefreshToken,
        HttpServletRequest req
) {
    String refreshToken = authService.extractRefreshFromHeaders(authorization, xRefreshToken);
    RefreshResult result = authService.rotateTokens(refreshToken, req);

    HttpHeaders headers = new HttpHeaders();
    headers.setCacheControl("no-store");
    headers.add("Pragma", "no-cache");

    RefreshResponse body = (result.refreshToken() == null)
            ? RefreshResponse.accessOnly(result.accessToken())
            : RefreshResponse.rotated(result.accessToken(), result.refreshToken(), result.refreshTtlSeconds());

    return new ResponseEntity<>(body, headers, HttpStatus.OK);
}

이제 이 로직을 MockMvc + @WebMvcTest 기반의 단위 테스트로 검증해야 했다.


테스트 구성: @WebMvcTest로 슬라이스 테스트

보안 필터와 OAuth2 자동 설정을 모두 제외하고,
순수하게 컨트롤러 계층만 검증하기 위해 다음과 같은 테스트 설정을 구성했다.

@WebMvcTest(controllers = AuthController.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {
                        SecurityConfig.class,
                        JwtAuthFilter.class
                })
        }
)
@AutoConfigureMockMvc(addFilters = false)
class AuthControllerRefreshTest {
    @Autowired MockMvc mockMvc;
    @MockBean AuthService authService;
    @MockBean RefreshCookieHelper refreshCookieHelper;
    @MockBean ELKUserRepository elkUserRepository;
}

테스트는 세 가지 시나리오로 나누었다.

  1. 갱신 발생: access + refresh + TTL 모두 반환
  2. 갱신 없음: access만 반환 (refresh 관련 필드는 미포함)
  3. 헤더 전달 검증: Authorization / X-Refresh-Token 헤더가 서비스로 정확히 전달되는지 확인

문제 1: 404와 의존성 누락

첫 실행에서 마주한 문제는 단순했다.

No handler found for POST /refresh

원인은 컨트롤러의 기본 경로였다.
@RequestMapping("/api/auth") 가 선언되어 있었는데, 테스트에서는 /refresh 로 호출하고 있었다.

해결

테스트 요청 경로를 모두 /api/auth/refresh 로 수정했다.


문제 2: UnfinishedStubbingExceptionNPE

경로를 수정하자 이번에는 다른 에러가 발생했다.

org.mockito.exceptions.misusing.UnfinishedStubbingException
Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because "refreshTtlSeconds" is null

처음엔 Mockito 설정 문제처럼 보였지만, 실제 원인은 RefreshResult 생성자 내부의 언박싱이었다.

테스트 코드에서 다음과 같이 작성했기 때문이다.

when(authService.rotateTokens(eq("rt-123"), any(HttpServletRequest.class)))
        .thenReturn(new RefreshResult("acc", null, null)); // NPE 발생

refreshTtlSeconds 필드가 long 타입이었고,
null을 넘기는 순간 언박싱이 일어나며 NullPointerException이 발생했다.
Mockito는 thenReturn() 호출까지 도달하지 못했고, “미완성 스텁” 예외를 뱉었다.

해결

RefreshResultWrapper(Long) 타입으로 수정하고, null-safe 팩토리 메서드를 제공했다.

public record RefreshResult(String accessToken, String refreshToken, Long refreshTtlSeconds) {
    public static RefreshResult rotated(String access, String refresh, long ttlSeconds) {
        return new RefreshResult(access, refresh, ttlSeconds);
    }
    public static RefreshResult accessOnly(String access) {
        return new RefreshResult(access, null, null);
    }
}

테스트에서도 팩토리 메서드를 사용하도록 수정했다.

when(authService.rotateTokens(eq("rt-123"), any(HttpServletRequest.class)))
        .thenReturn(RefreshResult.accessOnly("acc")); // null-safe

제발 아무 생각 없이 primitve type으로 작성하지 말길....


문제 3: getRefreshToken() 컴파일 오류

NPE가 해결되자 이번에는 단순한 컴파일 에러가 발생했다.

cannot find symbol: method getRefreshToken()

이유는 record의 메서드 네이밍 규칙 때문이었다.
record는 getXxx() 형태의 게터를 만들지 않고,
필드명 그대로의 접근자(refreshToken()) 를 제공한다.

해결

컨트롤러에서 모든 get...() 호출을 필드명 기반으로 수정했다.

RefreshResponse body = (result.refreshToken() == null)
        ? RefreshResponse.accessOnly(result.accessToken())
        : RefreshResponse.rotated(result.accessToken(), result.refreshToken(), result.refreshTtlSeconds());

결과

> Task :test
AuthControllerRefreshTest > 갱신 발생 PASSED
AuthControllerRefreshTest > 갱신 없음 PASSED
AuthControllerRefreshTest > 헤더 전달 검증 PASSED

모든 테스트가 정상적으로 통과했다.

  • 갱신이 있을 때: accessToken, refreshToken, refreshTtlSeconds가 모두 포함
  • 갱신이 없을 때: refreshToken, refreshTtlSeconds 필드는 직렬화되지 않음 (@JsonInclude.NON_NULL)
  • 헤더 전달: Authorization / X-Refresh-Token 값이 정확히 서비스로 전달됨

정리

문제원인해결책
404 Not Found컨트롤러 경로 불일치/api/auth/refresh 로 수정
UnfinishedStubbingExceptionnull 언박싱으로 인한 NPELong 타입 + 팩토리 메서드 사용
getRefreshToken() 오류record 접근자 이름 착각get...()필드명() 호출로 수정

배운 점

  1. record와 class의 게터 차이
    record는 getXxx()를 만들지 않는다.
    컨트롤러나 서비스에서 접근 시 혼용되지 않도록 일관된 규칙을 세워야 한다.

  2. Mockito 예외는 종종 다른 예외의 결과다
    UnfinishedStubbingException은 스텁 미완성이 아니라
    내부 NPE로 인해 thenReturn() 호출이 생략된 경우가 많다.

  3. Primitive 타입은 테스트의 적이다
    long, int보다는 Long, Integer로 선언해 nullable 상태를 표현하는 것이
    테스트 코드의 안정성을 크게 높인다.


참고

  • Spring Boot 3.5.3
  • JUnit 5.12.2
  • Mockito 5.x
  • MockMvc / @WebMvcTest
  • JsonInclude.NON_NULL
profile
일기장처럼 기록하는 용도

0개의 댓글