Published on

gRPC MSA에서 Deadline Exceeded 원인과 패턴

Authors

서버가 죽은 것도 아닌데 gRPC 호출이 DEADLINE_EXCEEDED로 끝나는 순간이 있습니다. 겉으로는 “타임아웃”처럼 보이지만, MSA 환경에서는 원인이 한 가지가 아니라 연쇄적인 지연의 합인 경우가 대부분입니다. 특히 gRPC는 HTTP/2 위에서 동작하고, 데드라인이 컨텍스트로 전파되며, 클라이언트/서버/프록시/로드밸런서의 타임아웃이 서로 영향을 주기 때문에 단순히 숫자(예: 3초)를 늘리는 방식으로는 해결이 잘 되지 않습니다.

이 글에서는 gRPC MSA에서 Deadline Exceeded가 발생하는 대표 원인, 자주 반복되는 실패 패턴, 그리고 진단 체크리스트와 완화 설계를 코드와 함께 정리합니다.

Deadline Exceeded의 정확한 의미

DEADLINE_EXCEEDED는 “서버가 반드시 느렸다”는 뜻이 아닙니다. 더 정확히는 다음 중 하나입니다.

  • 클라이언트가 설정한 데드라인(타임아웃)까지 응답을 받지 못함
  • 서버가 요청을 처리하던 중 컨텍스트 데드라인이 만료되어 중단
  • 중간 프록시/게이트웨이/로드밸런서가 더 짧은 타임아웃으로 연결을 끊고 클라이언트에서는 데드라인 만료처럼 관측됨

즉, 관측 지점(클라이언트 로그, 서버 로그, 프록시 로그)에 따라 같은 사건이 다르게 보일 수 있습니다.

MSA에서 흔한 원인 8가지

아래는 현업에서 반복적으로 맞닥뜨리는 원인들입니다. 중요한 건 “단일 원인”보다 겹쳐서 발생한다는 점입니다.

1) 큐잉 지연: 처리 시간보다 대기가 더 길다

서버가 느린 게 아니라 대기열이 길어서 데드라인을 초과하는 경우입니다.

  • 스레드/워커 풀 고갈
  • 동시 요청 폭증으로 인한 이벤트 루프 지연
  • CPU throttling, cgroup 제한

특징:

  • 서버 p99 latency가 갑자기 튐
  • 서버 측 로그에 “핸들러 실행 시작” 자체가 늦게 찍힘

2) 의존성 지연: DB/캐시/외부 API가 느리다

가장 전형적인 케이스입니다.

  • DB 락/슬로우 쿼리
  • 커넥션 풀 고갈
  • 외부 API rate limit, 간헐적 지연

이때 gRPC 서버는 “내가 느린 게 아니라 downstream이 느린 것”이지만, 클라이언트 입장에서는 동일하게 DEADLINE_EXCEEDED입니다.

외부 API의 재시도/백오프 패턴은 gRPC에서도 그대로 중요합니다. 다만 무작정 재시도하면 **증폭(thundering herd)**이 생기므로, 재시도 조건과 백오프를 명확히 설계해야 합니다. 관련해서는 OpenAI 429/RateLimitError 재시도·백오프 패턴의 원칙(지터, 상한, 재시도 조건 분리)이 그대로 적용됩니다.

3) 데드라인 전파 미흡: 상위는 2초, 하위는 10초

MSA에서 가장 위험한 패턴 중 하나는 데드라인을 전파하지 않는 것입니다.

  • A 서비스가 2초 데드라인으로 B를 호출
  • B는 C를 호출할 때 데드라인 없이(또는 기본 10초) 호출
  • 결과적으로 B는 C를 오래 기다리다가 A의 데드라인이 먼저 만료
  • A는 DEADLINE_EXCEEDED, B는 뒤늦게 취소를 감지하거나 계속 작업을 수행(낭비)

이 낭비는 다음 장애로 이어집니다.

  • 이미 취소된 요청을 계속 처리하면서 CPU/DB를 소모
  • 큐잉 지연이 증가해 다른 정상 요청까지 타임아웃

4) 재시도 폭탄: 타임아웃 + 재시도가 합쳐져 부하가 폭발

