Published on

gRPC 스트리밍 끊김 대응 - Retry·Circuit Breaker 설계

Authors

서버 스트리밍/양방향 스트리밍 gRPC를 운영에 올리면, 생각보다 자주 “끊깁니다”. 네트워크 플랩, L4/L7 로드밸런서의 idle timeout, 프록시 재시작, 서버 롤링 배포, 백프레셔, 메시지 폭주 등 이유는 다양하지만 결론은 같습니다. 스트리밍은 언제든 종료될 수 있고, 종료는 예외가 아니라 정상 상태입니다.

이 글은 “끊겼을 때 다시 붙이면 되지” 수준을 넘어서, **Retry(재시도) + Resume(재구독) + Circuit Breaker(차단) + Timeout/Keepalive(유지)**를 조합해 사용자 경험과 시스템 안정성을 동시에 지키는 설계를 다룹니다.

또한 타임아웃/데드라인 문제를 더 깊게 파고들고 싶다면 Go gRPC context deadline exceeded 9가지 원인도 함께 참고하면 원인 분류에 도움이 됩니다. 인프라 단의 504/idle timeout 루프가 의심된다면 EKS ALB Ingress에서 504 Idle timeout만 반복될 때도 연결해서 보세요.

gRPC 스트리밍이 끊기는 대표 시나리오

1) Idle timeout / 중간 장비의 연결 종료

ALB/NLB, Envoy, Nginx, NAT 게이트웨이 등 중간 장비는 “일정 시간 데이터가 없으면” 연결을 끊습니다. 스트리밍이지만 이벤트가 뜸하면 사실상 idle이므로 쉽게 끊깁니다.

2) 서버 롤링 배포/스케일링으로 인한 연결 드롭

Pod 교체, 노드 드레인, HPA 스케일 인 등으로 기존 스트림이 종료됩니다. 이때는 UNAVAILABLE, CANCELLED, INTERNAL 등 다양한 코드로 나타날 수 있습니다.

3) 클라이언트 백프레셔/리소스 고갈

클라이언트가 메시지를 처리하지 못하면 수신 버퍼가 차고, 결국 스트림이 끊기거나 지연이 누적됩니다. “끊김”이 아니라 “사실상 멈춤”으로 보이기도 합니다.

4) 데드라인/타임아웃 설정 미스

스트리밍에 일반 RPC처럼 짧은 데드라인을 걸어두면, 정상 동작 중에도 DEADLINE_EXCEEDED로 종료됩니다.

먼저 합의해야 할 목표: Retry vs Resume

스트리밍 복구는 크게 두 가지 레벨이 있습니다.

  • Retry(재연결): 끊기면 다시 스트림을 연다. 하지만 중간에 놓친 메시지는 유실될 수 있음.
  • Resume(재구독/이어받기): 마지막으로 처리한 위치(오프셋/시퀀스/커서)를 기준으로 유실 없이 이어받기.

운영 환경에서 “끊김은 흔하다”는 전제를 받아들이면, 중요한 스트림(결제 이벤트, 주문 상태, IoT 텔레메트리 등)은 결국 Resume까지 가야 합니다.

설계 1: 재시도(Retry) 정책을 ‘스트리밍용’으로 바꾸기

Unary RPC에서 흔히 쓰는 단순 재시도는 스트리밍에 그대로 적용하면 위험합니다.

  • 스트림을 재시도하면 서버 부하가 급증(연결/인증/초기화 반복)
  • 네트워크 장애 시 모든 클라이언트가 동시에 재연결(thundering herd)
  • 재연결 시점에 따라 중복 처리 또는 유실이 발생

따라서 스트리밍 재시도는 다음 규칙을 권장합니다.

스트리밍 재시도 권장 규칙

  1. 지수 백오프 + 지터(jitter) 필수
  2. 재시도 가능한 상태코드만 선별 (UNAVAILABLE, 일부 RESOURCE_EXHAUSTED 등)
  3. 애플리케이션 레벨에서 최대 재시도 시간/횟수 상한
  4. 재연결 성공 후 초기 동기화(Resume 핸드셰이크) 수행

