LLM WikiAccess-protected knowledge portal
← 스터디 홈
4편 · 약 20분

서비스 디스커버리와 로드밸런싱

gRPC에서 로드밸런싱이 까다로운 이유

HTTP/1.1 기반 REST API에서는 요청 하나가 TCP 연결 하나에 1:1로 대응한다. L4 로드밸런서가 TCP 연결을 여러 백엔드에 분산하면 자연스럽게 요청도 분산된다. 그러나 gRPC는 HTTP/2 위에서 동작하며, HTTP/2는 하나의 TCP 연결 위에 여러 RPC를 동시에 다중화(multiplex)한다.

문제는 여기서 생긴다. 클라이언트가 서버와 연결을 맺고 나면 그 연결 안에서 수천 개의 RPC가 흐르더라도 연결 자체는 하나다. L4 로드밸런서는 TCP 연결을 보고 분산하므로, 연결이 한번 맺어지면 이후 모든 RPC가 같은 백엔드로 쏠린다. 새 클라이언트가 연결을 만들 때만 다른 백엔드를 고른다. 그 결과 특정 백엔드에 부하가 집중되고 다른 백엔드는 놀게 된다.

Kubernetes의 ClusterIP 서비스도 같은 함정이다. ClusterIP는 단일 가상 IP를 DNS로 돌려주므로 클라이언트는 그 VIP로 연결을 만든 뒤 끊지 않는다. 파드를 수십 개로 수평 확장해도 기존 gRPC 클라이언트들은 연결 당시 배정받은 파드 한두 개에만 계속 요청을 보낸다.

이 문제를 해결하는 방법은 크게 세 가지다.

  1. 프록시 기반 L7 로드밸런싱 — Envoy, NGINX Plus, Istio 같은 프록시가 gRPC를 L7에서 종단하고 백엔드에 재분산
  2. 클라이언트 사이드 로드밸런싱 — 클라이언트가 백엔드 목록을 직접 알고, RPC마다 백엔드를 선택
  3. xDS / Proxyless 서비스 메시 — 컨트롤 플레인이 xDS 프로토콜로 클라이언트에게 라우팅 정보를 전달
1. L7 프록시 기반
Client Envoy / Istio
sidecar (L7)
Pod A Pod B Pod C
2. 클라이언트 사이드 (DNS headless)
Client
(round_robin)
DNS 조회
A × N
Pod A Pod B Pod C
3. xDS Proxyless
Client
(xDS resolver)
xDS
Control Plane
Pod A Pod B Pod C
gRPC 로드밸런싱 세 가지 접근 방식

gRPC 클라이언트의 내부 구조: 이름 결정과 LB 정책

gRPC 클라이언트가 채널을 여는 순간부터 RPC를 보내는 시점까지의 흐름을 이해하면 로드밸런싱 설계가 명확해진다.

이름 결정기(Name Resolver)

