이지은님의 블로그
250305 - Java Spring 아웃소싱 프로젝트 구현과 트러블 슈팅: Filter와 OncePerRequestFilter의 차이점, 테스트코드, 쿠키로 토큰 관리(http-only) 본문
250305 - Java Spring 아웃소싱 프로젝트 구현과 트러블 슈팅: Filter와 OncePerRequestFilter의 차이점, 테스트코드, 쿠키로 토큰 관리(http-only)
queenriwon3 2025. 3. 6. 03:36▷ 오늘 배운 것
아웃소싱 팀프로젝트를 진행하면서, 새로 배우게 되거나 트러블 슈팅을 한 내용을 작성해보려고 한다.
<<목차>>
1. Filter와 OncePerRequestFilter의 차이점
2. 쿠키로 토큰을 관리해보자.
1) refresh token을 쿠키에 담아보자
2) 쿠키에 있는 refresh token을 가져와보자.
3. NotAMockException
4. PotentialStubbingProblem
1. Filter와 OncePerRequestFilter의 차이점
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String url = request.getRequestURI();
// 회원가입 + (로그인, 토큰 재발급)은 비로그인도 접근 가능
if (url.equals("/auths/login") || url.equals("/auths/refresh")|| url.equals("/users/signup")) {
filterChain.doFilter(request, response);
return;
}
// 토큰 유무 확인 + filter 는 공통 예외처리 불가
String bearerJwt = request.getHeader("Authorization");
if (bearerJwt == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt);
try {
Claims claims = jwtUtil.extractClaims(jwt);
// 유저정보 유무 확인 + filter 는 공통 예외처리 불가
if (claims == null) {
handleException(response, ErrorCode.BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
request.setAttribute("userId", Long.parseLong(claims.getSubject()));
request.setAttribute("email", claims.get("email"));
request.setAttribute("userRole", claims.get("userRole"));
filterChain.doFilter(request, response);
} catch (Exception e) {
...
}
}
}
이 코드는 로그인 사용자와 비로그인 사용자를 판단하기 위해서 사용한 filter이다. 처음에는 filter를 사용했었다. 아래는 수정하기 전, Filter를 이용해서 작성한 코드이다.
@Slf4j
@RequiredArgsConstructor
public class JwtFilterA implements Filter {
private final JwtUtil jwtUtil;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String url = request.getRequestURI();
// 회원가입 + (로그인, 토큰 재발급)은 비로그인도 접근 가능
if (url.equals("/auths/login") || url.equals("/auths/refresh")|| url.equals("/users/signup")) {
filterChain.doFilter(request, response);
return;
}
// 토큰 유무 확인 + filter 는 공통 예외처리 불가
String bearerJwt = request.getHeader("Authorization");
if (bearerJwt == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt);
try {
Claims claims = jwtUtil.extractClaims(jwt);
// 유저정보 유무 확인 + filter 는 공통 예외처리 불가
if (claims == null) {
handleException(response, ErrorCode.BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
request.setAttribute("userId", Long.parseLong(claims.getSubject()));
request.setAttribute("email", claims.get("email"));
request.setAttribute("userRole", claims.get("userRole"));
filterChain.doFilter(request, response);
} catch (Exception e) {
log.error("Invalid JWT token, 유효하지 않는 JWT 토큰 입니다.", e);
handleException(response, ErrorCode.AUTHORIZATION, "유효하지 않는 JWT 토큰입니다.");
}
}
}
하지만 OncePerRequestFilter를 사용하는게 좋을 것 같다는 팀원의 조언으로 수정하게 되었다. 여기서 Filter사용에서 OncePerRequestFilter로 수정하면서 어떤 점이 정확하게 다른건지 TIL로 정리해보려고 한다.
참고한 블로그
https://minkukjo.github.io/framework/2020/12/18/Spring-142/
OncePerRequestFilter와 Filter의 차이
OncePerRequestFilter와 Filter
minkukjo.github.io
https://hohodu.tistory.com/entry/Filter%EC%99%80-OncePerRequestFilter
[Spring] Filter와 OncePerRequestFilter
들어가기 전에 spring-security-jwt프로젝트를 진행하면서 Filter를 구현할 때, Filter가 아닌 OncePerRequestFilter를 상속하는 이유를 알아보자 클라이언트에 요청이 올때 가장 먼저 Filter를 호출하고 응답을
hohodu.tistory.com
filter는 언제나 봤듯이 DispatcherServlet을 통과하기 전에 그 앞단에서 공통적으로 실행된다. 그래서 인증이나 로깅등을 위해서 filter를 많이 사용한다.
이 블로그를 보면서 GenericFilterBean에 대해서도 알게되었다.(Spring의 설정 정보를 가져올 수 있음, 정보저장 가능)
이 Filter(와 GenericFilterBean)는 매 서블릿 마다 호출이 된다는 것이다.
서블릿은 사용자의 요청을 받으면 서블릿을 생성해 메모리에 저장해두고, 같은 클라이언트의 요청을 받으면 생성해둔 서블릿 객체를 재활용하여 요청을 처리한다고 한다.(forward 방식, 이 방식은 client 최초 요청 그대로 다른 url로 바로 전달하는 방식이다.)
예를 들어 /forward-before API에서 forward로 다른 /forward-after API를 호출했을 때,
바로 /forward-after API를 호출하는게 아니라 다시 필터를 거쳐서 API로 호출한다.
filter로 구현되는 인증과 인가는 RequestDispatcher 클래스에 의해 다른 서블릿으로 dispatch되게 되는데, 이 때 이동할 서블릿에 도착하기 전에 다시 한번 filter chain을 거치게 된다.
바로 이 때 또 다른 서블릿이 우리가 정의해둔 필터가 Filter나 GenericFilterBean로 구현된 filter를 또 타면서 필터가 두 번 실행되는 현상이 발생할 수 있다.
OncePerRequestFilter는 그 이름에서도 알 수 있듯이 모든 서블릿에 일관된 요청을 처리하기 위해 만들어진 필터이다.
이 추상 클래스를 구현한 필터는 사용자의 한번에 요청 당 딱 한번만 실행되는 필터를 만들 수 있다.
OncePerRequestFilter가 Filter 중복 호출을 방지하고 클래스 이름 그대로 하나의 Request에 한 번만 호출되도록 하는 필터이다.
필터 중복 호출은 불필요한 리소스 낭비차원과 성능 문제 뿐만 아니라,
인증, 인가 과정에서 하나의 요청에 대해 불필요한 인증 작업을 두 번이상 진행할 수도 있는 점을 고려해보았을 때 요청 처리 과정에서 치명적인 결함이 발생할 수 있다.(성능 문제로 인해 OncePerRequestFilter사용 권장)
개념적으로는 다음과 같지만, 실제 구현된 코드를 살펴보면 구성 또한 다르다는 것을 알 수 있다.
대표적으로는 Filter는 다음과 같은 인터페이스로 이루어져 있다.
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {}
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
메서드 | 설명 |
init(FilterConfig filterConfig) | 필터가 초기화될 때 호출됨. |
doFilter(ServletRequest request, ServletResponse response, FilterChain chain) | 요청/응답을 처리하는 핵심 로직. |
destroy() | 필터가 종료될 때 호출됨. |
모든 요청마다 실행됨 → 동일 요청이 여러 번 필터링될 수 있음.
그러나 OncePerRequestFilter는 다음과 같은 형태의 추상클래스로 이루어져 있다. 거기에 GenericFilterBean이라는 추상 클래스를 상속 받고 있다. 앞에서 한번 언급했듯이 GenericFilterBean는 Filter처럼 필터가 두번 실행 될 수 있다.
public abstract class OncePerRequestFilter extends GenericFilterBean {
public static final String ALREADY_FILTERED_SUFFIX = ".FILTERED";
public OncePerRequestFilter() {
}
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request instanceof HttpServletRequest httpRequest) {
if (response instanceof HttpServletResponse httpResponse) {
String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
if (!this.skipDispatch(httpRequest) && !this.shouldNotFilter(httpRequest)) {
if (hasAlreadyFilteredAttribute) {
if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
this.doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}
filterChain.doFilter(request, response);
} else {
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
this.doFilterInternal(httpRequest, httpResponse, filterChain);
} finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
} else {
filterChain.doFilter(request, response);
}
return;
}
}
throw new ServletException("OncePerRequestFilter only supports HTTP requests");
}
private boolean skipDispatch(HttpServletRequest request) {
if (this.isAsyncDispatch(request) && this.shouldNotFilterAsyncDispatch()) {
return true;
} else {
return request.getAttribute("jakarta.servlet.error.request_uri") != null && this.shouldNotFilterErrorDispatch();
}
}
protected boolean isAsyncDispatch(HttpServletRequest request) {
return DispatcherType.ASYNC.equals(request.getDispatcherType());
}
protected boolean isAsyncStarted(HttpServletRequest request) {
return WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted();
}
protected String getAlreadyFilteredAttributeName() {
String name = this.getFilterName();
if (name == null) {
name = this.getClass().getName();
}
return name + ".FILTERED";
}
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return false;
}
protected boolean shouldNotFilterAsyncDispatch() {
return true;
}
protected boolean shouldNotFilterErrorDispatch() {
return true;
}
protected abstract void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException;
protected void doFilterNestedErrorDispatch(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
filterChain.doFilter(request, response);
}
}
GenericFilterBean에 대해 조금 언급해보자면,
GenericFilterBean는 Spring에서 제공하는 조금 확장된 Filter라고 생각하면 되며, 기존 Filter에서 얻어 올 수 없었던 Spring 설정 정보를 가져올 수 있게 하는 추상클래스이다.
이 추상클래스는 여러 인터페이스를 상속받고 있지만, 그중 Filter를 상속받고 있다.
public abstract class GenericFilterBean implements Filter, BeanNameAware, EnvironmentAware, EnvironmentCapable, ServletContextAware, InitializingBean, DisposableBean {
protected final Log logger = LogFactory.getLog(this.getClass());
@Nullable
private String beanName;
@Nullable
private Environment environment;
@Nullable
private ServletContext servletContext;
@Nullable
private FilterConfig filterConfig;
private final Set<String> requiredProperties = new HashSet(4);
public GenericFilterBean() {
}
...
}
다시 OncePerRequestFilter로 돌아가자면, OncePerRequestFilter는 한 요청에 대해 한 번만 실행되도록 설계되어있다. 요청 속성을 설정하여 중복실행을 방지한다. 필터링 로직을 doFilterInternal()에서 구현하도록 강제하기도 한다.
메서드 | 역할 |
doFilter() | 한 요청당 한 번만 실행하도록 보장 |
doFilterInternal() | 실제 필터링 로직을 구현하는 메서드 (추상 메서드) |
skipDispatch() | 비동기 또는 에러 요청 필터링 여부 결정 |
shouldNotFilter() | 특정 조건에서 필터 실행 여부 결정 |
isAsyncDispatch() | 요청이 비동기 요청인지 확인 |
shouldNotFilterAsyncDispatch() | 비동기 요청을 필터링할지 여부 |
shouldNotFilterErrorDispatch() | 에러 요청을 필터링할지 여부 |
getAlreadyFilteredAttributeName() | 중복 실행 방지용 속성 이름 생성 |
이번 코드에서 사용된 메서드는 doFilterInternal()를 사용했는데 Filter의 doFilter와 비교해보자면 다음과 같다. 거의 비슷한 형태를 띄고 있으므로 어렵지 않게 OncePerRequestFilter를 사용한 코드로 변경할 수 있었다.
로직 작성 메서드 | OncePerRequestFilter | Filter |
메서드 이름 | protected void doFilterInternal() | public void doFilter() |
매개변수 request | HttpServletRequest request | ServletRequest servletRequest |
매개변수 response | HttpServletResponse response | ServletResponse servletResponse |
매개변수 chain | FilterChain filterChain | FilterChain filterChain |
2. 쿠키로 토큰을 관리해보자.
참고한 블로그
https://be-student.tistory.com/72
쿠키로 Jwt RefreshToken 관리하기! (내 쿠키는 어디갔지?)
어떤 문제가 있었는가? 이럴 거면 왜 RefreshToken을 사용하는 거지? 과거의 로그인 과정에서 로그인을 진행하면, ResponseBody로 AccessToken과, RefreshToken 이 넘어가게 됩니다. 클라이언트는 이를 모두 Loca
be-student.tistory.com
http://dncjf64.tistory.com/292
SpringBoot에서 HttpOnly 쿠키방식을 이용한 refreshToken 발급
jwt의 access_token과 refresh_token를 구현하는 과정에서 프로젝트 프론트 팀원분이 refresh_token은 쿠키에 담아서 전송해달라는 요청이 들어왔다. 과정은 대략 아래와 같다. 전송방식은 Http Only 방식으로
dncjf64.tistory.com
처음에는 다음과 같이 AccessToken과 RefreshToken을 같이 반환했다.
그러나 AccessToken과 RefreshToken은 다른 역할을 가지고 있으므로 서로 따로 관리를 해야하는 것이 좋다.
블로그에서는 HTTP-Only방식을 통해, XSS 공격을 방지할 수 있다고 한다.
HTTP-Only 방식이란, 브라우저의 JavaScript에서 쿠키를 접근하지 못하도록 설정하는 보안 기능이다.
쿠키의 HttpOnly 속성을 true로 설정하면 클라이언트(JavaScript)에서 해당 쿠키를 읽거나 수정할 수 없고, 오직 서버에서만 접근 가능하게 할 수 있다. 따라서 HTTPOnly 방식을 사용한다는 전제 하에, refreshToken을 저장 가능할 수 있도록 할 것이다.
1) refresh token을 쿠키에 담아보자
@GetMapping("/refresh")
public Response<AuthAccessResponse> reissueAccessToken(@RefreshToken String refreshToken, HttpServletResponse response) {
AuthTokenResponse authTokenResponse = authService.reissueAccessToken(refreshToken);
setRefreshTokenCookie(response, authTokenResponse.getRefreshToken());
AuthAccessResponse authAccessResponse = new AuthAccessResponse(authTokenResponse.getAccessToken());
return Response.of(authAccessResponse);
}
private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setSecure(true);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
}
컨트롤러에 리프레시 토큰을 쿠키에 담을 수 있는 메서드를 만들었다.
먼저 리플레시 토큰을 쿠키에 저장하고, 유효기간을 설정(7일), HTTPS 연결에만 쿠키가 전송될수 있도록 설정(HTTP에서는 불가), JavaScript에서 쿠키에 접근하지 못하도록 설정, 그리고 쿠키가 유효한 경로를 설정한다.
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setSecure(true);
cookie.setHttpOnly(true);
cookie.setPath("/");
설정을 모두 마친 뒤, 쿠키를 응답값으로 넘겨줄 수 있다.
2) 쿠키에 있는 refresh token을 가져와보자.
로그인 시간을 연장하기 위해서는 access token을 받아야하며, access token을 재발급 받기 위해서는 refresh token을 제공해야한다.(클라이언트 측)
그럼 서버측에서는 refresh token이 유효한지를 따져보기 위해서 쿠키를 통해 refresh token을 전달할 수 있다.
쿠키를 쉽게 전달하기 위해서 argument resolver를 사용했다.
public class RefreshTokenArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasRefreshTokenAnnotation = parameter.getParameterAnnotation(RefreshToken.class) != null;
boolean isStringType = parameter.getParameterType().equals(String.class);
if (hasRefreshTokenAnnotation != isStringType) {
throw new UnauthorizedException("@RefreshToken과 String 타입은 함께 사용되어야 합니다.");
}
return hasRefreshTokenAnnotation;
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
throw new UnauthorizedException("리프레시 토큰이 존재하지 않습니다. 다시 로그인 해주세요.");
}
}
SupportParameter()메서드는 @RefreshToken어노테이션이 붙어있고, 타입이 Stirng이라면 값을 가져올 수 있도록 한다.
resolverArgument()에는 위 조건이 충족됐을 때, 쿠키를 가져오는 방법을 작성한다.
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
throw new UnauthorizedException("리프레시 토큰이 존재하지 않습니다. 다시 로그인 해주세요.");
쿠키에는 많은 값들이 있다. 쿠키에 아무 값이 없다면 당연히 리프레시 토큰이 존재하지 않기때문에, 예외를 던진다.
만약 쿠키에 값이 존재한다면, 쿠키마다 확인, 그중 refreshToken이라고 설정한 값을 가져온다. 이렇게 하면 컨트롤러에서 입력값으로 쿠키의 refresh Token을 가져올 수 있다.
3. NotAMockException
Argument passed to verify() is of type RefreshToken and is not a mock!
Make sure you place the parenthesis correctly!
See the examples of correct verifications:
verify(mock).someMethod();
verify(mock, times(10)).someMethod();
verify(mock, atLeastOnce()).someMethod();
org.mockito.exceptions.misusing.NotAMockException:
Argument passed to verify() is of type RefreshToken and is not a mock!
Make sure you place the parenthesis correctly!
See the examples of correct verifications:
verify(mock).someMethod();
verify(mock, times(10)).someMethod();
verify(mock, atLeastOnce()).someMethod();
at com.example.deliveryappproject.domain.auth.service.TokenServiceTest.토큰만료_성공(TokenServiceTest.java:88)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
이 에러는 다음 테스트 코드를 작성했을 때 발생했다.
@Test
void 토큰만료_성공() {
// given
Long userId = 1L;
RefreshToken refreshToken = new RefreshToken(userId);
given(refreshTokenRepository.findById(anyLong())).willReturn(Optional.of(refreshToken));
// when
tokenService.revokeRefreshToken(userId);
// then
verify(refreshTokenRepository, times(1)).findById(userId);
verify(refreshToken, times(1)).updateTokenStatus(INVALIDATED);
}
에러의 내용은 NotAMockException,
위 코드에서 refreshToken은 Mockito의 mock() 객체가 아니라 실제 엔티티 객체이기 때문에 verify()에서 검증할 수 없다는 뜻이다.
verify에서는 mock()객체만 확인해볼 수 있다.
에러코드를 자세히보면 해결방법의 힌트가 나와있다.
verify(mock).someMethod();
verify(mock, times(10)).someMethod();
verify(mock, atLeastOnce()).someMethod();
내가 작성한 코드에서는 RefreshToken이 엔티티를 설정해준 class로, verity에서는 mock으로 설정한 class만 실행했는지 확인가능하다.
따라서 RefreshToken을 mock 객체로 설정하면 문제를 해결할 수 있다.
@Test
void 토큰만료_성공() {
// given
Long userId = 1L;
RefreshToken mockRefreshToken = mock(RefreshToken.class);
given(refreshTokenRepository.findById(anyLong())).willReturn(Optional.of(mockRefreshToken));
// when
tokenService.revokeRefreshToken(userId);
// then
verify(refreshTokenRepository, times(1)).findById(userId);
verify(mockRefreshToken, times(1)).updateTokenStatus(INVALIDATED);
}
4. PotentialStubbingProblem
Strict stubbing argument mismatch. Please check:
- this invocation of 'createAccessToken' method:
jwtUtil.createAccessToken(1L, null, null);
-> at com.example.deliveryappproject.domain.auth.service.TokenService.createAccessToken(TokenService.java:25)
- has following stubbing(s) with different arguments:
1. jwtUtil.createAccessToken(0L, null, null);
-> at com.example.deliveryappproject.domain.auth.service.TokenServiceTest.토큰발급_AccessToken_발급_성공(TokenServiceTest.java:47)
Typically, stubbing argument mismatch indicates user mistake when writing tests.
Mockito fails early so that you can debug potential problem easily.
However, there are legit scenarios when this exception generates false negative signal:
- stubbing the same method multiple times using 'given().will()' or 'when().then()' API
Please use 'will().given()' or 'doReturn().when()' API for stubbing.
- stubbed method is intentionally invoked with different arguments by code under test
Please use default or 'silent' JUnit Rule (equivalent of Strictness.LENIENT).
For more information see javadoc for PotentialStubbingProblem class.
org.mockito.exceptions.misusing.PotentialStubbingProblem:
Strict stubbing argument mismatch. Please check:
- this invocation of 'createAccessToken' method:
jwtUtil.createAccessToken(1L, null, null);
-> at com.example.deliveryappproject.domain.auth.service.TokenService.createAccessToken(TokenService.java:25)
- has following stubbing(s) with different arguments:
1. jwtUtil.createAccessToken(0L, null, null);
-> at com.example.deliveryappproject.domain.auth.service.TokenServiceTest.토큰발급_AccessToken_발급_성공(TokenServiceTest.java:47)
Typically, stubbing argument mismatch indicates user mistake when writing tests.
Mockito fails early so that you can debug potential problem easily.
However, there are legit scenarios when this exception generates false negative signal:
- stubbing the same method multiple times using 'given().will()' or 'when().then()' API
Please use 'will().given()' or 'doReturn().when()' API for stubbing.
- stubbed method is intentionally invoked with different arguments by code under test
Please use default or 'silent' JUnit Rule (equivalent of Strictness.LENIENT).
For more information see javadoc for PotentialStubbingProblem class.
at com.example.deliveryappproject.config.JwtUtil.createAccessToken(JwtUtil.java:41)
at com.example.deliveryappproject.domain.auth.service.TokenService.createAccessToken(TokenService.java:25)
at com.example.deliveryappproject.domain.auth.service.TokenServiceTest.토큰발급_AccessToken_발급_성공(TokenServiceTest.java:50)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
이 에러는 다음 테스트 코드를 작성했을 때 발생했다.
@Test
void 토큰발급_AccessToken_발급_성공() {
// given
Long userId = 1L;
String accessToken = "access-token";
User user = new User(userId);
given(jwtUtil.createAccessToken(0L, null, null)).willReturn(accessToken);
// when
String createdToken = tokenService.createAccessToken(user);
// then
assertEquals(accessToken, createdToken);
}
에러의 내용은 PotentialStubbingProblem, 엄격한 stubbing 설정으로 발생하는 에러이다. stubbing은 주로 given()을 하면서 생기는 과정으로, 해당 given코드에서 발생하는 것으로 확인할 수 있다.
여기서 when에서 id값이 1L인 user를 사용해서 코드를 실행했는데, given(가짜동작)에서는 0L인 user를 통해서 createAccessToken을 실행하려고 했으므로 값이 달라 출력하는 에러이다.
해결방법은 총 3가지이다.
실제 코드 출력 값에 맞추어 1L인 user를 입력한다거나,
given(jwtUtil.createAccessToken(1L, null, null)).willReturn(accessToken);
범용적인 매개변수를 처리하하는 방법이 있다.(단위테스트의 의미를 살려 any()사용이 권장된다.)
given(jwtUtil.createAccessToken(anyLong(), any(), any())).willReturn(accessToken);
고민 끝에 이미 작성한 user가 있어, 이 user의 값을 가져오는 것으로 에러를 해결할 수 있었다.
@Test
void 토큰발급_AccessToken_발급_성공() {
// given
Long userId = 1L;
String accessToken = "access-token";
User user = new User(userId);
given(jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getUserRole())).willReturn(accessToken);
// when
String createdToken = tokenService.createAccessToken(user);
// then
assertEquals(accessToken, createdToken);
}