스트리밍 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 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) 를 그대로 상속한다.
수신 측의 처리 속도가 송신 측보다 느리면 윈도우가 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