| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- HashMap
- vscode
- SpringBoot
- string
- CSS
- Eclipse
- 문자열
- input
- list
- junit5
- IntelliJ
- html
- 단위테스트
- 정규식
- Array
- math
- ArrayList
- 인텔리제이
- js
- javascript
- Java
- 스프링부트
- 자바
- 자바문법
- 자바스크립트
- 테스트자동화
- Visual Studio Code
- junit
- java테스트
- 배열
- Today
- Total
어제 오늘 내일
[Spring Security] 14편 - 필터 예외 처리의 정석 (ExceptionHandlerFilter) 본문
[Spring Security] 14편 - 필터 예외 처리의 정석 (ExceptionHandlerFilter)
hi.anna 2026. 2. 24. 07:48
사실 실무에서는 "토큰이 만료되었는지", "서명이 위조되었는지", "형식이 잘못되었는지"를 구분해서 프론트엔드에게 알려줘야 할 때가 많습니다. (예: 만료면 재발급 요청, 위조면 강제 로그아웃 등)
하지만 기존 코드처럼 JwtTokenProvider에서 예외를 try-catch에서 false만 반환하면, 구체적인 이유를 알 수 없게 되죠.
이를 해결하기 위해 1. Provider가 예외를 던지게 수정하고, 2. 앞단 필터(ExceptionHandlerFilter)가 이를 잡아서 처리하는 완벽한 구조를 만들어 봅시다.
스프링 시큐리티를 쓰다 보면 당황스러운 점이 하나 있습니다.
"왜 @ControllerAdvice가 JWT 예외를 못 잡지?"
이유는 간단합니다. 필터(Filter)는 컨트롤러보다 먼저 실행되기 때문입니다. 토큰 검증 중에 에러가 터지면 컨트롤러까지 도달하지도 못하고 필터에서 끝나버립니다.
이 문제를 해결하는 가장 깔끔한 패턴인 "필터 앞의 필터", ExceptionHandlerFilter를 구현해 보겠습니다.
Step 1. JwtTokenProvider 수정 (예외 던지기)
가장 먼저 할 일은 JwtTokenProvider가 예외를 삼키지 않고 밖으로 던지도록 수정하는 것입니다.
기존의 try-catch 블록을 제거하거나 수정해야 합니다.
- 위치:
src/main/java/com/example/board/jwt/JwtTokenProvider.java
// ... 기존 코드 ...
// 3. 토큰 유효성 검증 (수정됨)
// 기존에는 boolean을 반환했지만, 이제는 문제가 있으면 예외를 던집니다.
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
throw e; // ★ 다시 던짐
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
throw e; // ★ 다시 던짐
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
throw e; // ★ 다시 던짐
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
throw e; // ★ 다시 던짐
}
}Step 2. ExceptionHandlerFilter 만들기 (핵심)
JwtAuthenticationFilter보다 먼저 실행되면서, 뒤쪽 필터에서 발생하는 예외를 try-catch로 감싸 안아줄 필터입니다.
- 위치:
src/main/java/com/example/board/jwt/JwtExceptionFilter.java
package com.example.board.jwt;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// 다음 필터(JwtAuthenticationFilter)로 이동
filterChain.doFilter(request, response);
} catch (JwtException e) {
// JWT 관련 예외 발생 시 잡아서 처리
setErrorResponse(response, e);
}
}
private void setErrorResponse(HttpServletResponse response, JwtException e) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", e.getMessage()); // 예외 메시지 그대로 전달 (또는 커스텀 가능)
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
}
}Step 3. JwtAuthenticationFilter 확인하기
이 필터 코드를 수정할 필요는 없지만,Provider가 예외를 던지게 바뀌었으니, 이 필터도 코드를 잠깐 같이 봅시다.
이제 validateToken이 실패하면 false가 아니라 예외가 발생(throw)하므로, if문 구조를 바꿀 필요는 없지만 흐름을 이해해야 합니다.
- 위치:
src/main/java/com/example/board/jwt/JwtAuthenticationFilter.java
// ... 기존 코드 ...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
// ★ 여기서 예외가 발생하면 -> 앞단의 JwtExceptionFilter가 잡습니다!
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
Step 4. SecurityConfig에 필터 등록
이제 새로 만든 JwtExceptionFilter를 JwtAuthenticationFilter "앞에" 배치해야 합니다. 그래야 뒤에서 발생한 Exception을 앞에서 잡을 수 있으니까요.
- 위치:
src/main/java/com/example/board/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtExceptionFilter jwtExceptionFilter; // ★ 주입
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... (기존 설정들) ...
// [필터 순서 중요]
// ExceptionFilter -> JwtAuthFilter -> UsernamePasswordFilter 순서
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); // ★ 이것 추가!
return http.build();
}
}💡 전체 동작 흐름 (Sequence)
이 구조가 어떻게 동작하는지 그림으로 그려보면 아주 명확합니다.

🔍 다이어그램 해설
- 진입 (1~2번): 요청이 오면 가장 먼저 JwtExceptionFilter가 받습니다. 여기서 try 블록을 열고 다음 필터인 JwtAuthenticationFilter를 호출합니다.
- 검증 (3번): JwtAuthenticationFilter는 Provider에게 토큰 검사를 시킵니다.
- 예외 발생 (4~5번): Provider에서 throw를 던지면, JwtAuthenticationFilter는 별도의 예외 처리를 하지 않으므로 그대로 자신을 호출했던 JwtExceptionFilter로 예외가 튕겨 나갑니다.
- 예외 포착 (6번): 기다리고 있던 JwtExceptionFilter의 catch 블록이 이 예외를 딱 잡아서, 미리 준비해둔 JSON 에러 메시지를 만들어 클라이언트에게 보내줍니다.
이 구조 덕분에 컨트롤러나 다른 곳에서는 예외 처리를 신경 쓸 필요 없이, 깔끔하게 보안 관련 에러를 처리할 수 있게 됩니다.
Step 5. 테스트 결과
이제 만료된 토큰으로 API를 요청하면 다음과 같이 구체적인 에러 메시지가 나옵니다.
응답 (JSON):
{
"status": 401,
"error": "Unauthorized",
"message": "만료된 JWT 토큰입니다."
}
또는 서명을 위조해서 보내면:
{
"status": 401,
"error": "Unauthorized",
"message": "잘못된 JWT 서명입니다."
}
🎉 마무리
이제 여러분의 서버는:
- 일반적인 접근 권한 에러는
AuthenticationEntryPoint가 처리하고, - 토큰 자체의 문제(만료, 위조)는
JwtExceptionFilter가 구체적으로 처리하는
완벽한 2중 방어선을 갖추게 되었습니다.
프론트엔드 개발자가 "왜 에러가 났는지 모르겠어요"라고 물어볼 일이 확 줄어들겠죠? 😉
'IT > SpringBoot' 카테고리의 다른 글
| [Spring Security] 16편 - "3초 만에 가입" OAuth2 소셜 로그인 연동 (Google) (0) | 2026.02.25 |
|---|---|
| [Spring Security] 15편 - API 문서를 자동으로! Swagger (SpringDoc) 적용하기 (0) | 2026.02.25 |
| [Spring Security] 13편 - 예외 처리 (401/403 예쁘게 응답하기) (0) | 2026.02.24 |
| [Spring Security] 12편 - Redis 도입 & TTL로 토큰 자동 삭제하기 (0) | 2026.02.23 |
| [Spring Security] 11편 - 로그아웃은 싫어! 리프레시 토큰(Refresh Token) 완벽 구현하기 (1) | 2026.02.23 |
