Spring 7기 프로젝트/코드 개선 + 테스트 코드 프로젝트

[트러블슈팅] JWT 예외 처리 시스템 구축

JuNo_12 2025. 6. 6. 19:24

전 편과 이어집니다. 전 편을 읽어보신 뒤에 이 글을 읽으시면 이해가 더 잘되실겁니다!


기존 예외 처리의 한계

기존 순수 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. "로깅은 운영의 눈이다"

단순히 예외만 처리하는 것이 아니라, 운영 단계에서 빠른 장애 대응이 가능하도록 적절한 로그 레벨과 메시지를 설계해야 합니다.