[MSA] Spring Cloud Gateway


개요

Spring Cloud Gateway는 외부 사용자의 요청을 단일 진입점(Single Entry Point)에서 수신하여, 기본적인 인증(Authentication)과 인가(Authorization)를 처리한 뒤 적절한 내부 서버로 요청을 전달하는 API Gateway 역할의 서버이다.

Gateway에서는 URL 패턴, 헤더, HTTP 메서드 등의 조건을 기반으로 요청을 어떤 내부 서비스로 포워딩할지 라우팅 규칙을 정의할 수 있다.

이러한 역할은 Kubernetes 환경에서의 Ingress와 유사해 보일 수 있지만, 두 컴포넌트는 책임의 위치와 범위가 다르다.

Ingress는 네트워크 레벨에서 트래픽을 분산하고 어떤 요청이 어떤 서비스로 전달될지를 결정하는 L7 Router에 가깝다.

반면 Spring Cloud Gateway는 애플리케이션 레벨에서 동작하며, JWT 기반 인증, Role 기반 인가와 같은 사용자 컨텍스트를 활용한 제어가 가능하다.

MSA 환경에서는 각 서비스가 외부에 직접 노출되지 않고 신뢰할 수 있는 내부 네트워크에서만 동작하도록 구성하는 것이 일반적이다.
이러한 구조에서는 외부 트래픽이 반드시 하나의 진입점을 거치도록 강제할 수 있는 API Gateway의 역할이 더욱 중요해진다.

Spring Cloud Gateway는 인증과 인가를 중앙에서 처리함으로써 각 내부 서비스가 비즈니스 로직에만 집중할 수 있도록 돕는 핵심 컴포넌트라고 볼 수 있다.


용어

  • Spring Cloud Gateway
  • Spring Webflux
  • Spring Security

왜 인증을 Gateway에서 처리해야 하나?

인증(Authentication)을 Gateway에서 처리하는 가장 큰 이유는 보안 책임을 중앙화하고, 내부 서비스의 복잡도를 최소화하기 위함이다.

만약 각 마이크로서비스가 개별적으로 인증 로직을 가지고 있다면, JWT 검증 방식, 만료 정책, Role 처리 방식 등이 서비스마다 조금씩 달라질 수 있고 이는 보안 취약점이나 정책 불일치로 이어질 가능성이 높아진다.

또한 인증 관련 변경 사항이 생길 경우, 모든 서비스를 동시에 수정하고 배포해야 하는 운영 부담도 함께 증가한다.

하지만 Gateway에서 인증과 인가를 전담하도록 구성하면, 외부 요청은 반드시 단일 진입점을 거치도록 강제할 수 있고 내부 서비스는 이미 검증된 요청만 전달받게 된다.


이 구조에서는 내부 서버가 JWT나 인증 헤더를 직접 해석할 필요가 없으며, 단순히 Gateway가 전달해준 사용자 식별 정보나 권한 정보를 신뢰하고 비즈니스 로직에만 집중할 수 있다.

따라서 인증을 Gateway로 중앙화함으로써, 보안 정책의 일관성, 서비스 간 책임 분리, 그리고 운영 안정성을 달성 시켜주는 MSA 설계상의 중요한 선택이라고 볼 수 있다.


Spring Webflux를 선택한 이유

보통 사용하는 Spring Web MVC는 요청 당 Thread를 하나씩 점유한다. (Request per Thread)

이 구조는 하나의 요청이 비교적 짧은 시간 안에 처리되거나, I/O 대기보다 CPU 연산의 비중이 높은 비즈니스 서버에 최적화되어 있다.

하지만 API Gateway는 복잡한 도메인 로직이나 데이터베이스 트랜잭션을 수행하지 않고, 외부 요청을 받아 인증 정보를 검증한 뒤 다른 내부 서버로 요청을 전달하는 중계 역할을 수행한다.


이 과정에서 대부분의 시간은 실제 연산이 아니라 내부 서버의 응답을 기다리는 I/O Waiting Time에 소비된다.

이러한 I/O 중심의 워크로드에서 요청마다 스레드를 점유하는 Web MVC 모델을 사용하게 되면, 응답을 기다리는 동안에도 스레드가 계속 점유되어 동시 요청 수가 증가할수록 스레드 고갈로 이어질 가능성이 높아진다.


특히 Gateway 처럼 한 요청에 대해 클라이언트 연결과 내부 서버 연결을 동시에 관리해야 하는 경우, 이러한 비효율은 더욱 두드러진다.

Spring WebFlux는 이러한 문제를 해결하기 위해 논블로킹(non-blocking) I/O와 Event Loop 기반의 실행 모델을 제공한다.


