- Published on
gRPC MSA에서 데드라인·리트라이 폭주 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려지면 클라이언트는 타임아웃(데드라인)으로 끊고, 끊긴 요청은 리트라이로 다시 쏩니다. 문제는 이 두 가지가 결합될 때입니다. 데드라인이 짧아질수록 실패율이 올라가고, 실패율이 올라갈수록 리트라이가 더 많은 트래픽을 생성해 서버를 더 느리게 만들며, 결국 전체 시스템이 타임아웃과 재시도에 잠식되는 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_EXCEEDED와 RESOURCE_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_codedeadline_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가지
- 클라이언트 데드라인을 너무 짧게 잡아 p99 변동을 흡수하지 못함
- 하위 호출에 고정 timeout을 박아 상위보다 길게 기다림
DEADLINE_EXCEEDED를 “네트워크 문제”로 보고 무한 재시도- 백오프에 지터가 없어 모든 인스턴스가 동시에 재시도
- 재시도/타임아웃만 만지고 동시성 제한과 큐 상태를 보지 않음
결론: 데드라인과 리트라이는 “성능 옵션”이 아니라 “안전장치 설계”다
gRPC MSA에서 데드라인과 리트라이는 지연을 줄이는 도구처럼 보이지만, 실제로는 장애 시 시스템을 지키는 안전장치입니다. 안전장치는 단일 파라미터(예: timeout=200ms, retries=3)로 끝나지 않습니다.
- 데드라인은 전파하고 감산해 불필요한 작업을 줄이고
- 리트라이는 조건을 좁히고 예산을 두어 실패를 증폭시키지 않게 만들며
- 동시성 제한/서킷/관측성으로 피드백 루프를 끊는 것
이 3가지를 함께 적용하면, 장애가 와도 “전체가 같이 죽는” 폭주 대신 “제한된 범위에서 빠르게 실패하고 복구하는” 형태로 시스템을 바꿀 수 있습니다.