[Let's Git It] CustomAccessDeniedHandler — 401과 403 응답 형식 통일

dobby·2026년 5월 3일

Let's git it BE

목록 보기
16/20

Let's Git It 프로젝트 코드 리뷰에서 발견된 401/403 응답 형식 불일치 문제와 해결 방법을 정리합니다.


문제 발견

PR 리뷰에서 아래 코드에 대한 피드백을 받았다.

.exceptionHandling(ex -> ex
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))

피드백 내용

인증은 되었지만 접근 권한이 없는 경우에는 Spring Security 기본 403 응답이 내려갈 수 있어, 이 경우도 ErrorResponse 형식과 달라질 수 있습니다. 401과 403은 성격이 다르므로 각각 분리해서 처리하면 좋겠습니다.


401 vs 403 차이

Spring Security에서 인증/인가 실패는 두 가지로 나뉜다.

401 Unauthorized403 Forbidden
의미인증되지 않음인증은 됐지만 권한 없음
예시토큰 없이 접근일반 유저가 관리자 API 접근
처리 클래스AuthenticationEntryPointAccessDeniedHandler
해결 방법로그인 필요권한 필요

두 가지 실패 상황이 완전히 다른 클래스에서 처리된다.


Spring Security 예외 처리 흐름

HTTP 요청
    ↓
JwtAuthenticationFilter
    ↓
Spring Security 인가 처리
    ├── 인증 안 됨 (토큰 없음/유효하지 않음)
    │       ↓
    │   AuthenticationEntryPoint → 401
    │
    └── 인증은 됐지만 권한 없음
            ↓
        AccessDeniedHandler → 403

기존 코드는 AuthenticationEntryPoint만 커스텀하고 AccessDeniedHandler는 Spring Security 기본값을 사용했다. 기본 AccessDeniedHandler는 바디 없이 403 상태 코드만 반환한다.


해결 방법

CustomAuthenticationEntryPoint.java — 401 처리

package com.gitcat.letsgitit.global.security;

import java.io.IOException;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gitcat.letsgitit.global.exception.ErrorCode;
import com.gitcat.letsgitit.global.exception.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    // 인증 실패 (토큰 없음, 유효하지 않은 토큰) → 401
    @Override
    public void commence(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException authException) throws IOException {

        log.debug("인증 실패: {}", request.getRequestURI());

        ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.AUTHENTICATION_REQUIRED);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }
}

CustomAccessDeniedHandler.java — 403 처리 (신규)

package com.gitcat.letsgitit.global.security;

import java.io.IOException;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gitcat.letsgitit.global.exception.ErrorCode;
import com.gitcat.letsgitit.global.exception.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    // 인가 실패 (인증은 됐지만 권한 없음) → 403
    @Override
    public void handle(
        HttpServletRequest request,
        HttpServletResponse response,
        AccessDeniedException accessDeniedException) throws IOException {

        log.debug("권한 없음: {}", request.getRequestURI());

        ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.ACCESS_DENIED);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }
}

SecurityConfig.java 수정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;
    private final CustomUserDetailsService userDetailsService;
    private final AuthRedisRepository authRedisRepository;
    private final ObjectMapper objectMapper;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; // 추가
    private final CustomAccessDeniedHandler customAccessDeniedHandler;           // 추가

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .logout(AbstractHttpConfigurer::disable)

            // 기존 HttpStatusEntryPoint → 커스텀 핸들러로 변경
            // 401(인증 실패)과 403(권한 없음)을 각각 공통 ErrorResponse 형식으로 처리
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler))

            .authorizeHttpRequests(auth -> auth
                .requestMatchers(/* 퍼블릭 경로 */).permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(
                new JwtAuthenticationFilter(
                    jwtProvider, userDetailsService, authRedisRepository, objectMapper),
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

응답 형식 비교

변경 전 — 권한 없음 (403)

HTTP/1.1 403 Forbidden
(바디 없음)

변경 후 — 권한 없음 (403)

{
  "status": 403,
  "code": "ACCESS_DENIED",
  "message": "접근 권한이 없습니다.",
  "errors": []
}

전체 인증/인가 실패 흐름 정리

HTTP 요청
    ↓
JwtAuthenticationFilter
    ├── 블랙리스트 토큰 → sendUnauthorized(TOKEN_BLACKLISTED) → 401
    ├── 동시접속 차단  → sendUnauthorized(INVALID_TOKEN) → 401
    └── 정상 → SecurityContext 등록
    ↓
Spring Security 인가 처리
    ├── 인증 안 됨
    │   → CustomAuthenticationEntryPoint
    │   → ErrorResponse.of(AUTHENTICATION_REQUIRED) → 401
    │
    └── 인증 됐지만 권한 없음
        → CustomAccessDeniedHandler
        → ErrorResponse.of(ACCESS_DENIED) → 403

모든 인증/인가 실패 응답이 공통 ErrorResponse 형식으로 통일된다.


파일 구조

global/
└── security/
    ├── CustomAuthenticationEntryPoint.java  ← 401 처리
    └── CustomAccessDeniedHandler.java       ← 403 처리 (신규)

핵심 정리

Before: AccessDeniedHandler 기본값 → 403 바디 없음
After:  CustomAccessDeniedHandler → 403 + ErrorResponse 공통 형식

401과 403은 성격이 다른 에러다.
각각 AuthenticationEntryPointAccessDeniedHandler로 분리해서 처리하고,
둘 다 ObjectMapper + ErrorResponse로 공통 형식을 보장한다.

profile
느리게 한걸음

0개의 댓글