한 줄 요약: API Gateway는 단순한 리버스 프록시가 아니라, 인증·Rate Limit·Circuit Breaker·변환을 한 곳에 응집시켜 수백 개의 마이크로서비스를 클라이언트로부터 격리하는 시스템의 현관문이다.


실제 사고: API Gateway가 없으면 어떤 일이 벌어지나

카카오 2022년 데이터센터 화재 연쇄 장애: 화재 자체는 일부 서버에 국한됐지만, API 트래픽이 분산되지 않고 한 진입점으로 집중되면서 정상 서비스까지 연쇄적으로 다운됐습니다. 개별 서비스들이 서로의 장애를 격리하지 못한 채 직접 호출하다가 타임아웃 연쇄가 전파됐습니다. 단일 컴포넌트의 장애가 전체 서비스 불능으로 이어진 이유는 서비스 간 호출을 중재하는 레이어가 없었기 때문입니다.

AWS 2020년 Kinesis 장애 — 연쇄 인증 실패: AWS의 Kinesis 서비스에 장애가 발생하자, 해당 서비스와 무관한 Cognito·CloudWatch·Lambda 같은 서비스들도 줄줄이 인증 실패를 겪었습니다. 이유는 이 서비스들이 내부적으로 Kinesis에 인증 토큰을 검증 요청했기 때문입니다. 인증 검증이 게이트웨이 레이어에서 캐시되지 않고 매번 업스트림 호출로 처리되면, 인증 서버 하나의 장애가 전체 플랫폼 인증 불능으로 확산됩니다.

쿠팡 2021년 피크 시즌 Rate Limit 미작동: 로켓배송 특가 행사 시작 직후 외부 파트너 API에서 초당 수천 건의 재고 조회 요청이 쏟아졌습니다. Rate Limit이 서비스별로 제각각 구현되어 있었고, 그중 하나의 서비스에 Rate Limit 버그가 있었습니다. 파트너사 봇이 해당 서비스를 집중 공격하면서 DB 커넥션이 고갈됐고, 해당 서비스를 공유하던 다른 기능들도 함께 다운됐습니다.

이 세 사고의 공통 교훈은 하나입니다. 인증·Rate Limit·Circuit Breaker가 각 서비스에 분산 구현되면, 하나의 구멍이 전체를 무너뜨린다. API Gateway는 이 모든 횡단 관심사(cross-cutting concern)를 한 레이어에 집중시켜 방어선을 단일화합니다.


1. 설계 의사결정 로드맵

API Gateway를 설계할 때 가장 먼저 내려야 할 다섯 가지 결정입니다. 각 결정은 이후 성능, 운영 복잡도, 장애 격리 능력의 전체 균형을 규정합니다.

결정 1: 게이트웨이 레이어 수 — 단일 vs 이중

후보 장점 단점 언제 적합한가
단일 게이트웨이 구조 단순, 운영 부담 적음 외부·내부 트래픽 정책 혼재, 장애 반경 큼 마이크로서비스 초기 단계, 서비스 10개 이하
이중 게이트웨이 (Edge + Mesh) 외부 트래픽은 Edge, 내부 서비스 간은 Service Mesh로 분리 두 레이어 관리 복잡도 서비스 50개 이상, 팀 간 보안 경계 필요
BFF(Backend for Frontend) 분리 모바일·웹·파트너 API를 각각 최적화된 게이트웨이로 분리 게이트웨이 코드 중복 위험 클라이언트 종류가 3개 이상이고 응답 형태가 극단적으로 다를 때

우리의 선택: 외부 Edge Gateway + 내부 Service Mesh

클라이언트에서 오는 트래픽(인증·Rate Limit·SSL 종료)과 마이크로서비스 간 내부 트래픽(서비스 디스커버리·Circuit Breaker·분산 추적)은 관심사가 다릅니다. 이 둘을 하나의 게이트웨이에 몰아넣으면 운영 정책이 뒤섞입니다. Edge Gateway는 외부 경계를 담당하고, Istio나 Linkerd 같은 Service Mesh가 내부 동-서 트래픽을 처리합니다.


결정 2: 라우팅 방식 — 정적 설정 vs 동적 서비스 디스커버리

후보 장점 단점 언제 적합한가
정적 설정 (nginx.conf, YAML) 예측 가능, 디버깅 쉬움 서비스 추가·제거 시 수동 배포 필요 서비스 수가 적고 변경이 드문 환경
동적 디스커버리 (Consul, Kubernetes Endpoints) 서비스 인스턴스 증감을 실시간 반영 디스커버리 서버 가용성에 의존 컨테이너 오케스트레이션 기반 동적 스케일링 환경
하이브리드 (정적 경로 + 동적 인스턴스) 라우팅 규칙은 안정적이되 인스턴스는 동적 등록 설정 계층 이해 필요 대부분의 프로덕션 환경

우리의 선택: 경로 규칙은 Git 관리, 인스턴스는 Kubernetes Endpoints 동적 동기화