gRPC 클라이언트에서 타임아웃을 짧게 잡고, 실패 시 즉시 재시도하면 다음이 발생합니다.

  • 원래 1회 호출이 3회 호출로 증가
  • 서버는 더 바빠지고 더 느려짐
  • 더 많은 타임아웃 발생

특히 타임아웃이 짧을수록 재시도가 더 많이 발생하는 역설이 생깁니다.

5) 연결/이름해결 지연: DNS, 엔드포인트 갱신, 프록시

의외로 “애플리케이션 로직”이 아니라 이름해결/DNS가 병목인 경우가 있습니다.

  • Pod DNS가 느려서 채널 생성/리졸브가 지연
  • 엔드포인트 변경이 잦아 연결 재수립 비용 증가

EKS에서 특정 상황에 Pod DNS만 느려지는 문제는 실제로 많이 발생합니다. gRPC는 채널을 오래 유지하는 편이지만, 장애/스케일링 시점에 리졸브가 빈번해지면 타임아웃에 영향을 줍니다. 관련 튜닝은 EKS에서 Pod DNS만 느릴 때 ndots·search 튜닝을 참고하세요.

6) HTTP/2 레벨 병목: 스트림 제한, 플로우 컨트롤

gRPC는 HTTP/2 스트림 위에서 다중화를 합니다.

  • 단일 커넥션에 스트림이 과도하게 몰림
  • 서버/프록시의 최대 동시 스트림 제한
  • 큰 메시지로 인한 플로우 컨트롤 지연

증상:

  • 연결은 살아있는데 특정 RPC만 지연
  • 대용량 응답/스트리밍에서 데드라인 초과가 집중

7) 리소스 고갈과 재시작: CrashLoop, NotReady

서버가 간헐적으로 재시작하거나 NotReady 상태가 반복되면, 클라이언트에서는 다음처럼 관측됩니다.

  • 일부 요청은 즉시 실패
  • 일부 요청은 재시도하다가 데드라인 초과

이 경우는 애플리케이션 문제라기보다 “플랫폼 레벨” 문제일 수 있습니다. 원인 진단은 Kubernetes CrashLoopBackOff 원인 12가지와 진단 같은 체크리스트가 큰 도움이 됩니다.

8) 타임아웃 불일치: 클라이언트, 서버, 프록시가 서로 다르다

예:

  • 클라이언트 데드라인: 3초
  • Envoy/Ingress idle timeout: 2초
  • 서버 keepalive 정책: 5분

이런 불일치가 있으면, 클라이언트는 3초를 줬는데 중간에서 2초에 끊겨 더 빨리 실패하거나, 반대로 서버는 오래 처리하지만 클라이언트는 이미 포기해 “낭비 작업”이 됩니다.

실패 패턴 5가지: 현장에서 자주 보는 모양새

원인을 더 빨리 찾기 위해서는 “증상 패턴”을 보는 게 유리합니다.

패턴 A: p50은 정상인데 p99만 데드라인 초과

  • 큐잉 지연, 락, GC, noisy neighbor 가능성
  • 리소스 제한(CPU throttling)이나 커넥션 풀 고갈을 의심

패턴 B: 특정 메서드만 데드라인 초과

  • 특정 쿼리/특정 외부 API/특정 파티션 핫키
  • 메시지 크기, 직렬화 비용, 대용량 응답

패턴 C: 배포 직후만 데드라인 초과

  • 콜드 스타트, 캐시 워밍, JIT, 커넥션 풀 초기화
  • readiness 설정 미흡으로 트래픽이 너무 빨리 유입

패턴 D: 장애 시점에 재시도 트래픽이 폭증

  • 재시도 정책이 공격적
  • 지터 없이 동기화된 재시도

패턴 E: 클라이언트는 데드라인 초과, 서버는 성공 로그

  • 서버가 응답을 만들었지만 네트워크/프록시에서 드랍
  • 혹은 클라이언트가 먼저 취소했는데 서버가 취소를 늦게 감지

진단 체크리스트: 어디서 시간을 잃는지 쪼개기

Deadline Exceeded를 해결하려면 “총 시간”을 분해해야 합니다.

  1. 클라이언트 관점 타임라인
  • 채널 생성/리졸브 시간
  • 커넥션 수립 시간
  • RPC 시작부터 첫 바이트까지
  • 응답 수신/역직렬화 시간
  1. 서버 관점 타임라인
  • 요청 수신 시각
  • 핸들러 진입 시각(큐잉 여부)
  • downstream 호출 시작/종료
  • 응답 write 시각
  • 컨텍스트 취소 감지 시각
  1. 중간 계층
  • Envoy/Nginx/Ingress의 upstream timeout, idle timeout
  • 로드밸런서의 connection draining
  1. 관측 도구
  • 분산 트레이싱: OpenTelemetry로 span 간 시간 분해
  • gRPC status code 비율: DEADLINE_EXCEEDED, UNAVAILABLE, RESOURCE_EXHAUSTED 구분
  • 서버 메트릭: 큐 길이, 워커 풀 사용률, DB pool 사용률

설계/구현 패턴: 데드라인을 다루는 방법

아래 패턴은 “데드라인 숫자 늘리기” 대신, 예산 기반으로 실패를 통제하는 접근입니다.

1) 데드라인 전파: 컨텍스트를 그대로 넘겨라 (Go 예시)

Go gRPC는 context.Context에 데드라인이 들어있고, 이를 downstream 호출에 그대로 전달하는 것이 기본입니다.

func (s *Server) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.GetOrderResponse, error) {
    // 데드라인/취소를 downstream에 전파
    user, err := s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: req.UserId})
    if err != nil {
        return nil, err
    }

    items, err := s.itemClient.ListItems(ctx, &itempb.ListItemsRequest{OrderId: req.OrderId})
    if err != nil {
        return nil, err
    }

    return &pb.GetOrderResponse{User: user, Items: items.Items}, nil
}

핵심은 “새로운 background 컨텍스트를 만들지 않는 것”입니다. 예를 들어 context.Background()로 호출하면 상위 데드라인이 끊기고, 불필요한 작업이 늘어납니다.

2) 예산 기반 타임아웃: 남은 시간에서 서브타임아웃을 계산

상위 데드라인이 2초인데, 내부에서 DB 2초, 외부 API 2초를 각각 주면 합이 4초가 되어 결국 상위에서 끊깁니다.

남은 시간(remaining budget)을 계산해 서브 호출에 배분합니다.

func withSubTimeout(ctx context.Context, portion float64) (context.Context, context.CancelFunc) {
    deadline, ok := ctx.Deadline()
    if !ok {
        // 상위 데드라인이 없다면 보수적으로 제한
        return context.WithTimeout(ctx, 2*time.Second)
    }

    remaining := time.Until(deadline)
    sub := time.Duration(float64(remaining) * portion)
    if sub < 50*time.Millisecond {
        sub = 50 * time.Millisecond
    }
    return context.WithTimeout(ctx, sub)
}

func (s *Server) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.GetOrderResponse, error) {
    dbCtx, cancel := withSubTimeout(ctx, 0.4)
    defer cancel()

    order, err := s.repo.LoadOrder(dbCtx, req.OrderId)
    if err != nil {
        return nil, err
    }

    apiCtx, cancel2 := withSubTimeout(ctx, 0.4)
    defer cancel2()

    user, err := s.userClient.GetUser(apiCtx, &userpb.GetUserRequest{Id: order.UserId})
    if err != nil {
        return nil, err
    }

    return &pb.GetOrderResponse{User: user}, nil
}

이렇게 하면 상위 데드라인을 존중하면서도, 내부 의존성 호출이 “끝까지 버티다” 전체 요청을 망치는 상황을 줄일 수 있습니다.

3) 취소 전파에 반응: 서버는 ctx.Done을 적극적으로 확인

서버가 이미 데드라인이 지난 요청을 계속 처리하면, 다음 요청들의 큐잉 지연이 증가해 연쇄 타임아웃이 납니다.

func (s *Server) Heavy(ctx context.Context, req *pb.HeavyRequest) (*pb.HeavyResponse, error) {
    for i := 0; i < 10_000; i++ {
        select {
        case <-ctx.Done():
            return nil, status.Error(codes.DeadlineExceeded, "request cancelled or deadline exceeded")
        default:
        }
        // 작업 단위
        doWorkChunk()
    }
    return &pb.HeavyResponse{Ok: true}, nil
}

특히 CPU 바운드/루프 작업, 대용량 처리, 스트리밍 처리에서 중요합니다.

4) 재시도는 조건부로: 멱등성 + 코드 기반으로 제한

gRPC에서 모든 실패를 재시도하면 안 됩니다.

  • DEADLINE_EXCEEDED는 이미 “시간을 다 썼다”는 의미라서, 같은 데드라인 예산 안에서 재시도해도 성공 확률이 낮습니다.
  • UNAVAILABLE(일시적 네트워크/서버)에는 제한적 재시도가 유효할 수 있습니다.
  • 멱등하지 않은 요청(예: 결제, 주문 생성)은 재시도 시 중복 부작용이 생깁니다.

클라이언트 측에서 코드 기반으로 분기하고 지터를 넣습니다.

func retryable(err error) bool {
    st, ok := status.FromError(err)
    if !ok {
        return false
    }
    switch st.Code() {
    case codes.Unavailable, codes.ResourceExhausted:
        return true
    default:
        return false
    }
}

func callWithRetry(ctx context.Context, fn func(context.Context) error) error {
    backoff := 50 * time.Millisecond
    for attempt := 0; attempt < 3; attempt++ {
        err := fn(ctx)
        if err == nil {
            return nil
        }
        if !retryable(err) {
            return err
        }
        // 단순 지터
        jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
        sleep := backoff + jitter

        t := time.NewTimer(sleep)
        select {
        case <-ctx.Done():
            t.Stop()
            return ctx.Err()
        case <-t.C:
        }
        if backoff < 400*time.Millisecond {
            backoff *= 2
        }
    }
    return status.Error(codes.Unavailable, "exhausted retries")
}

포인트는 “재시도가 데드라인 예산을 더 갉아먹는다”는 사실을 전제로, 짧고 제한적으로 쓰는 것입니다.

5) 서킷 브레이커/벌크헤드: 느린 의존성의 전염을 차단

특정 downstream이 느려지면, upstream의 워커/스레드/커넥션 풀이 잠식됩니다. 이를 막는 전형적인 방법이:

  • 벌크헤드: 의존성별 동시성 제한(세마포어)
  • 서킷 브레이커: 실패율/지연이 높으면 빠르게 실패

gRPC 자체 기능이라기보다 라이브러리/미들웨어/서비스 메시 레벨에서 구현합니다.

데드라인 값 정하는 실전 가이드

“몇 초가 적당한가”는 서비스마다 다르지만, MSA에서는 다음 원칙이 유용합니다.

  • 사용자 요청 경로는 p99 목표로 역산: 예를 들어 화면 API 800ms 목표면, 내부 RPC는 100ms~300ms 단위로 쪼개기
  • 내부 배치/동기화는 더 길게: 대신 큐 기반으로 전환하거나, 스트리밍/페이지네이션으로 분할
  • 데드라인은 계층적으로 짧아져야 함: 상위가 2초면 하위는 1초, 그 하위는 300ms 같은 식으로 “예산”을 배분
  • 중간 프록시 타임아웃과 정합성 맞추기: 클라이언트 데드라인보다 짧게 끊지 않도록(혹은 의도적으로 더 짧게 끊는다면 그 이유를 문서화)

마무리: Deadline Exceeded는 “느림”이 아니라 “예산 초과”다

DEADLINE_EXCEEDED는 결과 코드지만, 원인은 네트워크, 큐잉, 의존성, 재시도, 프록시 설정 불일치까지 다양합니다. 해결의 핵심은:

  • 데드라인을 전파하고
  • 남은 시간을 예산으로 분해하며
  • 취소를 즉시 반영하고
  • 재시도를 조건부로 제한하고
  • 느린 의존성이 전체를 망치지 않게 격리하는 것

이 다섯 가지를 적용하면, 타임아웃 숫자를 무작정 늘리지 않고도 Deadline Exceeded를 구조적으로 줄일 수 있습니다.