설계 2: Resume(이어받기)를 위한 커서/오프셋 프로토콜

Resume을 하려면 “마지막으로 어디까지 처리했는가”를 표현하는 토큰이 필요합니다.

커서 설계 체크리스트

  • 커서는 단조 증가(monotonic)해야 함: seq, offset, event_id
  • 서버는 커서를 기준으로 재전송이 가능해야 함
  • 클라이언트는 커서를 내구성 있게 저장(메모리만 X, 최소 로컬 디스크/DB)
  • 재연결 시 중복 전송은 허용하고, 클라이언트는 idempotent 처리

proto 예시: 서버 스트리밍 + 커서

syntax = "proto3";

package stream.v1;

service EventStreamService {
  rpc Subscribe(SubscribeRequest) returns (stream Event);
}

message SubscribeRequest {
  string topic = 1;
  // 마지막으로 처리 완료한 커서(없으면 "")
  string resume_cursor = 2;
}

message Event {
  string cursor = 1;   // 서버가 부여한 다음 resume 기준
  string event_id = 2; // 멱등 처리 키
  bytes payload = 3;
}

클라이언트 처리 원칙

  • 이벤트를 처리하고 “커밋”한 뒤에만 커서를 업데이트
  • 이벤트 처리 자체는 event_id 기반으로 멱등하게

즉, 순서는 다음이 안전합니다.

  1. 이벤트 수신 → 2) 비즈니스 처리(또는 큐 적재) → 3) 커서 저장(커밋)

설계 3: Circuit Breaker는 ‘재연결 폭주’를 막는 장치

대규모 클라이언트가 동시에 스트리밍을 붙는 구조에서 장애가 나면, 재시도는 오히려 장애를 키웁니다. 이때 **Circuit Breaker(서킷 브레이커)**가 “지금은 붙지 말라”고 강제해줍니다.

스트리밍에서 서킷 브레이커가 필요한 순간

  • 서버가 과부하로 UNAVAILABLE/RESOURCE_EXHAUSTED를 뱉는 중
  • 배포 직후 준비가 덜 된 상태에서 연결이 몰리는 중
  • 네트워크 단절로 재시도가 무의미한 중

서킷 브레이커 상태 모델(권장)

  • Closed: 정상, 연결 시도 허용
  • Open: 일정 기간 연결 시도 차단(즉시 실패)
  • Half-Open: 소수의 탐색 연결만 허용해 회복 여부 확인

구현 포인트

  • 서킷은 “스트림 단위”가 아니라 “(서비스, 엔드포인트, 테넌트)” 단위로 잡는 게 일반적
  • Open 전환 기준은 “연속 실패 N회”보다 슬라이딩 윈도우 실패율이 안정적
  • Half-Open에서 성공하면 Closed로, 실패하면 Open으로

Go 예제: 스트리밍 재연결 + Resume + 간단 CB

아래 코드는 개념을 보여주는 예시입니다.

  • 끊기면 백오프+지터로 재연결
  • resume_cursor를 요청에 포함
  • 연속 실패가 쌓이면 서킷을 Open하여 일정 시간 차단
package main

import (
	"context"
	"errors"
	"math/rand"
	"sync"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	pb "example.com/stream/gen/stream/v1"
)

type CircuitBreaker struct {
	mu           sync.Mutex
	state        string // "closed", "open", "half"
	failCount    int
	openedAt     time.Time
	openFor      time.Duration
	failThreshold int
}

func NewCB(threshold int, openFor time.Duration) *CircuitBreaker {
	return &CircuitBreaker{state: "closed", failThreshold: threshold, openFor: openFor}
}

func (cb *CircuitBreaker) Allow() bool {
	cb.mu.Lock()
	defer cb.mu.Unlock()

	now := time.Now()
	if cb.state == "open" {
		if now.Sub(cb.openedAt) >= cb.openFor {
			cb.state = "half"
			return true // 탐색 1회 허용(단순화)
		}
		return false
	}
	return true
}