/api/v1/orders/** → order-service처럼 라우팅 규칙은 거의 바뀌지 않습니다. 하지만 order-service의 파드가 오토스케일링으로 3개에서 30개로 늘어나는 것은 매초 바뀔 수 있습니다. 이 두 가지를 같은 배포 주기에 묶으면 불필요한 배포가 늘거나 인스턴스 목록이 낡아집니다. 게이트웨이는 Kubernetes API를 watch해서 엔드포인트를 실시간으로 갱신하고, 라우팅 규칙만 ConfigMap이나 Git 관리 하에 둡니다.


결정 3: Rate Limiting 알고리즘 — 어떤 버킷을 쓸 것인가

알고리즘 동작 원리 장점 단점 언제 적합한가
Fixed Window Counter 1분 창을 고정해 요청 수 카운트 구현 최단, 메모리 최소 창 경계에서 2배 버스트 허용 정밀도보다 단순함이 중요한 내부 API
Sliding Window Log 모든 요청 타임스탬프를 저장 정확한 슬라이딩 윈도우 요청당 타임스탬프 저장, 메모리 O(요청 수) 낮은 TPS, 정확도 최우선
Sliding Window Counter 현재 창 + 이전 창 가중 평균 정확도와 메모리 절충 근사치(±1% 오차) 범용 API Gateway
Token Bucket 버킷에 토큰이 있으면 소비, 없으면 차단. 일정 속도로 충전 버스트 허용, 평균 속도 제한 대용량 버스트 가능성 단기 피크가 있는 정상 트래픽
Leaky Bucket 큐에 요청을 쌓고 일정 속도로 처리 (큐 초과 시 드롭) 출력 속도 평탄화 버스트 완전 차단, 큐 지연 발생 업스트림 서비스가 일정 QPS만 처리 가능할 때

우리의 선택: Sliding Window Counter (Redis Lua 구현)

Fixed Window는 59초에 100개, 61초에 100개를 모두 허용해 실질적으로 2초에 200개를 허용하는 버스트 허점이 있습니다. Token Bucket은 순간 버스트를 허용해 업스트림 서비스를 순간적으로 과부하시킬 수 있습니다. Sliding Window Counter는 이전 창의 요청 비율을 가중 평균으로 합산해 현재 창 기준의 정밀한 속도 제어를 근사값으로 달성합니다. 메모리는 사용자당 두 개의 카운터만 저장하므로 O(1)입니다.


결정 4: 인증 방식 — 게이트웨이 검증 vs 서비스 위임

후보 장점 단점 언제 적합한가
게이트웨이 집중 검증 JWT 서명 검증이 한 곳, 서비스 코드 간소화 게이트웨이가 단일 장애점, 검증 로직 집중 대부분의 REST API 서비스
각 서비스 위임 검증 서비스별 세밀한 권한 제어 검증 코드 중복, 버그 분산 위험 서비스마다 인증 정책이 극단적으로 다를 때
혼합 (게이트웨이 서명 검증 + 서비스 권한 결정) 위변조 방지는 게이트웨이, 비즈니스 권한은 서비스 역할 구분 명확히 해야 혼란 없음 대규모 멀티테넌트 플랫폼

우리의 선택: 게이트웨이에서 JWT 서명 검증 + Claim 전달, 서비스에서 권한 결정

JWT 서명 검증은 RSA 공개키로 수행하기 때문에 외부 서버 호출 없이 게이트웨이 메모리에서 완결됩니다. “이 토큰이 위변조되지 않았다”는 사실은 게이트웨이가 보장하고, “이 사용자가 이 리소스에 접근할 수 있는가”는 비즈니스 로직을 가진 서비스가 결정합니다. 게이트웨이는 검증된 클레임(user_id, role, scope)을 헤더에 주입해 서비스로 전달합니다.


결정 5: Circuit Breaker 위치 — 게이트웨이 vs 서비스 클라이언트

후보 장점 단점 언제 적합한가
게이트웨이 집중 Circuit Breaker 중앙 관리, 서비스 코드 불변 게이트웨이가 모든 서비스 상태를 관리 서비스가 단순하고 게이트웨이 팀이 운영
서비스 클라이언트 (Resilience4j, Hystrix) 서비스별 세밀한 임계값, 폴백 로직 포함 서비스마다 구현 필요 서비스 팀이 자율적으로 운영하는 조직
게이트웨이 + Service Mesh 분산 외부 진입은 게이트웨이, 내부 서비스 간은 Mesh가 처리 두 레이어 정책 일관성 유지 필요 대규모 마이크로서비스 아키텍처

우리의 선택: 게이트웨이에서 외부 → 서비스 Circuit Breaker + Service Mesh에서 내부 서비스 간 Circuit Breaker

외부 클라이언트 관점에서 “주문 서비스가 불안정하다”는 신호는 게이트웨이가 먼저 감지하고 차단해야 합니다. 내부에서 “결제 서비스가 재고 서비스를 호출하다 실패”하는 것은 Service Mesh의 Sidecar Proxy가 처리합니다. 두 레이어를 분리하면 장애 원인 추적도 명확해집니다.


2. 전체 아키텍처

API Gateway가 초당 50만 요청을 처리하는 구조입니다.

graph LR
  C["클라이언트"] --> GW["Edge Gateway"]
  GW --> A["인증 서비스"]
  GW --> S1["주문 서비스"]
  GW --> S2["상품 서비스"]
  GW --> K["Kafka"]

클라이언트의 모든 요청은 Edge Gateway 하나를 통과합니다. Gateway는 SSL 종료 → JWT 검증 → Rate Limit → 라우팅 순서로 처리합니다. 처리 결과는 Kafka에 비동기로 기록되어 분석과 감사 로그로 활용됩니다.

요청 처리 파이프라인 상세

게이트웨이를 통과하는 한 요청의 생애 주기를 단계별로 따라가겠습니다.

1단계 — SSL 종료 (0~1ms): TLS 핸드셰이크를 게이트웨이에서 종료합니다. 내부 서비스 간 통신은 mTLS(상호 인증 TLS)로 보호하거나 Service Mesh의 Sidecar가 처리합니다. SSL 종료를 게이트웨이에 집중하면 각 서비스가 인증서를 관리할 필요가 없고, 인증서 갱신도 한 곳에서 처리됩니다.

2단계 — 요청 파싱 및 유효성 검사 (1~2ms): HTTP 메서드, 경로, 헤더, Content-Type을 검사합니다. 페이로드가 JSON Schema를 위반하거나 필수 헤더가 빠졌다면 업스트림 서비스를 호출하기 전에 400 Bad Request를 반환합니다. 이를 입력 검증의 “조기 실패(fail-fast)” 원칙이라 부릅니다.

3단계 — 인증 및 인가 (2~5ms): Authorization 헤더에서 JWT를 추출하고 RS256 서명을 검증합니다. 공개키는 게이트웨이 메모리에 캐시되어 있어 외부 호출 없이 수십 마이크로초에 완료됩니다. 검증된 클레임은 X-User-Id, X-User-Role, X-Scope 헤더로 업스트림에 주입합니다.

4단계 — Rate Limiting (5~8ms): Redis에서 Sliding Window Counter를 조회·갱신합니다. 한도 초과 시 429 Too Many Requests와 함께 Retry-After 헤더를 반환합니다. 정상이면 다음 단계로 진행합니다.

5단계 — 라우팅 및 로드밸런싱 (8~10ms): 요청 경로와 헤더를 기반으로 업스트림 서비스와 인스턴스를 선택합니다. 라운드로빈·최소 연결·가중치 기반 알고리즘 중 서비스별로 설정합니다.

6단계 — 업스트림 호출 (10ms + 서비스 처리 시간): Circuit Breaker 상태를 확인하고 OPEN이면 즉시 503을 반환합니다. CLOSED이면 업스트림을 호출하고 응답을 기다립니다.

7단계 — 응답 변환 및 반환: 업스트림 응답에서 내부 헤더를 제거하고, 필요하면 응답 형식을 변환합니다. 로그 이벤트를 Kafka에 비동기 발행하고 클라이언트에 응답합니다.


3. 요청 라우팅 — 경로 매칭과 서비스 디스커버리

경로 매칭 알고리즘

라우팅 테이블은 경로 패턴을 구체적인 것부터 순서대로 매칭합니다. 이를 “가장 구체적인 일치 우선(most specific match first)” 원칙이라 합니다.

/api/v2/orders/bulk      → order-service (벌크 처리 전용 풀)
/api/v2/orders/{id}      → order-service
/api/v2/orders/**        → order-service
/api/v2/products/search  → search-service (검색 전용)
/api/v2/products/**      → product-service
/api/v1/**               → legacy-adapter (구버전 하위호환)

단순 문자열 매칭부터 경로 변수({id}), 와일드카드(**), 정규식까지 지원해야 합니다. 가장 구체적인 패턴이 먼저 매칭되므로 /api/v2/orders/bulk/api/v2/orders/{id}보다 우선합니다.

서비스 디스커버리와 헬스체크

Kubernetes 환경에서 게이트웨이는 API Server의 Endpoints 리소스를 Watch합니다. 파드가 Ready 상태가 되면 자동으로 로드밸런싱 풀에 추가되고, 파드가 종료되거나 헬스체크에 실패하면 즉시 제거됩니다.

헬스체크는 두 종류를 병행합니다. 액티브 헬스체크는 게이트웨이가 주기적으로 각 인스턴스의 /health 엔드포인트를 직접 호출해 응답 코드와 지연 시간을 확인합니다. 패시브 헬스체크는 실제 트래픽의 에러율과 타임아웃 빈도를 실시간으로 측정해 임계값(예: 5초 내 에러 50%)을 초과하면 해당 인스턴스를 임시로 제외합니다.

graph LR
  GW["게이트웨이"] --> K8S["K8s API Server"]
  K8S --> EP["Endpoints Watch"]
  EP --> LB["로드밸런서 풀"]
  LB --> HC["헬스체크 루프"]

카나리 배포와 트래픽 가중치

새 버전을 배포할 때 게이트웨이의 라우팅 가중치를 조정해 트래픽을 점진적으로 이동시킵니다.

routes:
  - path: /api/v2/orders/**
    backends:
      - service: order-service-v2
        weight: 95
      - service: order-service-v3
        weight: 5

v3로 흘러간 5% 트래픽의 에러율, 응답 시간, 비즈니스 지표(주문 완료율)가 v2와 동등하면 가중치를 10% → 25% → 50% → 100%로 단계적으로 올립니다. 문제가 감지되면 가중치를 0으로 내려 즉시 롤백합니다. 코드 배포 없이 라우팅 설정 변경만으로 릴리스 위험을 극적으로 낮추는 방식입니다.


4. 인증과 인가 — JWT 검증과 OAuth2

JWT 구조와 검증 흐름

JWT는 세 부분으로 나뉩니다. Header(알고리즘·타입), Payload(클레임·만료 시간), Signature(개인키로 서명한 해시)입니다. 게이트웨이는 Authorization 서버의 공개키로 Signature를 검증합니다. 서명이 유효하면 Payload의 내용을 신뢰할 수 있습니다.

graph LR
  C["클라이언트"] --> GW["게이트웨이"]
  GW --> PK["공개키 캐시"]
  PK --> V["서명 검증"]
  V --> UP["업스트림 서비스"]

공개키 캐시가 핵심입니다. Authorization 서버의 JWKS(JSON Web Key Set) 엔드포인트에서 공개키를 가져와 게이트웨이 메모리에 저장합니다. 이후 모든 JWT 검증은 인메모리에서 수십 마이크로초에 완료되어 Authorization 서버를 거치지 않습니다. 공개키가 갱신되면 Cache-Control 헤더의 만료 시간에 따라 자동으로 갱신합니다.

JWT 검증 단계별 체크리스트입니다.

1. Authorization 헤더 존재 여부 확인
2. "Bearer " 접두어 파싱
3. JWT 세 파트 분리 (header.payload.signature)
4. Base64URL 디코딩
5. 알고리즘이 RS256인지 확인 (none 알고리즘 공격 방어)
6. 공개키로 서명 검증
7. exp(만료 시간) 확인
8. iss(발행자) 확인
9. aud(대상 서비스) 확인
10. 검증된 클레임을 헤더에 주입

특히 5번 단계에서 알고리즘을 명시적으로 확인하는 것이 중요합니다. alg: none 공격은 Header에서 알고리즘을 none으로 지정해 서명 검증을 우회하는 기법입니다. 많은 초기 JWT 라이브러리가 이 공격에 취약했습니다.

OAuth2 플로우 처리

클라이언트 종류에 따라 OAuth2 Grant Type이 달라집니다.

모바일/SPA 앱: Authorization Code + PKCE 플로우를 사용합니다. 사용자가 로그인하면 Authorization Code가 발급되고, 앱이 Code Verifier와 함께 교환해 Access Token을 얻습니다. PKCE(Proof Key for Code Exchange)는 Authorization Code 탈취 공격을 방어합니다.

서버 간 통신: Client Credentials 플로우를 사용합니다. 서비스 A가 서비스 B를 호출할 때 client_id와 client_secret으로 Access Token을 발급받습니다. 게이트웨이는 이 토큰을 검증해 서비스 간 호출이 인가된 서비스에서 왔음을 보장합니다.

토큰 갱신: Access Token 만료(보통 15분~1시간)가 임박하면 클라이언트는 Refresh Token으로 새 Access Token을 요청합니다. Refresh Token은 장기 보관되므로 게이트웨이는 Refresh Token Rotation(사용 시 새 Refresh Token 발급)을 강제해 탈취 후 재사용을 방지합니다.


5. Rate Limiting — Redis Lua Sliding Window 구현

네 가지 알고리즘 직접 비교

Fixed Window Counter의 버스트 문제를 그림으로 이해해보겠습니다. 분당 100개 제한이라면, 00:59에 100개, 01:00에 100개가 모두 허용됩니다. 1초 사이에 200개가 통과하는 것입니다. 새 창이 열리는 순간 모든 사용자가 동시에 한도를 리셋받기 때문에 이 “경계 버스트”가 피크 트래픽 때 치명적입니다.

Leaky Bucket은 이름 그대로 새는 양동이입니다. 요청이 큐에 담기고, 양동이 바닥의 구멍(일정 처리 속도)으로 흘러나갑니다. 양동이가 가득 차면 넘치는 요청은 드롭됩니다. 출력 속도가 완벽하게 평탄화되어 업스트림 서비스를 보호합니다. 단점은 버스트 허용이 전혀 없어, 정상적인 순간 피크도 큐에서 기다리거나 드롭된다는 점입니다.

Token Bucket은 반대입니다. 빈 버킷에서 시작해 일정 속도로 토큰이 충전됩니다. 요청이 오면 토큰 하나를 소비하고, 토큰이 없으면 차단합니다. 버킷의 최대 용량만큼 토큰이 쌓여 있으면 순간적으로 그만큼의 버스트를 허용합니다. 평소에 조용하다가 특정 순간 폭발적으로 요청하는 패턴(예: 배치 작업 시작)에 유연합니다.

Sliding Window Counter는 현재 창과 이전 창의 카운터를 가중 평균으로 합산합니다. 예를 들어, 현재 시각이 1분 창의 30% 지점이라면, 이전 창 카운트 × 0.7 + 현재 창 카운트를 비교합니다. 이전 창의 트래픽이 현재 창에 얼마나 “흘러 들어왔는지”를 선형으로 근사합니다. Fixed Window의 버스트 문제를 해결하면서 Sliding Window Log의 메모리 문제도 피할 수 있습니다.

Redis Lua로 Sliding Window 구현

Redis에서 Rate Limit을 구현할 때 읽기-비교-쓰기를 원자적으로 수행해야 합니다. 비원자적으로 구현하면 두 요청이 동시에 “남은 한도 있음”을 읽고 동시에 카운터를 증가시켜 한도를 초과하는 TOCTOU(Time-of-Check Time-of-Use) 경쟁 조건이 생깁니다. Lua 스크립트는 Redis에서 원자적으로 실행됩니다.

-- Sliding Window Counter Rate Limiter
-- KEYS[1]: rate limit key (예: "rl:user:12345")
-- ARGV[1]: 현재 타임스탬프 (초 단위)
-- ARGV[2]: 창 크기 (초 단위, 예: 60)
-- ARGV[3]: 허용 한도 (예: 100)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- 현재 창과 이전 창의 키
local current_window = math.floor(now / window)
local prev_window = current_window - 1

local current_key = key .. ":" .. current_window
local prev_key = key .. ":" .. prev_window

-- 현재 창 내 경과 비율 (0.0 ~ 1.0)
-- 예: 창 시작 후 18초 경과, 창 크기 60초 → 0.3 경과
local elapsed_ratio = (now % window) / window

-- 이전 창 카운트 조회 (없으면 0)
local prev_count = tonumber(redis.call("GET", prev_key) or "0")

-- 현재 창 카운트 조회 (없으면 0)
local current_count = tonumber(redis.call("GET", current_key) or "0")

-- 슬라이딩 윈도우 추정치: 이전 창의 남은 비율 + 현재 창 카운트
-- (1 - elapsed_ratio)는 이전 창이 현재 창에 영향을 주는 비율
local estimated = (prev_count * (1 - elapsed_ratio)) + current_count

-- 한도 초과 여부 확인
if estimated + 1 > limit then
  -- 429 반환: 남은 한도 0, 다음 창까지 대기 시간 반환
  local retry_after = window - (now % window)
  return {0, retry_after}
end

-- 현재 창 카운터 증가 (창 만료 시간: window * 2, 이전 창 참조 가능하도록)
redis.call("INCR", current_key)
redis.call("EXPIRE", current_key, window * 2)

-- 남은 한도와 현재 창 만료까지 남은 시간 반환
local remaining = limit - math.floor(estimated) - 1
local reset_after = window - (now % window)
return {1, remaining, reset_after}

스크립트 동작을 한 줄씩 설명합니다.

current_window = math.floor(now / window): 현재 타임스탬프를 창 크기로 나눈 몫이 창 번호입니다. 창 크기가 60초이면 Unix Time 1716300000~1716300059가 모두 창 번호 28605000에 속합니다. 이렇게 창을 시간 슬롯으로 표현하면 창 경계를 자동으로 처리합니다.

elapsed_ratio = (now % window) / window: 현재 창이 얼마나 진행됐는지 0.0에서 1.0 사이 값으로 나타냅니다. 창이 막 시작됐으면 0.0에 가깝고, 끝나가면 1.0에 가깝습니다. 이 값이 이전 창의 가중치를 결정합니다.

estimated = prev_count * (1 - elapsed_ratio) + current_count: 핵심 공식입니다. 이전 창의 요청이 균등하게 분포한다고 가정할 때, 현재 창의 슬라이딩 윈도우 안에 포함되는 이전 창 요청의 추정치를 계산합니다. 현재 창이 20% 진행됐다면 이전 창의 80%가 여전히 슬라이딩 윈도우 안에 있습니다.

EXPIRE current_key, window * 2: 현재 창 키의 만료를 창 크기의 2배로 설정합니다. 이렇게 하면 다음 창이 현재 창을 “이전 창”으로 참조할 수 있습니다. 창 크기만큼만 설정하면 창이 끝나는 순간 이전 창 데이터가 사라져 Sliding 효과가 없어집니다.

반환값 {1, remaining, reset_after}에서 첫 번째는 허용(1)/거부(0), 두 번째는 남은 한도, 세 번째는 창 리셋까지 남은 초입니다. 게이트웨이는 이 값을 응답 헤더에 담습니다.

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 23
X-RateLimit-Reset: 42
Retry-After: 42   (429 응답 시에만)

분산 환경 Rate Limit 동기화

Rate Limit의 진짜 어려움은 단일 서버가 아니라 다수의 게이트웨이 인스턴스에서 동일한 사용자의 요청이 분산될 때 발생합니다.

로컬 카운터 방식은 각 게이트웨이 인스턴스가 자체 카운터를 유지합니다. 분당 100개 제한에 인스턴스가 5개라면, 각 인스턴스가 20개씩을 허용하거나 첫 번째 인스턴스에 100개를 모두 허용합니다. 이 방식은 네트워크 왕복 없이 가장 빠르지만, 라운드로빈으로 분산될 때 사용자가 실제로 500개를 전송할 수 있습니다. 정밀도가 낮아 Rate Limit의 의미가 희석됩니다.

글로벌 Redis 카운터 방식은 모든 인스턴스가 하나의 Redis 클러스터에서 카운터를 공유합니다. 정확도가 높지만 모든 요청마다 Redis 왕복(약 1~3ms)이 추가됩니다. 초당 50만 요청이면 초당 50만 건의 Redis 연산입니다. Redis Cluster를 사용하면 수평 확장이 가능하지만, Redis 자체가 단일 장애점이 됩니다.

하이브리드: 로컬 버킷 + 글로벌 동기화 방식이 실용적인 절충안입니다. 각 인스턴스는 로컬에 작은 버킷(예: 10개)을 두고, 버킷이 소진되면 Redis에서 다음 버킷을 예약(배치로 당겨오기)합니다. 인스턴스가 5개이고 전체 한도가 100이면, 각 인스턴스가 Redis에서 20개씩 배치로 가져와 소진하고 다시 채웁니다. Redis 연산이 1/20로 줄고, 정확도는 버킷 크기(±10개) 이내로 보장됩니다.

graph LR
  I1["인스턴스1 버킷"] --> R["Redis 글로벌"]
  I2["인스턴스2 버킷"] --> R
  R --> Q["전체 한도 관리"]

6. Circuit Breaker — 장애 전파를 막는 차단기

세 가지 상태와 전이 조건

Circuit Breaker는 전기 회로 차단기에서 이름을 따왔습니다. 과전류가 흐르면 차단기가 열려(OPEN) 회로를 보호하고, 안정화되면 다시 닫아(CLOSE) 전류를 흘립니다. 소프트웨어 Circuit Breaker도 동일한 방식으로 동작합니다.

CLOSED 상태: 정상 동작 상태입니다. 모든 요청이 업스트림으로 전달됩니다. 에러율이 슬라이딩 윈도우 기준(예: 최근 10초 내 요청의 50% 이상 실패)을 초과하면 OPEN으로 전환합니다. 단순히 “에러가 X번 발생”이 아니라 “에러율이 X%”를 기준으로 삼는 이유는 트래픽이 적을 때 몇 건의 에러로 차단기가 오작동하는 것을 방지하기 위해서입니다. 최소 요청 수 임계값(예: 최근 10초에 20건 이상 요청 시에만 에러율 계산)을 함께 설정합니다.

OPEN 상태: 차단기가 열린 상태입니다. 업스트림 호출 없이 즉시 실패(Fail-Fast)를 반환합니다. 보통 503 Service Unavailable이나 미리 정의한 폴백 응답을 반환합니다. OPEN 상태에서는 설정된 대기 시간(예: 10초~60초) 후 자동으로 HALF_OPEN으로 전환됩니다. OPEN 상태에서 모든 요청을 차단하는 이유는 이미 불안정한 업스트림에 계속 요청을 보내면 회복 시도 중인 서비스에 추가 부하를 주기 때문입니다.

HALF_OPEN 상태: 탐색 상태입니다. 제한된 수의 요청(예: 5건)만 업스트림으로 허용해 회복 여부를 탐색합니다. 탐색 요청이 모두 성공하면 CLOSED로 복귀합니다. 탐색 요청 중 하나라도 실패하면 다시 OPEN으로 돌아가 대기합니다. HALF_OPEN이 없다면 OPEN에서 CLOSED로 직접 전환해야 하는데, 완전 복구 전에 모든 트래픽이 쏟아지면 회복 중인 서비스를 다시 무너뜨립니다. HALF_OPEN은 “조심스러운 탐색” 단계입니다.

graph LR
  CL["CLOSED"] --> OP["OPEN"]
  OP --> HO["HALF_OPEN"]
  HO --> CL
  HO --> OP

Circuit Breaker 구현 핵심 코드

import time
from enum import Enum
from collections import deque
from threading import Lock

class State(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold=0.5,   # 에러율 50% 초과 시 OPEN
        min_requests=20,          # 최소 요청 수 (이 이하면 에러율 무시)
        window_seconds=10,        # 슬라이딩 윈도우 크기
        open_timeout=30,          # OPEN → HALF_OPEN 대기 시간 (초)
        half_open_probes=5,       # HALF_OPEN 탐색 요청 수
    ):
        self.failure_threshold = failure_threshold
        self.min_requests = min_requests
        self.window_seconds = window_seconds
        self.open_timeout = open_timeout
        self.half_open_probes = half_open_probes

        self.state = State.CLOSED
        self.open_since = None
        self.probe_count = 0
        self.probe_success = 0

        # 슬라이딩 윈도우: (timestamp, is_failure) 튜플 저장
        self.window = deque()
        self.lock = Lock()

    def _evict_expired(self, now):
        """윈도우 크기를 벗어난 오래된 기록 제거"""
        cutoff = now - self.window_seconds
        while self.window and self.window[0][0] < cutoff:
            self.window.popleft()

    def _failure_rate(self, now):
        self._evict_expired(now)
        if len(self.window) < self.min_requests:
            return 0.0  # 최소 요청 미달 → 에러율 계산 안 함
        failures = sum(1 for _, is_fail in self.window if is_fail)
        return failures / len(self.window)

    def allow_request(self):
        """요청을 허용할지 판단. True=허용, False=차단"""
        with self.lock:
            now = time.time()

            if self.state == State.CLOSED:
                return True

            if self.state == State.OPEN:
                # 대기 시간이 지났으면 HALF_OPEN으로 전환
                if now - self.open_since >= self.open_timeout:
                    self.state = State.HALF_OPEN
                    self.probe_count = 0
                    self.probe_success = 0
                    return True  # 첫 탐색 요청 허용
                return False  # 아직 대기 중

            if self.state == State.HALF_OPEN:
                if self.probe_count < self.half_open_probes:
                    self.probe_count += 1
                    return True
                return False  # 탐색 한도 초과

    def record_result(self, success: bool):
        """요청 결과 기록 후 상태 전이 판단"""
        with self.lock:
            now = time.time()
            self.window.append((now, not success))

            if self.state == State.HALF_OPEN:
                if success:
                    self.probe_success += 1
                    # 모든 탐색 요청 성공 → CLOSED 복귀
                    if self.probe_success >= self.half_open_probes:
                        self.state = State.CLOSED
                        self.window.clear()
                else:
                    # 탐색 중 실패 → OPEN으로 복귀
                    self.state = State.OPEN
                    self.open_since = now
                return

            if self.state == State.CLOSED:
                rate = self._failure_rate(now)
                if rate >= self.failure_threshold:
                    self.state = State.OPEN
                    self.open_since = now

폴백(Fallback) 전략

Circuit Breaker가 OPEN일 때 단순히 에러를 반환하면 사용자 경험이 나빠집니다. 서비스별로 적절한 폴백 응답을 설계해야 합니다.

캐시 응답 반환: 상품 목록, 추천 콘텐츠처럼 약간 낡아도 괜찮은 데이터는 Redis 캐시에서 마지막으로 성공한 응답을 반환합니다. “실시간은 아니지만 사용 가능한” 응답입니다.

기본값 반환: 개인화 서비스가 다운됐을 때 “맞춤 추천 없음 → 인기 상품 목록”처럼 안전한 기본값을 반환합니다.

부분 응답: 복합 API(여러 서비스 응답을 조합)에서 일부 서비스만 실패했을 때, 실패한 부분을 null로 채워 부분 응답을 반환합니다. 완전 실패보다 부분 성공이 낫습니다.

즉시 거부: 결제, 인증처럼 정확성이 절대적인 기능은 폴백 없이 명확한 오류 메시지와 함께 거부합니다. 잘못된 정보를 돌려주는 것이 더 위험합니다.


7. Bulkhead 패턴 — 서비스 간 장애 격리

Bulkhead가 필요한 이유

Bulkhead는 선박의 격벽에서 이름을 따왔습니다. 타이타닉에 격벽이 제대로 작동했다면, 한 구획에 물이 차도 다른 구획은 멀쩡했을 것입니다. 소프트웨어의 Bulkhead도 마찬가지입니다. 한 서비스가 느려져도 그 서비스를 기다리는 스레드나 커넥션이 다른 서비스의 리소스를 잠식하지 못하게 격리합니다.

Circuit Breaker와의 차이를 명확히 구분해야 합니다. Circuit Breaker는 “이미 실패한 서비스 호출을 차단”합니다. Bulkhead는 “느린 서비스에 할당되는 리소스를 제한해 다른 서비스에 영향이 전파되지 않게” 합니다. Circuit Breaker는 장애 후 반응이고, Bulkhead는 장애 전 격리입니다.

스레드 풀 격리와 세마포어 격리

Bulkhead 구현에는 두 가지 주요 방식이 있습니다.

스레드 풀 격리는 각 서비스마다 별도의 스레드 풀을 할당합니다. order-service용 풀 20개 스레드, payment-service용 풀 10개 스레드, recommendation-service용 풀 30개 스레드를 독립적으로 운영합니다. recommendation-service가 응답이 느려 30개 스레드가 모두 블록되더라도, order-service의 20개 스레드는 전혀 영향받지 않습니다. 단점은 서비스 수만큼 스레드가 생성되어 컨텍스트 스위칭 비용이 늘고, 트래픽 패턴이 바뀌어도 풀 크기를 수동으로 조정해야 합니다.

세마포어 격리는 스레드 풀 대신 동시 요청 수를 카운터로 제한합니다. order-service는 동시에 최대 50개 요청만 허용하고, 그 이상이면 즉시 실패를 반환합니다. 스레드를 별도로 생성하지 않아 오버헤드가 적고, 서비스 수가 많은 환경에 적합합니다. 단, 타임아웃 제어가 스레드 풀만큼 세밀하지 않습니다.

import threading
from contextlib import contextmanager

class Bulkhead:
    """세마포어 기반 Bulkhead 구현"""
    def __init__(self, max_concurrent: int, max_queue: int = 0):
        # max_concurrent: 동시 처리 한도
        # max_queue: 대기 허용 수 (0 = 즉시 실패)
        self.semaphore = threading.Semaphore(max_concurrent)
        self.max_concurrent = max_concurrent
        self.max_queue = max_queue
        self._active = 0
        self._queued = 0
        self._lock = threading.Lock()

    @contextmanager
    def acquire(self, timeout: float = None):
        """컨텍스트 매니저로 Bulkhead 획득/해제"""
        with self._lock:
            if self._active >= self.max_concurrent:
                if self._queued >= self.max_queue:
                    raise BulkheadFullError(
                        f"Bulkhead full: {self._active} active, "
                        f"{self._queued} queued"
                    )
                self._queued += 1

        acquired = self.semaphore.acquire(timeout=timeout)
        if not acquired:
            with self._lock:
                self._queued -= 1
            raise BulkheadTimeoutError("Bulkhead acquire timeout")

        with self._lock:
            self._active += 1
            if self._queued > 0:
                self._queued -= 1

        try:
            yield
        finally:
            with self._lock:
                self._active -= 1
            self.semaphore.release()

class BulkheadFullError(Exception):
    pass

class BulkheadTimeoutError(Exception):
    pass

# 사용 예시: 서비스별 Bulkhead 설정
bulkheads = {
    "order-service":          Bulkhead(max_concurrent=50, max_queue=10),
    "payment-service":        Bulkhead(max_concurrent=20, max_queue=5),
    "recommendation-service": Bulkhead(max_concurrent=30, max_queue=20),
}

타임아웃 계층 구조

Bulkhead와 함께 타임아웃을 계층적으로 설계해야 합니다.

클라이언트 타임아웃:     30,000ms (사용자가 느끼는 최대 대기 시간)
게이트웨이 타임아웃:     10,000ms (게이트웨이 전체 처리 한도)
업스트림 연결 타임아웃:   3,000ms (TCP 연결 수립 한도)
업스트림 읽기 타임아웃:   8,000ms (응답 수신 완료 한도)
Bulkhead 대기 타임아웃:  2,000ms (세마포어 획득 대기 한도)

타임아웃이 계층적이지 않으면 상위 타임아웃이 먼저 끊겨도 하위에서 여전히 리소스를 점유합니다. 예를 들어 클라이언트가 30초 후 연결을 끊어도 게이트웨이는 업스트림 응답을 60초까지 기다리면 그 60초 동안 연결 리소스가 낭비됩니다. 각 계층의 타임아웃을 상위 계층보다 짧게 설정해 불필요한 리소스 점유를 방지합니다.


8. 요청/응답 변환과 로깅

요청 변환: 클라이언트 계약과 서비스 계약 분리

API Gateway의 중요한 역할 중 하나는 클라이언트 API 계약과 내부 서비스 계약을 분리하는 것입니다. 이 분리가 없으면 내부 서비스 변경이 클라이언트에 즉시 영향을 주고, 클라이언트 다양성(모바일 v1, 웹 v2, 파트너 v3)을 서비스가 직접 감당해야 합니다.

경로 변환: 클라이언트에게 /api/v2/orders로 노출하되 실제 서비스 URL은 order-service:8080/v3/orders로 라우팅합니다. 클라이언트는 내부 서비스 버전 변경을 알 필요가 없습니다.

헤더 주입 및 제거: 인증에서 추출한 X-User-Id를 주입하고, 내부 서비스 헤더(X-Internal-Trace-Id, X-Service-Version)를 클라이언트 응답에서 제거합니다. 내부 구현 세부사항을 외부에 노출하지 않습니다.

페이로드 변환: 레거시 XML 서비스를 JSON API로 노출하거나, 내부 snake_case 필드를 외부 camelCase로 변환합니다. gRPC 백엔드를 REST API로 트랜스코딩하는 것도 이 계층에서 처리합니다.

응답 집계(Aggregation): 클라이언트 한 번의 요청으로 여러 서비스 응답을 합쳐 반환합니다. 주문 상세 API가 주문 서비스 + 배송 서비스 + 리뷰 서비스를 병렬 호출해 단일 응답으로 조합합니다. 클라이언트가 3번 왕복할 것을 게이트웨이가 대신 처리합니다.

Kafka 비동기 로깅 파이프라인

게이트웨이는 모든 요청/응답을 기록해야 합니다. 이 로그는 디버깅, 감사, 이상 탐지, 비용 청구에 사용됩니다. 문제는 로깅이 요청 처리 지연에 영향을 주면 안 된다는 점입니다.

graph LR
  GW["게이트웨이"] --> BUF["로컬 버퍼"]
  BUF --> K["Kafka 토픽"]
  K --> ES["Elasticsearch"]

동기 로깅은 요청마다 디스크나 원격 스토리지에 쓰는 시간이 추가됩니다. Kafka 비동기 파이프라인은 이 문제를 해결합니다.

1단계 — 로컬 메모리 버퍼: 요청 처리가 완료되면 로그 이벤트를 로컬 메모리 큐(RingBuffer 또는 LinkedBlockingQueue)에 넣습니다. 이 작업은 메모리 쓰기이므로 수 마이크로초입니다.

2단계 — Kafka Producer Batching: 백그라운드 스레드가 메모리 큐에서 이벤트를 소비해 Kafka Producer의 배치 버퍼에 누적합니다. Kafka Producer는 두 조건 중 먼저 만족하는 시점에 배치를 전송합니다. batch.size(예: 64KB) 또는 linger.ms(예: 10ms) 중 하나가 충족되면 배치 전송을 시작합니다. linger.ms=10으로 설정하면 10ms 안에 쌓인 이벤트를 한 번의 네트워크 왕복으로 전송합니다. 이벤트 1개당 1번의 네트워크 호출에서 이벤트 100개당 1번으로 줄어듭니다.

3단계 — Kafka Consumer → Elasticsearch: Kafka Consumer가 토픽에서 이벤트를 가져와 Elasticsearch에 색인합니다. Elasticsearch는 Kibana로 시각화하거나 이상 탐지 쿼리에 사용됩니다.

// Kafka Producer 설정 (게이트웨이 로그용)
Properties props = new Properties();
props.put("bootstrap.servers", "kafka:9092");
props.put("key.serializer", StringSerializer.class);
props.put("value.serializer", JsonSerializer.class);

// 배치 설정: 성능과 지연의 균형
props.put("batch.size", 65536);       // 64KB 이상 쌓이면 즉시 전송
props.put("linger.ms", 10);           // 최대 10ms 대기 후 전송
props.put("compression.type", "lz4"); // 압축으로 네트워크 대역폭 절감
props.put("acks", "1");               // 리더 확인만 (로그는 가끔 유실 허용)
props.put("buffer.memory", 33554432); // Producer 버퍼 32MB

// 비동기 전송: 게이트웨이 요청 처리 블록 없음
producer.send(
    new ProducerRecord<>("gateway-access-log", requestId, logEvent),
    (metadata, exception) -> {
        if (exception != null) {
            // 로그 전송 실패는 메트릭으로 추적하되 요청 처리에는 영향 없음
            metricsRegistry.counter("kafka.send.failed").increment();
        }
    }
);

acks=1로 설정한 이유에 주목해야 합니다. 일반적으로 중요한 데이터는 acks=all(모든 복제본 확인)을 사용해야 합니다. 하지만 접근 로그는 일부 유실이 허용됩니다. acks=all은 Kafka 리더와 팔로워 모두가 응답을 확인하기까지 기다려야 해서 Producer 지연이 늘어납니다. 로그보다 처리 성능이 우선인 경우 acks=1이 실용적인 트레이드오프입니다.

로그 이벤트 스키마

게이트웨이 로그 이벤트는 Kafka 메시지 하나에 담깁니다.

{
  "timestamp": "2026-05-21T09:15:32.847Z",
  "request_id": "req-a1b2c3d4",
  "trace_id": "trace-xyz789",
  "method": "POST",
  "path": "/api/v2/orders",
  "status_code": 201,
  "user_id": "user-12345",
  "client_ip": "203.0.113.42",
  "user_agent": "CoupangApp/5.2.1 iOS",
  "upstream_service": "order-service",
  "upstream_instance": "order-service-pod-7d9f",
  "latency_ms": {
    "gateway_total": 48,
    "upstream": 35,
    "auth": 3,
    "rate_limit": 2
  },
  "request_size_bytes": 1024,
  "response_size_bytes": 512,
  "rate_limit_remaining": 82,
  "circuit_breaker_state": "closed"
}

latency_ms 하위 필드에 각 단계별 지연을 기록하면, “게이트웨이 총 48ms 중 업스트림이 35ms, 인증이 3ms”처럼 지연 원인을 분석할 수 있습니다. trace_id는 분산 추적(Distributed Tracing) 시스템과 연계해 게이트웨이에서 시작한 요청이 내부 서비스를 거쳐 어떻게 처리됐는지 전체 경로를 추적합니다.


9. 트래픽 폭주 시 Graceful Degradation

부하 단계별 전략

트래픽 폭주는 갑작스러운 이벤트(인기 상품 출시, 뉴스 이슈, 마케팅 이메일 발송)로 인해 예상 피크의 10배~100배 트래픽이 몰리는 상황입니다. 이때 게이트웨이가 “모 아니면 도”로 동작하면 정상 사용자까지 모두 서비스를 받지 못합니다.

단계 1 — Load Shedding (자동 과부하 탈출): 게이트웨이가 처리할 수 있는 동시 요청 수의 상한(예: 10만 동시 연결)을 설정하고, 초과 요청은 즉시 503을 반환합니다. 이미 처리 중인 요청의 품질을 보장하기 위해 새 요청을 의도적으로 포기합니다. “10만 명에게 정상 응답”이 “100만 명에게 30초 타임아웃”보다 낫습니다.

단계 2 — Priority Queue (우선순위 처리): 모든 요청이 동등하지 않습니다. 결제 완료 요청은 상품 검색 요청보다 중요합니다. 요청 우선순위를 경로와 사용자 등급으로 분류하고, 부하가 높을 때 낮은 우선순위 요청을 먼저 드롭합니다.

우선순위 1: 결제 확인, 주문 완료, 환불 처리
우선순위 2: 장바구니 조작, 주문 조회
우선순위 3: 상품 검색, 추천, 리뷰 조회
우선순위 4: 통계, 로그, 이벤트 수집

단계 3 — Feature Flag 기반 기능 비활성화: 트래픽 폭주 시 비필수 기능을 즉시 비활성화합니다. 게이트웨이가 특정 경로를 차단하거나 503 대신 미리 준비한 “현재 서비스 점검 중” 응답을 반환합니다. 추천 기능을 끄고, 실시간 재고 업데이트를 캐시 값으로 대체하고, 개인화를 기본값으로 대체합니다. 핵심 구매 플로우만 살리고 나머지는 수동으로 비활성화하는 운영 레버입니다.

단계 4 — Back Pressure 전파: 게이트웨이가 업스트림 서비스에서 503이나 429 응답을 받으면, 해당 서비스로 향하는 요청 속도를 즉시 낮춥니다. 단순히 에러를 클라이언트에 중계하는 것이 아니라 게이트웨이 자신이 발신 속도를 조절(throttling)합니다. 업스트림 서비스가 회복할 시간을 줍니다.

자동 스케일링과 게이트웨이의 관계

HPA(Horizontal Pod Autoscaler)로 게이트웨이 인스턴스를 자동 증설할 때, 게이트웨이 확장 속도가 업스트림 서비스 확장 속도보다 빠르면 문제가 생깁니다. 게이트웨이가 초당 50만 요청을 수신해 업스트림으로 모두 전달하지만, 업스트림이 20만 요청만 처리 가능하면 30만 요청이 에러로 돌아옵니다.

이를 방지하려면 게이트웨이와 업스트림 서비스 모두에 적절한 Rate Limit을 설정하고, 업스트림 서비스의 처리 용량(throughput)을 게이트웨이의 메트릭 대시보드에서 실시간으로 모니터링해야 합니다. “게이트웨이가 얼마나 받았는가”와 “업스트림이 얼마나 처리했는가”의 차이가 30% 이상 벌어지면 알람을 울립니다.


10. 보안: 게이트웨이에서 방어해야 할 공격 유형

OWASP API Top 10 게이트웨이 방어

Broken Object Level Authorization (BOLA/IDOR): 사용자 A가 /api/orders/12345 대신 /api/orders/12346을 호출해 다른 사용자의 주문을 조회하는 공격입니다. 게이트웨이는 경로의 리소스 ID와 JWT의 user_id가 일치하는지 확인하는 미들웨어를 추가할 수 있지만, 이는 비즈니스 로직에 해당하므로 서비스에서 처리하는 것이 더 적절합니다. 게이트웨이에서는 JWT 클레임을 정확히 주입해 서비스가 검증할 수 있게 합니다.

Injection (SQL, NoSQL, Command): 요청 파라미터에 ; DROP TABLE orders-- 같은 주입 패턴이 포함되면 게이트웨이에서 WAF(Web Application Firewall) 규칙으로 차단합니다. 게이트웨이에 ModSecurity나 AWS WAF를 통합하면 OWASP Core Rule Set을 적용할 수 있습니다.

Unrestricted Resource Consumption: 게이트웨이의 Rate Limiting이 이 공격을 직접 방어합니다. 페이로드 크기 제한(예: 최대 10MB), 파라미터 개수 제한, 중첩 깊이 제한을 함께 설정합니다.

Mass Assignment: 클라이언트가 서버 내부 필드(예: is_admin: true, price: 0)를 임의로 포함해 전송하는 공격입니다. 게이트웨이에서 허용된 필드만 통과시키는 화이트리스트 필드 필터링을 구현합니다.

TLS 설정 강화

TLS 1.0, 1.1: 비활성화 (POODLE, BEAST 취약점)
TLS 1.2: 허용 (레거시 클라이언트 호환)
TLS 1.3: 권장 (핸드셰이크 1-RTT, 더 강한 암호화)

허용 암호 스위트 (우선순위 순):
  TLS_AES_256_GCM_SHA384      (TLS 1.3)
  TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3, 모바일 성능 우수)
  TLS_AES_128_GCM_SHA256      (TLS 1.3)
  ECDHE-RSA-AES256-GCM-SHA384 (TLS 1.2)

비활성화:
  RC4, 3DES, MD5, NULL, EXPORT 암호 스위트 전부

HSTS(HTTP Strict Transport Security) 헤더를 포함해 브라우저가 HTTP로 폴백하지 않도록 강제합니다.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

11. 성능 최적화: 어디서 시간이 사라지나

커넥션 풀과 Keep-Alive

게이트웨이-업스트림 구간에서 매 요청마다 새 TCP 연결을 맺으면 3-way 핸드셰이크(약 1RTT)와 TLS 핸드셰이크(약 2RTT)가 추가됩니다. 1RTT가 1ms라면 요청마다 3ms가 낭비됩니다. 초당 50만 요청에서 이는 엄청난 오버헤드입니다.

HTTP/1.1 Keep-Alive와 커넥션 풀로 이를 해결합니다. 업스트림 서비스당 미리 N개의 TCP 연결을 맺어두고 재사용합니다. 업스트림 인스턴스가 100개이고 게이트웨이가 20개라면, 각 게이트웨이가 각 인스턴스와 10개의 커넥션을 유지하면 총 2만 개의 커넥션이 항상 열려 있습니다. 요청이 오면 빈 커넥션을 풀에서 꺼내 즉시 사용합니다.

HTTP/2 멀티플렉싱은 더 진일보한 방식입니다. 하나의 TCP 연결 위에서 여러 요청/응답을 동시에 처리합니다. HTTP/1.1에서 파이프라인 없이 동시 요청 처리는 커넥션 수에 비례하지만, HTTP/2에서는 하나의 커넥션으로 수백 개의 스트림을 처리합니다.

응답 캐시

모든 요청을 업스트림으로 전달하면 업스트림 부하가 선형으로 증가합니다. 게이트웨이 레이어에서 응답을 캐시하면 업스트림 요청을 극적으로 줄일 수 있습니다.

캐시 가능 여부 판단 기준입니다.

캐시 가능 (GET 요청, 공개 데이터):
  /api/v2/products/{id}           → TTL 60초
  /api/v2/categories              → TTL 300초
  /api/v2/promotions/active       → TTL 30초

캐시 불가:
  POST, PUT, DELETE 요청 (부작용 있음)
  /api/v2/orders (사용자별 데이터)
  Authorization 헤더 포함 요청 (캐시 키에 포함 시 가능)
  Cache-Control: no-cache 또는 no-store

사용자별 응답은 Authorization 헤더의 user_id를 캐시 키에 포함해 사용자별로 분리 캐시합니다. 공개 데이터는 URL만으로 캐시 키를 만들어 모든 사용자가 공유합니다.

지연 시간 버짓 관리

전체 API 응답 지연 목표(예: 99번째 퍼센타일 100ms)를 계층별로 배분합니다.

클라이언트 ↔ 게이트웨이 네트워크:  10ms
SSL 종료 + 파싱:                    2ms
인증 (캐시 히트):                   1ms
Rate Limit (Redis):                 3ms
라우팅 결정:                        1ms
게이트웨이 ↔ 업스트림 네트워크:     5ms
업스트림 처리:                     70ms
응답 변환 + 로그 비동기 처리:       3ms
여유:                               5ms
합계:                             100ms

각 단계의 실제 지연을 지속적으로 측정하고, 특정 단계가 버짓을 초과하면 해당 단계를 집중적으로 최적화합니다.


12. 운영 관찰성: 게이트웨이를 어떻게 모니터링하나

황금 신호 4가지

Site Reliability Engineering(SRE)에서 정의한 Four Golden Signals를 게이트웨이에 적용합니다.

Latency(지연): 요청의 응답 시간입니다. 평균이 아니라 P50·P95·P99 퍼센타일을 함께 봐야 합니다. P50은 정상이지만 P99가 5초라면 1%의 사용자가 5초를 기다리는 것입니다. “성공 요청의 P95 지연”과 “실패 요청의 P95 지연”을 분리 측정하는 것도 중요합니다. 실패 요청의 지연이 짧으면 빠른 실패(타임아웃 전 Circuit Breaker 차단)를, 길면 타임아웃까지 기다린 것을 의미합니다.

Traffic(트래픽): 초당 요청 수(RPS), 경로별 분포, 사용자별 분포를 측정합니다. 특정 경로의 트래픽이 갑자기 10배로 증가하면 공격이나 클라이언트 버그일 수 있습니다.

Errors(에러): HTTP 5xx 에러율을 지속 측정합니다. 전체 에러율뿐 아니라 서비스별 에러율을 분리해 특정 서비스의 문제를 빠르게 격리합니다. 429(Rate Limit 초과)와 503(Circuit Breaker OPEN)을 별도 카운터로 추적합니다.

Saturation(포화): 커넥션 풀 사용률, CPU, 메모리, 파일 디스크립터 사용률을 측정합니다. “현재 얼마나 채워졌는가”가 포화 지표입니다. 커넥션 풀이 90% 이상 사용되고 있다면 풀 크기를 늘리거나 업스트림을 스케일 아웃해야 합니다.

분산 추적 연동

모든 요청에 고유한 Trace-Id를 생성하고 업스트림 서비스로 전파합니다. 업스트림 서비스는 이 ID를 자신의 로그와 Span에 포함합니다. Jaeger나 Zipkin 같은 분산 추적 시스템에서 하나의 Trace-Id로 전체 요청 경로를 시각화할 수 있습니다.

요청 수신 시:
  X-Trace-Id가 없으면 새로 생성 (UUID v4)
  X-Trace-Id가 있으면 그대로 전파

업스트림 호출 시:
  X-Trace-Id: {기존 trace id}
  X-Span-Id:  {이 게이트웨이 구간의 새 span id}
  X-Parent-Span-Id: {상위 span id}

13. 배포 전략: 게이트웨이를 무중단으로 업데이트하는 법

Blue-Green 배포

게이트웨이를 두 세트(Blue, Green)로 운영합니다. 현재 트래픽은 Blue로 들어오고, 새 버전은 Green에 배포합니다. Green의 헬스체크와 통합 테스트가 통과하면 로드밸런서의 가중치를 0:100에서 100:0으로 순간 전환합니다. 문제가 생기면 다시 100:0으로 롤백합니다.

단점은 항상 2배의 인스턴스를 유지해야 한다는 점입니다. 비용이 2배입니다.

Rolling Update

Kubernetes의 기본 배포 방식입니다. 파드를 하나씩 교체합니다. 10개 파드 중 1개를 새 버전으로 교체하고, 헬스체크가 통과하면 다음 파드를 교체합니다. 배포 중 구 버전과 신 버전이 동시에 서비스됩니다. API의 하위 호환성이 보장되어야 합니다.

maxUnavailable=0(항상 현재 수 이상 유지)과 maxSurge=1(최대 1개 추가)로 설정하면 배포 중 용량 손실 없이 안전하게 교체합니다.

설정 변경 무중단 적용

라우팅 규칙, Rate Limit 임계값, Circuit Breaker 설정 같은 설정 변경은 인스턴스 재시작 없이 Hot Reload로 적용해야 합니다. Kubernetes ConfigMap을 Watch해 변경이 감지되면 설정을 동적으로 재로드합니다. 인스턴스 재시작 없이 설정을 적용하면 배포 중 연결 끊김 없이 수초 내에 전파됩니다.


14. 면접 시 자주 나오는 심화 질문

Q1. 게이트웨이 자체가 단일 장애점(SPOF)이 아닌가요? 정확한 지적입니다. 게이트웨이가 단일 인스턴스라면 SPOF입니다. 이를 해결하는 방법은 세 가지입니다. 첫째, **수평 확장과 상태 비저장(Stateless) 설계**입니다. 게이트웨이 인스턴스는 로컬 상태를 갖지 않습니다. Rate Limit 카운터는 Redis에, 라우팅 설정은 ConfigMap에 있습니다. 인스턴스를 3개, 5개, 20개로 늘려도 동일하게 동작합니다. 인스턴스 하나가 죽어도 나머지가 트래픽을 처리합니다. 둘째, **다중 가용 영역(AZ) 분산**입니다. 각 가용 영역에 인스턴스를 배치하고 NLB(Network Load Balancer) 뒤에 둡니다. 하나의 데이터센터가 전체 다운되어도 나머지 AZ의 인스턴스가 살아있습니다. 셋째, **비상시 게이트웨이 우회 경로(Bypass)**입니다. 게이트웨이 전체가 불능이 되는 극단적 상황에 대비해, 특정 서비스(결제, 주문 완료)는 게이트웨이를 거치지 않고 직접 접근 가능한 비상 경로를 관리 IP 기반 접근 제어로 운영합니다. 이 경로는 평소에는 완전히 차단됩니다.
Q2. JWT 토큰을 서버에서 즉시 무효화할 수 없다는 게 사실인가요? 기본 JWT의 설계상 한계입니다. JWT는 서명 기반이라 서버가 발급 후 상태를 저장하지 않습니다. 만료 시간(exp)이 되기 전에는 어떤 서버도 이 토큰을 검증해 유효하다고 판단합니다. 사용자가 로그아웃해도 토큰이 만료될 때까지 재사용 가능합니다. 해결책은 **토큰 블랙리스트**입니다. 로그아웃 또는 토큰 무효화 이벤트 발생 시 `jti`(JWT ID, 토큰 고유 식별자)를 Redis에 저장하고, 게이트웨이는 서명 검증 후 블랙리스트를 추가 조회합니다. ``` 검증 순서: 1. 서명 검증 (공개키, 메모리) 2. exp 만료 확인 3. jti 블랙리스트 조회 (Redis, ~1ms) 4. 블랙리스트에 있으면 401 반환 ``` 단, 이 방식은 토큰 만료 시간만큼 블랙리스트 항목을 유지해야 하므로, Access Token 만료 시간을 짧게(15분~1시간)로 설정하는 것이 블랙리스트 크기를 제한하는 방법입니다. Refresh Token을 별도 DB에 저장하고 서버 사이드에서 완전히 관리하면 즉각 무효화가 가능합니다.
Q3. 게이트웨이에서 gRPC와 REST를 동시에 지원해야 한다면? gRPC Transcoding으로 gRPC 백엔드를 REST 인터페이스로 노출합니다. 클라이언트는 `POST /api/v2/orders`로 JSON을 보내고, 게이트웨이가 이를 gRPC의 `OrderService.CreateOrder` 메서드 호출로 변환합니다. 변환 규칙은 proto 파일에 Google API 어노테이션으로 정의합니다. ```protobuf rpc CreateOrder (CreateOrderRequest) returns (Order) { option (google.api.http) = { post: "/api/v2/orders" body: "*" }; } ``` Envoy Proxy는 이 변환을 기본으로 지원합니다. 내부 서비스 간 통신은 gRPC(HTTP/2 + Protocol Buffers, 낮은 직렬화 비용)로 유지하고, 외부 클라이언트에게는 REST로 노출합니다. 두 세계의 장점을 모두 취할 수 있습니다.
Q4. 게이트웨이에서 요청 재시도를 해야 하나요? 매우 신중하게 결정해야 합니다. **멱등성(Idempotency)이 보장된 요청만 재시도**해야 합니다. GET 요청은 멱등합니다. 같은 GET 요청을 여러 번 해도 결과가 동일하므로 재시도해도 안전합니다. POST 주문 요청은 멱등하지 않습니다. 재시도하면 동일한 주문이 두 번 생성될 수 있습니다. ``` 재시도 안전: GET (조회) PUT (전체 교체, 서비스가 멱등하게 구현된 경우) DELETE (없는 리소스 삭제는 200 또는 404, 둘 다 성공으로 처리 가능) 재시도 위험: POST (생성, 중복 생성 위험) PATCH (부분 업데이트, 재시도 시 과적용 위험) ``` POST 요청을 재시도해야 한다면, 클라이언트가 `Idempotency-Key` 헤더를 포함하고 서비스가 이 키로 중복 요청을 감지해 동일한 응답을 반환하는 구조가 필요합니다. Stripe의 결제 API가 이 방식을 사용합니다. 재시도 횟수와 백오프 전략도 중요합니다. 즉시 재시도보다 지수 백오프(1초, 2초, 4초) + 지터(랜덤 오프셋)를 적용해 모든 게이트웨이 인스턴스가 동시에 재시도하는 "재시도 폭풍(Retry Storm)"을 방지합니다.

15. 이 설계의 한계와 대안 — “이게 실패하면?”

지금까지의 설계는 이상적인 조건에서의 청사진입니다. 시니어 엔지니어가 리뷰에서 반드시 묻는 질문은 하나입니다. “이게 실패하면 어떻게 됩니까?” 각 컴포넌트의 실패 시나리오와 대안을 살펴봅니다.

게이트웨이 자체가 SPOF가 될 때

Q1에서 수평 확장과 AZ 분산을 답했지만, 현실은 더 복잡합니다. 게이트웨이 인스턴스가 20개여도 공유하는 Redis 클러스터나 서비스 디스커버리가 단일 지점이라면, 게이트웨이 레이어 전체가 동시에 오작동할 수 있습니다.

더 근본적인 대안은 게이트웨이 없는 아키텍처(Service Mesh)입니다. Istio나 Linkerd는 각 서비스 파드 옆에 Sidecar Proxy(Envoy)를 배치합니다. 인증·Rate Limit·Circuit Breaker가 각 서비스의 사이드카에서 분산 처리됩니다. 단일 진입점 자체가 없어지므로 게이트웨이 SPOF 문제가 구조적으로 사라집니다.

graph LR
  C["클라이언트"] --> LB["DNS 로드밸런서"]
  LB --> GW1["게이트웨이 AZ-A"]
  LB --> GW2["게이트웨이 AZ-B"]
  GW1 --> S1["서비스"]
  GW2 --> S1

트레이드오프는 분명합니다. Service Mesh는 운영 복잡도가 API Gateway보다 훨씬 높습니다. 사이드카 주입, mTLS 인증서 관리, 분산 정책 배포 모두 전담 플랫폼 팀이 필요합니다. 서비스 20개 이하 조직에서 Istio를 도입하면 오버엔지니어링일 가능성이 높습니다.

Rate Limiting Redis 장애 시

Redis가 내려갔습니다. 지금 어떻게 됩니까? 설계에 명시가 없다면, 두 가지 극단 중 하나입니다.

Allow-All(개방형 실패): Redis 조회 실패 시 요청을 허용합니다. 서비스 가용성을 지키지만, 장애 중 공격자가 Rate Limit 없이 무제한 요청을 보낼 수 있습니다. DDoS 방어선이 사라집니다.

Deny-All(폐쇄형 실패): Redis 조회 실패 시 요청을 차단합니다. 보안은 지키지만, Redis 장애가 전체 서비스 장애로 즉시 전파됩니다. Rate Limit 인프라 장애가 결제 서비스 장애가 됩니다.

실용적인 해법은 로컬 카운터 Fallback입니다. Redis 연결 실패 시 각 게이트웨이 인스턴스의 메모리에 있는 로컬 카운터로 전환합니다. 정확도는 떨어지지만(인스턴스 간 동기화 없음), 서비스는 살아있고 최소한의 Rate Limit 방어선은 유지됩니다. Redis 회복 시 자동으로 글로벌 카운터로 복귀합니다.

Redis 상태   │ 동작
──────────────┼───────────────────────────────
정상          │ 글로벌 Redis Sliding Window
연결 실패     │ 로컬 메모리 Token Bucket (임시)
연결 복구     │ 글로벌 Redis 재동기화

정책 결정은 팀이 해야 합니다. 금융 API는 Deny-All이 맞고, 콘텐츠 조회 API는 Allow-All이 맞을 수 있습니다. 정책을 코드가 아닌 설정으로 분리해 운영 중 변경 가능하게 해야 합니다.

Circuit Breaker 오판: 일시적 지연을 장애로 착각

Circuit Breaker의 가장 흔한 운영 사고는 정상 서비스를 실수로 차단하는 것입니다. GC(Garbage Collection) Pause, 배포 직후 워밍업, 새벽 배치 작업으로 인한 일시적 지연이 에러율 임계값을 초과시켜 OPEN으로 전환되고, 이후 트래픽이 모두 차단됩니다.

문제의 원인은 임계값 설정이 트래픽 특성을 반영하지 못한 데 있습니다. 해결책은 세 가지입니다.

첫째, 최소 요청 수 임계값을 충분히 높게 잡습니다. 트래픽이 적은 새벽에는 요청 3개 중 2개가 실패해도 에러율 67%가 됩니다. min_requests=20처럼 최소 요청 수 조건을 함께 설정하면 저트래픽 구간의 오판을 방지합니다.

둘째, 슬로우 콜(Slow Call) 임계값과 에러율 임계값을 분리합니다. 응답 시간이 느린 것과 에러가 발생하는 것은 다른 의미입니다. 단순 지연은 타임아웃 조정으로 흡수하고, 실제 5xx 에러율로만 OPEN 전환을 판단합니다.

셋째, OPEN 전환 시 알람 + 자동 차단 해제 주기 튜닝입니다. Circuit Breaker가 OPEN될 때 반드시 알람을 울리고, 운영자가 HALF_OPEN 전환 시점을 수동으로 앞당길 수 있는 API를 제공합니다.

graph LR
  SLOW["응답 지연"] --> JUDGE["오판 위험"]
  JUDGE --> OPEN["불필요한 OPEN"]
  OPEN --> BLOCK["정상 트래픽 차단"]
  BLOCK --> INCIDENT["장애 사고"]

JWT 즉시 무효화의 구조적 한계

Q2에서 블랙리스트를 답했지만, 블랙리스트 자체도 Redis에 의존합니다. Redis 장애 시 블랙리스트 조회 불가 → Deny-All이면 인증 전체 불능, Allow-All이면 무효화된 토큰이 통과합니다.

근본적인 설계 트레이드오프를 직시해야 합니다.

방식 즉시 무효화 성능 인프라 의존성
순수 JWT (짧은 TTL 15분) 불가 (최대 15분 지연) 최고 (메모리 검증) 없음
JWT + Redis 블랙리스트 가능 Redis 왕복 1ms 추가 Redis 가용성
Opaque Token (서버 사이드) 즉시 가능 매 요청 DB 조회 DB 가용성

“즉시 무효화가 반드시 필요한가”를 먼저 물어야 합니다. 대부분의 서비스는 TTL 15분짜리 JWT로 충분합니다. 사용자가 비밀번호를 변경하거나 로그아웃했을 때 15분 이내로 기존 세션이 만료되면 비즈니스 요구사항을 충족합니다. 금융·보안 API처럼 즉시 무효화가 필수라면 Opaque Token + 서버 사이드 세션이 더 적합한 설계입니다.

게이트웨이 레이턴시 오버헤드

게이트웨이는 네트워크 홉을 하나 추가합니다. “아무 작업도 하지 않는” 게이트웨이도 클라이언트-서비스 간 왕복 시간이 늘어납니다. 실측 기준으로 인증 + Rate Limit + 로깅까지 포함하면 3~10ms의 오버헤드가 발생합니다.

P99 레이턴시가 10ms 이하인 고성능 경로(예: 실시간 입찰 시스템, 고빈도 거래, 게임 매칭)에서는 이 오버헤드가 허용 불가일 수 있습니다. 이 경우 Direct Communication(게이트웨이 우회)을 허용합니다. 인증된 내부 네트워크 내에서 서비스 간 직접 통신을 허용하고, 게이트웨이는 외부 클라이언트용으로만 유지합니다.


16. 동시성과 분산 일관성 — 놓치기 쉬운 함정들

분산 Rate Limit의 동기화 문제

섹션 5에서 하이브리드 방식을 소개했지만, 실제 구현에서 추가로 고려해야 할 경쟁 조건이 있습니다.

“버킷 예약 충돌”: 두 인스턴스가 동시에 Redis에서 버킷을 예약할 때, 남은 한도가 15개인 상황에서 두 인스턴스가 각각 10개를 요청하면 합계 20개로 한도를 초과합니다. Redis Lua 스크립트의 원자 연산으로 예약을 직렬화해야 합니다.

-- 버킷 예약 원자 연산 (의사코드)
local remaining = GET(global_key)
if remaining >= batch_size then
  DECRBY(global_key, batch_size)
  return batch_size   -- 예약 성공
else
  return remaining    -- 남은 만큼만 예약
end

“인스턴스 크래시 후 미반환 버킷”: 인스턴스가 버킷 20개를 예약했다가 크래시되면, 그 20개는 회수되지 않고 한도에서 영구 차감됩니다. 버킷에 TTL을 설정하고, 인스턴스 재시작 시 미소진 버킷을 반환하는 로직이 필요합니다.

Circuit Breaker 상태 공유 문제

Circuit Breaker를 각 게이트웨이 인스턴스 로컬에 두면, 인스턴스 A는 OPEN이고 인스턴스 B는 CLOSED인 분산 스플릿브레인 상태가 됩니다. 같은 사용자의 요청이 어느 인스턴스에 도달하느냐에 따라 503이 될 수도, 200이 될 수도 있습니다.

방식 상태 일관성 성능 장애 격리
인스턴스별 독립 없음 (스플릿브레인) 최고 (로컬 판단) 독립적
Redis 공유 상태 있음 Redis 왕복 추가 Redis 의존
가십 프로토콜 결과적 일관성 중간 복잡

실용적인 선택은 인스턴스별 독립 상태 + 짧은 수렴 시간입니다. 인스턴스마다 독립적으로 에러율을 측정해 OPEN으로 전환합니다. 업스트림이 실제로 장애라면 모든 인스턴스가 짧은 시간(에러율 측정 윈도우) 내에 OPEN으로 수렴합니다. 완벽한 일관성보다 “충분히 빠른 수렴”이 더 실용적입니다.

서비스 디스커버리 캐시 일관성

게이트웨이가 Kubernetes Endpoints를 Watch해서 인스턴스 목록을 캐시합니다. 파드가 종료될 때 Kubernetes는 Endpoints에서 제거하기 전에 SIGTERM을 보내고 terminationGracePeriodSeconds(기본 30초)를 기다립니다.

경쟁 조건: 파드가 SIGTERM을 받고 종료 중인데, 게이트웨이의 캐시가 아직 갱신되지 않아 해당 파드로 새 요청이 라우팅됩니다. 파드가 요청을 거부하면 게이트웨이 입장에서 갑작스러운 에러입니다.

해결책은 두 가지를 함께 씁니다.

첫째, 파드의 preStop 훅에서 짧은 sleep(예: 5초)을 추가해 게이트웨이 캐시가 갱신될 시간을 줍니다.

둘째, 게이트웨이의 패시브 헬스체크가 에러를 감지하면 즉시 해당 인스턴스를 풀에서 제거합니다. 한 두 건의 에러 요청은 클라이언트 재시도로 흡수합니다.


17. 오버엔지니어링 경고 — 언제 더 단순한 것이 정답인가

API Gateway를 설계하는 자리에서 가장 용기 있는 말은 “지금 이 아키텍처는 우리 규모에 너무 복잡합니다”입니다. 복잡한 시스템은 더 많은 장애점, 더 높은 운영 비용, 더 느린 개발 속도를 의미합니다.

규모별 적정 아키텍처

실제 서비스 수와 팀 규모에 따라 적정 기술 수준이 다릅니다.

서비스 3개 이하 ──── Nginx reverse proxy로 충분
                     설정 파일 50줄, 운영 지식 1명이면 됩니다.
                     Rate Limit: nginx limit_req
                     인증: 각 서비스에서 처리

서비스 10개 전후 ─── Spring Cloud Gateway 또는 Kong (OSS)
                     동적 라우팅, JWT 플러그인, 기본 Rate Limit
                     Redis 하나 추가

서비스 50개 이상 ─── 전용 게이트웨이 클러스터 + Service Mesh 검토
                     독립 배포 파이프라인, 전담 플랫폼 팀 필요

“처음부터 50개 서비스 규모로 설계하겠습니다”는 초기에 옳은 판단이 아닙니다. 서비스 3개인 스타트업이 Istio를 운영하면 서비스 개발 시간의 40%를 인프라 관리에 씁니다.

API Gateway vs Service Mesh — 언제 Istio/Envoy가 더 나은가

API Gateway와 Service Mesh는 경쟁 관계가 아니라 계층이 다릅니다. 그러나 많은 팀이 둘 중 하나를 선택해야 할 때 혼란을 겪습니다.

graph LR
  EXT["외부 클라이언트"] --> GW["API Gateway (남북 트래픽)"]
  GW --> A["서비스 A"]
  GW --> B["서비스 B"]
  A --> MESH["Service Mesh (동서 트래픽)"]
  B --> MESH
  MESH --> C["서비스 C"]
  MESH --> D["서비스 D"]

API Gateway가 더 적합한 경우: 외부 클라이언트 인터페이스 관리가 주목적일 때, 다양한 클라이언트(모바일·웹·파트너)에 대한 API 버전 관리가 필요할 때, 팀이 쿠버네티스 네이티브 운영에 익숙하지 않을 때.

Service Mesh(Istio/Envoy)가 더 적합한 경우: 서비스 간 내부 트래픽(동-서 트래픽)의 mTLS, 트레이싱, 재시도가 주목적일 때, 50개 이상의 서비스가 복잡하게 얽혀 있을 때, 전담 플랫폼 엔지니어링 팀이 있을 때.

가장 흔한 오답: 서비스 내부 통신 문제를 해결하려고 API Gateway를 내부 서비스 앞에도 붙이는 것. 이렇게 하면 모든 서비스 간 호출이 게이트웨이를 경유해 레이턴시가 2배가 됩니다.


18. Kafka 보강 — 로그 유실 허용 범위와 감사 로그

섹션 8에서 acks=1로 설정한 것은 의도적이지만, 이 결정이 어떤 맥락에서 맞고 어떤 맥락에서 위험한지 명확히 해야 합니다.

로그 유형별 유실 허용 정책

모든 로그가 동일한 중요도가 아닙니다. 유실 허용 여부를 로그 유형별로 명시적으로 결정해야 합니다.

로그 유형 유실 허용 Kafka 설정 이유
접근 로그 (access log) 허용 (분석용) acks=1 일부 유실해도 트렌드 분석 가능
성능 메트릭 허용 (근사치) acks=1 집계 통계는 표본으로 충분
감사 로그 (audit log) 불가 acks=all + min.insync.replicas=2 규제 준수, 분쟁 증거
과금/청구 이벤트 불가 acks=all + min.insync.replicas=2 수익 직결
보안 이벤트 (침입 시도) 불가 acks=all + min.insync.replicas=2 법적 책임

“접근 로그는 유실 허용”을 팀 전체가 명시적으로 합의해야 합니다. 코드에 acks=1이 있는데 누군가 이를 “감사 로그도 이렇게 되어있다”고 오해하면 심각한 컴플라이언스 위반이 됩니다.

감사 로그 파이프라인 설계

감사 로그는 별도 토픽과 별도 설정으로 격리합니다.

// 감사 로그 전용 Producer 설정
Properties auditProps = new Properties();
auditProps.put("acks", "all");                    // 모든 복제본 확인
auditProps.put("min.insync.replicas", "2");        // 최소 2개 복제본 동기화
auditProps.put("retries", Integer.MAX_VALUE);      // 성공할 때까지 재시도
auditProps.put("enable.idempotence", "true");      // 중복 전송 방지
auditProps.put("max.in.flight.requests.per.connection", "5");

// 감사 이벤트는 동기 전송 (응답 처리에 영향 줘도 됨)
// 인증 실패, 권한 변경, 결제 이벤트 등
RecordMetadata meta = auditProducer
    .send(new ProducerRecord<>("gateway-audit-log", userId, auditEvent))
    .get();  // 블로킹: 복제 완료 확인 후 진행

acks=all은 Kafka 리더와 팔로워가 모두 디스크에 쓸 때까지 Producer가 기다립니다. 평균 추가 지연은 2~5ms입니다. 감사 이벤트 처리가 2~5ms 느려지는 것은 수용 가능하지만, 접근 로그 전체에 이를 적용하면 처리량이 크게 떨어집니다.

at-most-once vs at-least-once vs exactly-once

graph LR
  AMO["at-most-once\n유실 가능, 중복 없음"] --> ACCESS["접근 로그\nacks=0 또는 1"]
  ALO["at-least-once\n유실 없음, 중복 가능"] --> AUDIT["감사 로그\nacks=all + 재시도"]
  EO["exactly-once\n유실·중복 없음"] --> BILLING["과금 이벤트\nacks=all + idempotent + 트랜잭션"]

exactly-once는 Kafka Transactions API가 필요합니다. Producer의 트랜잭션 ID를 설정하고 transactional.send()로 배치를 원자적으로 커밋합니다. 처리 비용이 크므로 수익과 직결된 이벤트에만 사용합니다.

acks=0(전송 후 확인 없음)은 로그 유실이 완전히 허용되는 경우, 예를 들어 디버그 로그나 실시간 지표 스트리밍에서만 사용합니다. 운영 접근 로그에는 최소 acks=1을 권장합니다.


핵심 요약

API Gateway는 인증, Rate Limiting, Circuit Breaker, 라우팅이라는 네 가지 횡단 관심사를 한 레이어에 집중시켜 모든 마이크로서비스를 이 복잡성으로부터 해방시킵니다.

핵심 설계 결정을 다시 정리합니다.

  • Rate Limiting: Sliding Window Counter + Redis Lua 원자 연산으로 Fixed Window의 버스트 문제와 Sliding Window Log의 메모리 문제를 동시에 해결합니다.
  • JWT 인증: 공개키를 메모리에 캐시해 인증 서버 왕복 없이 수 마이크로초에 검증합니다. 클레임을 헤더로 주입해 서비스 코드를 인증 로직에서 분리합니다.
  • Circuit Breaker: CLOSED → OPEN → HALF_OPEN 세 상태로 장애 서비스를 격리하고, HALF_OPEN 탐색으로 회복된 서비스를 안전하게 복귀시킵니다.
  • Bulkhead: 서비스별 세마포어로 느린 서비스가 전체 커넥션 풀을 잠식하지 않도록 격리합니다.
  • Kafka 비동기 로깅: 로컬 메모리 버퍼 + Producer Batching으로 로깅이 요청 처리 지연에 영향을 주지 않습니다.
  • Graceful Degradation: Load Shedding → Priority Queue → Feature Flag → Back Pressure 순서로 트래픽 폭주에 단계적으로 대응합니다.

API Gateway가 모든 요청을 받는 단일 진입점이라는 사실은 위험이자 기회입니다. 잘 설계된 게이트웨이는 수백 개의 마이크로서비스를 보호하는 방패가 되고, 잘못 설계된 게이트웨이는 전체 시스템을 한 번에 다운시키는 단일 약점이 됩니다.

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →