Published on

gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단

Authors

서론

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 진단과 메모리 누수 추적 실전 같은 체크리스트로 “지연의 진짜 원인”을 분리하세요.


운영 체크리스트: 연쇄 장애를 막는 최소 세트

현장에서 즉시 적용 가능한 최소 세트를 정리하면 다음과 같습니다.

  1. 모든 gRPC 호출에 데드라인 강제(무데드라인 금지)
  2. 타임아웃 예산 기반 분배(홉별 고정값 남발 금지)
  3. 재시도 기본 OFF, 하더라도 멱등/제한/예산 내
  4. 서버 동시성 제한 + 빠른 실패(RESOURCE_EXHAUSTED)
  5. 서킷 브레이커/아웃라이어 감지로 느린 인스턴스 격리
  6. 관측 지표를 큐잉/인플라이트/남은 예산 중심으로 구성
  7. 리소스 고갈(OOM/FD/커넥션 풀) 징후를 tail latency와 함께 모니터링

결론

DEADLINE_EXCEEDED는 단순한 “타임아웃 에러”가 아니라, 부하-큐잉-재시도-리소스 고갈이 맞물릴 때 시스템이 스스로를 무너뜨리는 신호입니다. 이를 차단하려면 타임아웃을 늘리는 처방이 아니라,

  • 예산 기반 데드라인 전파,
  • 재시도 통제,
  • 동시성 제한과 로드셰딩,
  • 느린 인스턴스 격리,
  • 큐잉 중심 관측

같은 “안정성 설계”를 기본값으로 가져가야 합니다.

특히 gRPC는 컨텍스트/데드라인 전파가 강력한 만큼, 이를 제대로 쓰는 팀과 그렇지 않은 팀의 장애 양상이 극단적으로 갈립니다. 오늘부터는 DEADLINE_EXCEEDED를 ‘에러’가 아니라 ‘설계 부채’로 보고, 연쇄 장애가 시작되기 전에 끊어내는 구조를 만들어보세요.