요청이 외부 시스템의 응답을 기다리는 동안 스레드를 점유하지 않고 반환함으로써, 소수의 스레드로도 많은 동시 연결을 효율적으로 처리할 수 있다.

결과적으로, Spring Cloud Gateway와 같이 I/O 비중이 높고 요청을 중계하는 역할의 서버에서는 WebFlux의 실행 모델이 Web MVC보다 훨씬 자연스럽고 효율적인 선택이 된다.

[오해 금지]

CPU 연산 비중이 있는 비즈니스 서버에서 Webflux를 무작정 사용하는 것은 오히려 스레드 관련 문제나 성능 저하로 이어질 수 있다.
서버의 역할과 상황을 고려해서 적절하게 사용하도록 하자.


Spring Security를 사용한 이유

Gateway에서 인증과 인가를 처리하기로 결정했다면, 그 로직을 직접 구현하기보다는 검증된 보안 프레임워크를 사용하는 것이 합리적이다.

Spring Security는 Spring 생태계 전반에서 표준으로 사용되는 보안 프레임워크로, 인증(Authentication)과 인가(Authorization)를 명확하게 분리된 책임으로 다룰 수 있도록 설계되어 있다.

JWT 기반 인증을 직접 구현하는 것도 가능하지만, 요청 필터링, 인증 객체의 생명주기 관리, 보안 컨텍스트 전달,
인가 실패 시의 일관된 응답 처리 등을 모두 수작업으로 관리하게 되면 코드 복잡도가 빠르게 증가하고, 작은 실수가 보안 취약점으로 이어질 수 있다.

Spring Security를 사용하면 이러한 공통적인 보안 흐름을 프레임워크 레벨에서 위임할 수 있다.

특히 Gateway 환경에서는 요청이 인증되었는가, 어떤 Role을 가진 사용자인가와 같은 판단을 프레밍워크 레벨에서 어렵지 않게 할 수 있다.

Spring Security는 요청이 컨트롤러나 라우팅 로직에 도달하기 전에 필터 체인 단계에서 인증과 인가를 처리할 수 있도록 해주며, 이를 통해 인증되지 않은 요청을 초기에 차단할 수 있다.

그리고 WebFlux 기반의 Spring Security는 Non-Blocking 환경에 맞게 설계된 Reactive Security 체인을 제공한다.


이를 통해 Gateway의 WebFlux 기반 실행 모델을 유지하면서도 JWT 검증, Role 기반 접근 제어와 같은 보안 요구사항을 자연스럽게 통합할 수 있다.


구현 : Spring Security Filter Chain

사용자의 요청을 받으면, Bearer Header에서 JWT 토큰을 추출한 후, Spring Security Filter Chain에서 간단한 인증 / 인가 처리 후, 원 요청 서버로 포워딩 하면 된다.

결국 구현에서 가장 중요한 것은 Spring Security Filter Chain에서 JWT 기반 검증 / Role Check 등 로직을 추가해서 이 체인에 등록하는 것이 가장 중요하다.

Spring Security Filter Chain에 대해 간단히 설명하자면, 단일 인증 책임을 맡는 Filter들을 순차적으로 연결해서 한 체인을 실행하면 이 필터들을 거쳐 완결된 인증 / 인가를 수행할 수 있는 구조이다.

각 필터는 요청을 그대로 다음 필터로 전달할 수도 있고, 보안 조건을 만족하지 못한 경우 즉시 요청을 종료할 수도 있다.


구현 : Authentication 객체

조금 더 자세히 들어가서 Filter Chain에서 인증 정보를 가지고 있는 객체는 Authentication이다.

Spring Security는 “이 요청이 누구의 요청인가?”라는 질문에 대한 답을 Authentication 객체로 표현한다.

일반적인 흐름은 다음과 같다.

  1. 요청이 들어오면 Filter가 헤더나 쿠키에서 인증 정보를 추출한다.
  2. 추출한 정보를 바탕으로 인증을 수행하고 Authentication 객체를 생성한다.
  3. 인증이 성공하면 Authentication을 SecurityContext에 저장한다.
  4. 이후 필터들은 SecurityContext에 저장된 Authentication을 기준으로 접근 허용 여부를 판단한다.

위 구조를 본다면 자연스럽게 구현 방향을 떠올릴 수 있다.
우리의 인증 / 인가 로직을 SecurityContext 에 저장되기 전 Authentication 객체에 적절히 담아 넘겨주기만 하면 된다.


ServerAuthenticationConverter는 HTTP 요청 정보를 받아 Authentication 객체를 반환하는
convert 함수를 제공한다.

