Published on

gRPC MSA에서 데드라인·재시도 폭주 막는 법

Authors

서버가 느려진 게 원인인데, 클라이언트의 재시도가 그 느림을 더 악화시키는 상황을 gRPC MSA에서 자주 봅니다. 특히 DEADLINE_EXCEEDED가 늘어나는 순간 “타임아웃을 늘릴까, 재시도를 켤까” 같은 즉흥 대응이 들어가면 트래픽이 기하급수로 늘면서 장애가 증폭됩니다. 이 글은 데드라인과 재시도가 결합될 때 생기는 폭주 메커니즘을 분해하고, 설계·구현 단계에서 폭주를 막는 체크리스트를 제공합니다.

관련해서 DEADLINE_EXCEEDED를 더 깊게 다룬 글은 Go gRPC DEADLINE_EXCEEDED 원인과 재시도·타임아웃 설계도 함께 참고하면 좋습니다.

데드라인·재시도 폭주가 생기는 구조

1) 데드라인 미전파로 “서버는 계속 일함”

클라이언트는 타임아웃으로 요청을 포기했는데, 서버는 그 사실을 모르고 계속 처리합니다. 그 사이 클라이언트는 재시도를 보내고, 서버는 동일 작업을 중복 수행합니다. 결과적으로:

  • 서버 CPU와 스레드(또는 고루틴)가 중복 작업으로 고갈
  • DB/캐시/외부 API 호출이 중복되어 하위 의존성이 먼저 터짐
  • 큐잉 지연이 커지며 더 많은 요청이 데드라인을 초과

gRPC는 데드라인을 메타데이터로 전달할 수 있고, 서버는 컨텍스트 취소를 감지해 작업을 중단해야 합니다. 문제는 “전파는 했는데 실제로 중단을 안 하는 코드”가 많다는 점입니다.

2) 잘못된 재시도: 모든 코드에 일괄 재시도

재시도는 “실패를 숨기는 기능”이 아니라 “일시적 실패를 흡수하는 기능”입니다. 아래를 구분하지 않으면 폭주가 시작됩니다.

  • 재시도해도 되는 실패: UNAVAILABLE, RESOURCE_EXHAUSTED 일부, 네트워크 단절 등
  • 재시도하면 안 되는 실패: 비즈니스 오류, 권한 오류, 입력 오류
  • 재시도하면 더 위험한 실패: 서버 과부하로 인한 지연(이미 큐가 길면 재시도는 불에 기름)

3) 동기적 서비스 체인에서 데드라인 분배 실패

예를 들어 A가 B와 C를 순차 호출하면, A의 전체 데드라인을 B에 다 써버리고 C는 시작하자마자 데드라인 초과가 납니다. 이때 A는 C를 재시도하고, C는 이미 늦은 요청을 계속 받아서 더 느려집니다.

핵심은 “상위 요청의 전체 데드라인을 하위 호출에 그대로 쓰지 말고, 남은 시간에서 안전 마진을 떼어 분배”하는 것입니다.

원칙 1: 데드라인은 반드시 end-to-end로 전파한다

클라이언트: 합리적인 기본 데드라인을 강제

  • 무제한 데드라인(또는 매우 큰 값)은 금지
  • RPC별 SLO 기반으로 기본 데드라인을 정하고, 호출자가 명시하지 않으면 기본값을 적용

Go 예시:

ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: userID})

서버: 컨텍스트 취소를 감지해 작업을 중단

서버 핸들러에서 ctx.Done()을 무시하면 “클라이언트는 포기했는데 서버는 계속 일하는” 중복 비용이 남습니다.

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    // 예: DB 조회 전에 취소 여부 확인
    select {
    case <-ctx.Done():
        return nil, status.Error(codes.Canceled, "request canceled")
    default:
    }

    user, err := s.repo.FindUser(ctx, req.Id) // repo도 ctx를 받아야 함
    if err != nil {
        return nil, status.Error(codes.Internal, "db error")
    }
    return &pb.GetUserResponse{User: user}, nil
}

자바(Spring)에서도 동일합니다. DB/HTTP 클라이언트에 타임아웃을 연결하고, 인터럽트/취소가 실제로 전파되도록 설정해야 합니다.

원칙 2: 재시도는 “정책”이 아니라 “예산”으로 제한한다

