← all posts
DEV 2026.05.02 · 13 min read Intermediate

gRPC 스트리밍은 왜 Polling보다 효율적인가

Server Streaming의 HTTP/2 Frame 흐름부터 Bidirectional의 Half-close, Flow Control의 Window Size 튜닝, 그리고 Exponential Backoff 재연결까지, gRPC 스트리밍의 설계 철학을 추적한다.


gRPC 스트리밍을 처음 보면 “그냥 Polling보다 빠른 것”처럼 보인다. 하지만 내부를 들여다보면 HTTP/2의 멀티플렉싱, Flow Control의 Window 메커니즘, Half-close 프로토콜이 서로 맞물려 하나의 일관된 설계를 이룬다. 왜 단일 TCP 연결이 수천 개의 폴링보다 효율적인가?

연결 하나로 N개 스트림 — HTTP/2 멀티플렉싱

Polling의 구조적 문제는 연결 수에 있다. 클라이언트 100개가 1초마다 REST 호출을 하면 초당 100개의 RPC가 발생하고, 각 요청마다 TCP 3-way handshake와 HTTP 헤더(~1KB)가 붙는다. 변화가 없어도 응답이 나간다. 99%가 낭비다.

gRPC Server Streaming은 반대 방향으로 설계된다. 클라이언트가 한 번 HEADERS 프레임을 보내 스트림을 열면, 서버는 그 스트림 위에서 DATA 프레임을 계속 밀어 넣는다. 100개의 심볼 구독도 단일 TCP 연결 위에서 Stream ID로 구분되어 처리된다.

# TCP 연결 1개, Stream ID로 구분
Stream ID=1 → AAPL 구독
Stream ID=2 → GOOGL 구독
Stream ID=3 → MSFT 구독

# 100ms마다 DATA 프레임 Push
DATA(Stream 1): StockPrice{AAPL, 150.26}
DATA(Stream 2): StockPrice{GOOGL, 2850.50}
DATA(Stream 1): StockPrice{AAPL, 150.27}

성능 차이는 명확하다. Polling 100클라이언트: 연결 100개, 초당 100 RPC, 헤더 오버헤드 1MB/s. Server Streaming 100클라이언트: 연결 1개, 이벤트 발생 시만 전송, 헤더 오버헤드 50KB/s.

Client Streaming — O(1) 메모리로 대용량 전송

단방향이 Push 효율이라면, Client Streaming은 메모리 효율의 문제다. gRPC Unary RPC는 메시지 크기를 기본 4MB로 제한한다. 100MB 파일을 Unary로 보내면 MessageSizeTooLargeException이 발생한다.

Client Streaming은 파일을 청크로 잘라 순차 전송한다. 핵심은 클라이언트 메모리가 파일 크기와 무관하게 CHUNK_SIZE 고정이라는 점이다.

// 256KB 버퍼만 유지, 파일 크기와 무관
byte[] buffer = new byte[256 * 1024];
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1) {
    requestObserver.onNext(FileChunk.newBuilder()
        .setData(ByteString.copyFrom(buffer, 0, bytesRead))
        .build());
}
requestObserver.onCompleted(); // 이 호출이 END_STREAM 플래그

onCompleted()를 빠뜨리면 서버는 “다음 청크”를 타임아웃(30초)까지 기다린다. 서버는 onCompleted() 콜백에서만 응답을 생성한다 — 클라이언트가 모든 청크를 다 보냈다는 신호를 받은 뒤에야.

청크 크기는 64KB~256KB가 최적이다. 1KB는 오버헤드 4400%, 16MB는 부분 실패 시 재전송 비용이 크다.

Bidirectional과 Half-close

Bidirectional Streaming의 핵심 개념은 Half-close다. 클라이언트가 requestObserver.onCompleted()를 호출하면 HTTP/2 프레임에 END_STREAM 플래그가 붙는다. 이는 “전송 종료”이지 “연결 종료”가 아니다. 서버는 여전히 responseObserver.onNext()로 데이터를 계속 보낼 수 있다.

CLIENT → END_STREAM → SERVER   # 클라이언트: 더 이상 보내지 않음
CLIENT ← DATA(msg) ← SERVER    # 서버: 여전히 전송 가능
CLIENT ← END_STREAM ← SERVER   # 서버도 완료 → 스트림 종료

채팅에서 “exit” 명령을 보낸 뒤에도 다른 사용자의 메시지를 계속 받을 수 있는 것이 이 구조다.