convert 함수에서는 요청 헤더(Authorization 등)에서 토큰을 추출하고 이를 기반으로 아직 검증되지 않은 Authentication 객체를 생성하는 것이 목표이다.

이 단계에서는 JWT의 유효성 검증을 수행하지 않는다.
오직 이 요청에는 이런 인증 정보가 있다는 사실만 Spring Security에 전달한다.


AuthenticationManager는 Authentication 객체를 받아 실제 인증을 수행하는 authenticate 함수를 제공한다.

authenticate 함수에서 JWT 검증을 하여 인증이 완료된 Authentication 객체를 생성하는 것이 목표이다.

이 과정이 성공하면 Spring Security는 해당 요청을 신뢰 가능한 요청으로 판단한다.


AuthenticationManager를 구현한 뒤에는, 이를 Spring Security Filter Chain에 명시적으로 등록해야 한다.

WebFlux 환경에서는 AuthenticationWebFilter를 사용하여 우리가 구현한 인증 로직을 SecurityWebFiltersOrder.AUTHENTICATION 위치에 추가한다.

또한, 이 필터가 내부적으로 우리가 만든 ServerAuthenticationConverter를 사용하도록 설정함으로써, Request -> Converter -> AuthenticationManager 이라는 흐름을 하나의 인증 파이프라인으로 연결할 수 있다.


본 프로젝트의 코드는 아래 링크에서 볼 수 있다.


