[Let's Git It] JWT 필터 인증 실패 응답 형식 통일 — ObjectMapper + ErrorResponse

dobby·2026년 5월 3일

Let's git it BE

목록 보기
14/20

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


문제 발견

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

private void sendUnauthorized(HttpServletResponse response, String code, String message)
    throws IOException {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setContentType("application/json;charset=UTF-8");
    response.getWriter().write(
        "{\"status\": 401, \"code\": \"%s\", \"message\": \"%s\", \"errors\": []}"
            .formatted(code, message)
    );
}

피드백 내용

JwtAuthenticationFilter에서 인증 실패 응답을 직접 문자열로 작성하고 있습니다. GlobalExceptionHandler도 타지 않고 AuthenticationEntryPoint도 타지 않기 때문에, 이 응답만 ErrorResponse 생성 규칙에서 벗어나 있습니다. 응답 필드가 변경될 때 필터 코드도 따로 수정해야 하고, 다른 인증 실패 응답과 형식이 달라질 위험이 있습니다.


왜 이런 문제가 생겼나

HTTP 필터의 위치

HTTP 요청
    ↓
JwtAuthenticationFilter   ← Spring Security 필터 체인
    ↓
DispatcherServlet
    ↓
Controller
    ↓
GlobalExceptionHandler    ← @RestControllerAdvice

GlobalExceptionHandlerDispatcherServlet 안에서 동작한다. 반면 JwtAuthenticationFilter는 그 바깥에 있다.

즉, 필터에서 발생하는 예외나 응답은 GlobalExceptionHandler가 잡을 수 없다. 필터에서 직접 response를 작성해야 한다.

문제점

필터에서 직접 JSON 문자열을 작성하는 방식의 문제:

// ❌ 문자열 직접 작성
response.getWriter().write(
    "{\"status\": 401, \"code\": \"%s\", \"message\": \"%s\", \"errors\": []}"
        .formatted(code, message)
);
  1. ErrorResponse 필드가 추가/변경되면 필터도 따로 수정해야 함
  2. 오타나 형식 실수가 생겨도 컴파일 단계에서 못 잡음
  3. 다른 에러 응답과 형식이 달라질 위험이 있음

해결 방법 — ObjectMapper + ErrorResponse

ErrorResponse.of(errorCode)로 공통 응답 객체를 생성하고, ObjectMapper로 직렬화한다.

변경된 JwtAuthenticationFilter.java

package com.gitcat.letsgitit.global.jwt;

import java.io.IOException;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gitcat.letsgitit.domain.auth.repository.AuthRedisRepository;
import com.gitcat.letsgitit.domain.member.model.CustomUserDetails;
import com.gitcat.letsgitit.global.exception.ErrorCode;
import com.gitcat.letsgitit.global.exception.ErrorResponse;

import io.jsonwebtoken.ExpiredJwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final UserDetailsService userDetailsService;
    private final AuthRedisRepository authRedisRepository;
    private final ObjectMapper objectMapper; // 추가

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null) {
            try {
                if (jwtProvider.validateToken(token)) {

                    // 1. 블랙리스트 체크 — 로그아웃된 토큰
                    if (authRedisRepository.isAccessTokenBlacklisted(token)) {
                        log.debug("블랙리스트 처리된 토큰: {}", request.getRequestURI());
                        sendUnauthorized(response, ErrorCode.TOKEN_BLACKLISTED);
                        return;
                    }

                    String email = jwtProvider.getEmail(token);
                    UserDetails userDetails = userDetailsService.loadUserByUsername(email);

                    // 2. Redis에 저장된 AT와 일치 여부 확인 — 동시접속 차단
                    if (userDetails instanceof CustomUserDetails customUserDetails) {
                        String storedToken = authRedisRepository.getAccessToken(
                            customUserDetails.getMemberId());

                        if (!token.equals(storedToken)) {
                            log.debug("동시접속 차단. 다른 기기에서 로그인된 토큰: {}", request.getRequestURI());
                            sendUnauthorized(response, ErrorCode.INVALID_TOKEN);
                            return;
                        }
                    }

                    // 3. SecurityContext 등록
                    UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities());
                    authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (ExpiredJwtException e) {
                log.debug("만료된 Access Token: {}", request.getRequestURI());
            }
        }

        filterChain.doFilter(request, response);
    }

    // ErrorCode를 받아서 공통 ErrorResponse 형식으로 직렬화
    // ObjectMapper를 사용해 ErrorResponse 필드가 바뀌어도 자동으로 반영됨
    private void sendUnauthorized(HttpServletResponse response, ErrorCode errorCode)
        throws IOException {
        ErrorResponse errorResponse = ErrorResponse.of(errorCode);
        response.setStatus(errorCode.getStatus());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

SecurityConfig.java에서 ObjectMapper 주입

JwtAuthenticationFilter는 Spring Bean이 아니라 직접 new로 생성하기 때문에 ObjectMapper를 생성자로 넘겨줘야 한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ... 생략
        .addFilterBefore(
            new JwtAuthenticationFilter(
                jwtProvider,
                userDetailsService,
                authRedisRepository,
                objectMapper  // 추가
            ),
            UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

SecurityConfigObjectMapper 필드 주입 추가:

private final ObjectMapper objectMapper;

변경 전 vs 변경 후

변경 전 — 문자열 직접 작성

// ❌ 형식 변경 시 여기도 수정해야 함
response.getWriter().write(
    "{\"status\": 401, \"code\": \"%s\", \"message\": \"%s\", \"errors\": []}"
        .formatted(code, message)
);

변경 후 — ObjectMapper + ErrorResponse

// ✅ ErrorResponse 필드 변경 시 자동으로 반영됨
ErrorResponse errorResponse = ErrorResponse.of(errorCode);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));

GlobalExceptionHandler가 필터를 못 잡는 이유

필터 (Spring Security)
    ↓
DispatcherServlet ──────────────────────────────┐
    ↓                                           │
Controller                                      │
    ↓                                    GlobalExceptionHandler
Service                                  @RestControllerAdvice
    ↓                                           │
예외 발생 ───────────────────────────────────────┘

GlobalExceptionHandlerDispatcherServlet 내부에서만 동작한다. 필터는 DispatcherServlet 바깥에 있기 때문에 필터에서 발생하는 예외나 응답은 GlobalExceptionHandler가 처리할 수 없다.

그래서 필터에서는 response를 직접 작성해야 하는데, 이때 ObjectMapper + ErrorResponse를 사용해 공통 형식을 유지하는 게 핵심이다.


핵심 정리

Before: 필터에서 JSON 문자열 직접 작성 → 형식 불일치 위험
After:  ObjectMapper + ErrorResponse.of(errorCode) → 공통 형식 보장

필터는 GlobalExceptionHandler 바깥에 있어서 예외를 직접 처리해야 한다.
이때 문자열 하드코딩 대신 ErrorResponse 객체를 ObjectMapper로 직렬화하면
응답 형식을 프로젝트 전체와 통일할 수 있다.

profile
느리게 한걸음

0개의 댓글