0. 개요
쇼핑몰 프로젝트를 혼자 다시 구현하면서, 이전과는 달리 refresh token과 블랙리스트 기능을 새롭게 추가하게 되면서 gateway 로직을 다시 짜게 되었다.
Gateway는 클라이언트 요청의 Authorization 헤더에 담긴 JWT를 검증하고, 인증 API에 요청을 보내서 해당 토큰이 블랙리스트에 등록된 것인지 확인한 후, 토큰 안에 담긴 사용자 정보를 파싱해 다음을 헤더에 추가해서 백엔드 API에 전달한다.
- X-MEMBER-ID: 사용자 식별자
- X-MEMBER-ROLES: 사용자 권한
처음에는 인증 서버와 통신하기 위해 Spring Cloud OpenFeign을 사용했지만, Spring Cloud Gateway는 Spring WebFlux 기반의 비동기 논블로킹 환경에서 동작하기 때문에, 동기 방식의 OpenFeign를 사용하는 순간 아래와 같은 문제가 발생했다.
- @FeignClient 사용 시 순환 참조 오류
- block() 호출로 인한 Netty 이벤트 루프 블로킹
- 스레드 고갈(thread starvation) 경고
결국 해당 문제를 해결하기 위해, Spring WebFlux와 같은 리액티브 환경에서는 동기식 클라이언트(OpenFeign) 대신, 비동기식 논블로킹 방식으로 동작하는 WebClient를 사용했다.
이 글은 gateway에서 OpenFeign을 사용하면서 마주친 문제들과, 그 해결 과정을 정리한 기록이다.
1. Gateway 흐름
Gateway의 JWT 검증/파싱 로직에 대해서 간략하게 그림으로 표현했다.
문제는 '3. 토큰 블랙리스트 확인'에서 인증 서버로 블랙리스트 확인 요청을 OpenFeign(@FeignClient)으로 보내면서 발생했다.
2. 문제 상황: 순환 참조 문제
(1) 순환 참조 문제 발생
Gateway의 jwtAuthorizationHeaderFilter(Authorization 헤더의 JWT를 검증하고 파싱 후, 사용자 정보를 Request 헤더에 추가하는 필터)에서 순환 참조 문제가 발생했다.
그래서 jwtAuthorizationHeaderFilter와 AuthClient 간의 의존성 주입 문제만 해결하면 될 줄 알았다.
- JwtAuthorizationHeaderFilter는 AuthClient(@FeignClient)를 의존성 주입받는다.
- AuthClient(@FeignClient)는 빈으로 등록되면서 GatewayAutoConfiguration를 로드한다.
- GatewayAutoConfiguration는 커스텀 필터가 있으면 해당 필터를 등록시킨다.
- 결국 순환 참조 문제 발생.
* 기존 코드
JwtAuthorizationHeaderFilter
/**
* Authorization 헤더의 JWT를 검증하고 파싱 후, 사용자 정보를 Request 헤더에 추가하는 필터
*/
@Slf4j
@Component
public class JwtAuthorizationHeaderFilter extends AbstractGatewayFilterFactory<JwtAuthorizationHeaderFilter.Config> {
private final JwtUtils jwtUtils;
private final AuthClient authClient;
public static class Config {}
public JwtAuthorizationHeaderFilter(JwtUtils jwtUtils, AuthClient authClient) {
super(Config.class);
this.jwtUtils = jwtUtils;
this.authClient = authClient;
}
/**
* 필터를 거치지 않는 경로 리스트(로그인이 필요없는 경로 리스트)
*/
private static final List<String> WHITELIST = List.of(
// Auth
"/auth/login",
// API
"/api/members"
);
/**
* JWT 검증 필터
* @param config - 설정 클래스
* @return GatewayFilter
*/
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain)->{
ServerHttpRequest request = exchange.getRequest();
String accessToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
// 화이트 리스트에 포함된 경로면 필터 통과
if (WHITELIST.contains(request.getURI().getPath())) {
return chain.filter(exchange);
}
// header 검증
if(!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
throw new UnauthorizedException(ErrorCode.MISSING_AUTHORIZATION_HEADER, "Authorization 헤더가 없습니다.");
}else{
// accessToken 검증, 블랙리스트 체크
if(!jwtUtils.isValidToken(accessToken) || authClient.isTokenBlacklisted(accessToken)) {
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_ACCESS_TOKEN, "유효하지 않은 accessToken 입니다.");
}
// Request 헤더에 X-MEMBER-ID 등록
String memberId = jwtUtils.extractMemberId(accessToken);
exchange.mutate().request(builder -> {
builder.header("X-MEMBER-ID",memberId);
});
// Request 헤더에 X-MEMBER-ROLE 등록
List<String> memberRoles = jwtUtils.extractRoles(accessToken);
exchange.mutate().request(builder -> {
builder.header("X-MEMBER-ROLES", String.join(",", memberRoles));
});
}
return chain.filter(exchange);
};
}
}
AuthClient
/**
* auth-api와 통신하는 Feign Client
*/
@FeignClient(name = "AUTH-API")
public interface AuthClient {
@GetMapping("/auth/blacklist/{accessToken}")
boolean isTokenBlacklisted(@PathVariable String accessToken);
}
(2) @Lazy를 활용하여 순환 참조 문제 해결
@Lazy는 Spring이 해당 빈(Bean)을 즉시 초기화하지 않고, 실제로 필요할 때 초기화하도록 만드는 어노테이션이다.
Spring은 @Lazy가 붙은 AuthClient를 JwtAuthorizationHeaderFilter가 생성될 때 실제 인스턴스로 초기화하지 않고, 프록시 객체(proxy bean)를 대신 주입한다. 그리고 나중에 authClient.isTokenBlacklisted(...)와 같이 메서드가 호출되는 시점에 실제 대상 빈을 초기화하여 실행한다.
* JwtAuthorizationHeaderFilter의 생성자 주입 (@Lazy 적용)
public JwtAuthorizationHeaderFilter(JwtUtils jwtUtils, @Lazy AuthClient authClient) {
super(Config.class);
this.jwtUtils = jwtUtils;
this.authClient = authClient;
}
3. 문제 상황: WebFlux 비동기 환경에서 OpenFeign 사용 시 발생하는 동기 호출 문제
(1) block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-2 문제 발생
순환 참조 문제를 해결한 뒤, Gateway에서 JWT의 블랙리스트 여부를 확인하기 위해 OpenFeign을 통해 인증 서버에 요청을 보내던 중 또 다른 문제가 발생했다.
block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-2
이 에러는 Spring WebFlux의 비동기 처리 흐름을 위반했을 때 나타나는 대표적인 경고다.
문제의 핵심은 block()이나 blockFirst(), blockLast() 같은 동기 호출 메서드가 사용되었고, 그 호출이 Netty의 이벤트 루프 스레드(reactor-http-nio-*)에서 실행되었다는 점이다.
이게 왜 문제가 되냐면, Spring Cloud Gateway는 내부적으로 Spring WebFlux를 기반으로 동작한다.
Spring WebFlux는 Netty 기반의 논블로킹 리액티브 시스템이다. 이벤트 루프는 적은 수의 스레드로 수많은 요청을 처리하기 위해
절대로 블로킹(blocking) 되어서는 안 되는 구조다. 그러나 OpenFeign은 기본적으로 동기식 클라이언트이기 때문에, WebFlux의 흐름 안에서 OpenFeign을 그대로 호출하면 내부적으로 block()과 같은 동기 처리가 발생하게 되고, 그로 인해 WebFlux의 비동기 스레드가 멈춰버리는 문제가 생긴다.
결국 이 문제는 Netty 기반의 논블로킹 리액티브 시스템인 Spring Cloud Gateway에서 동기식 클라이언트인 OpenFeign의 요청으로 발생한 문제였던 것이다.
4. 해결: Spring WebClient
이 문제를 근본적으로 해결하기 위해, 인증 서버와의 통신을 OpenFeign에서 WebClient로 전환했다.
WebClient는 Spring WebFlux의 일부로, 비동기/논블로킹 방식으로 외부 HTTP 요청을 처리할 수 있는 클라이언트다.
덕분에 WebFlux의 철학에 어긋나지 않으면서도 안정적으로 외부 API와 통신할 수 있다.
* 수정한 코드
JwtAuthorizationHeaderFilter
package com.chokchok.gateway.filter;
import com.chokchok.gateway.exception.code.ErrorCode;
import com.chokchok.gateway.exception.base.UnauthorizedException;
import com.chokchok.gateway.util.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* Authorization 헤더의 JWT를 검증하고 파싱 후, 사용자 정보를 Request 헤더에 추가하는 필터
*/
@Slf4j
@Component
public class JwtAuthorizationHeaderFilter extends AbstractGatewayFilterFactory<JwtAuthorizationHeaderFilter.Config> {
private final JwtUtils jwtUtils;
private final WebClient.Builder webClient;
public static class Config {}
public JwtAuthorizationHeaderFilter(JwtUtils jwtUtils, WebClient.Builder webClient) {
super(Config.class);
this.jwtUtils = jwtUtils;
this.webClient = webClient;
}
/**
* 필터를 거치지 않는 경로 리스트(로그인이 필요없는 경로 리스트)
*/
private static final List<String> WHITELIST = List.of(
// Auth
"/auth/login",
// API
"/api/members"
);
/**
* JWT 검증 필터
* @param config - 설정 클래스
* @return GatewayFilter
*/
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain)->{
ServerHttpRequest request = exchange.getRequest();
String accessToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
// 화이트 리스트에 포함된 경로면 필터 통과
if (WHITELIST.contains(request.getURI().getPath())) {
return chain.filter(exchange);
}
// header 검증
if(!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
throw new UnauthorizedException(ErrorCode.MISSING_AUTHORIZATION_HEADER, "Authorization 헤더가 없습니다.");
}else {
// accessToken 검증
if (!jwtUtils.isValidToken(accessToken)) {
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_ACCESS_TOKEN, "유효하지 않은 accessToken 입니다.");
}
// 블랙리스트 확인
return webClient.build()
.get()
.uri("lb://AUTH-API/auth/blacklist/{accessToken}", accessToken)
.retrieve()
.bodyToMono(Boolean.class)
.flatMap(isBlacklisted -> {
if (isBlacklisted) {
return Mono.error(new UnauthorizedException(ErrorCode.UNAUTHORIZED_ACCESS_TOKEN, "이미 로그인 중인 사용자입니다."));
}
// Request 헤더에 X-MEMBER-ID 등록
String memberId = jwtUtils.extractMemberId(accessToken);
exchange.mutate().request(builder -> {
builder.header("X-MEMBER-ID", memberId);
});
// Request 헤더에 X-MEMBER-ROLE 등록
List<String> memberRoles = jwtUtils.extractRoles(accessToken);
exchange.mutate().request(builder -> {
builder.header("X-MEMBER-ROLES", String.join(",", memberRoles));
});
return chain.filter(exchange);
});
}
};
}
}
5. 마무리
Spring Cloud Gateway는 WebFlux 기반이기 때문에, 동기식 OpenFeign을 사용할 경우 block() 예외가 발생했다.
이 문제는 WebClient를 사용해 비동기·논블로킹 방식으로 전환함으로써 해결할 수 있었다.
이번 경험을 통해 프레임워크의 동작 방식과 환경에 맞는 도구 선택의 중요성을 다시 한 번 느꼈다.
참고
'프로젝트 > 쇼핑몰 프로젝트' 카테고리의 다른 글
[쇼핑몰 프로젝트] FeignClient 에러 처리: ErrorDecoder (0) | 2025.08.02 |
---|---|
[쇼핑몰 프로젝트] Spring 예외 처리: ErrorCode 기반 공통 처리로 단순화하기 (3) | 2025.08.01 |
[쇼핑몰 프로젝트] Spring JWT 재발급 로직에서 Interceptor 대신 Filter를 선택한 이유 (+Spring Cloud OpenFeign, Spring MVC) (2) | 2025.08.01 |
[쇼핑몰 프로젝트] Spring Boot 민감 정보 보호와 배포 자동화: GitHub Actions secrets & .env 활용 (0) | 2025.07.31 |
[쇼핑몰 프로젝트] Spring MVC 구조 - 단위 테스트 코드 작성하기 (0) | 2025.03.26 |