
테스트가 모두 통과했는데, 왜 이렇게 오래 걸렸을까?
이번 글에서는 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 기반의 단위 테스트로 검증해야 했다.
보안 필터와 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;
}
테스트는 세 가지 시나리오로 나누었다.
첫 실행에서 마주한 문제는 단순했다.
No handler found for POST /refresh
원인은 컨트롤러의 기본 경로였다.
@RequestMapping("/api/auth") 가 선언되어 있었는데, 테스트에서는 /refresh 로 호출하고 있었다.
테스트 요청 경로를 모두 /api/auth/refresh 로 수정했다.
UnfinishedStubbingException과 NPE경로를 수정하자 이번에는 다른 에러가 발생했다.
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() 호출까지 도달하지 못했고, “미완성 스텁” 예외를 뱉었다.
RefreshResult를 Wrapper(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으로 작성하지 말길....
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)| 문제 | 원인 | 해결책 |
|---|---|---|
| 404 Not Found | 컨트롤러 경로 불일치 | /api/auth/refresh 로 수정 |
| UnfinishedStubbingException | null 언박싱으로 인한 NPE | Long 타입 + 팩토리 메서드 사용 |
| getRefreshToken() 오류 | record 접근자 이름 착각 | get...() → 필드명() 호출로 수정 |
record와 class의 게터 차이
record는 getXxx()를 만들지 않는다.
컨트롤러나 서비스에서 접근 시 혼용되지 않도록 일관된 규칙을 세워야 한다.
Mockito 예외는 종종 다른 예외의 결과다
UnfinishedStubbingException은 스텁 미완성이 아니라
내부 NPE로 인해 thenReturn() 호출이 생략된 경우가 많다.
Primitive 타입은 테스트의 적이다
long, int보다는 Long, Integer로 선언해 nullable 상태를 표현하는 것이
테스트 코드의 안정성을 크게 높인다.