어제 오늘 내일

[Spring Security] 13편 - 예외 처리 (401/403 예쁘게 응답하기) 본문

IT/SpringBoot

[Spring Security] 13편 - 예외 처리 (401/403 예쁘게 응답하기)

hi.anna 2026. 2. 24. 01:16

 

Spring Security를 사용할 때 가장 많이 겪는 혼란 중 하나가 "왜 @ControllerAdvice@ExceptionHandler가 작동하지 않지?" 입니다.

이유는 간단합니다.

필터(Filter)는 컨트롤러(Controller)보다 앞단에서 실행되기 때문입니다.

토큰 검증 단계에서 에러가 나면 컨트롤러까지 가지도 못하고 필터에서 튕겨 나가기 때문에, 우리가 평소에 쓰던 예외 처리 방식이 통하지 않습니다.

Spring Security에서는 이 문제를 해결하기 위해 AuthenticationEntryPoint (인증 실패)AccessDeniedHandler (권한 실패)라는 두 가지 인터페이스를 제공합니다.

 


1. 문제 상황

현재 상태에서:

  • 로그인 안 하고(토큰 없이) /members/test 접근 시 ➡️ 403 Forbidden (본문 없음)
  • 토큰이 만료되었거나 위조된 경우 ➡️ 서버 로그엔 에러가 찍히지만 클라이언트는 403 또는 500 (원인을 모름)

우리는 클라이언트에게 이렇게 명확한 JSON을 내려주고 싶습니다.

{
    "status": 401,
    "code": "UNAUTHORIZED",
    "message": "유효하지 않은 토큰입니다."
}

Step 1. 인증 실패 처리 (AuthenticationEntryPoint)

"로그인이 필요합니다" 또는 "토큰이 이상합니다" (401 Unauthorized) 상황을 처리합니다.

  • 위치: src/main/java/com/example/board/jwt/JwtAuthenticationEntryPoint.java
package com.example.board.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할 때 401
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        Map<String, Object> json = new HashMap<>();
        json.put("status", 401);
        json.put("code", "UNAUTHORIZED");
        json.put("message", "인증이 실패하였습니다. (로그인 필요)");
        // authException.getMessage()를 넣으면 시큐리티가 주는 기본 메시지가 나갑니다.

        response.getWriter().print(objectMapper.writeValueAsString(json));
    }
}

Step 2. 권한 실패 처리 (AccessDeniedHandler)

"로그인은 했는데, 관리자 페이지에 일반 유저가 접근하려 할 때" (403 Forbidden) 상황을 처리합니다.

  • 위치: src/main/java/com/example/board/jwt/JwtAccessDeniedHandler.java
package com.example.board.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할 때 403
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        Map<String, Object> json = new HashMap<>();
        json.put("status", 403);
        json.put("code", "ACCESS_DENIED");
        json.put("message", "접근 권한이 없습니다.");

        response.getWriter().print(objectMapper.writeValueAsString(json));
    }
}

Step 3. SecurityConfig에 등록

이제 만든 두 핸들러를 시큐리티 설정에 끼워 넣습니다. .exceptionHandling() 메서드를 사용합니다.

  • 위치: src/main/java/com/example/board/config/SecurityConfig.java
// ... import ...
import com.example.board.jwt.JwtAccessDeniedHandler; // 추가
import com.example.board.jwt.JwtAuthenticationEntryPoint; // 추가

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; // 주입
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;         // 주입

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // ... (기존 설정: csrf, session 등) ...
            .csrf((csrf) -> csrf.disable())
            .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // [예외 처리 설정 추가]
            .exceptionHandling((exception) -> exception
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 에러 핸들링
                .accessDeniedHandler(jwtAccessDeniedHandler)         // 403 에러 핸들링
            )

            .authorizeHttpRequests((auth) -> auth
                .requestMatchers("/members/login", "/members/join", "/members/reissue").permitAll()
                // 권한 테스트를 위해 USER만 접근 가능하게 설정
                .requestMatchers("/members/test").hasRole("USER") 
                .anyRequest().authenticated()
            )
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    // ...
}

Step 4. JwtAuthenticationFilter 수정 (중요!)

사실 EntryPoint만 등록한다고 해서 모든 JWT 에러가 잡히는 것은 아닙니다.
JwtAuthenticationFilter에서 validateToken이 실패했을 때,

그냥 return false만 하고 끝내버리면 시큐리티는 "어? 토큰 없네?" 하고 익명 사용자로 처리한 뒤 뒤늦게 401을 띄웁니다.

명확한 에러 처리를 위해 Filter 내부에서 예외가 발생하면 EntryPoint로 넘기지 않고 바로 응답을 주는 방법이나, request에 속성을 담아서 EntryPoint에서 구분하는 방법 등이 있습니다.

가장 간단한 방법은 필터 내부에서는 검증만 하고, 예외는 시큐리티 체인에 맡기는 것입니다. 우리가 작성했던 validateToken 코드는 로그만 찍고 false를 리턴하므로, 시큐리티는 이를 "인증되지 않은 사용자"로 간주하여 AuthenticationEntryPoint를 호출하게 됩니다.

따라서 Step 3까지만 적용해도 401 응답은 정상적으로 JSON으로 나갑니다.


💡 테스트

  1. 토큰 없이 /members/test 접근:
  • 결과: 401 Unauthorized
  • Body: {"status": 401, "code": "UNAUTHORIZED", "message": "인증이 실패하였습니다..."}
  1. 유효하지 않은 토큰(오타)으로 접근:
  • 결과: 401 Unauthorized (동일)
  1. 권한 부족 테스트:
  • 회원가입 시 role을 "GUEST"로 하고, /members/test (USER 권한 필요) 접근.
  • 결과: 403 Forbidden
  • Body: {"status": 403, "code": "ACCESS_DENIED", "message": "접근 권한이 없습니다."}

🎉 마무리

이제 여러분의 서버는 에러가 발생했을 때 불친절한 흰색 화면 대신, 프론트엔드 개발자가 좋아하는 깔끔한 JSON을 내려주게 되었습니다.

실무에서는 만료된 토큰(ExpiredJwtException)인지, 잘못된 토큰(SignatureException)인지 구분해서 내려줘야 프론트엔드가 "로그인을 다시 하세요"라고 할지, "토큰 재발급을 하세요"라고 할지 결정할 수 있습니다.

이 부분은 JwtAuthenticationFilter 앞에 ExceptionHandlerFilter라는 커스텀 필터를 하나 더 둬서 try-catch로 감싸 처리하는 방식이 많이 사용됩니다. 

반응형
Comments