Spring Cloud Gateway는 비동기 방식의 요청 처리를 지원하는 API Gateway 입니다. 하지만 이와 같은 비동기 환경에서 동기식 HTTP 클라이언트를 사용할 경우 성능 저하나 예상치 못한 문제가 발생할 수 있습니다. 이번 글에서는 Spring Cloud Gateway에서 FeignClient를 사용할 때 발생할 수 있는 문제와 그 해결 방법에 대해 다루어 보겠습니다.
문제 상황
@Component
public class CheckUserRegisterFilter implements GlobalFilter {
@Value("${service.jwt.secret-key}")
private String secretKey;
private final AuthClient authClient;
public CheckUserRegisterFilter(AuthClient authClient) {
this.authClient = authClient;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String authorizationHeader = request.getHeaders().getFirst("Authorization");
String token = authorizationHeader.substring(7);
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
Jws<Claims> jwsClaims = Jwts.parser()
.verifyWith(key)
.build().parseSignedClaims(token);
String userId = jwsClaims.getPayload().get("user_id").toString();
HttpStatusCode resultCode = authClient.validateUserExists(userId).getStatusCode();
if (!(resultCode.value() == 200)) {
}
return chain.filter(exchange);
}
}
@FeignClient(name = "auth")
public interface AuthClient {
@PostMapping("/auth/validate")
ResponseEntity<Void> validateUserExists(String userId);
}
위 코드는 Spring Cloud Gateway에서 사용되는 GlobalFilter를 구현한 커스텀 필터이다. 이 필터는 Authorization 헤더에 포함된 JWT 토큰을 검증하고, 토큰에서 추출한 user_id가 실제로 회원가입된 User의 ID인지 확인한다. 이 과정에서 FeignClient를 사용하여 auth 서버에 검증 요청을 보내게 된다.
문제 원인
위 코드에서 문제가 발생할 수 있는 부분은 바로 authClient.validateUserExists(userId).getStatusCode()이다. IntelliJ에서는 아래와 같은 경고를 한다.
Possibly blocking call in non-blocking context could lead to thread starvation is occured
직역하면 "넌블로킹 컨텍스트에서 블로킹 호출을 할 경우, 스레드 고갈(thread starvation)이 발생할 수 있다"는 경고문이다.
이 경고가 발생한 원인은 동기식 HTTP 클라이언트인 FeignClient가 비동기 처리 방식으로 동작하는 Spring WebFlux 컨텍스트에서 사용되고 있기 때문이다. 이제 이 문제를 좀 더 자세히 살펴보자.
Spring WebFlux와 Spring Cloud Gateway에서의 리액티브 프로그래밍
Spring WebFlux와 같은 리액티브(reactive) 환경에서는 요청을 비동기적으로 처리하며, 각 요청은 이벤트 루프(event loop) 기반으로 처리된다. 이 구조에서는 한정된 수의 스레드가 비동기적으로 작업을 처리하기 때문에, 특정 스레드가 오랫동안 블러킹되지 않고 빠르게 작업을 완료하고 다른 작업을 이어서 처리하는 것이 매우 중요하다.
리액티브 시스템의 목표는 최소한의 스레드로 최대한 많은 요청을 처리하는 것이며, 이를 통해 높은 성능과 확장성을 유지한다. 그러나 동기식 호출이 발생하면, 해당 호출을 처리하는 스레드는 해당 작업이 완료될 때까지 차단된다. 이로 인해 그 스레드는 다른 요청을 처리할 수 없게 되고 전체 시스템의 처리 능력이 저하된다.
동기식 호출이 리액티브 시스템에 미치는 영향
동기식 호출이 지속적으로 발생하면, 리액티브 시스템의 핵심 장점인 비동기적이고 넌블로킹 방식의 이점을 잃게 된다. 스레드가 블로킹 호출로 인해 차단되면, 새로운 요청이 해당 스레드에 할당되지 못해 대기 상태에 놓이게 된다. 이러한 상황이 반복되면, 시스템이 처리할 수 있는 요청의 양이 제한되며, 응답 시간이 길어지고, 심지어 시스템 전체가 멈추는 상황에 이를 수 있다.
예를 들어, 하나의 요청이 authClient.validateUserExists(userId).getStatusCode()와 같은 동기식 메서드 호출에 의해 블로킹되면, 그 요청을 처리하는 스레드는 해당 HTTP 응답을 받을 때까지 차단된다. 이 동안, 그 스레드는 다른 요청을 처리하지 못하고 비활성화 상태가 된다. 만약 이러한 블로킹 요청이 여러 번 발생하거나, 높은 트래픽 상황에서 자주 일어나면, 시스템 전체의 스레드 풀이 고갈될 수 있으며, 이를 '스레드 고갈(thread starvation)'이라고 한다.
스레드 고갈이 발생하면 시스템은 더 이상 새로운 요청을 처리할 수 없게 되며, 모든 요청이 대기 상태에 빠지게 되어 서비스의 가용성에 심각한 문제가 발생할 수 있다. 이는 특히 실시간 처리가 중요한 시스템에서 치명적일 수 있다.
문제 해결
이 문제를 해결하기 위해서는 Spring WebFlux와 같은 리액티브 환경에서는 동기식 클라이언트(FeginClient) 대신, 비동기식 논블로킹 방식으로 동작하는 WebClient를 사용하는 것이 권장된다. WebClient는 Spring WebFlux의 일부분으로, 비동기적으로 HTTP 요청을 처리할 수 있으며, 이를 통해 리액티브 시스템의 비동기 처리 모델을 방해하지 않고 시스템의 성능을 유지할 수 있다.
아래는 FeignClient 대신, WebClient를 사용하도록 수정한 코드이다.
@Service
@Slf4j
public class AuthClient {
@Value("${auth.host}")
private String authHost;
private final WebClient webClient;
public AuthClient(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.build();
}
public Mono<HttpStatus> validateUserExists(String userId) {
return webClient.get()
.uri(authHost + "/auth/validate?userId=" + userId)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, clientResponse -> {
return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND));})
.toBodilessEntity()
.map(response -> (HttpStatus) response.getStatusCode());
}
}
결론적으로, 리액티브 시스템에서는 모든 I/O 작업이 비동기적이고 넌블로킹 방식으로 처리되어야 하며, 이를 통해 높은 처리량과 빠른 응답 시간을 유지할 수 있다. FeignClient와 같은 동기식 호출은 이러한 리액티브 환경에서 피해야 하며, WebClient와 같은 비동기식 클라이언트를 사용하는 것이 바람직하다. 이를 통해 시스템의 성능을 최적화하고, 스레드 고갈과 같은 성능 문제를 예방할 수 있다.
추후에 알게된 내용인데, ReactiveFeign 이라는 비동기식 FeignClient가 있다고 한다.
Tip: Spring MVC vs Spring WebFlux, 무슨 차이?
Spring WebFlux
- Reactive Stack: Spring WebFlux는 리액티브, 비동기 프레임워크이다. 이는 많은 수의 요청을 비동기적으로 처리할 수 있도록 설계되었으며, 적은 수의 스레드로 높은 수준의 동시성을 처리해야 하는 애플리케이션에 적합다.
- Reactor: 비차단(non-blocking) 애플리케이션을 구축하기 위한 리액티브 프로그래밍 라이브러리이다. Reactor는 Spring WebFlux의 핵심으로, 리액티브 스트림을 처리하기 위한 도구와 연산자를 제공한다.
- Netty, Servlet 3.1+ 컨테이너, 리액티브 스트림 어댑터: Spring WebFlux는 Netty와 Servlet 3.1+ 사양을 지원하는 모든 서버에서 실행할 수 있으며, 리액티브 스트림 라이브러리와 어댑터를 통해 작업할 수 있다.
- Spring Security Reactive: Spring WebFlux에서 보안 작업은 비동기적으로 처리된다. 즉, 보안 작업이 비차단 방식으로 처리된다.
- Spring Data 리포지토리: Spring WebFlux는 비차단 방식으로 MongoDB, Cassandra, Redis와 같은 데이터베이스에 접근할 수 있는 리액티브 데이터 리포지토리를 지원한다.
Spring MVC
- 명령형 프로그래밍: Spring MVC는 전통적인 웹 프레임워크로, 동기적이며 블로킹 프로그래밍 모델을 사용한다. 이 모델은 각 요청을 전용 스레드로 처리하는 단순한 동시성 모델에 적합하다.
- 서블릿 컨테이너: Spring MVC는 Tomcat, Jetty 또는 Servlet API를 지원하는 모든 전통적인 서블릿 컨테이너에서 실행된다.
- 서블릿 API: Spring MVC는 동기 방식으로 웹 요청을 처리하기 위해 설계된 블로킹 I/O API인 서블릿 API 위에 구축되어 있다.
- Spring Security: Spring MVC에서는 보안 작업이 동기적으로 처리된다. 보안 구성 요소는 프레임워크의 블로킹 특성에 맞게 설계되었다.
- Spring Data 리포지토리: Spring MVC는 JPA, JDBC 등과 같은 블로킹 I/O 모델을 사용하는 데이터베이스에 적합한 전통적인 블로킹 데이터 리포지토리를 지원한다.
참조
'Development > Diary' 카테고리의 다른 글
[Diary][Spring] 식별,비식별 관계? 관계의 방향? 외래 키의 주인? (1) | 2024.08.27 |
---|---|
[Diary] @SpringBootApplication의 @ComponentScan 범위 (0) | 2024.08.26 |
[Diary] AWS EC2에서 겪은 포트포워딩 문제: 리눅스 네트워크 인터페이스 eth0와 enX0 (1) | 2024.08.01 |
[Diary] 지저분한 Service 메서드 코드 Command DesignPattern 적용기 (5) | 2024.07.22 |
[개발 일기][Spring] grafana_session 쿠키 문제와 Custom Login Filter 구현하기 (0) | 2024.07.17 |