Spring Cloud Gateway에서 발생한 UnsupportedOperationException

왜 null 입니까?
김주혁's avatar
Oct 09, 2025
Spring Cloud Gateway에서 발생한 UnsupportedOperationException
 
Spring Cloud Gateway에서 JWT 토큰을 발급하여 다운스트림 서비스로 전달하는 글로벌 필터를 구현하던 중, java.lang.UnsupportedOperationException이 발생했습니다. 이 글에서는 문제의 원인과 해결 과정을 공유합니다.

문제 상황

 
Spring Cloud Gateway에서 모든 요청에 JWT 토큰을 추가하여 백엔드 서비스로 전달하는 글로벌 필터를 구현하려고 했습니다.

초기 구현 코드

 
@Component public class JwtIssuanceGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = generateJwtToken(); ServerHttpRequest modifiedRequest = exchange.getRequest() .mutate() .header("Authorization", "Bearer " + token) .build(); ServerWebExchange modifiedExchange = exchange.mutate() .request(modifiedRequest) .build(); return chain.filter(modifiedExchange); } }

발생한 에러

위 코드를 실행하면 다음과 같은 에러가 발생했습니다.
java.lang.UnsupportedOperationException: null

원인 분석

 
Spring Cloud Gateway GitHub Repository의 Issues에서 java.lang.UnsupportedOperationException를 검색해보면 약 400개가 넘는 관련 이슈를 확인할 수 있습니다.
특히 다음 두 이슈가 이번 문제와 밀접하게 관련되어 있습니다:

header()

 
ServerHttpRequest.Builderheader() 메서드는 다음과 같이 정의되어 있습니다:
/** * Set or override the specified header values under the given name. * <p>If you need to add header values, remove headers, etc., use * {@link #headers(Consumer)} for greater control. * @param headerName the header name * @param headerValues the header values * @since 5.1.9 * @see #headers(Consumer) */ Builder header(String headerName, String... headerValues);
이 메서드는 지정된 이름의 헤더 값을 설정하거나 덮어쓰도록 설계되었습니다. 매우 직관적이고 편리한 API처럼 보이지만, 실제로는 내부적으로 문제가 있었습니다.
 

근본 원인: ReadOnlyHttpHeaders

 
Spring Boot 3.3.5로 업데이트하면서 Spring Security와 함께 사용할 때, exchange.getRequest().mutate()를 통해 얻은 HttpHeaders 객체가 겉으로는 수정 가능해 보이지만 실제로는 ReadOnly 상태가 되는 문제가 발생했습니다.
 
이는 HttpHeaders.writableHttpHeaders() 메서드가 헤더가 여러 겹으로 감싸져 있을 때(특히 StrictFirewallHttpHeaders처럼) 제대로 언래핑(unwrap)하지 못해서 발생한 버그였습니다.
 
