API Gateway는 왜 단순한 프록시가 아닌가
필터 체인의 실행 순서부터 서비스 디스커버리, Rate Limiting, JWT 오프로딩, BFF 패턴까지 — MSA 진입점의 설계 결정을 추적한다.
- 01 MSA는 왜 도입하는가 — 모놀리스가 무너지는 시점
- 02 MSA 통신 계층은 왜 이렇게 복잡한가
- 03 MSA에서 데이터는 어떻게 분리되고 어떻게 다시 합쳐지는가
- 04 MSA에서 분산 트랜잭션을 어떻게 다룰 것인가
- 05 MSA 탄력성 패턴 — 장애는 차단하고, 서비스는 살린다
- 06 API Gateway는 왜 단순한 프록시가 아닌가
- 07 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가 어떻게 연쇄 장애를 막는지 추적한다.