LLM WikiAccess-protected knowledge portal
← 스터디 홈
2편 · 약 17분

스트리밍 RPC 패턴 심화

단순 요청-응답을 넘어서

REST API는 기본적으로 요청 하나에 응답 하나다. gRPC도 Unary RPC는 같은 모델이다. 하지만 다음 상황에서는 단순 요청-응답이 비효율적이거나 불가능하다.

  • 대용량 결과 페이지네이션: 쿼리 결과 10만 건을 한 번에 직렬화해 전송하면 메모리와 지연 모두 문제가 된다.
  • 실시간 피드: 주가 스트림, 로그 테일처럼 서버가 지속적으로 데이터를 밀어줘야 한다.
  • 청크 업로드: 파일이나 센서 데이터를 분할해 순차 전송하고 서버가 최종 결과를 돌려주는 패턴.
  • 양방향 실시간 통신: 채팅, 협업 편집, 게임 상태 동기화.

gRPC는 이 네 가지 시나리오를 각각의 스트리밍 RPC 유형 으로 공식 지원한다. 모두 HTTP/2 한 연결 위의 단일 스트림 또는 다중 스트림으로 구현되기 때문에 WebSocket처럼 별도 프로토콜을 얹을 필요가 없다.

프로토 파일에서 스트리밍 선언하기

스트리밍 여부는 .proto 파일의 서비스 정의에서 stream 키워드 하나로 결정된다.

syntax = "proto3";
package demo.v1;

// 서버 스트리밍: 요청 1건 → 응답 N건
rpc ListLogs(LogQuery) returns (stream LogEntry);

// 클라이언트 스트리밍: 요청 N건 → 응답 1건
rpc UploadMetrics(stream Metric) returns (UploadSummary);

// 양방향 스트리밍: 요청 N건 ⇄ 응답 M건 (독립 스트림)
rpc Chat(stream ChatMessage) returns (stream ChatMessage);

protoc는 이 정의로부터 언어별 스텁을 생성한다. Go에서는 Send() / Recv() 메서드를 가진 스트림 인터페이스가, Java에서는 StreamObserver<T>가 생성된다.

세 가지 스트리밍 패턴 흐름

① Server Streaming
클라이언트 → 요청 1건 서버 → 응답 스트림 (N건) 응답 N (EOF) 로그 스트리밍, 결과 페이지네이션
② Client Streaming
클라이언트 → 요청 스트림 (N건) 요청 N (Half-Close) 서버 → 응답 1건 파일 업로드, 배치 삽입
③ Bidirectional Streaming
클라이언트 → 요청 스트림 서버 → 응답 스트림 (독립) 클라이언트 → 추가 요청 서버 → 추가 응답 채팅, 협업 편집, 게임 싱크
gRPC 스트리밍 RPC 메시지 흐름 비교

Server Streaming RPC 심화

서버 측 구현 (Go)

func (s *Server) ListLogs(req *pb.LogQuery,
                          stream pb.LogService_ListLogsServer) error {
    for _, entry := range s.queryLogs(req.Filter) {
        if err := stream.Send(entry); err != nil {
            return err  // 클라이언트 연결 끊김
        }
        // 클라이언트가 컨텍스트를 취소했는지 확인
        if stream.Context().Err() != nil {
            return stream.Context().Err()
        }
    }
    return nil  // nil 반환 → 스트림 정상 종료 (서버 EOF)
}

서버는 Send()를 여러 번 호출해 메시지를 흘려보낸다. 함수가 nil을 반환하면 HTTP/2 DATA 프레임 전송이 끝나고 END_STREAM 플래그가 붙은 HEADERS 프레임이 전송된다.

클라이언트 측 수신 (Go)

stream, err := client.ListLogs(ctx, &pb.LogQuery{Filter: "ERROR"})
if err != nil {
    log.Fatal(err)
}
for {
    entry, err := stream.Recv()
    if err == io.EOF {
        break           // 서버가 스트림을 닫았다
    }
    if err != nil {
        log.Fatalf("recv: %v", err)
    }
    fmt.Println(entry.Message)
}

Recv()io.EOF를 반환하면 서버가 스트림을 정상 종료했다는 신호다. 그 외 에러는 네트워크 오류, DEADLINE_EXCEEDED, CANCELLED 같은 실제 오류다.

