- Published on
gRPC MSA에서 데드라인·재시도 폭주 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려진 게 원인인데, 클라이언트의 재시도가 그 느림을 더 악화시키는 상황을 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가 곧바로 트래픽 폭주로 이어지는 악순환을 상당 부분 끊을 수 있습니다.