[Let's Git It] CustomAuthenticationEntryPoint — 인증 실패 응답 형식 통일

dobby·2026년 5월 3일

Let's git it BE

목록 보기
13/20

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인데 응답 형식이 달라서 일관된 에러 처리가 어렵다.


해결 방법 — CustomAuthenticationEntryPoint 구현

AuthenticationEntryPoint 인터페이스를 직접 구현해서 ErrorResponse 형식으로 응답을 내려준다.

CustomAuthenticationEntryPoint.java

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));
    }
}

SecurityConfig.java 수정

@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();
    }
}

변경 전 vs 변경 후

변경 전 — 토큰 없이 보호 API 호출 시:

HTTP/1.1 401 Unauthorized
(바디 없음)

변경 후 — 토큰 없이 보호 API 호출 시:

{
  "status": 401,
  "code": "AUTHENTICATION_REQUIRED",
  "message": "로그인이 필요한 서비스입니다.",
  "errors": []
}

401 vs 403 차이

이번 작업을 하면서 401과 403의 차이를 명확히 정리했다.

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

Spring Security는 기본적으로 인증 실패 시 403을 반환하는 경우가 있다. 이를 방지하기 위해 AuthenticationEntryPoint를 명시적으로 등록해 401을 보장한다.


AuthenticationEntryPoint가 호출되는 시점

1. 보호된 API에 토큰 없이 접근
2. 만료된 토큰으로 접근 (필터에서 SecurityContext 미등록)
3. 형식이 잘못된 토큰으로 접근

필터(JwtAuthenticationFilter)에서 토큰 검증에 실패하면 SecurityContext에 인증 정보가 등록되지 않는다. 이후 Spring Security가 인증이 필요한 경로임을 확인하고 AuthenticationEntryPoint를 호출한다.


핵심 정리

Before: HttpStatusEntryPoint → 401 상태 코드만 (바디 없음)
After:  CustomAuthenticationEntryPoint → 401 + ErrorResponse 바디 (공통 형식)

작은 변경이지만 클라이언트 입장에서 에러 응답 형식이 통일되어 일관된 에러 처리가 가능해진다.

profile
느리게 한걸음

0개의 댓글