Client Streaming RPC 심화

클라이언트가 여러 메시지를 보내고 서버가 집계 결과를 한 번 돌려주는 패턴이다.

서버 측 구현 (Go)

func (s *Server) UploadMetrics(stream pb.MetricService_UploadMetricsServer) error {
    var total int64
    for {
        metric, err := stream.Recv()
        if err == io.EOF {
            // 클라이언트가 Half-Close → 스트림 입력 끝
            return stream.SendAndClose(&pb.UploadSummary{
                TotalProcessed: total,
            })
        }
        if err != nil {
            return err
        }
        total += metric.Value
    }
}

io.EOF는 클라이언트가 Half-Close 했다는 신호다. 이 시점에 서버는 SendAndClose()로 응답 한 건을 보내고 스트림을 완전히 닫는다.

클라이언트 측 전송 (Go)

stream, _ := client.UploadMetrics(ctx)
for _, m := range metrics {
    if err := stream.Send(m); err != nil {
        log.Fatal(err)
    }
}
// CloseSend → Half-Close (서버에 입력 완료를 알림)
summary, err := stream.CloseAndRecv()

CloseAndRecv()CloseSend() + Recv()의 합성이다. 클라이언트가 전송을 끝냈음을 서버에 알리고, 서버의 최종 응답을 기다린다.

Bidirectional Streaming RPC 심화

양방향 스트리밍은 두 스트림이 완전히 독립적 으로 동작한다. 클라이언트가 메시지를 보내는 순서와 서버가 응답하는 순서가 반드시 일치할 필요가 없다. 이 자유도가 가장 복잡한 동시성 문제도 낳는다.

서버 측 구현 (Go)

func (s *Server) Chat(stream pb.ChatService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        reply := &pb.ChatMessage{
            User: "server",
            Text: "echo: " + msg.Text,
        }
        if err := stream.Send(reply); err != nil {
            return err
        }
    }
}

클라이언트 측 — 송신과 수신을 별도 고루틴으로

stream, _ := client.Chat(ctx)

// 수신 고루틴: 서버 메시지를 독립적으로 처리
go func() {
    for {
        msg, err := stream.Recv()
        if err != nil { return }
        fmt.Println("서버:", msg.Text)
    }
}()

// 송신: 메인 스레드에서 메시지 전송
for _, text := range userInputs {
    stream.Send(&pb.ChatMessage{User: "client", Text: text})
}
stream.CloseSend()  // 클라이언트 전송 완료 → 서버에 Half-Close

핵심은 송신과 수신을 별도 고루틴(또는 스레드) 에서 처리한다는 점이다. 같은 스레드에서 순차적으로 Send → Recv를 반복하면 서버가 응답을 먼저 보내거나 클라이언트가 먼저 보내야 하는 시나리오에서 데드락이 생길 수 있다.

HTTP/2 흐름 제어와 역압(Backpressure)

gRPC 스트리밍은 HTTP/2 위에서 동작하므로 HTTP/2의 흐름 제어 윈도우(flow control window) 를 그대로 상속한다.

클라이언트 Send Buffer window: 65535 bytes DATA 프레임 WINDOW_UPDATE 서버 Recv Buffer 남은 크레딧 소비 역압 신호 • 윈도우 = 0 → 전송 중단 • WINDOW_UPDATE 수신 후 전송 재개 • gRPC 기본값: 65535 B • 조정 가능 (권장: 1 MB+)
HTTP/2 흐름 제어 윈도우와 gRPC 스트리밍

수신 측의 처리 속도가 송신 측보다 느리면 윈도우가 0이 되고 송신 측은 자동으로 전송을 멈춘다. 이것이 역압(backpressure) 의 HTTP/2 수준 구현이다. 대용량 스트리밍에서 기본값 65535 바이트는 너무 작아 성능 병목이 생길 수 있으므로 채널 옵션으로 윈도우 크기를 늘리는 것이 좋다.

// Go: 초기 윈도우 크기를 1 MB로 설정
conn, _ := grpc.NewClient(addr,
    grpc.WithInitialWindowSize(1 * 1024 * 1024),
    grpc.WithInitialConnWindowSize(4 * 1024 * 1024),
)

