API Gateway 설계 — JWT 검증을 중앙화한 이유

정영범·2026년 5월 12일

토이프로젝트

목록 보기
11/11

왜 Gateway가 필요했나

서비스가 3개를 넘어가면서 문제가 두 가지 생겼다.

문제 1: 포트 파편화

POST :8081/orders        ← 주문
POST :8085/auth/login    ← 인증
GET  :8086/products      ← 상품
GET  :8087/settlements   ← 정산

서비스 하나가 추가될 때마다 포트가 늘어난다. 외부에 서비스 포트를 전부 노출하는 것도 보안상 좋지 않다.

문제 2: JWT 검증의 분산

common-auth 모듈로 JWT 코드는 공통화했지만, 각 서비스가 직접 토큰을 검증하는 구조 자체는 여전히 분산된 상태였다. 인증 방식이 바뀌면 모든 서비스를 수정해야 한다.


Spring Cloud Gateway MVC 선택

선택지는 세 가지였다.

nginxSpring Cloud GatewaySpring Cloud Gateway MVC
기반CWebFlux (비동기)Spring MVC (서블릿)
JWT 검증 코드 관리어려움가능 (재작성 필요)가능 (기존 필터 재사용)
기존 서블릿 필터 재사용불가불가가능

nginx는 JWT 검증 같은 로직을 코드로 관리하기 어렵다.

WebFlux 기반 Spring Cloud Gateway는 처리량이 높지만 이미 서블릿 기반으로 작성된 common-auth 필터를 그대로 쓸 수 없다. 재작성해야 한다.

Spring Cloud Gateway MVC를 선택했다. OncePerRequestFilter를 그대로 사용할 수 있어서 common-auth의 기존 코드를 재사용할 수 있었다.


구조 — JWT 검증 후 헤더로 전파

클라이언트 → :8080 (단일 진입점)
  → GatewayJwtFilter
       ├── 공개 경로면 그대로 통과
       ├── JWT 토큰 검증
       ├── Redis 블랙리스트 체크 (로그아웃된 토큰 차단)
       └── X-User-Id, X-User-Role 헤더 추가
  → 내부 서비스로 전달
       각 서비스: 헤더를 읽어서 SecurityContext 세팅
                  (JWT 재검증 없음)

게이트웨이에서 한 번 검증하면 내부 서비스는 헤더만 믿으면 된다.


GatewayJwtFilter 구현

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class GatewayJwtFilter(
    private val jwtTokenProvider: JwtTokenProvider,
    private val redisTemplate: StringRedisTemplate
) : OncePerRequestFilter() {

    companion object {
        private val PUBLIC_PATHS = listOf(
            "/api/auth/login",
            "/api/auth/signup",
            "/api/auth/refresh",
            "/api/auth/password",
            "/api/payments/webhook",
            "/actuator"
        )
    }

    override fun doFilterInternal(...) {
        // 공개 경로는 그대로 통과
        if (isPublicPath(request)) {
            filterChain.doFilter(request, response)
            return
        }

        val token = resolveToken(request)
            ?: return response.sendError(401, "Missing token")

        if (!jwtTokenProvider.validateToken(token))
            return response.sendError(401, "Invalid or expired token")

        if (jwtTokenProvider.getTokenType(token) != "access")
            return response.sendError(401, "Access token required")

        // 블랙리스트 체크 (로그아웃된 토큰)
        if (redisTemplate.hasKey("blacklist:$token") == true)
            return response.sendError(401, "Token has been revoked")

        // 검증 통과 → 헤더에 사용자 정보 추가
        val userId = jwtTokenProvider.getUserId(token)
        val role = jwtTokenProvider.getRole(token)

        val mutatedRequest = MutableHttpServletRequest(request)
        mutatedRequest.addHeader("X-User-Id", userId.toString())
        mutatedRequest.addHeader("X-User-Role", role.name)

        filterChain.doFilter(mutatedRequest, response)
    }

    private fun isPublicPath(request: HttpServletRequest): Boolean {
        val uri = request.requestURI
        if (PUBLIC_PATHS.any { uri == it || uri.startsWith("$it/") }) return true

        // 상품 목록/상세 조회는 비로그인 허용 (GET만, UUID 형식만)
        val uuidPattern = Regex("/api/products/[0-9a-fA-F-]{36}")
        if (request.method == "GET" && (uri == "/api/products" || uri.matches(uuidPattern)))
            return true

        return false
    }
}

