Published on

gRPC MSA에서 데드라인·리트라이 폭주 막는 법

Authors

서버가 느려지면 클라이언트는 타임아웃(데드라인)으로 끊고, 끊긴 요청은 리트라이로 다시 쏩니다. 문제는 이 두 가지가 결합될 때입니다. 데드라인이 짧아질수록 실패율이 올라가고, 실패율이 올라갈수록 리트라이가 더 많은 트래픽을 생성해 서버를 더 느리게 만들며, 결국 전체 시스템이 타임아웃과 재시도에 잠식되는 retry/deadline storm이 발생합니다.

이 글은 gRPC 기반 MSA에서 이런 폭주를 설정 한 줄이 아니라, 전파 규칙·예산(budget)·백오프·서킷 브레이커·관측성까지 포함해 끝까지 막는 실전 패턴을 다룹니다.

폭주의 전형적인 시나리오

1) 데드라인 불일치와 “즉시 타임아웃”

  • A 서비스(프론트/게이트웨이)가 전체 요청 데드라인 300ms
  • A→B 호출에 250ms를 설정
  • B→C 호출에 250ms를 설정(전파 없이 고정값)

이때 B에서 C 호출은 이미 남은 시간이 거의 없는데도 250ms로 “새” 데드라인을 줍니다. 결과적으로:

  • A는 300ms에 끊음
  • B는 계속 C를 호출(서버는 이미 클라이언트가 떠난 요청을 처리)
  • C는 뒤늦게 응답을 만들어도 버려짐

이는 NGINX 499(클라이언트 abort) 폭주 패턴과 유사합니다. (HTTP 레이어 사례지만 관찰 신호가 비슷합니다: 클라이언트가 먼저 끊고, 서버는 뒤늦게 처리) 필요하면 EKS NGINX Ingress 499 폭주 원인과 해결도 함께 참고하세요.

2) 리트라이가 “실패를 증폭”시키는 구조

리트라이가 안전하게 작동하려면 최소한 아래가 만족되어야 합니다.

  • 재시도 가능한 오류만 재시도한다
  • 백오프/지터가 있다
  • 요청당 최대 재시도 횟수 제한이 있다
  • 시스템 전체 재시도 예산(Retry Budget)이 있다

이 중 하나라도 빠지면, 장애 시 트래픽이 “원래 QPS × (1 + 재시도 횟수)”로 단순 증가하지 않고 큐 적체 + tail latency 증가로 인해 실패율이 더 커져 기하급수적으로 악화됩니다.

원칙 1: 데드라인은 “전파”하고 “감산”하라

남은 시간 기반 감산(deadline propagation with budget)

  • 상위 요청의 데드라인을 하위 호출에 그대로 전달하되
  • 하위 호출은 남은 시간에서 여유(slack) 를 빼고 설정합니다.

여유는 다음 비용을 고려해 확보합니다.

  • 직렬화/역직렬화
  • 큐잉/스레드 스케줄링
  • 네트워크 변동
  • 애플리케이션 후처리

Go(gRPC) 예제: 남은 시간에서 slack 차감

// upstream ctx는 이미 deadline을 갖고 있다고 가정
func callB(ctx context.Context, client pb.BServiceClient) (*pb.Res, error) {
    // 상위 데드라인이 없다면, 서비스 기본값을 설정(무한 대기 방지)
    if _, ok := ctx.Deadline(); !ok {
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, 800*time.Millisecond)
        defer cancel()
    }

    // 남은 시간에서 slack(예: 30ms) 확보
    deadline, _ := ctx.Deadline()
    remain := time.Until(deadline)
    slack := 30 * time.Millisecond
    timeout := remain - slack
    if timeout <= 0 {
        return nil, status.Error(codes.DeadlineExceeded, "no time left for downstream call")
    }

    dctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    return client.Do(dctx, &pb.Req{})
}

핵심은 하위 호출이 상위 호출보다 더 오래 기다리면 안 된다는 점입니다. 상위가 이미 끊을 예정인데 하위가 계속 일하면, CPU/DB/큐를 계속 태워 장애를 악화시킵니다.

서버도 데드라인을 존중해야 한다

클라이언트가 데드라인을 주더라도 서버 핸들러가 이를 무시하면 효과가 없습니다.

  • ctx.Done()을 주기적으로 확인
  • DB/외부 호출에도 동일 ctx를 전달