Half-Close 메커니즘

gRPC 스트리밍에서 Half-Close 는 "나는 더 이상 보낼 것이 없다"는 단방향 종료 신호다. HTTP/2에서는 END_STREAM 플래그가 붙은 프레임으로 표현된다. 중요한 것은 Half-Close 이후에도 반대 방향의 스트림은 계속 열려 있다 는 점이다.

  • 클라이언트 Client Streaming: CloseSend() → 서버는 Recv()에서 io.EOF를 받고 응답을 보낸 뒤 스트림을 완전 종료.
  • 서버 Server Streaming: 서버 핸들러가 nil 반환 → 클라이언트는 Recv()에서 io.EOF를 받음.
  • 양방향 Streaming: 클라이언트 CloseSend() 후에도 서버는 응답을 계속 보낼 수 있다. 서버가 남은 처리를 완료하고 핸들러를 반환하면 그때 전체 스트림이 닫힌다.

에러 처리 패턴

스트리밍 RPC에서는 에러 처리가 Unary보다 복잡하다. 스트림 중간에 에러가 발생하면 스트림 전체가 중단되기 때문이다.

상황서버에서 본 에러클라이언트에서 본 에러
서버가 에러 반환return status.Errorf(...)Recv() 또는 CloseAndRecv()에서 gRPC 상태 코드
클라이언트가 컨텍스트 취소stream.Context().Err()Send()에서 Canceled 에러
데드라인 초과stream.Context().Err()DEADLINE_EXCEEDED
네트워크 단절Recv() / Send() 에러UNAVAILABLE

스트리밍에서도 gRPC 상태 코드를 그대로 사용한다. 서버 핸들러가 status.Errorf(codes.NotFound, "log not found") 를 반환하면 클라이언트의 Recv()가 해당 상태 코드와 메시지를 담은 에러를 돌려준다. io.EOF와 실제 에러를 구분하는 것이 올바른 스트리밍 루프의 핵심이다.

데드라인과 컨텍스트 전파

장기 실행 스트리밍 RPC에서 데드라인 설정은 어렵지만 꼭 필요하다. 클라이언트가 컨텍스트를 취소하거나 데드라인이 만료되면 서버의 stream.Context().Err()가 즉시 non-nil이 된다. 서버는 주기적으로 이 값을 확인해 불필요한 처리를 조기에 중단해야 한다.

for _, record := range bigDataset {
    if err := stream.Context().Err(); err != nil {
        return err  // 클라이언트가 연결을 끊었다
    }
    stream.Send(record)
}

마이크로서비스 체인에서는 수신한 컨텍스트를 그대로 하위 gRPC 호출에 전달해 데드라인을 전파하는 것이 표준 패턴이다. 상위 호출 데드라인이 만료되면 하위 호출도 자동으로 취소된다.

스트리밍 유형 선택 가이드

질문권장 유형
서버가 큰 결과를 나눠 보내야 하나?Server Streaming
클라이언트가 대량 데이터를 업로드하나?Client Streaming
양쪽이 비동기로 메시지를 주고받나?Bidirectional Streaming
위 중 어느 것도 아닌 단순 호출?Unary

스트리밍은 연결 수명이 길어지므로 연결 관리 복잡도가 올라간다. 반드시 필요한 경우에만 도입하고, Unary로 해결 가능하면 Unary를 선택하는 것이 운영 측면에서 유리하다.

References

  • https://grpc.io/docs/what-is-grpc/core-concepts/
  • https://grpc.io/docs/languages/go/basics/
  • https://grpc.io/docs/guides/flow-control/
  • https://oneuptime.com/blog/post/2026-01-08-grpc-streaming-patterns/view
  • https://oneuptime.com/blog/post/2026-01-24-grpc-bidirectional-streaming/view
  • https://oneuptime.com/blog/post/2026-01-24-grpc-streaming/view
  • https://dev.to/ramonberrutti/grpc-streaming-best-practices-and-performance-insights-219g
  • https://www.iamraghuveer.com/posts/grpc-streaming-patterns/
  • https://learn.microsoft.com/en-us/aspnet/core/grpc/performance