LLM WikiAccess-protected knowledge portal

WIKI

스트리밍 RPC 패턴 심화

단순 요청 응답을 넘어서 REST API는 기본적으로 요청 하나에 응답 하나다. gRPC도 Unary RPC는 같은 모델이다. 하지만 다음 상황에서는 단순 요청 응답이 비효율적이거나 불가능하다. 대용량 결과 페이지네이션 쿼리 결과 10만 건을 한 번에 직렬화해 전송하면 메모리와 지연 모두 문제가 된다. 실시간 피드 주가 스트림, 로그 테일처럼 서버가 지속적으로 데이터를 밀어줘야 한다. 청크 업로드 파일이나 센서 데이터를 분할해

경로human/study/content/grpc/02-streaming-rpc-patterns.md
카테고리Study
태그#content #crawler #grpc #infra #patterns #rpc #streaming #study

단순 요청-응답을 넘어서

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

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 이후에도 반대 방향의 스트림은 계속 열려 있다 는 점이다.

에러 처리 패턴

스트리밍 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