- Published on
gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
MSA에서 gRPC는 낮은 오버헤드와 강력한 IDL(Proto) 덕분에 내부 통신 표준으로 자주 선택됩니다. 하지만 운영에서 가장 흔하게 만나는 오류 중 하나가 DEADLINE_EXCEEDED이고, 이게 무서운 이유는 **단일 지연이 아니라 연쇄 장애(cascading failure)**의 기폭제가 되기 때문입니다.
전형적인 시나리오는 이렇습니다.
- A 서비스가 B를 호출하고, B가 C를 호출하는 체인에서 C가 느려짐
- B의 in-flight 요청이 쌓이고 스레드/커넥션/큐가 고갈됨
- A는 B로부터 타임아웃을 받고 재시도 → B 부하가 더 증가
- 결국 정상 트래픽까지 함께 실패하며, 장애가 “확대 재생산”됨
이 글에서는 DEADLINE_EXCEEDED를 “에러 처리”가 아니라 “시스템 설계” 문제로 보고, gRPC MSA에서 연쇄 장애를 차단하는 구체적인 패턴을 정리합니다. (Go 예제로 설명하지만 개념은 언어 불문입니다.)
관련해서 gRPC 타임아웃/재시도 설계를 더 깊게 다룬 글은 Go gRPC DEADLINE_EXCEEDED 원인과 재시도·타임아웃 설계도 함께 참고하면 좋습니다.
DEADLINE_EXCEEDED가 연쇄 장애로 번지는 구조
DEADLINE_EXCEEDED는 “서버가 늦다”만 의미하지 않습니다. 실제로는 다음 중 하나(또는 복합)일 가능성이 큽니다.
1) 상위 서비스의 타임아웃이 과도하게 짧음
상위 서비스(A)가 B에 100ms 데드라인을 걸었는데, 네트워크 왕복/큐잉/GC/DB까지 고려하면 100ms는 현실적으로 불가능할 수 있습니다. 이 경우 B는 정상 동작 중인데도 계속 타임아웃을 맞습니다.
2) 큐잉 지연(Queueing delay)이 지연의 대부분
서버의 “실제 처리 시간”이 아니라 대기 시간이 길어지는 순간, 타임아웃이 폭증합니다. 특히 다음이 트리거가 됩니다.
- 동시성 제한 없는 핸들러(고루틴/스레드 폭증)
- 다운스트림 호출이 느려져 in-flight가 누적
- 커넥션 풀/DB 풀 고갈
DB 풀 고갈은 HTTP든 gRPC든 동일한 연쇄 장애 패턴을 만듭니다. (예: HikariCP 고갈) 필요하면 Spring Boot HikariCP 커넥션 고갈 원인 8가지처럼 “리소스 풀이 막히며 상위가 타임아웃”되는 구조를 함께 떠올리면 이해가 빠릅니다.
3) 재시도(retry)가 부하 증폭기 역할을 함
타임아웃이 발생하면 “한 번 더”는 합리적으로 보이지만, 이미 느린 시스템에 재시도를 추가하면 다음이 발생합니다.
- 동일 요청이 중복 실행되어 서버 작업량 증가
- 타임아웃이 더 늘어남 → 재시도 더 증가
- 결국 폭주(thundering herd)
4) 타임아웃 전파/취소(cancel) 미흡
gRPC는 데드라인이 컨텍스트로 전파될 수 있는데, 서버가 이를 무시하거나(백그라운드 고루틴 지속), 다운스트림에 전달하지 않으면 이미 실패가 확정된 작업이 계속 리소스를 점유합니다.
차단 전략 1: “타임아웃 예산(Budget)”으로 데드라인을 설계
연쇄 장애를 막는 첫걸음은 각 홉이 제멋대로 타임아웃을 잡지 않게 하는 것입니다.
핵심 원칙:
- 전체 요청에 End-to-End 예산을 둔다 (예: 800ms)
- 각 다운스트림 호출은 예산 내에서만 수행한다
- 남은 예산이 너무 적으면 호출하지 않고 빠르게 실패(fail-fast)
Go 예제: 남은 데드라인 기반 다운스트림 호출
func callB(ctx context.Context, client pb.BClient, req *pb.BRequest) (*pb.BResponse, error) {
// 상위에서 ctx에 deadline이 설정되어 있다고 가정
deadline, ok := ctx.Deadline()
if !ok {
// 무데드라인은 운영에서 위험. 기본값을 강제.
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
deadline, _ = ctx.Deadline()
}
remaining := time.Until(deadline)
// 다운스트림 호출 최소 보장 시간(예: 80ms)보다 작으면 호출하지 않음
if remaining < 80*time.Millisecond {
return nil, status.Error(codes.DeadlineExceeded, "not enough time budget to call B")
}
// B 호출에는 남은 예산 중 일부만 할당 (예: 60%)
timeout := time.Duration(float64(remaining) * 0.6)
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.Do(dctx, req)
}
이 방식의 장점은 단순히 “타임아웃을 늘리자”가 아니라, 상위 SLA를 지키는 선에서 하위를 호출하게 만들어 시스템 전체를 안정화한다는 점입니다.
차단 전략 2: 재시도는 “기본 OFF”, 하더라도 조건부/제한적으로
재시도는 연쇄 장애를 키우기 쉬우므로 다음 원칙을 권장합니다.
- 기본값: 재시도 OFF
- 켜야 한다면:
- 멱등(idempotent) 요청만
DEADLINE_EXCEEDED에는 원칙적으로 재시도하지 않거나, 매우 제한적으로- 지수 백오프 + 지터(jitter)
- 전체 예산 내에서만 재시도
Go 예제: 예산 내 1회 제한 재시도 + 지터
func callWithRetry(ctx context.Context, fn func(context.Context) error) error {
// 1회만 재시도 (총 2번 시도)
var last error
for attempt := 0; attempt < 2; attempt++ {
if err := fn(ctx); err != nil {
last = err
st, _ := status.FromError(err)
// DEADLINE_EXCEEDED는 보통 서버가 느리거나 큐잉이므로 재시도 비권장
if st.Code() == codes.DeadlineExceeded {
return err
}
// 남은 예산이 없으면 중단
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 60*time.Millisecond {
return err
}
// backoff + jitter
base := 30 * time.Millisecond
jitter := time.Duration(rand.Intn(20)) * time.Millisecond
time.Sleep(base + jitter)
continue
}
return nil
}
return last
}
포인트는 “재시도 구현”이 아니라 재시도를 시스템 안정성 관점에서 통제하는 것입니다.
차단 전략 3: 서버 측 동시성 제한과 로드셰딩(Load Shedding)
DEADLINE_EXCEEDED는 종종 “서버가 느리다”가 아니라 “서버가 너무 바쁘다”입니다. 이때 서버가 무제한으로 요청을 받아 처리하려 하면 큐가 늘고, 결국 모든 요청이 타임아웃으로 죽습니다.
따라서 서버는 다음 중 하나를 반드시 가져야 합니다.
- 동시 처리 제한(Concurrency limit)
- 제한 초과 시 빠른 실패(예:
RESOURCE_EXHAUSTED) - 우선순위 큐(중요 요청 먼저)
Go 예제: semaphore로 동시성 제한 + 빠른 실패
type Server struct {
pb.UnimplementedFooServer
sem chan struct{}
}
func NewServer(maxConcurrent int) *Server {
return &Server{sem: make(chan struct{}, maxConcurrent)}
}
func (s *Server) Handle(ctx context.Context, req *pb.Request) (*pb.Response, error) {
select {
case s.sem <- struct{}{}:
defer func() { <-s.sem }()
default:
// 큐잉하지 말고 즉시 거절: 연쇄 장애 차단의 핵심
return nil, status.Error(codes.ResourceExhausted, "server overloaded")
}
// 실제 처리 로직...
return &pb.Response{Ok: true}, nil
}
여기서 중요한 설계 의도는 “거절을 두려워하지 않는 것”입니다. 제한 없이 받으면 모두가 늦게 실패하고, 제한하면 일부가 빨리 실패하며 시스템은 살아남습니다.
차단 전략 4: 서킷 브레이커 + 아웃라이어 감지로 느린 인스턴스 격리
MSA에서는 특정 인스턴스만 느려지는 경우가 많습니다(예: noisy neighbor, GC, I/O stall). 이때 클라이언트가 계속 그 인스턴스로 트래픽을 보내면 DEADLINE_EXCEEDED가 집중됩니다.
해결은:
- 서킷 브레이커: 실패율/지연이 임계치 넘으면 잠시 차단
- Outlier detection: 느린/오류 많은 엔드포인트를 LB에서 제외
서비스 메시(Istio/Envoy)나 클라이언트 사이드 LB에서 지원되는 경우가 많고, gRPC는 Envoy와 궁합이 좋아 운영 난이도를 낮출 수 있습니다.
차단 전략 5: 관측(Observability)을 “데드라인 예산” 중심으로 재구성
장애 때 흔히 보는 대시보드는 QPS/에러율/CPU 정도인데, DEADLINE_EXCEEDED 연쇄 장애를 잡으려면 다음이 핵심입니다.
- p95/p99 서버 처리 시간 vs 큐잉 시간 분리
- gRPC status code 별 비율(
DEADLINE_EXCEEDED,UNAVAILABLE,RESOURCE_EXHAUSTED) - in-flight 요청 수, 워커/스레드 풀 사용량
- 다운스트림별 latency 기여도(트레이싱)
실전 팁: 로그에 “남은 예산”을 남겨라
서버가 받은 요청의 deadline과 현재 시간을 비교해 남은 예산을 기록하면, 원인이 “서버 지연”인지 “상위가 너무 짧게 줌”인지 즉시 구분됩니다.
func logBudget(ctx context.Context, method string) {
if dl, ok := ctx.Deadline(); ok {
remaining := time.Until(dl)
slog.Info("grpc budget", "method", method, "remaining_ms", remaining.Milliseconds())
} else {
slog.Warn("grpc budget missing", "method", method)
}
}
차단 전략 6: 리소스 고갈(OOM/FD/커넥션)로 인한 지연을 선제 차단
DEADLINE_EXCEEDED는 때로는 네트워크/코드가 아니라 노드/컨테이너 리소스 이슈가 근본 원인입니다.
- OOM 직전 GC thrash → 응답 지연 증가 → 타임아웃
- FD 고갈(EMFILE) → 커넥션/파일 오픈 실패 → 지연/오류
Kubernetes 환경에서는 OOMKilled가 발생하기 전부터 tail latency가 급격히 나빠질 수 있습니다. 이때는 애플리케이션 레벨 튜닝만으로는 한계가 있어, 리소스 요청/제한, 누수 추적, 노드 압박을 함께 봐야 합니다. 필요하면 Kubernetes OOMKilled 진단과 메모리 누수 추적 실전 같은 체크리스트로 “지연의 진짜 원인”을 분리하세요.
운영 체크리스트: 연쇄 장애를 막는 최소 세트
현장에서 즉시 적용 가능한 최소 세트를 정리하면 다음과 같습니다.
- 모든 gRPC 호출에 데드라인 강제(무데드라인 금지)
- 타임아웃 예산 기반 분배(홉별 고정값 남발 금지)
- 재시도 기본 OFF, 하더라도 멱등/제한/예산 내
- 서버 동시성 제한 + 빠른 실패(RESOURCE_EXHAUSTED)
- 서킷 브레이커/아웃라이어 감지로 느린 인스턴스 격리
- 관측 지표를 큐잉/인플라이트/남은 예산 중심으로 구성
- 리소스 고갈(OOM/FD/커넥션 풀) 징후를 tail latency와 함께 모니터링
결론
DEADLINE_EXCEEDED는 단순한 “타임아웃 에러”가 아니라, 부하-큐잉-재시도-리소스 고갈이 맞물릴 때 시스템이 스스로를 무너뜨리는 신호입니다. 이를 차단하려면 타임아웃을 늘리는 처방이 아니라,
- 예산 기반 데드라인 전파,
- 재시도 통제,
- 동시성 제한과 로드셰딩,
- 느린 인스턴스 격리,
- 큐잉 중심 관측
같은 “안정성 설계”를 기본값으로 가져가야 합니다.
특히 gRPC는 컨텍스트/데드라인 전파가 강력한 만큼, 이를 제대로 쓰는 팀과 그렇지 않은 팀의 장애 양상이 극단적으로 갈립니다. 오늘부터는 DEADLINE_EXCEEDED를 ‘에러’가 아니라 ‘설계 부채’로 보고, 연쇄 장애가 시작되기 전에 끊어내는 구조를 만들어보세요.