func (s *Server) Heavy(ctx context.Context, req *pb.Req) (*pb.Res, error) {
    // 예: 루프에서 취소/데드라인 확인
    for i := 0; i < 10_000; i++ {
        select {
        case <-ctx.Done():
            return nil, status.Error(codes.Canceled, "request canceled")
        default:
        }
        // ... work ...
    }
    return &pb.Res{}, nil
}

원칙 2: “무조건 리트라이”는 금지 — 재시도 가능한 조건을 좁혀라

gRPC 상태코드 중 재시도를 고려할 수 있는 것은 제한적입니다.

  • UNAVAILABLE: 일시적 네트워크/서버 불가
  • RESOURCE_EXHAUSTED: 서버가 과부하(단, 재시도가 오히려 악화 가능)
  • DEADLINE_EXCEEDED: 타임아웃(대개 이미 혼잡 신호)

하지만 현실적으로 DEADLINE_EXCEEDEDRESOURCE_EXHAUSTED리트라이하면 더 나빠지는 경우가 많습니다. 따라서 기본값은:

  • UNAVAILABLE만 제한적으로 재시도
  • 나머지는 빠르게 실패하고 상위에서 폴백/캐시/서킷으로 처리

또한 멱등성(idempotency) 이 보장되지 않으면 리트라이는 데이터 정합성을 깨뜨립니다.

  • 쓰기 요청(Create/Charge/Reserve 등)은 원칙적으로 리트라이 금지
  • 꼭 필요하면 idempotency key(요청 ID) 기반 중복 제거를 서버에서 구현

원칙 3: 백오프 + 지터는 필수, 그리고 “데드라인 안에서만” 재시도

리트라이는 데드라인과 독립적으로 동작하면 안 됩니다.

  • 남은 시간이 40ms인데 3회 재시도? 의미 없습니다.
  • 재시도는 남은 시간 예산 안에서만 수행해야 합니다.

Go 예제: 데드라인 예산 기반 재시도(간단 구현)

func retryUnary(ctx context.Context, maxAttempts int, base time.Duration, fn func(context.Context) error) error {
    var last error

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        if err := ctx.Err(); err != nil {
            return err
        }

        err := fn(ctx)
        if err == nil {
            return nil
        }
        last = err

        st, ok := status.FromError(err)
        if !ok {
            return err
        }

        // 재시도 조건을 좁게: UNAVAILABLE만
        if st.Code() != codes.Unavailable {
            return err
        }

        // 마지막 시도면 종료
        if attempt == maxAttempts {
            break
        }

        // 백오프(지터 포함)
        backoff := base * time.Duration(1<<(attempt-1))
        jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
        sleep := backoff/2 + jitter

        // 남은 데드라인을 초과하면 재시도하지 않음
        if dl, ok := ctx.Deadline(); ok {
            if time.Until(dl) <= sleep {
                break
            }
        }

        timer := time.NewTimer(sleep)
        select {
        case <-ctx.Done():
            timer.Stop()
            return ctx.Err()
        case <-timer.C:
        }
    }

    return last
}

이 구현은 단순하지만 중요한 안전장치를 포함합니다.

  • 상태코드 기반 필터링
  • 지터로 동시 재시도 파동 완화
  • 데드라인 예산 내에서만 재시도

원칙 4: Retry Budget(재시도 예산)으로 “시스템 전체” 폭주를 막아라

개별 요청의 maxAttempts=3만으로는 부족합니다. 장애 시 모든 요청이 3회씩 재시도하면 이미 폭주입니다.

Retry Budget은 “정상 시 성공 트래픽의 일부만 재시도에 사용”하도록 제한하는 정책입니다.

  • 예: 최근 10초 동안 성공 요청이 10,000이면
  • 재시도는 그 중 5%(500)까지만 허용

구현 방식은 다양하지만 운영에서 효과적인 형태는 다음 중 하나입니다.

  • 클라이언트 라이브러리/사이드카에서 토큰 버킷(token bucket)
  • 서비스 메시(Envoy)에서 outlier detection + retry budget 유사 정책
  • 중앙 설정(동적)으로 per-route/per-method 제한

핵심은 장애 시 재시도가 자동으로 줄어드는 피드백 루프를 만드는 것입니다.

원칙 5: 서킷 브레이커 + 동시성 제한으로 큐 적체를 끊어라