주의해야 할 것은 responseObserver.onNext()의 Thread-safety다. gRPC StreamObserver는 스레드 안전하지 않다. 여러 스레드에서 동시 호출하면 HTTP/2 Frame 순서가 뒤섞여 연결이 손상된다. 방법은 간단하다 — synchronized (observer) 블록으로 감싸거나, 단일 스레드 큐를 통해 직렬화한다.

Flow Control — Window Size가 처리량을 결정한다

기본값 64KB의 함정

HTTP/2 Flow Control Window의 기본값은 64KB다. 서버가 초당 100MB를 전송하고 클라이언트가 초당 10MB를 처리하면, 64KB Window는 매 청크마다 WINDOW_UPDATE 프레임을 기다려야 한다. 100MB 파일 전송에 80초가 걸릴 수 있다.

Flow Control은 빠른 서버가 느린 클라이언트를 압도하지 못하도록 OS 레벨에서 속도를 제어한다. 클라이언트의 수신 버퍼가 Window Size만큼 차면 서버의 send()가 블록된다. 클라이언트가 데이터를 처리하고 WINDOW_UPDATE를 보낼 때까지.

이 메커니즘 덕분에 onNext() 안에서 무거운 작업을 하면 자동으로 서버 속도가 떨어진다. Backpressure가 자동으로 작동하는 것이다. 역으로, onNext()를 빠르게 반환하고 무거운 작업을 별도 스레드에 위임하면 Window가 빨리 복구돼 처리량이 올라간다.

@Override
public void onNext(StockPrice price) {
    updateUIQuickly(price);  // 즉시 반환 (밀리초)
    processingExecutor.submit(() -> {
        persistPrice(price);       // 별도 스레드에서 처리
        notifyAnalyticsService(price);
    });
}

Window Size 튜닝은 단순하다: 대용량 스트리밍이면 256MB로 올려라. 처리량이 최대 10배 향상된다.

트레이드오프

Window Size를 크게 설정하면 처리량이 올라가지만 클라이언트 메모리 사용량도 올라간다. 느린 클라이언트가 256MB Window를 열면 서버가 256MB를 한 번에 밀어 넣을 수 있다. 클라이언트 처리 속도와 가용 메모리를 함께 고려해야 한다.

에러 처리 — Status Code와 Exponential Backoff

스트리밍은 장시간 연결이므로 네트워크 끊김, 서버 재시작이 필연적으로 발생한다. 서버 에러는 RST_STREAM 프레임으로 전파돼 클라이언트의 onError(StatusRuntimeException)를 트리거한다.

재시도 판단은 Status Code로 한다.

재시도 가능: UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, INTERNAL
재시도 불가: INVALID_ARGUMENT, NOT_FOUND, PERMISSION_DENIED, CANCELLED

재시도는 반드시 Exponential Backoff와 함께다. 즉시 재연결은 서버 과부하 중에 폭주를 유발한다.

long delayMs = Math.min(300_000L, 1000L * (long) Math.pow(2, retryAttempt));
scheduler.schedule(() -> subscribeInternal(id), delayMs, TimeUnit.MILLISECONDS);

중복 수신을 막으려면 메시지에 sequence_number를 넣고, 재연결 후 마지막 수신 번호 다음부터 요청한다. 부분 실패도 같은 원리다 — 실패한 항목의 ID만 응답에 담아 클라이언트가 그 항목만 재전송하게 한다.

정리

  • gRPC 스트리밍은 단일 TCP 연결 위에서 HTTP/2 멀티플렉싱으로 수백 개의 스트림을 동시 처리한다.
  • Client Streaming은 O(1) 메모리로 무제한 크기의 데이터를 전송한다. onCompleted() 누락은 30초 타임아웃으로 귀결된다.
  • Bidirectional의 Half-close는 전송만 종료하고 수신은 유지한다. responseObserver.onNext()synchronized로 보호해야 한다.
  • Flow Control Window Size(기본 64KB)가 처리량 병목이다. 대용량 스트리밍은 256MB로 올려야 한다.
  • onError()에서 Status Code를 보고 재시도 여부를 결정하고, Exponential Backoff로 서버를 보호한다.

다음 글에서는 gRPC의 TLS와 mTLS가 HTTP/2 핸드셰이크와 어떻게 맞물리는지, 그리고 서비스 간 상호 인증을 어떻게 구현하는지 추적한다.