Let's Git It 프로젝트 코드 리뷰에서 발견된 인증 실패 응답 형식 불일치 문제와 해결 방법을 정리합니다.
PR 리뷰에서 아래 코드에 대한 피드백을 받았다.
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
피드백 내용
HttpStatusEntryPoint를 사용하면 인증 실패 시 401 상태 코드만 내려가고 ErrorResponse 바디는 생성되지 않습니다. 클라이언트가 다른 인증 실패 응답과 동일한 형식으로 처리하기 어렵습니다.
보호된 API에 토큰 없이 접근하거나 유효하지 않은 토큰으로 접근하면 Spring Security가 AuthenticationEntryPoint를 호출한다.
토큰 없이 /api/v1/auth/logout 호출
↓
JwtAuthenticationFilter → SecurityContext 비어있음
↓
Spring Security → AuthenticationEntryPoint 호출
↓
HttpStatusEntryPoint → 401 상태 코드만 반환 (바디 없음)
프로젝트의 다른 에러 응답은 전부 이 형식이다:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "로그인이 필요한 서비스입니다.",
"errors": []
}
근데 HttpStatusEntryPoint는 바디 없이 상태 코드만 내려준다:
HTTP/1.1 401 Unauthorized
(바디 없음)
클라이언트 입장에서 같은 401인데 응답 형식이 달라서 일관된 에러 처리가 어렵다.
AuthenticationEntryPoint 인터페이스를 직접 구현해서 ErrorResponse 형식으로 응답을 내려준다.
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;
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.debug("인증 실패: {}", request.getRequestURI());
// 공통 ErrorResponse 형식으로 응답
ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.AUTHENTICATION_REQUIRED);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=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 CustomAuthenticationEntryPoint customAuthenticationEntryPoint; // 추가
@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 → CustomAuthenticationEntryPoint로 변경
.exceptionHandling(ex -> ex
.authenticationEntryPoint(customAuthenticationEntryPoint))
.authorizeHttpRequests(auth -> auth
.requestMatchers(/* 퍼블릭 경로 */).permitAll()
.anyRequest().authenticated())
.addFilterBefore(
new JwtAuthenticationFilter(jwtProvider, userDetailsService, authRedisRepository),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
변경 전 — 토큰 없이 보호 API 호출 시:
HTTP/1.1 401 Unauthorized
(바디 없음)
변경 후 — 토큰 없이 보호 API 호출 시:
{
"status": 401,
"code": "AUTHENTICATION_REQUIRED",
"message": "로그인이 필요한 서비스입니다.",
"errors": []
}
이번 작업을 하면서 401과 403의 차이를 명확히 정리했다.
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| 의미 | 인증되지 않음 | 인증은 됐지만 권한 없음 |
| 예시 | 토큰 없이 접근 | 일반 유저가 관리자 API 접근 |
| 해결 방법 | 로그인 필요 | 권한 필요 |
Spring Security는 기본적으로 인증 실패 시 403을 반환하는 경우가 있다. 이를 방지하기 위해 AuthenticationEntryPoint를 명시적으로 등록해 401을 보장한다.
1. 보호된 API에 토큰 없이 접근
2. 만료된 토큰으로 접근 (필터에서 SecurityContext 미등록)
3. 형식이 잘못된 토큰으로 접근
필터(JwtAuthenticationFilter)에서 토큰 검증에 실패하면 SecurityContext에 인증 정보가 등록되지 않는다. 이후 Spring Security가 인증이 필요한 경로임을 확인하고 AuthenticationEntryPoint를 호출한다.
Before: HttpStatusEntryPoint → 401 상태 코드만 (바디 없음)
After: CustomAuthenticationEntryPoint → 401 + ErrorResponse 바디 (공통 형식)
작은 변경이지만 클라이언트 입장에서 에러 응답 형식이 통일되어 일관된 에러 처리가 가능해진다.