인증되지 않은 사용자가 보호된 API에 접근하면 로그인 페이지로 이동하지만, REST API에서는 JSON 에러를 반환해야 합니다. 이 차이는 어떻게 처리할까요?

Spring Security에서 인증·인가 실패 시의 처리는 ExceptionTranslationFilter가 담당합니다. 이 필터의 동작을 이해하면 401과 403 응답을 원하는 형태로 커스터마이징할 수 있습니다.

개념 정의

ExceptionTranslationFilter 는 보안 예외를 HTTP 응답으로 변환하는 필터입니다. 인증 실패는 AuthenticationEntryPoint(401)에, 인가 실패는 AccessDeniedHandler(403)에 위임합니다.

ExceptionTranslationFilter 동작 흐름

PLAINTEXT
요청 → ... → ExceptionTranslationFilter → AuthorizationFilter
                    │                              │
                    │                    AccessDeniedException 발생
                    │                              │
                    ├── 인증 안 됨 → AuthenticationEntryPoint (401)
                    └── 인증 됨, 권한 없음 → AccessDeniedHandler (403)

ExceptionTranslationFilter의 동작을 의사 코드로 표현하면 다음과 같습니다.

JAVA
// ExceptionTranslationFilter 핵심 동작 (개념적 코드)
try {
    chain.doFilter(req, res);  // 다음 필터(AuthorizationFilter 등) 실행
} catch (AuthenticationException ex) {
    authenticationEntryPoint.commence(request, response, ex);  // → 401
} catch (AccessDeniedException ex) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || auth instanceof AnonymousAuthenticationToken) {
        authenticationEntryPoint.commence(request, response, ...);  // → 401
    } else {
        accessDeniedHandler.handle(request, response, ex);          // → 403
    }
}

여기서 핵심은 AccessDeniedException이 발생했을 때의 분기입니다. 인증 자체가 안 된 상태(anonymous)라면 401을, 인증은 됐지만 권한이 부족하면 403을 반환합니다. 이 구분이 있기 때문에 ** 같은 예외라도 인증 상태에 따라 다른 응답 **이 나옵니다.

커스텀 AuthenticationEntryPoint

REST API용 JSON 응답

기본 AuthenticationEntryPoint/login 페이지로 리다이렉트합니다. REST API에서는 JSON 에러 응답이 필요하므로 커스터마이징합니다.

JAVA
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

commence() 메서드에서 직접 JSON 응답을 작성합니다. 필터 레벨에서 동작하므로 @ControllerAdvice로는 처리할 수 없습니다.

JAVA
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        ErrorResponse errorResponse = new ErrorResponse(
            "UNAUTHORIZED", "인증이 필요합니다", request.getRequestURI());
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}

AuthenticationEntryPoint는 필터 레벨에서 동작하기 때문에 @ExceptionHandler@ControllerAdvice로는 처리할 수 없습니다. 반드시 이 인터페이스를 구현해야 합니다.

ErrorResponse DTO

JAVA
public record ErrorResponse(
    String code,
    String message,
    String path
) {}

커스텀 AccessDeniedHandler

인증된 사용자가 권한 없는 리소스에 접근할 때 403 응답을 JSON으로 반환합니다.

JAVA
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    public CustomAccessDeniedHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

handle() 메서드에서 403 응답을 JSON으로 반환하여 REST API에 적합한 에러 응답을 제공합니다.

JAVA
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        ErrorResponse errorResponse = new ErrorResponse(
            "FORBIDDEN", "접근 권한이 없습니다", request.getRequestURI());
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}

SecurityConfig에 등록

커스텀 핸들러를 exceptionHandling()으로 등록합니다.

JAVA
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .exceptionHandling(exception -> exception
            .authenticationEntryPoint(authEntryPoint)      // 401 처리
            .accessDeniedHandler(accessDeniedHandler)      // 403 처리
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .build();
}

이 설정으로 인증 실패 시 CustomAuthenticationEntryPoint가, 인가 실패 시 CustomAccessDeniedHandler가 호출됩니다.