1. application.yml 설정

    YAML
    spring:
      main:
        web-application-type: reactive
      cloud:
        gateway:
          server:
            webflux:
              routes:
                - id: internal-api
                  uri: http://localhost:8080
                  predicates:
                    - Path=/api/**
    • web-application-type: reactive 설정으로 WebFlux 기반으로만 실행하라고 명시한다.
    • 실제로 비즈니스 API 없이 Gateway의 역할만 하므로 모든 요청을 WebFlux 기반으로 실행하면 된다.
    • spring.cloud.gateway.server.webflux.routes에 포워딩 할 서비스들을 입력하면 된다.
    • 테스트 환경으로 1개만 등록해두었지만, API Path 기반으로 적절히 여러 서비스로 포워딩 하도록 설정하면 된다.

    2. JwtAuthenticationConverter 구현

      exchange.request.headers에서 헤더를 가져올 수 있다.
      UsernamePasswordAuthenticationToken 객체에 토큰을 설정한 뒤 반환하면 된다.

      Kotlin
      @Component
      class JwtAuthenticationConverter: ServerAuthenticationConverter {
          override fun convert(exchange: ServerWebExchange): Mono<Authentication> {
              val authHeader = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
                  ?: return Mono.empty()
      
              if (!authHeader.startsWith("Bearer ")) return Mono.empty()
      
              val token = authHeader.removePrefix("Bearer ").trim()
              return Mono.just(UsernamePasswordAuthenticationToken(null, token))
          }
      }

      3. JwtAuthenticationToken 구현

        JwtAuthenticationToken 객체는 검증이 끝났음을 나타내는 객체이다.
        isAuthenticated = true로 설정해야 Spring Security가 인증된 사용자로 취급하도록 할 수 있다.

        Kotlin
        class JwtAuthenticationToken(
            private val userId: String,
            authorities: Collection<GrantedAuthority>,
        ) : AbstractAuthenticationToken(authorities) {
        
            init {
                isAuthenticated = true
            }
        
            override fun getPrincipal(): Any = userId
        
            override fun getCredentials(): Any? = null
        }

        Authentication 객체는 아래와 같이 구성돼 있다.

        Authentication (interface)
         ├─ principal      ← 누구인가?
         ├─ credentials    ← 증명 수단 (비밀번호 / 토큰)
         ├─ authorities    ← 권한
         └─ isAuthenticated
        • credentials는 후에 사용할 일이 없으므로 안전하게 항상 null을 반환하도록 설정하였다.

        4. JwtAuthenticationManager 구현

          실제 인증을 수행하는 authenticate 함수를 구현하면 된다.
          JWT 검증 / User ID 추출 / Role 추출 / Authentication 객체 생성 순으로 구현되어 있다.

          Kotlin
          @Component
          class JwtAuthenticationManager(
              private val jwtProperties: JwtProperties
          ) : ReactiveAuthenticationManager {
          
              private val secretKey = Keys.hmacShaKeyFor(
                  jwtProperties.secret.toByteArray(StandardCharsets.UTF_8)
              )
          
              override fun authenticate(authentication: Authentication): Mono<Authentication> {
                  val token = authentication.credentials as? String
                      ?: return Mono.error(BadCredentialsException("Missing JWT token"))
          
                  return try {
                      val claims: Claims = Jwts.parserBuilder()
                          .setSigningKey(secretKey)
                          .build()
                          .parseClaimsJws(token)
                          .body
          
                      val userId = claims.subject
                          ?: return Mono.error(BadCredentialsException("JWT subject is missing"))
          
                      val role = claims["role"] as? String ?: "USER"
                      val authorities = listOf(
                          SimpleGrantedAuthority("ROLE_$role")
                      )
          
                      Mono.just(
                          JwtAuthenticationToken(
                              userId = userId,
                              authorities = authorities
                          )
                      )
                  } catch (e: JwtException) {
                      Mono.error(BadCredentialsException("Invalid JWT", e))
                  }
              }
          }

          5. SecurityConfig 설정

            아래 Config는 Health는 전체 공개, /api/admin/** 경로는 ADMIN 권한의 유저만 접근 가능하도록, 나머지는 인증이 되어야 접근 가능하도록 설정하였다.

            addFilterAt으로 위에서 구현한 jwtAuthenticationManager를 등록해주면 된다.

            securityExceptionHandler는 필수는 아니지만, Spring Security에서 권한 부족 / 인증 실패 등으로 예외가 던져졌을 때, 프로젝트에서 정의한 BaseResponse로 결과를 반환하도록 설정하기 위함이다.

            Kotlin
            @Configuration
            @EnableWebFluxSecurity
            class SecurityConfig(
                private val jwtAuthenticationManager: JwtAuthenticationManager,
                private val jwtAuthenticationConverter: JwtAuthenticationConverter,
                private val jwtAuthenticationFailureHandler: JwtAuthenticationFailureHandler,
                private val securityExceptionHandler: SecurityExceptionHandler,
            ) {
            
                @Bean
                fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
            
                    val jwtFilter = AuthenticationWebFilter(jwtAuthenticationManager).apply {
                        setServerAuthenticationConverter(jwtAuthenticationConverter)
                        setAuthenticationFailureHandler(jwtAuthenticationFailureHandler)
                    }
            
                    return http
                        .csrf { it.disable() }
                        .httpBasic { it.disable() }
                        .formLogin { it.disable() }
            
                        .authorizeExchange {
                            it.pathMatchers("/health").permitAll()
                            it.pathMatchers("/api/admin\/**").hasRole("ADMIN")
                            it.anyExchange().authenticated()
                        }
            
                        .exceptionHandling {
                            it.authenticationEntryPoint(securityExceptionHandler)
                            it.accessDeniedHandler(securityExceptionHandler)
                        }
            
                        .addFilterAt(jwtFilter, SecurityWebFiltersOrder.AUTHENTICATION)
                        .build()
                }
            }

            6. Forwarding Filter 등록

              인증 성공 시 내부 서버로 토큰이 아닌 User ID를 헤더에 담아 포워딩 해 줄 수 있도록 설정하였다.
              내부 Controller에서는 아래와 같이 헤더에서 X-User-Id를 추출해서 사용한다.

              Kotlin
              @GetMapping("/{id}")
              fun findChatroomById(
                  @RequestHeader("X-User-Id") userId: String,
                  @PathVariable id: String,
              ): BaseResponse<ChatroomResponse> {
                  return BaseResponse.ok(
                      data = chatroomService.findChatroomById(userId, id),
                  )
              }
              Kotlin
              @Component
              class UserHeaderFilter : GlobalFilter {
              
                  override fun filter(
                      exchange: ServerWebExchange,
                      chain: GatewayFilterChain
                  ): Mono<Void> {
              
                      return exchange.getPrincipal<Authentication>()
                          .flatMap { authentication ->
              
                              val userId = authentication.name
                              val mutatedExchange = exchange.mutate()
                                  .request { request ->
                                      request.header("X-User-Id", userId)
                                  }
                                  .build()
              
                              chain.filter(mutatedExchange)
                          }
                          .switchIfEmpty(
                              chain.filter(exchange)
                          )
                  }
              }

              테스트 환경

              1. 내부 서비스는 8080 포트에서 돌아가고 있고, Gateway 서버는 8181 포트에서 돌아가고 있다.
              2. GET /api/admin/todos는 모든 Todo를 조회하는 API로, Admin User만 조회할 수 있다.
              3. GET /api/chatroom는 특정 사용자의 채팅방을 페이징으로 조회하는 API로, 인증이 되어있어야 사용할 수 있다.

              테스트 1 : 일반 유저가 모든 Todo 조회

              예상 결과 : Admin User가 아니므로, 403 Access Denied이 떠야 한다.

              토큰 생성

              요청

              결과

              JSON
              {
                  "statusCode": 403,
                  "message": "Access Denied",
                  "data": null,
                  "error": {
                      "message": "Access Denied"
                  }
              }

              테스트 2 : Admin 유저가 모든 Todo 조회

              예상 결과 : 200이 떠야 한다.

              토큰 생성

              요청

              결과

              JSON
              {
                  "statusCode": 200,
                  "message": "Succeed",
                  "data": [
                      {
                          "id": "5a12a950-a827-4757-a56a-a604ff023b9d",
                          "title": "Todo 7",
                          "description": "Todo 7 Description (Updated)",
                          "isCompleted": false,
                          "tags": [],
                          "createdAt": "2025-12-16T12:10:48.171411",
                          "updatedAt": "2025-12-20T11:12:57.339641"
                      },
                      ...
                  ]
              }

              테스트 3 : 토큰 없이 모든 Todo 조회

              예상 결과 : 401 Unauthorized이 떠야 한다.

              요청

              결과

              JSON
              {
                  "statusCode": 401,
                  "message": "Unauthorized",
                  "data": null,
                  "error": {
                      "message": "Unauthorized"
                  }
              }

              테스트 4 : 일반 유저가 채팅방 조회

              예상 결과 : 200이 떠야 한다.

              토큰 생성

              요청

              결과

              JSON
              {
                  "statusCode": 200,
                  "message": "Succeed",
                  "data": {
                      "items": [
                          {
                              "id": "SVCVB4K9KQMNZ11B56N66ZABMG",
                              ...
                          },
                          ...
                      ],
                      ...
                  },
                  "error": null
              }

              테스트 5 : 토큰 없이 채팅방 조회

              예상 결과 : 401 Unauthorized이 떠야 한다.

              요청

              결과

              JSON
              {
                  "statusCode": 401,
                  "message": "Unauthorized",
                  "data": null,
                  "error": {
                      "message": "Unauthorized"
                  }
              }

              테스트 6 : 잘못된 토큰으로 채팅방 조회

              예상 결과 : 401 Invalid Token이 떠야 한다.

              요청

              결과

              JSON
              {
                  "statusCode": 401,
                  "message": "Invalid JWT",
                  "data": null,
                  "error": {
                      "message": "Invalid JWT"
                  }
              }

              테스트 7 : 만료된 토큰으로 채팅방 조회

              예상 결과 : 401 Expired Token이 떠야 한다.

              토큰 생성

              • exp 필드에 있는 시간은 2018년으로 8년 전에 만료된 상황으로 테스트한다.

              요청

              결과

              JSON
              {
                  "statusCode": 401,
                  "message": "JWT expired",
                  "data": null,
                  "error": {
                      "message": "JWT expired"
                  }
              }

              마무리

              JWT 인증 기반의 WebFlux Spring Cloud Gateway를 직접 구현해보았다.

              단순히 트래픽을 포워딩하는 것이 아닌, JWT 검증과 간단한 인증, 인가까지 처리하여, 실제 운영 환경에서 사용할법한 포인트들을 반영해보았다.

              Gateway는 MSA 구조에서의 Entry Point로써, 서비스 전체의 신뢰 경계(Trust Boundary) 역할을 한다.

              Gateway에서 인증을 처리하기 때문에 내부 서비스에 중복된 인증 로직을 작성할 필요가 없고, 장애나 보안 이슈가 발생했을 때 영향 범위를 Gateway 레벨에서 차단할 수 있다.

              특히 JWT 기반 인증에서는 어디까지를 Gateway 책임으로 가져갈 것인가를 명확히 정하는 것이 중요한데, 이번 구현에서는 Gateway는 검증까지만, 비즈니스 판단은 내부 서버로라는 원칙을 지켰다.

              본 서버에서는 데이터베이스 커넥션을 사용하지 않고, JWT 서명 검증과 Role 기반 접근 제어 정도의 최소한의 인증·인가만 처리하는 것을 목표로 설계했다.

              이러한 구조에서는 요청마다 I/O 비용이 큰 작업이 발생하지 않기 때문에, 비동기·논블로킹 처리에 최적화된 WebFlux를 사용하는 것이 기술적으로도 자연스러운 선택이라고 생각한다.

              또한, Gateway가 수행하는 작업을 최대한 가볍게 유지함으로써, 높은 동시성 환경에서도 안정적으로 요청을 처리할 수 있고, 서비스 트래픽이 증가할수록 WebFlux 기반 Gateway의 장점은 더욱 명확하게 드러날 것이라고 기대한다.

              답글 남기기

              Dalmeng's Footprints에서 더 알아보기

              지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

              계속 읽기