Exception Handling — 인증·인가 실패 시 어떤 일이 벌어질까
인증되지 않은 사용자가 보호된 API에 접근하면 로그인 페이지로 이동하지만, REST API에서는 JSON 에러를 반환해야 합니다. 이 차이는 어떻게 처리할까요?
Spring Security에서 인증·인가 실패 시의 처리는 ExceptionTranslationFilter가 담당합니다. 이 필터의 동작을 이해하면 401과 403 응답을 원하는 형태로 커스터마이징할 수 있습니다.
개념 정의
ExceptionTranslationFilter 는 보안 예외를 HTTP 응답으로 변환하는 필터입니다. 인증 실패는 AuthenticationEntryPoint(401)에, 인가 실패는 AccessDeniedHandler(403)에 위임합니다.
ExceptionTranslationFilter 동작 흐름
요청 → ... → ExceptionTranslationFilter → AuthorizationFilter
│ │
│ AccessDeniedException 발생
│ │
├── 인증 안 됨 → AuthenticationEntryPoint (401)
└── 인증 됨, 권한 없음 → AccessDeniedHandler (403)
ExceptionTranslationFilter의 동작을 의사 코드로 표현하면 다음과 같습니다.
// 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 에러 응답이 필요하므로 커스터마이징합니다.
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
commence() 메서드에서 직접 JSON 응답을 작성합니다. 필터 레벨에서 동작하므로 @ControllerAdvice로는 처리할 수 없습니다.
@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
public record ErrorResponse(
String code,
String message,
String path
) {}
커스텀 AccessDeniedHandler
인증된 사용자가 권한 없는 리소스에 접근할 때 403 응답을 JSON으로 반환합니다.
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
public CustomAccessDeniedHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
handle() 메서드에서 403 응답을 JSON으로 반환하여 REST API에 적합한 에러 응답을 제공합니다.
@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()으로 등록합니다.
@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로 처리해야 합니다.
@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", "유효하지 않은 토큰입니다");
}
}
에러 응답을 보내는 유틸 메서드는 다음과 같습니다.
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 정리
| 상태 코드 | 의미 | 처리 컴포넌트 | 상황 |
|---|---|---|---|
| 401 | Unauthorized | AuthenticationEntryPoint | 인증 안 됨 (로그인 필요) |
| 403 | Forbidden | AccessDeniedHandler | 인증 됨, 권한 부족 |
401은 "누구세요?"(인증 실패), 403은 "당신은 여기 출입 금지입니다"(인가 실패)로 구분합니다.
주의할 점
@ControllerAdvice로 보안 예외를 처리하려는 실수
@ExceptionHandler로 AccessDeniedException을 잡으려고 하면 동작하지 않습니다. 보안 예외는 ** 컨트롤러에 도달하기 전** 필터 레벨에서 발생하기 때문입니다. 반드시 AuthenticationEntryPoint와 AccessDeniedHandler를 구현해야 합니다.
커스텀 필터 예외가 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로 처리 불가 |