JWT 인증 실패 처리

JWT 필터는 ExceptionTranslationFilter보다 ** 앞에서 실행 **됩니다. 이 순서 때문에 JWT 필터에서 발생한 예외는 Security의 예외 처리 메커니즘을 거치지 않습니다. 따라서 필터 내부에서 직접 try-catch로 처리해야 합니다.

JAVA
@Override
protected void doFilterInternal(HttpServletRequest request,
                                 HttpServletResponse response,
                                 FilterChain filterChain) throws ServletException, IOException {
    try {
        String token = extractToken(request);
        if (token != null && jwtProvider.validateToken(token)) {
            setAuthentication(token);
        }
        filterChain.doFilter(request, response);
    } catch (ExpiredJwtException e) {
        sendErrorResponse(response, SC_UNAUTHORIZED, "TOKEN_EXPIRED", "토큰이 만료되었습니다");
    } catch (MalformedJwtException e) {
        sendErrorResponse(response, SC_UNAUTHORIZED, "INVALID_TOKEN", "유효하지 않은 토큰입니다");
    }
}

에러 응답을 보내는 유틸 메서드는 다음과 같습니다.

JAVA
private void sendErrorResponse(HttpServletResponse response, int status,
                                String code, String message) throws IOException {
    response.setStatus(status);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    String json = String.format("{\"code\":\"%s\",\"message\":\"%s\"}", code, message);
    response.getWriter().write(json);
}

이 패턴을 모르면 JWT 만료 시 클라이언트에 500 에러나 빈 응답이 반환되어 디버깅이 어렵습니다.

401 vs 403 정리

상태 코드의미처리 컴포넌트상황
401UnauthorizedAuthenticationEntryPoint인증 안 됨 (로그인 필요)
403ForbiddenAccessDeniedHandler인증 됨, 권한 부족

401은 "누구세요?"(인증 실패), 403은 "당신은 여기 출입 금지입니다"(인가 실패)로 구분합니다.

주의할 점

@ControllerAdvice로 보안 예외를 처리하려는 실수

@ExceptionHandlerAccessDeniedException을 잡으려고 하면 동작하지 않습니다. 보안 예외는 ** 컨트롤러에 도달하기 전** 필터 레벨에서 발생하기 때문입니다. 반드시 AuthenticationEntryPointAccessDeniedHandler를 구현해야 합니다.

커스텀 필터 예외가 ExceptionTranslationFilter에 도달하지 않는 문제

UsernamePasswordAuthenticationFilter 앞에 배치한 JWT 필터에서 발생한 예외는 ExceptionTranslationFilter의 try-catch 범위 ** 밖 **입니다. 커스텀 AuthenticationEntryPoint를 등록했는데도 JWT 만료 시 호출되지 않는 이유가 바로 이것입니다.

익명 사용자(AnonymousAuthenticationToken)와 미인증의 혼동

Spring Security는 인증되지 않은 사용자에게 AnonymousAuthenticationToken을 부여합니다. 따라서 SecurityContextHolder.getContext().getAuthentication()null이 아니라 AnonymousAuthenticationToken을 반환할 수 있습니다. AccessDeniedException 발생 시 이것을 체크하지 않으면 미인증 사용자에게 401 대신 403을 반환하는 버그가 생깁니다.

정리

항목설명
예외 처리 필터ExceptionTranslationFilter 가 보안 예외를 포착하여 핸들러에 위임
401 처리AuthenticationEntryPoint — 인증되지 않은 접근
403 처리AccessDeniedHandler — 인증 됨, 권한 부족
REST API두 핸들러를 커스터마이징하여 JSON 에러 응답 반환
JWT 필터 예외ExceptionTranslationFilter 앞에서 발생하므로 ** 필터 내부에서 직접 처리**
@ControllerAdvice보안 예외는 필터 레벨이므로 @ExceptionHandler로 처리 불가
댓글 로딩 중...