func (cb *CircuitBreaker) OnSuccess() {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	cb.failCount = 0
	cb.state = "closed"
}

func (cb *CircuitBreaker) OnFailure() {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	cb.failCount++
	if cb.failCount >= cb.failThreshold {
		cb.state = "open"
		cb.openedAt = time.Now()
	}
}

func isRetryable(err error) bool {
	if err == nil {
		return false
	}
	st, ok := status.FromError(err)
	if !ok {
		return true // 네트워크/기타
	}
	switch st.Code() {
	case codes.Unavailable, codes.ResourceExhausted, codes.Internal:
		return true
	case codes.Canceled, codes.DeadlineExceeded:
		// 클라이언트 컨텍스트 취소/데드라인이면 보통 재시도 X
		return false
	default:
		return false
	}
}

func backoff(attempt int) time.Duration {
	// 200ms * 2^attempt, max 10s
	base := 200 * time.Millisecond
	d := base * time.Duration(1<<min(attempt, 6)) // 12.8s 근처에서 캡
	if d > 10*time.Second {
		d = 10 * time.Second
	}
	// jitter 0.5~1.5
	j := 0.5 + rand.Float64()
	return time.Duration(float64(d) * j)
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// cursor 저장은 DB/파일/Redis 등으로 대체 가능
var cursorMu sync.Mutex
var lastCursor string

func loadCursor() string {
	cursorMu.Lock()
	defer cursorMu.Unlock()
	return lastCursor
}

func saveCursor(c string) {
	cursorMu.Lock()
	defer cursorMu.Unlock()
	lastCursor = c
}

func runStream(ctx context.Context, conn *grpc.ClientConn, cb *CircuitBreaker) error {
	client := pb.NewEventStreamServiceClient(conn)

	resume := loadCursor()
	req := &pb.SubscribeRequest{Topic: "orders", ResumeCursor: resume}

	// 스트리밍은 데드라인을 짧게 잡지 않는 편이 안전합니다.
	// 대신 keepalive/idle timeout 대응은 별도(아래 섹션 참고).
	stream, err := client.Subscribe(ctx, req)
	if err != nil {
		return err
	}

	for {
		evt, err := stream.Recv()
		if err != nil {
			return err
		}

		// 1) 비즈니스 처리 (멱등: evt.event_id 기준)
		if err := handleEvent(evt); err != nil {
			// 처리 실패 시 커서를 커밋하지 않음
			return err
		}

		// 2) 처리 성공 후 커서 커밋
		saveCursor(evt.Cursor)
		cb.OnSuccess()
	}
}

func handleEvent(evt *pb.Event) error {
	// TODO: event_id로 중복 방지(예: Redis SETNX, DB unique key)
	if len(evt.Payload) == 0 {
		return errors.New("empty payload")
	}
	return nil
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	conn, _ := grpc.Dial("dns:///stream-svc:443", grpc.WithInsecure())
	defer conn.Close()

	cb := NewCB(5, 15*time.Second)

	attempt := 0
	for {
		if !cb.Allow() {
			time.Sleep(500 * time.Millisecond)
			continue
		}

		err := runStream(ctx, conn, cb)
		if err == nil {
			attempt = 0
			continue
		}

		if !isRetryable(err) {
			// 운영에서는 로깅/알림 후 종료 또는 상위로 전파
			break
		}

		cb.OnFailure()
		time.Sleep(backoff(attempt))
		attempt++
	}
}

이 예제에서 중요한 점은 “재연결” 자체보다도 커서 커밋 시점서킷으로 재연결 폭주를 막는 것입니다.

설계 4: Keepalive/Heartbeat로 idle timeout을 ‘의도적으로’ 깨기

스트리밍이 뜸하면 중간 장비가 끊습니다. 이를 막는 전형적인 방법은 두 가지입니다.

  1. gRPC keepalive ping(transport 레벨)
  2. 애플리케이션 heartbeat 메시지(메시지 레벨)

언제 어떤 걸 쓰나?

  • L4/L7가 “데이터가 없으면 끊는” 문제라면 keepalive가 효과적
  • 서버가 “구독이 살아있는지”를 비즈니스적으로 확인해야 하면 heartbeat가 유리

다만 keepalive는 과도하게 설정하면 서버/프록시에 부담을 주고, 일부 환경에서는 정책적으로 차단되기도 합니다. 특히 모바일/배터리 환경에서는 heartbeat 주기를 길게 가져가야 합니다.

설계 5: Retry와 Circuit Breaker 사이에 ‘Rate Limit’도 고려

서킷은 “아예 막기”, 백오프는 “시간을 늘리기”라면, 그 중간에 **재연결 속도 제한(rate limit)**이 있으면 운영이 편해집니다.

  • 테넌트별 동시 스트림 수 제한
  • 클라이언트 프로세스당 재연결 시도 QPS 제한
  • 서버가 RESOURCE_EXHAUSTED를 줄 때 Retry-After 유사 힌트를 메타데이터로 제공

이 조합은 장애 시 서버를 보호하고, 회복 시에도 점진적으로 트래픽이 돌아오게 합니다.

관측(Observability): 끊김을 “원인별로” 나누지 않으면 답이 없다

스트리밍 끊김은 결과가 비슷해 보여도 원인이 다릅니다. 최소한 아래를 메트릭/로그로 남겨야 합니다.

필수 메트릭

  • 스트림 종료 코드별 카운트: UNAVAILABLE, DEADLINE_EXCEEDED, CANCELLED
  • 평균 스트림 지속 시간, P50/P95
  • 재연결 시도 횟수/성공률
  • Resume 성공률(커서 기반 재구독)
  • 중복 이벤트 감지 횟수(멱등 키 충돌)

로그/트레이싱 팁

  • 서버는 종료 시점에 **원인(드레인/배포/오버로드)**을 구조화 로그로 남기기
  • 클라이언트는 “마지막 커서”와 “재연결 attempt”를 함께 기록

인프라 레벨에서 502/504처럼 보일 때는 애플리케이션 로그가 비어 있을 수 있습니다. 이 경우는 로드밸런서/헬스체크가 핵심 단서가 되는데, 비슷한 문제 해결 흐름은 EKS Ingress 502인데 Pod 로그가 비면? ALB/NLB 헬스체크부터도 참고할 만합니다.

흔한 안티패턴 6가지

  1. 스트리밍에 짧은 데드라인을 걸고 DEADLINE_EXCEEDED를 재시도
  2. 재연결 시 커서 없이 처음부터 다시 구독(중복/부하 폭발)
  3. 이벤트 처리 전에 커서를 먼저 저장(장애 시 유실)
  4. 멱등 키 없이 “한 번만 오겠지” 가정(재연결 시 중복은 필연)
  5. 모든 오류를 무조건 재시도(권한/인증 오류까지 재시도)
  6. 장애 시 전체 클라이언트가 동시에 붙는 구조(지터 없는 고정 간격 재시도)

운영에서 통하는 권장 조합(레시피)

  • Resume 커서 + 멱등 처리: 데이터 무결성의 핵심
  • 지수 백오프 + 지터: 재연결 폭주 방지의 기본
  • Circuit Breaker: 장애 시 서버 보호, 회복 시 점진적 복귀
  • Keepalive/Heartbeat: idle timeout 환경에서 생존율 개선
  • 메트릭/로그 표준화: 원인 분류가 빨라져야 튜닝이 가능

마무리

gRPC 스트리밍은 “한 번 연결하면 계속 간다”가 아니라, 끊김과 재연결이 정상 흐름인 프로토콜로 보는 순간 설계가 바뀝니다. 재시도만으로는 부족하고, Resume(커서) + 멱등성 + 서킷 브레이커까지 들어가야 운영에서 안정적으로 굴러갑니다.

다음 단계로는 (1) 커서 저장소를 내구성 있게 만들기, (2) 서버가 커서 기반 재전송을 효율적으로 지원하도록(예: 로그/이벤트 스토어) 만들기, (3) 로드밸런서 idle timeout/keepalive 정책을 환경에 맞게 조정하기를 권합니다.