재시도를 켜는 순간, 실패율이 높아질 때 트래픽이 늘어납니다. 따라서 재시도는 “허용 가능한 추가 트래픽”이라는 예산으로 통제해야 합니다.

재시도 예산(Retry Budget) 개념

  • 최근 성공 요청 수를 기준으로 재시도 가능한 최대량을 제한
  • 예: “성공 트래픽의 10퍼센트까지만 재시도 허용”

간단한 모델:

  • budget = success_count * 0.1
  • 재시도 1회당 예산 1 차감
  • 예산이 0이면 재시도 금지

이렇게 하면 장애 시 재시도가 무한히 늘어나는 걸 구조적으로 막습니다.

재시도 조건을 엄격히

권장 필터:

  • 재시도 가능: UNAVAILABLE, DEADLINE_EXCEEDED는 보통 “재시도 금지 또는 매우 제한”
  • 멱등 요청만 재시도: GET성 조회, 또는 멱등 키를 가진 쓰기
  • 서버가 retry-after를 주면 그 값을 우선

gRPC에서는 서비스 메시 단(Envoy)이나 클라이언트 인터셉터에서 재시도 정책을 적용하는 경우가 많습니다.

원칙 3: 백오프와 지터는 필수, 즉시 재시도는 금지

문제: 동시 재시도로 스파이크가 생김

타임아웃이 300ms면, 300ms마다 모든 클라이언트가 동시에 재시도합니다. 이게 “리트라이 스톰”의 전형입니다.

해결: 지수 백오프 + 지터

  • 백오프: base * 2^n
  • 지터: 랜덤 분산으로 동시성을 깨기

Go 의사코드:

base := 50 * time.Millisecond
max  := 800 * time.Millisecond

for attempt := 0; attempt < 3; attempt++ {
    err := call()
    if err == nil { return nil }

    if !retryable(err) { return err }

    backoff := base * (1 << attempt)
    if backoff > max { backoff = max }

    // full jitter: 0..backoff
    sleep := time.Duration(rand.Int63n(int64(backoff)))
    time.Sleep(sleep)
}
return err

지터 전략은 full jitter가 폭주 완화에 유리한 편입니다.

원칙 4: 데드라인을 “분배”하고, 하위 호출에는 안전 마진을 둔다

상위 요청 데드라인이 1초이고, 하위로 B와 C를 호출한다면:

  • B에 700ms를 주고
  • C에 200ms를 주며
  • 나머지 100ms는 직렬화/네트워크/후처리 마진으로 남겨두는 식

Go 예시:

func budgetedChildCtx(parent context.Context, childBudget time.Duration) (context.Context, context.CancelFunc, error) {
    deadline, ok := parent.Deadline()
    if !ok {
        return context.WithTimeout(parent, childBudget)
    }

    remaining := time.Until(deadline)
    if remaining <= 0 {
        return nil, nil, context.DeadlineExceeded
    }

    if childBudget > remaining {
        childBudget = remaining
    }
    return context.WithTimeout(parent, childBudget)
}

이 패턴을 쓰면 “이미 늦은 요청이 하위 서비스로 전파되어 쓸데없는 부하를 만드는 것”을 크게 줄일 수 있습니다.

원칙 5: 멱등성 없는 요청은 재시도 대신 멱등 키를 설계한다

주문 생성, 결제 승인 같은 쓰기 요청을 재시도하면 중복 데이터가 생깁니다. 이걸 막으려면:

  • 클라이언트가 idempotency_key를 생성
  • 서버가 키 기준으로 “이미 처리한 요청이면 같은 결과 반환”

proto 예시:

message CreateOrderRequest {
  string idempotency_key = 1;
  string user_id = 2;
  repeated string item_ids = 3;
}

서버는 idempotency_key를 저장(예: Redis, DB unique index)하고, 중복 요청이면 이전 응답을 반환합니다. 이렇게 하면 재시도는 “중복 생성”이 아니라 “중복 전송”만 발생합니다.

원칙 6: 헤지드 리퀘스트는 제한적으로만 사용한다

헤지드 리퀘스트(hedged request)는 tail latency를 줄이기 위해 “느린 요청이 일정 시간 넘으면 다른 인스턴스에 동일 요청을 한 번 더 보내는” 전략입니다.

