← all posts
DEV 2026.05.02 · 12 min read Intermediate

API Gateway는 왜 단순한 프록시가 아닌가

필터 체인의 실행 순서부터 서비스 디스커버리, Rate Limiting, JWT 오프로딩, BFF 패턴까지 — MSA 진입점의 설계 결정을 추적한다.


MSA에서 API Gateway는 흔히 “앞단에서 트래픽을 분산하는 Nginx”로 오해받는다. 실제로는 인증, Rate Limiting, 서비스 디스커버리, 클라이언트별 응답 최적화까지 처리하는 정책 엔진이다. 이 모든 기능이 하나의 진입점에 집중되는 것은 우연이 아니라 설계다. 그 설계는 어떤 공통 철학 위에 서 있는가?

단일 진입점이라는 선택

Nginx는 L4/L7 트래픽을 분산한다. Spring Cloud Gateway는 거기서 한 발 더 나아가 필터 체인으로 모든 처리를 플러그인 방식으로 구성한다.

요청은 NettyWebServer → DispatcherHandler → RoutePredicateHandlerMapping → FilteringWebHandler 순으로 흐른다. FilteringWebHandler가 실제 필터 체인을 실행하는 곳이다. 각 필터는 Ordered 인터페이스로 우선순위를 지정하고, Pre-phase(요청 처리) → 라우팅 → Post-phase(응답 처리) 순으로 실행된다.

// 필터 우선순위: 숫자가 작을수록 먼저
@Override
public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE + 10; // 최우선 실행
}

GlobalFilter는 모든 요청에 적용되고, GatewayFilter는 라우트별로 적용된다. 이 둘을 혼동하면 필터가 예상과 다른 순서로 실행되고, JWT 검증 전에 헤더가 변조되거나 Rate Limit이 우회되는 미묘한 버그가 생긴다.

서비스 위치는 누가 알아야 하는가

마이크로서비스는 동적으로 생성되고 제거된다. IP를 하드코딩하면 새 인스턴스가 뜰 때마다 설정 파일을 고쳐야 한다. 서비스 디스커버리는 이 문제를 두 가지 방식으로 푼다.

Eureka(Client-Side Discovery): 클라이언트가 레지스트리를 직접 조회해 인스턴스를 선택한다. Heartbeat(30초 주기)로 건강 상태를 확인하고, 90초간 응답 없으면 제거(Eviction)한다. 네트워크 분할 상황에서는 Self-Preservation Mode가 켜져 기존 등록 정보를 보존한다 — 잘못된 제거보다 오래된 정보가 낫다는 판단이다. 장애 감지에 30~90초가 걸리는 것이 이 구조의 대가다.

