전 편과 이어집니다. 전 편을 읽어보신 뒤에 이 글을 읽으시면 이해가 더 잘되실겁니다!
기존 예외 처리의 한계
기존 순수 Filter 방식에서는 예외 처리가 일관되지 않았습니다.
// AS-IS: 산발적인 예외 처리
public class JwtFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
try {
// JWT 검증 로직
} catch (Exception e) {
// 어떤 예외든 500 에러로...
response.setStatus(500);
response.getWriter().write("Internal Server Error");
}
}
}
문제점:
- JWT 만료, 서명 오류, 형식 오류 등 세밀한 예외 구분 불가능
- 클라이언트가 정확한 오류 원인을 알 수 없음
- 일관되지 않은 에러 응답 형식
예외 처리 설계 원칙
"예외가 발생하는 레벨에 따라 처리 방식을 달리하자"
1. 레벨별 예외 분류
// Spring Security Filter Level (컨트롤러 이전)
JWT 토큰 없음/유효하지 않음 → 401 Unauthorized
권한 부족 → 403 Forbidden
// Controller Level (비즈니스 로직)
잘못된 요청 데이터 → 400 Bad Request
비즈니스 규칙 위반 → 409 Conflict
서버 내부 오류 → 500 Internal Server Error
2. Spring Security vs GlobalExceptionHandler
핵심 인사이트: "필터 예외와 컨트롤러 예외는 다른 차원"
graph TD
A[HTTP Request] --> B[JWT Filter]
B --> C{JWT 유효?}
C -->|No| D[AuthenticationEntryPoint<br/>401 응답]
C -->|Yes| E[Controller]
E --> F{권한 충족?}
F -->|No| G[AccessDeniedHandler<br/>403 응답]
F -->|Yes| H[Business Logic]
H --> I{예외 발생?}
I -->|Yes| J[GlobalExceptionHandler<br/>400/500 응답]
I -->|No| K[정상 응답]
Step 1: JWT 예외 세분화
문제 상황: JWT 관련 다양한 예외를 단순히 "인증 실패"로 뭉뚱그리면 디버깅이 어려움
해결책: Spring Security 표준 예외 활용
@Component
public class JwtAuthenticationProvider {
public Authentication getAuthentication(HttpServletRequest request) {
String token = extractTokenFromRequest(request);
if (!StringUtils.hasText(token)) {
throw new BadCredentialsException("JWT 토큰이 없습니다.");
}
return createAuthentication(token);
}
private Authentication createAuthentication(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
// UserPrincipal 생성 및 Authentication 반환
} catch (ExpiredJwtException e) {
throw new CredentialsExpiredException("JWT 토큰이 만료되었습니다.");
} catch (SignatureException | MalformedJwtException | UnsupportedJwtException e) {
throw new BadCredentialsException("유효하지 않은 JWT 토큰입니다.");
} catch (NumberFormatException e) {
throw new BadCredentialsException("JWT 토큰의 사용자 ID가 올바르지 않습니다.");
} catch (IllegalArgumentException e) {
throw new BadCredentialsException("JWT 토큰 정보가 올바르지 않습니다.");
}
}
}
개선 효과:
- JWT 라이브러리의 세밀한 예외를 Spring Security 표준 예외로 변환
- 만료, 서명 오류, 형식 오류 등 구체적인 오류 메시지 제공
- Spring Security가 자동으로 적절한 HTTP 상태 코드 설정
Step 2: Filter 예외 처리 간소화
기존 방식의 문제:
// AS-IS: 필터에서 직접 응답 처리 (복잡함)
try {
// JWT 검증
} catch (JwtException e) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
return; // 필터 체인 중단
}
개선된 방식:
// TO-BE: Spring Security에 위임 (간단함)
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
Authentication authentication = jwtProvider.getAuthentication(request);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (AuthenticationException e) {
// Spring Security가 알아서 EntryPoint로 처리
log.debug("JWT 인증 실패: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response); // 항상 계속 진행
}
}
핵심 변화:
- 예외 발생해도 필터 체인 중단하지 않음
- Spring Security가 나중에 "인증되지 않은 요청"으로 판단하여 EntryPoint 호출
- 필터는 인증 설정에만 집중, 응답 처리는 EntryPoint에 위임
Step 3: 인증/인가 전용 예외 핸들러
AuthenticationEntryPoint (401 처리)
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.warn("인증 실패 - URI: {}, 메시지: {}", request.getRequestURI(), authException.getMessage());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", HttpStatus.UNAUTHORIZED.name());
errorResponse.put("code", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("message", authException.getMessage()); // 세밀한 에러 메시지
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
}
}
AccessDeniedHandler (403 처리)
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
log.warn("접근 권한 부족 - URI: {}, 사용자: {}",
request.getRequestURI(),
request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : "unknown");
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", HttpStatus.FORBIDDEN.name());
errorResponse.put("code", HttpStatus.FORBIDDEN.value());
errorResponse.put("message", "접근 권한이 없습니다.");
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
}
}
Step 4: 계층별 예외 처리 완성
SecurityConfig에 예외 핸들러 등록
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
.accessDeniedHandler(jwtAccessDeniedHandler) // 403
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
GlobalExceptionHandler는 비즈니스 로직 전담
// domain/common/exception 패키지로 이동
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvalidRequestException.class)
public ResponseEntity<Map<String, Object>> handleInvalidRequest(InvalidRequestException ex) {
return getErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(AuthException.class) // 로그인/회원가입 실패
public ResponseEntity<Map<String, Object>> handleAuthException(AuthException ex) {
return getErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage());
}
// JWT 예외는 이미 EntryPoint에서 처리되므로 여기서는 제외
}
최종 예외 처리 구조
// 📊 예외 처리 매트릭스
┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ 예외 유형 │ HTTP 코드 │ 처리 위치 │ 응답 형식 │
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ JWT 토큰 없음 │ 401 (인증) │ EntryPoint │ JSON │
│ JWT 토큰 만료 │ 401 (인증) │ EntryPoint │ JSON │
│ JWT 서명 오류 │ 401 (인증) │ EntryPoint │ JSON │
│ 권한 부족 │ 403 (인가) │ AccessDenied │ JSON │
│ 잘못된 요청 │ 400 (요청) │ GlobalHandler │ JSON │
│ 로그인 실패 │ 401 (인증) │ GlobalHandler │ JSON │
│ 서버 오류 │ 500 (서버) │ GlobalHandler │ JSON │
└─────────────────┴─────────────────┴─────────────────┴─────────────────┘
예외 처리 개선 효과
1. 클라이언트 친화적 에러 메시지
// JWT 만료 시
{
"status": "UNAUTHORIZED",
"code": 401,
"message": "JWT 토큰이 만료되었습니다."
}
// 권한 부족 시
{
"status": "FORBIDDEN",
"code": 403,
"message": "접근 권한이 없습니다."
}
// 비즈니스 로직 오류 시
{
"status": "BAD_REQUEST",
"code": 400,
"message": "Todo not found"
}
2. 개발자 친화적 로깅
# 인증 실패 로그
WARN : 인증 실패 - URI: /admin/users/1, 메시지: JWT 토큰이 만료되었습니다.
# 권한 부족 로그
WARN : 접근 권한 부족 - URI: /admin/comments/1, 사용자: user@example.com
# 디버그 로그
DEBUG: JWT 인증 성공: user@example.com
3. 운영 환경 안정성
- 세밀한 예외 분류로 정확한 장애 대응 가능
- 일관된 응답 형식으로 클라이언트 오류 처리 단순화
- 적절한 로깅 레벨로 성능 영향 최소화
예외 처리 핵심 인사이트
1. "예외 발생 지점과 처리 지점을 분리하라"
// ❌ 잘못된 방식: 필터에서 모든 예외 직접 처리
if (jwtExpired) {
response.setStatus(401);
response.getWriter().write("Token expired");
return;
}
// ✅ 올바른 방식: 예외 던지고 전문 핸들러가 처리
throw new CredentialsExpiredException("JWT 토큰이 만료되었습니다.");
2. "Spring Security 표준을 따르면 확장성이 보장된다"
표준 예외 체계를 따르면 나중에 OAuth2, SAML 등 다른 인증 방식 추가 시에도 동일한 예외 처리 구조를 재사용할 수 있습니다.
3. "로깅은 운영의 눈이다"
단순히 예외만 처리하는 것이 아니라, 운영 단계에서 빠른 장애 대응이 가능하도록 적절한 로그 레벨과 메시지를 설계해야 합니다.
'Spring 7기 프로젝트 > 코드 개선 + 테스트 코드 프로젝트' 카테고리의 다른 글
| [트러블슈팅] Spring Boot JWT 인증 시스템의 보안 업그레이드 (JWT 토큰 블랙리스트 + Rate Limiting 시스템) (0) | 2025.06.08 |
|---|---|
| [트러블슈팅] JWT + RefreshToken 구현 중 만난 5가지 트러블슈팅 (6) | 2025.06.08 |
| [트러블슈팅] Spring Boot JWT 인증 시스템 : 나만의 설계 기준을 잡아보자. (0) | 2025.06.06 |
| [트러블슈팅] Spring Boot 어드민 API 로깅 시스템 구현 - 인터셉터와 AOP (0) | 2025.06.05 |
| [트러블슈팅] Spring JPA에서 연관관계 null 체크의 중요성 (0) | 2025.06.05 |