클라이언트는 채널을 생성할 때 대상 주소를 문자열(dns:///myservice:50051, xds:///myservice 등)로 받는다. 이 주소를 해석해 실제 백엔드 IP 목록을 반환하는 것이 이름 결정기다.

이름 결정기주소 형식동작
passthrough (기본)ip:port주소를 그대로 사용, 분산 없음
dnsdns:///host:portDNS A 레코드 조회, 여러 IP 반환 가능
xdsxds:///servicexDS 컨트롤 플레인에서 엔드포인트 수신
커스텀직접 등록Consul, etcd, ZooKeeper 등 연동 가능

이름 결정기는 단순히 IP 목록만 반환하는 게 아니다. 서비스 컨피그(service config) 도 함께 반환할 수 있다. 서비스 컨피그는 JSON 구조체로, "이 서비스에 대해 어떤 LB 정책을 쓸 것인가"를 포함한다.

로드밸런싱 정책(LB Policy)

이름 결정기가 IP 목록을 반환하면 LB 정책이 각 IP에 대해 서브채널(subchannel) 을 생성하고 연결 상태를 관리하며, RPC가 들어올 때마다 "어느 서브채널로 보낼지"를 결정한다.

gRPC Channel
(xds:///myservice)
Name Resolver
(xDS / DNS)
address list
+ service config
address list LB Policy
(round_robin)
Subchannel A
10.0.0.1:50051
Subchannel B
10.0.0.2:50051
Subchannel C
10.0.0.3:50051
RPC 1 Subchannel A RPC 2 → Subchannel B RPC 3 → Subchannel C
이름 결정 → LB 정책 → 서브채널 흐름

내장 LB 정책

정책동작주요 특징
pick_first연결 가능한 첫 번째 주소를 사용, 이후 모든 RPC를 그쪽으로기본값. 분산 없음. 단일 백엔드 시나리오
round_robin모든 READY 서브채널에 RPC를 순서대로 분배가장 널리 쓰이는 분산 정책
weighted_round_robin백엔드의 부하 신호를 바탕으로 가중치 부여 후 분배백엔드 성능이 불균일할 때 유용
grpclb외부 LB 서버가 실시간으로 서버 목록과 가중치를 클라이언트에 전달Deprecated. xDS로 대체됨
xDS 계층xDS 컨트롤 플레인이 지역(locality), 우선순위, 가중치, 엔드포인트 정책을 조합고급 시나리오. Istio, GCP Traffic Director 등

pick_first는 기본값이기 때문에 별도 설정 없이 gRPC 채널을 만들면 DNS가 여러 IP를 반환해도 첫 번째 IP 하나에만 모든 트래픽이 몰린다. 다중 백엔드를 실제로 활용하려면 명시적으로 round_robin이나 상위 정책으로 전환해야 한다.

서비스 컨피그: LB 정책을 클라이언트에 전달하는 통로

서비스 컨피그는 JSON 구조체로, 이름 결정기가 클라이언트에게 내려보내는 런타임 설정이다. DNS TXT 레코드, xDS 컨트롤 플레인, 또는 코드에서 직접 지정할 수 있다.

{
  "loadBalancingConfig": [
    { "round_robin": {} }
  ],
  "methodConfig": [
    {
      "name": [{ "service": "OrderService" }],
      "retryPolicy": {
        "maxAttempts": 3,
        "initialBackoff": "0.1s",
        "maxBackoff": "1s",
        "backoffMultiplier": 2,
        "retryableStatusCodes": ["UNAVAILABLE"]
      }
    }
  ]
}

loadBalancingConfig는 배열 형태로 우선순위를 표현한다. 클라이언트는 자신이 지원하는 첫 번째 정책을 선택한다. 이렇게 하면 서버 측(서비스 소유자)이 클라이언트 코드를 바꾸지 않고도 LB 정책을 업데이트할 수 있다.

Go에서 코드로 직접 지정할 때는 WithDefaultServiceConfig를 사용한다.

conn, err := grpc.NewClient(
    "dns:///myservice.namespace.svc.cluster.local:50051",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin": {}}]}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)

Kubernetes에서 클라이언트 사이드 로드밸런싱

ClusterIP 서비스가 문제인 이유

Kubernetes의 일반적인 Service(type: ClusterIP)는 DNS 쿼리에 단일 클러스터 IP를 반환한다. 클라이언트는 그 VIP에 연결을 맺고, kube-proxy가 연결 수준에서 파드로 분산한다. 그러나 gRPC 클라이언트는 연결을 맺고 나면 그 연결을 재사용한다. kube-proxy는 새 연결이 생길 때만 다른 파드를 고르므로, 기존 gRPC 클라이언트는 계속 같은 파드에만 보낸다.

헤드리스 서비스(Headless Service)

해결책은 clusterIP: None으로 헤드리스 서비스를 만드는 것이다. 헤드리스 서비스는 DNS 조회 시 VIP 대신 각 파드의 IP를 A 레코드 여러 개로 직접 반환한다.

apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  clusterIP: None      # 헤드리스
  selector:
    app: order-service
  ports:
    - port: 50051
      targetPort: 50051

DNS 조회 결과:

# ClusterIP 서비스
order-service.default.svc.cluster.local → 10.96.0.1 (VIP)

# 헤드리스 서비스
order-service.default.svc.cluster.local → 10.0.0.10
                                         → 10.0.0.11
                                         → 10.0.0.12

클라이언트가 dns:/// 리졸버 + round_robin 정책을 사용하면 세 파드에 대한 서브채널을 모두 만들고 RPC를 순서대로 분산한다.

gRPC Client
round_robin
DNS 조회
headless svc
A: 10.0.0.10
A: 10.0.0.11
A: 10.0.0.12
Subchannel
10.0.0.10
Subchannel
10.0.0.11
Subchannel
10.0.0.12
RPC 1 → .10 RPC 2 → .11 RPC 3 → .12 RPC 4 → .10
Headless Service + round_robin 흐름

주의사항이 있다. DNS 기반 클라이언트 사이드 LB는 파드 IP가 바뀔 때(롤링 업데이트, 재스케줄링 등) DNS TTL이 만료되기 전까지는 새 파드를 알지 못한다. 일부 언어/라이브러리는 DNS를 주기적으로 재조회하지 않으므로, 이 경우 서비스 메시나 xDS 방식이 더 안전하다.

xDS: 프록시 없는 동적 서비스 메시

xDS란

xDS는 Envoy 프로젝트에서 시작한 데이터 플레인 API로, 컨트롤 플레인이 클라이언트에게 엔드포인트 목록, 라우팅 규칙, 로드밸런싱 정책을 실시간으로 전달하는 프로토콜이다. gRPC는 Proxyless gRPC라는 이름으로 xDS를 직접 구현해, 사이드카 프록시 없이도 서비스 메시 수준의 트래픽 제어를 클라이언트 라이브러리 안에서 수행한다.

구분프록시 기반 (Istio/Envoy)xDS Proxyless gRPC
사이드카파드마다 Envoy 컨테이너불필요
오버헤드추가 네트워크 홉, 메모리컨트롤 플레인 gRPC 연결만
언어 지원언어 무관gRPC 지원 언어만 (Go, Java, C-core 등)
기능폭넓음 (HTTP, TCP, gRPC)gRPC 특화
운영 복잡도높음중간 (컨트롤 플레인 구성 필요)

xDS 주소 형식은 xds:///serviceName이며, 클라이언트는 부트스트랩 파일이나 환경 변수로 컨트롤 플레인 주소를 받는다. Google Cloud Service Mesh(Traffic Director), Istio, 커스텀 xDS 서버(Envoy control plane API 구현)를 컨트롤 플레인으로 사용할 수 있다.

xDS LB 계층 구조

xDS는 단순 round_robin보다 복잡한 계층 구조를 지원한다.

xDS Control Plane Listener → Route → Cluster → Endpoint
Priority LB
지역(locality) 우선순위
Weighted RR
locality 가중치
Endpoint LB
pick_first / round_robin
xDS 로드밸런싱 계층
  • Priority LB: 가까운 데이터센터 우선, 장애 시 다음 우선순위 지역으로 페일오버
  • Weighted Round Robin: 서울 리전 70%, 도쿄 리전 30% 같은 locality 단위 트래픽 분배
  • Endpoint LB: locality 안에서 개별 파드 수준의 round_robin

세 가지 방식 선택 기준

상황권장 방식
단순 서비스, 단일 리전Headless Service + DNS + round_robin
폴리글랏 환경 (gRPC 외 HTTP, TCP 서비스 혼재)Istio/Envoy 사이드카 프록시
gRPC 전용, 고급 트래픽 제어 필요xDS Proxyless (Traffic Director, Istio + proxyless)
사이드카 오버헤드를 줄여야 하는 고밀도 환경xDS Proxyless
레거시 클라이언트 라이브러리 변경 불가L7 프록시(Envoy, NGINX)

프록시 기반 방식은 클라이언트 언어와 무관하게 동작하므로 폴리글랏 환경에 유리하다. 반면 추가 사이드카 컨테이너마다 메모리와 CPU를 소비하고 네트워크 홉이 늘어난다. 클라이언트 사이드 방식은 프록시 오버헤드가 없고 레이턴시가 낮지만, 클라이언트 라이브러리가 해당 기능을 지원해야 하고 운영 복잡도가 애플리케이션 레이어로 이동한다.

운영 설계 패턴

1. Kubernetes에서 헤드리스 서비스로 전환할 때

단순히 clusterIP: None만 설정하면 되는 게 아니다. 클라이언트가 dns:/// 리졸버를 명시하고 round_robin 정책을 활성화해야 한다. Kubernetes 기본 DNS TTL은 짧지만(통상 5~30초), DNS 캐시 계층(ndots, dnsmasq, systemd-resolved)이 개입하면 실제 갱신이 지연될 수 있다.

2. 파드 증설 후 트래픽이 균등하지 않은 경우

DNS 기반 LB에서 롤링 업데이트나 HPA로 새 파드가 생겼을 때, 기존 연결을 유지하고 있는 클라이언트는 새 파드를 즉시 인식하지 못한다. 이때는 클라이언트 채널에 주기적 재연결(keepalive, max connection age) 설정을 추가하거나, xDS를 통해 컨트롤 플레인이 엔드포인트 변경을 Push해주는 방식을 고려한다.

서버 측에서는 GOAWAY 프레임을 보내 클라이언트가 새 연결을 만들도록 유도할 수 있다. grpc.MaxConnectionAge(Go) 또는 keepAliveTime(Java) 설정이 이에 해당한다.

// 서버: 연결 최대 수명 설정 (재연결 유도)
server := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionAge:      30 * time.Minute,
        MaxConnectionAgeGrace: 5 * time.Second,
    }),
)

