Post

[gRPC 백엔드] Day 2: 네 가지 스트리밍 - 단항부터 양방향까지

[gRPC 백엔드] Day 2: 네 가지 스트리밍 - 단항부터 양방향까지

이 글은 AI(Claude)의 도움을 받아 작성하고, 작성자가 검토·편집했습니다.

서론: HTTP/2가 열어준 네 가지 통신 모델

REST는 요청 하나에 응답 하나로 끝난다. gRPC는 HTTP/2의 멀티플렉싱 위에서 네 가지 통신 패턴을 제공한다. 단항(unary), 서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍이다. 각 패턴이 적합한 상황을 알면 폴링·웹소켓·청크 업로드를 위한 임시방편을 한 모델로 통합할 수 있다.

1. 네 패턴의 선언

1
2
3
4
5
6
7
8
9
10
11
12
13
service DataService {
  // 1) 단항: 요청 1 → 응답 1 (REST와 동일)
  rpc GetItem(GetRequest) returns (Item);

  // 2) 서버 스트리밍: 요청 1 → 응답 N
  rpc ListItems(ListRequest) returns (stream Item);

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

  // 4) 양방향 스트리밍: 요청 N ↔ 응답 N
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

stream 키워드 하나로 메서드의 통신 모델이 결정된다.

2. 단항 RPC: 가장 흔한 경우

대부분의 API는 단항이다. 동기 함수 호출처럼 쓰면 된다.

1
2
3
4
5
6
7
func (s *server) GetItem(ctx context.Context, req *pb.GetRequest) (*pb.Item, error) {
    item, err := s.db.Find(ctx, req.Id)
    if err != nil {
        return nil, status.Errorf(codes.NotFound, "item %s 없음", req.Id)
    }
    return item, nil
}

단항은 캐시·로드밸런싱·디버깅이 가장 쉽다. 스트리밍이 꼭 필요한 게 아니면 단항을 기본으로 택한다.

3. 서버 스트리밍: 큰 결과를 흘려보내기

수만 건의 결과를 한 응답에 담으면 메모리가 터지고 첫 바이트까지 오래 걸린다. 서버 스트리밍은 준비되는 대로 하나씩 보낸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (s *server) ListItems(req *pb.ListRequest,
                           stream pb.DataService_ListItemsServer) error {
    rows, _ := s.db.Query(stream.Context(), req.Filter)
    defer rows.Close()

    for rows.Next() {
        item := scanItem(rows)
        // 한 건씩 즉시 전송 — 전체를 메모리에 모으지 않음
        if err := stream.Send(item); err != nil {
            return err   // 클라이언트가 끊으면 여기서 종료
        }
    }
    return nil   // return이 스트림 종료 신호
}

대량 조회·실시간 피드·진행률 보고에 적합하다. 클라이언트는 첫 결과를 즉시 받기 시작한다.

4. 클라이언트 스트리밍: 청크 업로드와 집계

파일 업로드나 대량 적재처럼 클라이언트가 많이 보내고 서버가 한 번 응답한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (s *server) UploadItems(stream pb.DataService_UploadItemsServer) error {
    var count int32
    for {
        item, err := stream.Recv()
        if err == io.EOF {
            // 클라이언트가 다 보냄 → 요약 응답 후 종료
            return stream.SendAndClose(&pb.UploadSummary{Received: count})
        }
        if err != nil {
            return err
        }
        s.db.Insert(stream.Context(), item)
        count++
    }
}

io.EOF가 “클라이언트 전송 완료” 신호다. 큰 페이로드를 청크로 쪼개 메모리·타임아웃 압박 없이 받는다.

5. 양방향 스트리밍: 실시간 대화

요청과 응답이 독립적으로 흐른다. 채팅·실시간 협업·게임 상태 동기화에 쓴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (s *server) Chat(stream pb.DataService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        // 받는 즉시 처리하고 응답 — 수신과 송신이 비동기
        reply := s.process(msg)
        if err := stream.Send(reply); err != nil {
            return err
        }
    }
}

수신과 송신 타이밍이 무관하다. 한쪽이 빠르게 여러 개 보내는 동안 다른 쪽이 천천히 응답해도 된다(독립 흐름이라 백프레셔 설계 필요).

6. 패턴 선택 가이드

1
2
3
4
단항          → 일반 CRUD, 조회/명령. 의심되면 이것부터.
서버 스트리밍  → 큰 결과 집합, 실시간 피드, 진행률
클라이언트 스트리밍 → 청크 업로드, 대량 적재, 센서 데이터 수집
양방향        → 채팅, 협업, 양쪽이 독립적으로 말해야 할 때

주의: 스트리밍은 강력하지만 로드밸런서·프록시·재시도 처리가 단항보다 까다롭다. 스트림은 단일 연결에 묶여 중간에 재분배되지 않으므로, 장수명 스트림은 운영 복잡도를 키운다. 필요할 때만 쓴다.

7. Day 2 체크리스트

  1. stream 키워드로 네 가지 통신 모델이 정해짐을 이해했다.
  2. 단항을 기본으로 삼고, 스트리밍은 필요할 때만 쓰는 원칙을 잡았다.
  3. 서버 스트리밍으로 대량 결과를 메모리에 모으지 않고 흘려보냈다.
  4. 클라이언트 스트리밍에서 io.EOF로 전송 완료를 감지했다.
  5. 양방향 스트리밍에서 수신·송신이 독립적임을 파악했다.

다음 편 예고

다음 편부터는 7월입니다. Day 3에서는 모든 RPC를 가로지르는 횡단 관심사 — 인터셉터로 인증·로깅·메트릭을 한 곳에서 처리하는 방법을 다룬다.

This post is licensed under CC BY 4.0 by the author.