이 글을 쓰게 된 배경
개인 프로젝트를 진행하면서 처음에는 프로젝트를 빠르게 완성하기 위해서 jwt 토큰에 신경을 못썼지만 추후에 리팩토링 하면서 발생한 과정에 대한 내용입니다.
다룰 내용
- 문제
- 이유
- 필터 내부에서 예외 처리
- 핸들러를 이용해 예외 처리
- 정리
문제
사용자의 액세스 토큰이 만료되면 리프레시 토큰을 이용해서 재발급 받습니다. 근데 리프레시 토큰도 만료되면? 사용자는 다시 로그인을 해야합니다. 그러기 위해서 리프레시 토큰도 만료된 사용자가 접근을 시도할 때 로그인 화면으로 보내주는 로직이 필요합니다. 이 로직은 스프링 시큐리티의 필터에서 토큰을 검증할 때 처리되어야 합니다.
다음은 리프레시 액세스 토큰을 재발급 하는 메소드 입니다. 여기서 리프레시 토큰도 만료되었다면 사용자를 로그인 화면으로 보내주면 됩니다.
public String reissueAccessToken(String accessToken) {
if (StringUtils.hasText(accessToken)) {
log.info("accesToken = {}", accessToken);
String username = extractUserIdFromAccessToken(accessToken);
log.info("username = {}", username);
Member member = memberRepository.findByUsername(username)
.orElseThrow(MemberNotFoundException::new);
String refreshToken = member.getRefreshToken();
// 리프레시 토큰 검증 후 괜찮은거면 액세스 토큰 재 발금
if (validRefreshToken(refreshToken)) {
String reissueAccessToken = generateAccessToken(getAuthentication(refreshToken));
log.info("재발급 됨");
return reissueAccessToken;
} else{
throw new RefreshTokenNotFoundException();
}
}
return null;
}
@Slf4j
public class RefreshTokenNotFoundException extends BusinessException {
public RefreshTokenNotFoundException() {
super(ErrorCode.RefreshTokenNotFoundException);
log.info("컨트롤러어드바이스에서 관리하는 비즈니스예외처리 일어남");
}
}
저는 리프레시 토큰이 레디스에 존재하지 않을 때(만료 되었을 때) BusinessException(RuntimeException)을 터트렸습니다.
사실 이렇게 필터에서 예외를 터트리면 당연히 예외가 동작하지 않을 줄 알았습니다. 컨트롤러어드바이스는 서블릿 컨텍스트 전역의 BusinessException을 처리하는 구조이고 예외를 처리하는 과정은 디스패처 서블릿에서 이루어집니다.
아래의 사진은 웹 어플리케이션의 처리과정입니다.
스프링 시큐리티 필터는 위처럼 서블릿 이전에 실행됩니다. @ControllerAdvice는 서블릿에 컨텍스터에서 발생한 예외를 DispatcherServlet에서 처리하기 때문에 필터에서 발생한 예외는 처리할 수 없다고 생각했습니다.
그런데 위의 로그를 보면 리프레시 토큰 예외가 정상적으로 처리되고 있습니다.
하지만 이 예외가 터졌을 때 응답을 보면 이렇게 날아옵니다.
내가 설정한 응답
RefreshTokenNotFoundException(500, "T505", "리프레시 토큰을 찾을 수 없음"),
실제로 날아온 응답
{
"timestamp": "2024-12-27T06:35:59.912+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/my"
}
분명히 예외가 잘 잡혔는데 응답은 BasicErrorController가 제공하는 기본응답으로 날아왔습니다.
이유
사실 실제로 예외는 컨트롤러어드바이스에서 처리된 것이 아닙니다. 예외가 필터 체인에서 발생했지만 스프링 부트의 기본 로깅이나 스택 트레이스 출력 기능 덕분에 "잡히는 것처럼 보이는 것"입니다.
예외 처리 방법
결국 필터에서 예외를 처리하는 방법은 여러가지가 있지만 제가 시도한 방법 2가지를 설명하겠습니다.
- 필터 내부에서 예외를 처리하는 것
- 핸들러를 이용해 예외 처리
필터 내부에서 예외 처리
필터 내부에서 예외를 처리하는 것은 아주 간단합니다.
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.setContentType("application/json");
httpResponse.getWriter().write("{\"code\": \"T505\", \"message\": \"리프레시 토큰을 찾을 수 없음\"}");
httpResponse.getWriter().flush();
return; // 체인 종료
이런 식으로 필터 내부에서 응답을 보내고 싶은 형식으로 response를 만들어서 보낸 후 return;을 통해서 필터 체인을 종료하면 됩니다. 필터 체인을 종료하지 않으면 필터가 계속해서 다음으로 넘어가서 다른 respons로 덮어쓰여질 가능성이 있습니다.
핸들러를 이용해 예외 처리
필터
try~catch문을 이용하여 발생한 예외를 catch에서 예외 핸들러를 통해서 처리합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String accessToken = resolveToken(request);
if (StringUtils.hasText(accessToken) && tokenProvider.validateToken(accessToken)) {
if (tokenProvider.validTokenInRedis(accessToken)) {
setAuthentication(accessToken);
} else {
throw new IllegalArgumentException("Invalid token in Redis");
}
} else {
String reissueAccessToken = tokenProvider.reissueAccessToken(accessToken);
if (StringUtils.hasText(reissueAccessToken)) {
setAuthentication(reissueAccessToken);
response.setHeader(AUTHORIZATION, "Bearer " + reissueAccessToken);
} else {
throw new IllegalStateException("Unable to reissue access token");
}
}
filterChain.doFilter(request, response);
} catch (Exception e) {
log.error("필터 처리 중 예외 발생: {}", e.getMessage(), e);
exceptionHandler.handleException(request, response, e);
}
}
핸들러
예외를 처리하는 응답코드를 보내줍니다.
@Component
public class CustomExceptionHandler {
public void handleException(HttpServletRequest request, HttpServletResponse response, Exception e) throws IOException {
// 로그 작성
String errorMessage = e.getMessage();
String requestURI = request.getRequestURI();
// 클라이언트 응답 작성
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.getWriter().write("{\"code\": \"T501\", \"message\": \"" + errorMessage + "\"}");
response.getWriter().flush();
}
}
결과
{
"code": "T501",
"message": "Unable to reissue access token"
}
2025.03.10 추가
그전에는 권한이 없어서 AccessDeinedHandler에 이상이 있었는데도 몰랐는데 권한을 추가하면서 발견한 오류를 해결하는 과정과 이번에 다시 재정립한 스프링 시큐리티 필터 예외 개념에 대해서 다시 정리해보겠습니다.
다음은 제가 이번에 새로 받은 오류 로그입니다.
java.lang.IllegalStateException: Cannot call sendError() after the response has been committed
at org.apache.catalina.connector.ResponseFacade.checkCommitted(ResponseFacade.java:489) ~[tomcat-embed-core-10.1.28.jar:10.1.28]
at org.apache.catalina.connector.ResponseFacade.sendError(ResponseFacade.java:333) ~[tomcat-embed-core-10.1.28.jar:10.1.28]
오류 로그를 보면 response가 이미 커밋된 이후에 sendError를 보낼 수 없어 IllegalStateException이 터졌다는 내용을 확인할 수 있었습니다. 스택 트레이스로 오류가 난 곳을 봤을 때는 AccessDeniedHandler의 다음 코드였습니다.
리팩토링 전 AccessDeniedHandler
response.sendRedirect("http://localhost:3000/login");
response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
제가 처음에 이렇게 코드를 작성한 이유는 403에러와 함께 사용자를 로그인화면으로 보내고 싶어서 였습니다.
하지만 response.sendRedirect를 하면 상태코드 302와 함께 위의 주소로 자동으로 이동시킵니다.
이미 response가 나갔는데 아래에서 sendError를 호출하니 이미 response가 커밋되었다는 오류가 터진것입니다.
리팩토링 후 AccessDeniedHandler
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"ACCESS_DENIED\", \"message\": \"접근 권한이 없습니다.\"}");
리다이렉트를 빼고 에러 메시지를 보낸 이유는 사용자의 페이지를 이동시키는 역할은 프론트엔드에서 전담하는 것이 더 바람직한 역할의 분리라고 생각해서입니다.
필터 리팩토링
관련 오류를 해결하면서 같이 프로젝트를 하는 분 중 유저와 스프링 시큐리티 담당 개발자 분에게 조언을 구하고, 제가 처한 문제에 대해서 이야기 한 결과 제가 잘못 이해하고 있는 부분이 있다는 것을 확인했고 커스텀 필터 부분을 리팩토링했습니다.
새로 리팩토링한 TokenAuthenticationFilter
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveToken(request);
if(StringUtils.hasText(accessToken)) {
if(tokenProvider.validTokenInRedis(accessToken)) {
setAuthentication(accessToken);
}
} else {
String reissueAccessToken = tokenProvider.reissueAccessToken(accessToken);
if (StringUtils.hasText(reissueAccessToken)) {
setAuthentication(reissueAccessToken);
response.setHeader(AUTHORIZATION, "Bearer " + reissueAccessToken);
}
}
filterChain.doFilter(request, response);
}
설명
🔹 Spring Security에서 인증/인가 오류 처리 방식
이전까지는 필터에서 try~catch를 사용하여 오류를 잡고 CustomExceptionHandler로 넘기는 방식을 사용했습니다.
물론 이 방법도 틀린 것은 아니며, 위에서도 성공적으로 response를 받을 수 있었습니다.
하지만 제가 Spring Security를 사용한 주 목적은 인증/인가를 필터에서 직접 처리하려는 것이었습니다.
Spring Security를 많이 사용하는 이유는, 이미 인증(Authentication)과 인가(Authorization)를 처리하는 핸들러들이 구현되어 있기 때문입니다.
🔹 Spring Security의 기본 예외 처리 핸들러
- 인증(Authentication) 오류 → AuthenticationEntryPoint에서 처리
- 인가(Authorization) 오류 → AccessDeniedHandler에서 처리
현재 코드에서는 정상적인 상황에서 authentication을 생성하는 로직만 있을 뿐,
비정상적인 상황에 대한 오류 처리(예: throw exception)를 직접 하고 있지 않습니다.
그런데도 인증/인가 오류가 자동으로 처리되는 이유?
Spring Security에서는 필터 체인을 통해 요청을 다음 필터로 넘기면, 뒤에 있는 기본 필터들이 알아서 오류를 처리하기 때문입니다.
즉, 인증 객체(authentication)를 생성하지 않고 그냥 필터 체인을 타고 넘어가면,
결국 Spring Security 내부의 기본 필터가 오류를 감지하고 401 Unauthorized 또는 403 Forbidden을 응답합니다.
커스텀 AuthenticationEntryPoint와 AccessDeniedHandler가 필요할까?
- 커스텀하지 않아도 Spring Security의 기본 핸들러가 401과 403을 자동으로 던져줍니다.
- 하지만 JSON 응답을 반환해야 하거나, 로그를 남기는 등 추가적인 작업이 필요하다면 커스텀 핸들러를 구현하는 것이 좋습니다.
정리
블로그에 작성한 것 이외에도 성공적인 결과를 얻기까지 여러 시행착오를 거쳤습니다. 다음은 제가 어떤 과정으로 위 결과에 도달했는지에 대한 정리입니다.
- ControllerAdvice를 이용하는 기존의 예외 처리 방식과 같이 필터에서도 예외 처리를 함
→ 예외가 처리된 것처럼 보였지만 정상적으로 동작하지 않음 - 필터 내부에서 예외 발생 시 로그인 화면으로 리다이렉트하도록 구현
→ 필터 체인을 종료하지 않아서 다른 예외 처리 로직에 의해 덮어씌워짐 - 예외 발생 시 리다이렉트 후 필터 체인을 종료
→ 리다이렉트 과정에서 문제가 생겨 정상 동작하지 않음 - 백엔드에서 클라이언트의 리다이렉트를 구현하는 것이 책임의 분리성을 고려했을 때 맞지 않다고 생각
→ 따로 응답 코드를 만들어서 반환하는 로직으로 변경 - 필터 내부에서 응답 코드 생성 후 반환 & 필터 체인 종료
→ 정상 동작
→ 하지만 필터 내부에서 직접 응답 코드를 만들고 반환하는 것은 확장성과 책임 분리 측면에서 적절하지 않음 - 예외를 처리하는 핸들러를 만들어서 응답 코드를 만들고 반환하는 로직을 핸들러에서 처리
→ 성공 - JWT에 Role 추가 후 403 에러가 정상적으로 나오지 않음
→ Spring Security 커스텀 필터 리팩토링 - CustomAuthenticationEntryPoint & CustomAccessDeniedHandler 적용 → 401, 403 에러 핸들링
→ 정상 동작
'Spring' 카테고리의 다른 글
스프링 시큐리티의 구조와 대체 방안 (3) | 2025.01.02 |
---|---|
스프링 부트에서 예외와 처리 방법 (2) | 2024.12.30 |
커넥션 풀 vs 복잡한 로직 시간 비용 (3) | 2024.12.22 |
트랜잭션 by JPA (6) | 2024.12.17 |
연관관계 매핑이 꼭 필요한가 (2) | 2024.12.16 |