0. 개요
쇼핑몰 프로젝트를 혼자 다시 구현하면서, 이전에 구현하지 못했던 Refresh Token 기반의 재발급 로직과 블랙리스트 기능을 새롭게 추가했다. Access Token이 만료됐을 때, 쿠키에 담긴 Refresh Token을 활용해 Auth 서버가 Redis에서 해당 토큰의 존재 여부를 확인하고, 유효한 경우 Access Token을 자동으로 재발급하는 구조를 구현했다. 로그인 후 발급되는 Access/Refresh Token은 모두 HttpOnly 쿠키로 설정되며, 이후 모든 요청에 함께 전달된다. 이 점을 활용해, 로그인한 사용자의 요청을 서버에서 선처리하고, Refresh Token은 존재하지만 Access Token이 없거나 만료된 경우 Auth 서버로 재발급 요청을 보내도록 설계했다.
이를 통해 프론트에서 따로 재요청을 처리하지 않아도, 백엔드에서 JWT 재발급이 자동으로 처리되도록 구성할 수 있었다.
이 글에서는 로그인한 사용자의 요청을 가로채 Access Token의 존재 여부를 확인하기 위해 Interceptor를 활용한 JWT 재발급 로직을 구현했으며, 이 과정에서 OpenFeign와 함께 사용하면서 발생한 순환 참조 문제를 어떻게 해결했는지에 대해 다룬다.
1. 인증 로직의 전체 흐름
인증 로직에 대해서 간략하게 그림으로 표현했다.
문제가 발생한 부분은 '6. access token이 만료된 요청'을 캐치하고 재발급하는 로직을 Interceptor로 구현했는데, 인증서버로 재발급 요청을 FeignClient로 보내면서 순환 참조 문제가 발생했다.
2. 문제 상황: 순환 참조 문제 발생
(1) 왜 Intercepto로 재발급 로직을 구현했는가?
예외 처리 때문이다.
Spring MVC에서 Filter와 Interceptor는 처리 흐름상 다음과 같은 차이를 갖는다.
- Filter는 DispatcherServlet 이전에 동작하는 서버 단 컴포넌트다.
-> Filter 내부에서 발생한 예외는 @ControllerAdvice에서 처리할 수 없다. - Interceptor는 DispatcherServlet 이후, 컨트롤러가 호출되기 직전/직후에 동작하는 Spring 컴포넌트다.
-> Interceptor 내부에서 발생한 예외는 @ControllerAdvice의 예외 처리 대상이 됩니다.
@ControllerAdvice는 DispatcherServlet이 처리하는 요청에만 적용되기 때문에(@ControllerAdvice는 적용 범위가 Dispatcher Servlet 내부다), DispatcherServlet 이전 단계인 Filter에서는 예외가 해당 영역으로 전파되지 않는다. 이러한 구조적 차이로 인해, 예외를 일관되게 처리하고 싶어서 Interceptor를 선택했다.
(2) 1차 순환 참조 문제 발생
처음에는 FeignClient가 초기화될 때 내부적으로 Spring의 WebMvc 관련 설정을 건드리고, 그 과정에서 WebMvcAutoConfiguration이 로딩되며 WebConfig와 순환 참조가 발생한다는 구조를 몰랐다.
@Configuration(WebConfig)과 @Component(JwtInterceptor) 간의 의존성 꼬임 문제라고 생각했다.
그래서 webConfig에서 jwtInterceptor를 생성자 주입이 아닌 수동 @Bean 등록 방식으로 바꾸면 해결될 줄 알았다.
- WebConfig에서 JwtInterceptor를 생성자 주입함.
- JwtInterceptor는 AuthClient(@FeignClient)를 필요로 함.
- AuthClient(@FeignClient)는 초기화 시 Spring의 자동 구성(WebMvcAutoConfiguration)을 트리거함.
- 그 설정 안에서 다시 WebMvcConfigurer를 구현한 WebConfig에 접근하게 됨.
- 결국 WebConfig → JwtInterceptor → AuthClient → WebMvcAutoConfiguration → WebConfig 순환 참조 발생.
* 기존에 구현한 코드
JWTInterceptor
- HandlerInterceptor: Spring에서 제공하는 인터페이스로, 컨트롤러 실행 전/후에 요청을 가로채서 부가 작업을 수행
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtInterceptor implements HandlerInterceptor {
private final CookieUtils cookieUtils;
private final AuthClient authClient;
private static final int REFRESH_TOKEN_EXPIRATION_TIME = 30 * 24 * 60 * 60; // 30일
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Cookie[] cookies = request.getCookies();
String accessToken = cookieUtils.getValueFromCookie(cookies, ACCESS_TOKEN);
String refreshToken = cookieUtils.getValueFromCookie(cookies, REFRESH_TOKEN);
if(accessToken == null && refreshToken != null) {
try {
log.info("=== accessToken 만료, JWT 재발급 시작 ===");
TokenResponseDto responseDto = authClient.reissue().getData();
Cookie accessTokenCookie = cookieUtils.createCookie(ACCESS_TOKEN, responseDto.getAccessToken(), responseDto.getExpiresIn().intValue());
Cookie refreshTokenCookie = cookieUtils.createCookie(REFRESH_TOKEN, responseDto.getRefreshToken(), REFRESH_TOKEN_EXPIRATION_TIME);
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
log.info("=== JWT 재발급 완료 ===");
return true;
}catch (FeignException e) {
log.error("JWT 재발급 실패");
response.sendRedirect("/members/login");
}
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
WebMvcConfigurer
- WebMvcConfigurer: Spring MVC의 기본 설정을 개발자가 원하는 대로 바꿀 수 있게 해주는 인터페이스.
- InterceptorRegistry: Spring MVC의 인터셉터 체인을 관리하는 객체.
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final JwtInterceptor jwtInterceptor;
/**
* Spring MVC 인터셉터를 설정하는 메소드입니다.
* @param registry 인터셉터 등록을 위한 Registry 객체
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/members/login", "/members/register");
WebMvcConfigurer.super.addInterceptors(registry);
}
}
* 1차 수정한 코드 - jwtInterceptor 빈 수동 등록
JWTInterceptor
@Slf4j
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
private final CookieUtils cookieUtils;
private final AuthClient authClient;
private static final int REFRESH_TOKEN_EXPIRATION_TIME = 30 * 24 * 60 * 60; // 30일
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Cookie[] cookies = request.getCookies();
String accessToken = cookieUtils.getValueFromCookie(cookies, ACCESS_TOKEN);
String refreshToken = cookieUtils.getValueFromCookie(cookies, REFRESH_TOKEN);
if(accessToken == null && refreshToken != null) {
try {
log.info("=== accessToken 만료, JWT 재발급 시작 ===");
TokenResponseDto responseDto = authClient.reissue().getData();
Cookie accessTokenCookie = cookieUtils.createCookie(ACCESS_TOKEN, responseDto.getAccessToken(), responseDto.getExpiresIn().intValue());
Cookie refreshTokenCookie = cookieUtils.createCookie(REFRESH_TOKEN, responseDto.getRefreshToken(), REFRESH_TOKEN_EXPIRATION_TIME);
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
log.info("=== JWT 재발급 완료 ===");
return true;
}catch (FeignException e) {
log.error("JWT 재발급 실패");
response.sendRedirect("/members/login");
}
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
WebMvcConfigurer
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final CookieUtils cookieUtils;
private final AuthClient authClient;
@Bean
public JwtInterceptor jwtInterceptor() {
return new JwtInterceptor(cookieUtils, authClient);
}
/**
* Spring MVC 인터셉터를 설정하는 메소드입니다.
* @param registry 인터셉터 등록을 위한 Registry 객체
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/members/login", "/members/register");
WebMvcConfigurer.super.addInterceptors(registry);
}
}
(2) 2차 순환 참조 문제 발생, 진짜 원인은?
여전히 문제는 해결되지 않았다. 다시 한 번 잘 살펴보니
- webConfig는 WebMvcConfigurer(Spring MVC 설정을 간편하게 커스터마이즈할 수 있도록 도와주는 인터페이스)를 구현한다.
- webConfig에서 AuthClient(@FeignClient)를 의존성 주입한다.
- AuthClient를 빈으로 만드는 과정에서 OpenFeign 자동설정이 실행되는데 이때 MVC 관련 설정이 필요하고, 그 중 하나인 WebMvcAutoConfiguration(Spring Boot가 Spring MVC 애플리케이션을 자동으로 설정해주는 클래스)가 로딩된다.
- WebMvcAutoConfiguration는 인데 WebMvcConfigurer가 구현되어 있다면 해당 구현체를 참조한다. 따라서 webConfig를 참조하는 것이다.
즉, WebConfig(WebMvcConfigurer)가 직접 AuthClient(@FeignClient)를 주입하면 안 되는 구조인 것이다.
3. 해결: Filter 사용
필터를 사용하는 3가지 방식 중 간단하게 @Component를 사용해서 모든 요청에 적용되도록 했다.
@Component | 모든 URL에 필터 자동 적용 | 간단하고 빠름 | URL 패턴 설정 불가 (전역 적용됨) |
@WebFilter + @ServletComponentScan | 특정 URL에만 필터 적용 | 서블릿 표준 방식, URL 설정 가능 | @ServletComponentScan 필요, Spring스럽지 않음 |
FilterRegistrationBean (@Bean) | 자바 코드로 필터 등록 | 가장 유연함, 순서·조건 제어 가능 | 설정 코드 많아짐 |
* JwtFilter
package com.chokchok.chokchokfront.common.filter;
import com.chokchok.chokchokfront.common.client.AuthClient;
import com.chokchok.chokchokfront.common.utils.CookieUtils;
import com.chokchok.chokchokfront.dto.member.TokenResponseDto;
import feign.FeignException;
import jakarta.servlet.*;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import static com.chokchok.chokchokfront.controller.MemberController.ACCESS_TOKEN;
import static com.chokchok.chokchokfront.controller.MemberController.REFRESH_TOKEN;
/**
* access token이 만료되고 refresh token만 있으면, JWT 토큰을 재발급하는 필터입니다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final CookieUtils cookieUtils;
private final AuthClient authClient;
private static final int REFRESH_TOKEN_EXPIRATION_TIME = 30 * 24 * 60 * 60; // 30일
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
Cookie[] cookies = httpRequest.getCookies();
String accessToken = cookieUtils.getValueFromCookie(cookies, ACCESS_TOKEN);
String refreshToken = cookieUtils.getValueFromCookie(cookies, REFRESH_TOKEN);
if (accessToken == null && refreshToken != null) {
try {
TokenResponseDto responseDto = authClient.reissue().getData();
Cookie accessTokenCookie = cookieUtils.createCookie(ACCESS_TOKEN, responseDto.getAccessToken(), responseDto.getExpiresIn().intValue());
Cookie refreshTokenCookie = cookieUtils.createCookie(REFRESH_TOKEN, responseDto.getRefreshToken(), REFRESH_TOKEN_EXPIRATION_TIME);
httpResponse.addCookie(accessTokenCookie);
httpResponse.addCookie(refreshTokenCookie);
} catch (FeignException e) {
log.error("JWT 재발급 실패", e);
httpResponse.sendRedirect("/members/login");
return;
}
}
chain.doFilter(request, response);
}
}
4. 마무리
이번 구현을 통해 단순한 JWT 재발급 로직을 넘어, Spring Cloud OpenFeign과 Spring MVC의 WebMvcAutoConfiguration, WebMvcConfigurer 간의 순환 참조 문제를 경험하며 Spring 내부 구조를 깊이 이해하게 되었다.
Interceptor는 예외 처리가 가능해 유리했지만, @FeignClient 사용 과정에서 순환 참조가 발생해 결국 Filter로 전환하여 문제를 해결했다.
이 과정에서 Interceptor와 Filter의 동작 시점 차이, 예외 처리 범위, 자동 설정의 흐름 등과 Spring MVC에 대해 더욱 공부할 수 있었다.
참고
- https://www.youtube.com/watch?v=v86B35pwk6s
- https://github.com/jwtk/jjwt?tab=readme-ov-file#jwt-signed-with-hmac
'프로젝트 > 쇼핑몰 프로젝트' 카테고리의 다른 글
[쇼핑몰 프로젝트] Spring 예외 처리: ErrorCode 기반 공통 처리로 단순화하기 (3) | 2025.08.01 |
---|---|
[쇼핑몰 프로젝트] Spring Cloud Gateway와 OpenFeign을 같이 사용하면 발생하는 문제 (3) | 2025.08.01 |
[쇼핑몰 프로젝트] Spring Boot 민감 정보 보호와 배포 자동화: GitHub Actions secrets & .env 활용 (0) | 2025.07.31 |
[쇼핑몰 프로젝트] Spring MVC 구조 - 단위 테스트 코드 작성하기 (0) | 2025.03.26 |
[쇼핑몰 프로젝트] 분산 환경에서 데이터 캐싱 - Redis (1) | 2025.03.24 |