효과는 있지만, 잘못 쓰면 트래픽이 늘어납니다.

권장 가드레일:

  • 멱등 요청에만 적용
  • 동시에 1회만 추가 전송
  • 전체 트래픽의 일부(예: 1퍼센트)만 샘플링 적용
  • 서버가 과부하일 때는 비활성화(서킷 브레이커와 연동)

원칙 7: 서킷 브레이커와 동시성 제한으로 “밀어넣기”를 막는다

재시도 폭주는 결국 “느린 서버에 더 많은 요청을 밀어넣는 문제”입니다. 따라서 클라이언트 측에서 동시성 제한과 서킷 브레이커를 두면 폭주가 크게 줄어듭니다.

  • 동시성 제한: 특정 다운스트림으로 나가는 in-flight RPC 수 제한
  • 서킷 브레이커: 실패율/지연이 임계치 넘으면 빠르게 실패(fail fast)

실제 장애에서는 DB 커넥션 고갈이 함께 터지는 경우가 많습니다. 서버가 느려져서 재시도가 늘고, 그 재시도가 DB 풀을 더 고갈시키며 연쇄 장애가 납니다. 이 축은 Spring Boot 3에서 HikariCP 커넥션 고갈 원인 9가지도 같이 보면 원인 파악에 도움이 됩니다.

원칙 8: 타임아웃을 “짧게”가 아니라 “계층적으로” 잡는다

흔한 실수:

  • 모든 RPC를 200ms로 통일
  • 또는 장애가 나면 5초, 10초로 늘림

둘 다 위험합니다.

권장 접근:

  • 사용자 요청 전체 데드라인: 예를 들어 1초
  • 내부 서비스 호출: 100ms~400ms 등 기능별로 다르게
  • DB 쿼리: 20ms~100ms 등 더 짧게
  • 외부 API: 별도 회로(서킷) + 더 보수적 제한

중요한 건 “하위 계층이 상위보다 짧아야” 상위가 남은 시간으로 fallback을 시도할 수 있다는 점입니다.

원칙 9: 관측 가능성으로 폭주를 조기 탐지한다

데드라인·재시도 폭주는 지표로 보면 패턴이 뚜렷합니다.

필수 지표:

  • RPC별 DEADLINE_EXCEEDED, UNAVAILABLE 비율
  • 재시도 횟수(클라이언트/프록시 기준)
  • in-flight 요청 수
  • 큐잉 지연(서버 핸들러 진입 전 대기)
  • downstream(DB/캐시) 에러율과 p95, p99

분산 추적(Trace)에서는:

  • 동일 trace에서 같은 span이 반복되는지(재시도)
  • 서버 span이 클라이언트보다 오래 남는지(취소 미반영)

이 두 가지만 봐도 “재시도 폭주인지, 서버가 취소를 무시하는지”를 빠르게 구분할 수 있습니다.

실전 체크리스트

설계

  • 모든 RPC에 기본 데드라인 정의
  • 상위 데드라인을 하위 호출에 분배(마진 포함)
  • 멱등성 없는 쓰기에는 idempotency_key 설계
  • 재시도는 예산 기반으로 제한

구현

  • 서버는 ctx.Done()을 감지해 작업 중단
  • DB/HTTP 클라이언트까지 타임아웃 전파
  • 재시도는 UNAVAILABLE 중심, 즉시 재시도 금지
  • 백오프 + 지터 적용
  • 동시성 제한 + 서킷 브레이커 적용

운영

  • 재시도 횟수와 DEADLINE_EXCEEDED를 대시보드 상단에 배치
  • 장애 시 “타임아웃 증가”보다 “재시도 축소, 동시성 제한 강화”를 우선 검토

마무리

gRPC MSA에서 데드라인과 재시도는 각각만 보면 유용하지만, 결합되면 장애 증폭기가 됩니다. 폭주를 막는 핵심은 “데드라인을 end-to-end로 전파하고, 재시도를 예산과 지터로 통제하며, 과부하 시 빠르게 실패하도록 밀어넣기를 차단”하는 것입니다. 이 3가지만 지켜도 DEADLINE_EXCEEDED가 곧바로 트래픽 폭주로 이어지는 악순환을 상당 부분 끊을 수 있습니다.