Let's Git It 프로젝트 코드 리뷰에서 발견된 401/403 응답 형식 불일치 문제와 해결 방법을 정리합니다.
PR 리뷰에서 아래 코드에 대한 피드백을 받았다.
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
피드백 내용
인증은 되었지만 접근 권한이 없는 경우에는 Spring Security 기본 403 응답이 내려갈 수 있어, 이 경우도 ErrorResponse 형식과 달라질 수 있습니다. 401과 403은 성격이 다르므로 각각 분리해서 처리하면 좋겠습니다.
Spring Security에서 인증/인가 실패는 두 가지로 나뉜다.
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| 의미 | 인증되지 않음 | 인증은 됐지만 권한 없음 |
| 예시 | 토큰 없이 접근 | 일반 유저가 관리자 API 접근 |
| 처리 클래스 | AuthenticationEntryPoint | AccessDeniedHandler |
| 해결 방법 | 로그인 필요 | 권한 필요 |
두 가지 실패 상황이 완전히 다른 클래스에서 처리된다.
HTTP 요청
↓
JwtAuthenticationFilter
↓
Spring Security 인가 처리
├── 인증 안 됨 (토큰 없음/유효하지 않음)
│ ↓
│ AuthenticationEntryPoint → 401
│
└── 인증은 됐지만 권한 없음
↓
AccessDeniedHandler → 403
기존 코드는 AuthenticationEntryPoint만 커스텀하고 AccessDeniedHandler는 Spring Security 기본값을 사용했다. 기본 AccessDeniedHandler는 바디 없이 403 상태 코드만 반환한다.
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));
}
}
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));
}
}
@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은 성격이 다른 에러다.
각각 AuthenticationEntryPoint와 AccessDeniedHandler로 분리해서 처리하고,
둘 다 ObjectMapper + ErrorResponse로 공통 형식을 보장한다.