@Test void writeHttpHeadersWhenNewHttpHeadersFails() { HttpHeaders originalExchangeHeaders = HttpHeaders.readOnlyHttpHeaders(new HttpHeaders()); HttpHeaders firewallHeaders = new HttpHeaders(originalExchangeHeaders); HttpHeaders writeable = HttpHeaders.writableHttpHeaders(firewallHeaders); writeable.set("test", "this"); // UnsupportedOperationException 발생 }

Spring Security와 Spring Cloud Gateway의 충돌

더 구체적으로 살펴보면:
  1. *Spring Security 6.0+**에서 StrictServerWebExchangeFirewall이 보안 강화를 위해 HttpHeaders를 읽기 전용으로 래핑합니다.
  1. Spring Framework 6.0 이전 버전에서 HttpHeaders.writableHttpHeaders() 메서드가 중첩된 읽기 전용 헤더를 제대로 처리하지 못했습니다.
  1. 결과적으로 Builder 내부로 넘어온 HttpHeaders는 사실상 "읽기 전용" 상태가 되어, header() 메서드나 headers(Consumer<HttpHeaders>) 방식 모두 UnsupportedOperationException을 발생시켰습니다.

해결 과정

1차 시도: Workaround 적용 (실패)

처음에는 기존 Spring Boot 버전을 변경하지 않고 Spring Cloud Gateway 공식 이슈에서 제안된 workaround를 참고하여 코드 레벨에서 해결하려고 했습니다.
@Bean @Order(Ordered.HIGHEST_PRECEDENCE) WebFilter writeableHeaders() { return (exchange, chain) -> { // 원본 요청의 헤더를 복사하여 수정 가능한(writable) 헤더 객체를 생성 HttpHeaders writeableHeaders = HttpHeaders.writableHttpHeaders( exchange.getRequest().getHeaders()); // 기존 요청 객체를 래핑 // getHeaders() 메서드만 오버라이드하여 수정 가능한 헤더를 반환 ServerHttpRequestDecorator writeableRequest = new ServerHttpRequestDecorator( exchange.getRequest()) { @Override public HttpHeaders getHeaders() { return writeableHeaders; } }; ServerWebExchange writeableExchange = exchange.mutate() .request(writeableRequest) .build(); return chain.filter(writeableExchange); }; }
하지만 여전히 같은 문제가 발생했습니다. HttpHeaders.writableHttpHeaders() 자체에 버그가 있었기 때문입니다.

2차 시도: 버전 변경 (성공)

이번에는 Spring Boot와 Spring Cloud 버전을 함께 변경했습니다.
// 이전 버전 plugins { id 'java' id 'org.springframework.boot' version '3.2.11' id 'io.spring.dependency-management' version '1.1.4' } ext { springCloudVersion = '2023.0.3' }
// 변경 후 버전 plugins { id 'java' id 'org.springframework.boot' version '3.2.12' id 'io.spring.dependency-management' version '1.1.7' } ext { springCloudVersion = '2023.0.3' }

최종 해결 코드

버전을 변경하고 workaround 패턴을 함께 적용한 결과, 문제가 해결되었습니다.
@Component @RequiredArgsConstructor @Slf4j @Order(-50) public class JwtIssuanceGlobalFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return JwtStaticProvider .createAccToken("gateway") .flatMap(token -> { HttpHeaders writableHeaders = HttpHeaders.writableHttpHeaders( exchange.getRequest().getHeaders()); writableHeaders.set("Authorization", "Bearer " + token); ServerHttpRequest modifiedRequest = new ServerHttpRequestDecorator( exchange.getRequest()) { @Override public HttpHeaders getHeaders() { return writableHeaders; } }; ServerWebExchange modifiedExchange = exchange.mutate() .request(modifiedRequest) .build(); return chain.filter(modifiedExchange); }).onErrorResume(error -> { log.error("Failed to issue JWT token for : {}", error.getMessage()); return chain.filter(exchange); }); } }

왜 버전 변경이 필요했을까?

Spring Framework의 버그 수정

Spring Framework 이슈 #33789에서 bclozel 개발자가 커밋 a06bbcc를 통해 HttpHeaders.writableHttpHeaders() 메서드를 수정했습니다.
수정 내용은 HttpHeaders.writeableHttpHeaders should unwrap many times로, 여러 겹으로 감싸진 헤더 객체도 제대로 처리하여 진짜 쓰기 가능한 HttpHeaders 객체를 반환하도록 개선한 것입니다.
이 수정 사항은 Spring Framework 6.1.15 버전에 포함되었으며, 이를 사용하는 Spring Boot 3.2.12 버전과 3.3.6 버전에 포함되어 배포 됐습니다.
아마 3.2.12 버전 이상의 다른 버전들이면 문제가 없을 것으로 판단 되지만, 문제가 생길시는 Stable한 다른 버전으로 버전 변경을 권장드립니다.

버전별 호환성

Spring Boot
Spring Cloud
상태
3.4.x
2024.0.x
✅ 해결됨
3.2.12
2023.0.x
✅ 해결됨
3.2.11
2023.0.x
⚠️ 문제 있음
추가적으로, 버전만 올리고 기존의 초기 코드 방식(mutate().header())으로 진행하면 여전히 같은 에러가 발생합니다.
→ 버전만 올리면 해결됩니다.
→ 버전 문제가 해결 됐는데도, 동작하지 않을 때만 코드 변경을 권장합니다.
즉, 문제 해결을 위해서는
  1. 버전 업그레이드: Spring Framework 6.1.15 버전 이상의 버전 세팅
  1. 코드 패턴 변경: ServerHttpRequestDecorator를 사용한 헤더 재정의

요약

  1. ServerHttpRequest.Builderheader() 메서드는 헤더를 설정/덮어쓰도록 설계
  1. 하지만 HttpHeaders.writableHttpHeaders()가 중첩된 읽기 전용 헤더를 제대로 언래핑하지 못하는 버그가 있음
  1. Spring Security의 StrictServerWebExchangeFirewall과 Spring Cloud Gateway가 함께 사용될 때 이 문제가 발생
  1. 버전 변경

참고 자료

Share article

vlogue