3. 클라이언트 사이드 LB와 재시도를 함께 설계할 때

round_robin은 READY 상태의 서브채널에만 보낸다. 서브채널이 TRANSIENT_FAILURE 상태면 재연결을 시도하면서 나머지 READY 서브채널로 분산이 이어진다. 서비스 컨피그의 retryPolicy와 함께 쓰면 특정 백엔드 장애 시 자동으로 다른 백엔드로 retry가 붙는다.

단, retry는 幂等(idempotent)한 RPC에만 안전하다. 주문 생성, 결제처럼 부작용이 있는 RPC는 idempotency key를 함께 설계해야 중복 처리를 막을 수 있다.

실전 체크리스트

  • Kubernetes에서 gRPC 서비스를 배포할 때 ClusterIP 서비스로만 두면 트래픽이 쏠린다. 헤드리스 서비스 + round_robin 조합을 기본으로 검토한다.
  • 클라이언트 코드에서 dns:/// prefix와 loadBalancingConfig: round_robin을 명시하지 않으면 DNS가 여러 A 레코드를 반환해도 pick_first가 첫 번째 IP만 쓴다.
  • DNS TTL과 클라이언트 DNS 캐시 정책을 파악한다. 파드 IP 변경이 빠른 환경이라면 xDS 또는 짧은 MaxConnectionAge가 필요하다.
  • 사이드카 프록시(Istio)를 쓸 때는 HTTP/2 업그레이드를 프록시가 처리하므로 클라이언트 LB 정책 조정이 필요 없다. 단, 사이드카 리소스 비용을 측정한다.
  • xDS Proxyless는 클라이언트 라이브러리가 xDS를 지원해야 하고 부트스트랩 설정이 필요하다. gRPC-Go, gRPC-Java, gRPC C-core(C++, Python, Ruby)는 지원한다.
  • weighted_round_robin은 백엔드가 CPU 사용률이나 QPS 신호를 ORCA 메커니즘으로 보고할 때 자동으로 가중치를 조정한다. 백엔드 성능이 균일하지 않은 환경에서 유용하다.

References