폭주의 본질은 “서버가 느려져서 실패가 늘고, 실패가 재시도를 불러 더 느려지는” 루프입니다. 이를 끊는 강력한 방법이:

  • 서킷 브레이커: 실패율/지연이 임계치면 빠르게 실패
  • 동시성 제한: 다운스트림 호출을 일정 개수 이상 동시에 못 하게 막음

Go 예제: semaphore로 다운스트림 동시성 제한

type Limiter struct {
    sem chan struct{}
}

func NewLimiter(n int) *Limiter {
    return &Limiter{sem: make(chan struct{}, n)}
}

func (l *Limiter) Do(ctx context.Context, fn func() error) error {
    select {
    case l.sem <- struct{}{}:
        defer func() { <-l.sem }()
        return fn()
    case <-ctx.Done():
        return ctx.Err()
    }
}

// 사용
// limiter := NewLimiter(50) // B->C 동시 호출 50개 제한

동시성 제한은 tail latency를 줄이고, 큐가 무한히 쌓이는 것을 막아 데드라인/리트라이 폭주의 연료를 차단합니다.

원칙 6: 관측성(Observability) 없이는 튜닝이 아니라 도박이다

데드라인/리트라이 문제는 “어디서 시간이 새는지”를 알아야 해결됩니다. 최소한 아래를 갖추세요.

필수 지표

  • gRPC status code 비율(UNAVAILABLE, DEADLINE_EXCEEDED, CANCELED)
  • p50/p95/p99 latency (클라이언트/서버 모두)
  • 재시도 횟수(시도 수 분포)
  • 큐 길이/동시 처리 수(스레드풀, 워커, DB pool)

필수 트레이싱 태그

  • grpc.method, grpc.status_code
  • deadline_ms(요청에 설정된 데드라인)
  • attempt(몇 번째 시도인지)
  • retry_reason

특히 CANCELED가 서버에서 급증하면 “클라이언트가 먼저 끊었다”는 신호일 수 있습니다. HTTP 환경에서는 499로 보이기도 하며, EKS에서 네트워크/프록시 계층 문제와 엮이면 원인 추적이 더 어려워집니다. 비슷한 추적 접근이 필요할 때는 EKS에서 Pod egress만 502? Envoy/NLB 추적기도 도움이 됩니다.

실전 체크리스트: 폭주를 막는 설정/규칙

데드라인

  • 모든 inbound 요청에 기본 데드라인을 부여(무한 대기 금지)
  • 데드라인은 하위 호출로 전파
  • 하위 호출은 남은 시간에서 slack 차감
  • 서버는 ctx 취소를 존중(고비용 작업 중단)

리트라이

  • 기본은 “재시도 없음”, 필요한 메서드만 opt-in
  • UNAVAILABLE 위주로 제한
  • 백오프 + 지터 필수
  • 남은 데드라인 예산 내에서만 재시도
  • 멱등성 없는 쓰기 요청은 금지(또는 idempotency key)
  • Retry Budget으로 전체 재시도량 제한

보호장치

  • 동시성 제한(다운스트림별)
  • 서킷 브레이커(빠른 실패 + 점진적 복구)
  • 부하 시 폴백(캐시/근사값/부분 응답)

운영에서 자주 하는 실수 5가지

  1. 클라이언트 데드라인을 너무 짧게 잡아 p99 변동을 흡수하지 못함
  2. 하위 호출에 고정 timeout을 박아 상위보다 길게 기다림
  3. DEADLINE_EXCEEDED를 “네트워크 문제”로 보고 무한 재시도
  4. 백오프에 지터가 없어 모든 인스턴스가 동시에 재시도
  5. 재시도/타임아웃만 만지고 동시성 제한과 큐 상태를 보지 않음

결론: 데드라인과 리트라이는 “성능 옵션”이 아니라 “안전장치 설계”다

gRPC MSA에서 데드라인과 리트라이는 지연을 줄이는 도구처럼 보이지만, 실제로는 장애 시 시스템을 지키는 안전장치입니다. 안전장치는 단일 파라미터(예: timeout=200ms, retries=3)로 끝나지 않습니다.

  • 데드라인은 전파하고 감산해 불필요한 작업을 줄이고
  • 리트라이는 조건을 좁히고 예산을 두어 실패를 증폭시키지 않게 만들며
  • 동시성 제한/서킷/관측성으로 피드백 루프를 끊는 것

이 3가지를 함께 적용하면, 장애가 와도 “전체가 같이 죽는” 폭주 대신 “제한된 범위에서 빠르게 실패하고 복구하는” 형태로 시스템을 바꿀 수 있습니다.