Kubernetes Service(Server-Side Discovery): kube-proxy가 iptables/ipvs 규칙으로 Pod로 트래픽을 라우팅한다. 클라이언트는 DNS 이름(http://user-service)만 알면 된다. readinessProbe로 Pod 상태를 1~5초 내에 감지하므로 장애 대응이 빠르다. 대신 Kubernetes 없이는 동작하지 않는다.

✎ 트레이드오프

Eureka는 클라우드에 독립적이고 가용성 우선이다. Kubernetes Service는 빠른 장애 감지와 단순한 클라이언트 코드를 제공하지만 플랫폼에 종속된다. 금융처럼 빠른 복구가 중요한 서비스라면 Kubernetes의 1~5초 감지가 Eureka의 90초보다 결정적이다.

Rate Limiting: 분산 카운터가 필수인 이유

Gateway 인스턴스가 3개 있을 때 각자 로컬 메모리 카운터를 쓰면, 사용자가 인스턴스마다 100개씩 총 300개를 보낼 수 있다. Rate Limit이 무의미해진다.

해결책은 Redis를 중앙 카운터로 두는 것이다. 그런데 “현재 토큰 조회 → 충전 계산 → 소비 확인 → 저장”을 Java 코드 4단계로 구현하면 두 스레드가 동시에 같은 키를 읽을 때 Race Condition이 발생한다. Redis Lua 스크립트가 필수인 이유다 — Redis는 Lua 스크립트를 원자적으로 실행해 중간에 다른 명령이 끼어들 수 없다.

Token Bucket 알고리즘은 평상시 토큰을 쌓아두었다가 버스트 트래픽을 흡수한다. replenishRate: 100(초당 100 토큰 충전), burstCapacity: 200(최대 보유량)으로 설정하면, 평소 조용하던 클라이언트가 갑자기 200개를 보내도 허용된다. Sliding Window는 정확하지만 타임스탬프를 모두 저장해야 해서 메모리 부담이 크고, 윈도우 경계에서 순간적으로 2배 허용되는 문제가 있다.

JWT 검증은 한 번만

50개 마이크로서비스가 각자 JWT를 검증하면, 요청당 HMAC 계산이 50번 일어난다. Gateway에서 한 번 검증하고 내부 헤더로 전달하는 것이 옳다.

// Gateway: JWT 검증 후 헤더 교체
ServerWebExchange modifiedExchange = exchange.mutate()
    .request(req -> {
        req.header("X-User-ID", claims.getSubject());
        req.headers(headers -> headers.remove("Authorization")); // 원본 제거
    })
    .build();

각 마이크로서비스는 @RequestHeader("X-User-ID")만 읽으면 된다. JWT 검증 로직이 없다. 이 구조가 동작하려면 Gateway가 신뢰 경계(Trust Boundary)여야 한다 — 내부 서비스로 직접 오는 요청을 막는 NetworkPolicy나 방화벽이 전제다.

OAuth2는 Gateway의 BFF(Backend For Frontend) 역할로 자연스럽게 이어진다. Authorization Code를 클라이언트에 노출하지 않고, Backend가 Google과 직접 토큰을 교환한 뒤 우리 서비스의 JWT를 발급한다. Client Secret이 절대 클라이언트 쪽에 나가지 않는 것이 핵심이다.

BFF: 클라이언트마다 다른 진실

공통 API 하나로 웹, 모바일, 관리자를 모두 만족시키려 하면 오버페칭(불필요한 데이터 포함)과 언더페칭(부족해서 여러 번 호출)이 동시에 발생한다. BFF(Backend For Frontend)는 클라이언트별로 최적화된 API 레이어를 둔다.

모바일 BFF는 이름과 주문 상태 3개만 5KB로 반환한다. 웹 BFF는 GraphQL로 클라이언트가 필요한 필드만 선택하게 한다. 두 BFF 모두 내부에서 마이크로서비스를 병렬로 호출한다.

// 순차: 300ms + 200ms + 150ms = 650ms
// 병렬: max(300, 200, 150) = 300ms
return Mono.zip(userMono, ordersMono, paymentMono)
    .map(tuple -> DashboardResponse.from(
        tuple.getT1(), tuple.getT2(), tuple.getT3()));

정리

  • API Gateway의 필터 체인은 Ordered 인터페이스로 순서가 결정된다. 순서가 틀리면 인증이 우회된다.
  • 서비스 디스커버리는 Eureka(가용성 우선, 30~90초 감지)와 Kubernetes Service(빠른 감지, 플랫폼 종속) 중 요구사항에 따라 선택한다.
  • Rate Limiting은 Redis + Lua 스크립트로만 분산 환경에서 정확하게 동작한다. 로컬 카운터는 무의미하다.
  • JWT 검증은 Gateway에서 한 번, 이후 내부 헤더로 전달한다. 이것이 신뢰 경계의 의미다.
  • BFF는 클라이언트별 최적화이자 팀 소유권의 구조화다.

다음 글에서는 이 Gateway 뒤에서 서비스 간 호출이 실패할 때 — Circuit Breaker와 Retry가 어떻게 연쇄 장애를 막는지 추적한다.