MutableHttpServletRequest로 요청을 감싸서 헤더를 추가한다. HttpServletRequest는 불변이라 직접 헤더를 추가할 수 없기 때문이다. HttpServletRequestWrapper를 상속해서 헤더를 추가할 수 있도록 했다.

블랙리스트 체크도 게이트웨이에서 처리한다. 로그아웃 시 user-service가 Redis에 blacklist:{token} 키를 저장한다. 게이트웨이가 이 키를 확인해서 로그아웃된 토큰을 차단한다.


각 서비스 — 헤더만 읽으면 된다

게이트웨이가 검증을 마친 후 X-User-Id, X-User-Role 헤더를 붙여서 전달한다. 각 서비스는 이 헤더를 읽어서 SecurityContext를 세팅하면 된다. JWT를 다시 검증할 필요가 없다.

common-authGatewayAuthFilter를 만들어서 각 서비스가 가져다 쓰도록 했다.

class GatewayAuthFilter : OncePerRequestFilter() {
    override fun doFilterInternal(...) {
        val userId = request.getHeader("X-User-Id")
        val role = request.getHeader("X-User-Role")

        if (userId != null && role != null) {
            SecurityContextHolder.getContext().authentication =
                UsernamePasswordAuthenticationToken(
                    UUID.fromString(userId),
                    null,
                    listOf(SimpleGrantedAuthority("ROLE_$role"))
                )
        }

        filterChain.doFilter(request, response)
    }
}

각 서비스의 SecurityConfig에서 이 필터만 등록하면 된다. JWT 검증 코드가 각 서비스에 없다.


라우팅 설정

spring:
  cloud:
    gateway:
      mvc:
        routes:
          - id: order-service
            uri: http://order-service:8081
            predicates:
              - Path=/api/orders/**
            filters:
              - StripPrefix=1  # /api 제거 후 전달

          - id: user-service
            uri: http://user-service:8085
            predicates:
              - Path=/api/auth/**,/api/users/**
            filters:
              - StripPrefix=1

          - id: product-service
            uri: http://product-service:8086
            predicates:
              - Path=/api/products/**
            filters:
              - StripPrefix=1

          - id: settlement-service
            uri: http://settlement-service:8087
            predicates:
              - Path=/api/settlements/**
            filters:
              - StripPrefix=1

StripPrefix=1/api prefix를 제거하고 각 서비스로 전달한다. /api/orders/123으로 들어온 요청이 order-service에는 /orders/123으로 전달된다.


트러블슈팅: Docker network "needs to be recreated"

Gateway를 추가하면서 docker-compose를 수정하다가 이 에러가 생겼다.

ERROR: Network 'eventful-network' needs to be recreated

Docker 신버전에서 브리지 네트워크에 enable_ipv4 옵션이 추가됐다. 기존에 생성된 네트워크 설정과 불일치해서 발생하는 문제였다. docker network rm으로 지우고 다시 올리면 해결되지만 매번 번거로웠다.

networks:
  eventful-network:
    driver: bridge
    driver_opts:
      com.docker.network.enable_ipv4: "true"

명시적으로 선언하면 재생성 시에도 동일한 설정으로 만들어져서 불일치가 사라진다.


결과

  • 클라이언트는 포트 하나(8080)만 알면 된다
  • JWT 검증과 블랙리스트 체크가 게이트웨이 한 곳에서 처리된다
  • 각 서비스 포트는 Docker 내부 네트워크에서만 통신한다
  • 인증 방식이 바뀌어도 게이트웨이만 수정하면 된다
  • common-auth의 기존 서블릿 필터 코드를 재작성 없이 그대로 재사용했다
profile
벨로그 좋은것만 드려요

0개의 댓글