왜 L4 로드밸런서로는 부족한가

gRPC는 HTTP/2 위에서 동작한다. HTTP/2의 핵심은 하나의 TCP 커넥션을 오래 유지하면서 그 위로 수많은 요청(스트림)을 멀티플렉싱하는 것이다. 문제는 여기서 시작된다. L4(TCP) 로드밸런서는 커넥션 단위로만 분산한다. 클라이언트가 커넥션을 한 번 맺으면 그 뒤 수천 개의 RPC는 전부 같은 백엔드로만 흘러간다.

결과적으로 백엔드를 10대로 늘려도 트래픽은 최초 커넥션이 꽂힌 소수 인스턴스에 몰린다. 오토스케일링으로 새 파드를 띄워도 기존 커넥션은 이동하지 않으므로 신규 인스턴스는 놀고, 기존 인스턴스만 CPU가 튄다.

커넥션 재사용이 만드는 불균형

REST/HTTP1.1은 요청마다 커넥션을 새로 맺거나 짧게 재사용하기 때문에 L4 분산으로도 어느 정도 고르게 퍼진다. 반면 gRPC 클라이언트는 채널(channel)을 한 번 만들어 앱 생명주기 내내 붙잡는다. 이 "롱리브드 커넥션 + 멀티플렉싱" 조합이 L4 환경에서 부하 편중을 만드는 근본 원인이다.

해결 방향 두 가지

분산 단위를 커넥션이 아니라 요청(RPC)으로 내려야 한다. 방법은 크게 두 갈래다.

방식분산 단위특징
클라이언트 사이드 LBRPC프록시 홉 없음, 클라이언트가 엔드포인트 목록 관리
L7 프록시 LBRPC중앙 집중, 언어 무관, 운영 단순

클라이언트 사이드는 지연이 낮지만 모든 언어 클라이언트에 로직과 서비스 디스커버리를 심어야 한다. 폴리글랏 환경이라면 L7 프록시가 운영상 훨씬 단순하다.

L7 프록시 설정 예시

Envoy는 HTTP/2 스트림을 하나씩 열어 라우팅하므로 RPC 단위 분산이 가능하다. 핵심은 다운스트림을 h2로 받고 클러스터도 http2로 두는 것이다.

static_resources:
  clusters:
  - name: grpc_backend
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    # 백엔드도 HTTP/2로 말해야 gRPC 스트림이 유지된다
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options: {}
    load_assignment:
      cluster_name: grpc_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: grpc-svc, port_value: 50051 }

쿠버네티스에서의 함정

가장 흔한 실수는 그냥 ClusterIP Service를 물리는 것이다. kube-proxy는 L4라서 커넥션을 iptables로 고정 분산한다. 즉 gRPC 채널은 파드 하나에 붙어버린다. Headless Service로 파드 IP 목록을 직접 노출하고 클라이언트가 RPC마다 고르게 쏘도록 round_robin 정책을 켜야 한다.

import grpc

# headless service의 A레코드(파드 IP 여러 개)를 DNS로 해석
channel = grpc.insecure_channel(
    "dns:///grpc-svc-headless:50051",
    options=[
        ("grpc.lb_policy_name", "round_robin"),
        # keepalive로 죽은 커넥션을 빨리 걷어낸다
        ("grpc.keepalive_time_ms", 30000),
        ("grpc.keepalive_timeout_ms", 10000),
    ],
)
apiVersion: v1
kind: Service
metadata:
  name: grpc-svc-headless
spec:
  clusterIP: None   # headless: 파드 IP를 그대로 노출
  selector: { app: grpc-svc }
  ports:
  - name: grpc
    port: 50051

스케일 인/아웃 시 주의점

파드가 늘거나 줄 때 클라이언트가 이를 반영하지 못하면 다시 편중된다. DNS 기반 round_robin은 재해석 주기가 있어 반영이 느리다. 정밀 제어가 필요하면 xDS나 프록시가 서비스 디스커버리를 담당하게 해 엔드포인트 변경을 즉시 반영시키는 편이 안전하다. 스케일 인 때는 graceful shutdown으로 진행 중인 스트림을 다 흘려보낸 뒤 종료해야 요청이 끊기지 않는다.

정리

gRPC의 부하 편중은 버그가 아니라 HTTP/2 롱리브드 커넥션의 자연스러운 결과다. 분산 단위를 커넥션에서 RPC로 내리는 것이 핵심이며, 폴리글랏·운영 단순성을 원하면 Envoy 같은 L7 프록시, 지연에 민감하면 클라이언트 사이드 LB를 택한다. 쿠버네티스라면 ClusterIP 대신 Headless Service와 명시적 LB 정책이 최소 조건이다.