Published on

Go gRPC DEADLINE_EXCEEDED 원인과 재시도·타임아웃 설계

Authors

서버 간 통신을 gRPC로 바꾸면 “빠르고 안정적일 것”이라는 기대를 하게 되지만, 운영에서는 DEADLINE_EXCEEDED가 가장 자주(그리고 가장 애매하게) 마주치는 상태 코드 중 하나입니다. 특히 Go 환경에서는 context deadline exceeded 로그가 여기저기 흩어져 찍히며, 원인이 네트워크인지 서버 병목인지, 혹은 단순히 타임아웃 설계가 잘못된 것인지 판단이 어려워집니다.

이 글에서는 Go gRPC에서 DEADLINE_EXCEEDED가 발생하는 메커니즘을 먼저 정리한 뒤, 원인 분류(클라이언트/서버/네트워크/인프라), 관측 포인트, 그리고 재시도·타임아웃(Deadline) 설계 원칙을 실전 코드와 함께 설명합니다.

DEADLINE_EXCEEDED란 무엇인가

gRPC의 Deadline은 클라이언트가 “이 RPC는 언제까지 끝나야 한다”라는 시간 예산(time budget) 을 서버에 전달하는 개념입니다.

  • 클라이언트는 context.WithTimeout/WithDeadline로 Deadline을 설정합니다.
  • 이 Deadline은 메타데이터로 서버에 전달되고, 서버는 ctx.Done()을 통해 취소/만료를 감지할 수 있습니다.
  • 시간이 초과되면 클라이언트는 codes.DeadlineExceeded로 RPC를 실패 처리합니다.

중요한 점은 Deadline 초과는 ‘서버가 늦었음’을 의미하지 않을 수도 있다는 것입니다.

  • 요청이 서버에 도착하지 못했거나(네트워크/커넥션),
  • 서버는 처리했지만 응답이 돌아오지 못했거나,
  • 클라이언트 측 큐잉/스케줄링/커넥션 획득이 늦었거나,
  • 서버는 취소를 감지하지 못하고 계속 일했을 수도 있습니다.

DEADLINE_EXCEEDED는 “클라이언트 관점에서 시간 예산이 소진됨”입니다.

대표 원인 분류: 어디에서 시간이 새는가

1) 클라이언트 측 원인

(1) 타임아웃을 너무 짧게 잡음

가장 흔합니다. 특히 내부 호출인데도 “빠를 것”이라는 가정으로 50~100ms 같은 타임아웃을 박아두면, GC/스케줄링/일시적 네트워크 지연만으로도 쉽게 초과합니다.

(2) 커넥션/서브채널 준비 지연

gRPC는 내부적으로 connection을 만들고, LB 정책에 따라 sub-connection을 준비합니다. 첫 호출이거나, 연결이 끊겼다가 재수립되는 순간이라면 RPC 처리 이전에 이미 시간이 소진될 수 있습니다.

  • DNS 조회 지연
  • TCP handshake
  • TLS handshake
  • HTTP/2 설정

(3) 클라이언트 측 큐잉

애플리케이션 레벨에서 goroutine이 과도하게 늘어나거나, CPU가 포화되어 스케줄링이 밀리면 실제 네트워크 요청을 보내기 전에 context deadline이 만료될 수 있습니다.

2) 서버 측 원인

(1) 서버 핸들러/비즈니스 로직 지연

DB 쿼리, 외부 API 호출, 락 경합, 파일 I/O 등으로 처리 시간이 늘어납니다. 특히 DB 락/데드락/장기 트랜잭션은 gRPC 타임아웃과 결합되면 장애가 증폭됩니다. (DB 병목은 별도 진단이 필요합니다.)

(2) 서버가 취소를 무시함

클라이언트 deadline이 만료되어도 서버가 ctx.Done()을 체크하지 않으면, 서버는 계속 작업을 수행합니다. 이 경우:

  • 클라이언트는 실패로 간주하고 재시도
  • 서버는 이미 작업을 진행 중
  • 결과적으로 중복 작업/부하 폭증

(3) 리소스 부족으로 인한 큐잉

서버 인스턴스 CPU/메모리 포화, goroutine 폭증, 스레드풀/커넥션풀 고갈로 요청이 “처리 시작”조차 늦어질 수 있습니다. Kubernetes 환경에서는 readiness가 애매하게 설정되어 트래픽이 과부하 Pod로 계속 들어가기도 합니다.

> 운영에서 “Pod는 Running인데 503/지연이 난다” 류의 문제는 readiness/endpoint 반영 문제와 함께 나타나는 경우가 많습니다. 참고: EKS에서 Pod는 Running인데 503가 뜰 때 점검

3) 네트워크/인프라 원인

(1) LB/Ingress/NAT/보안장비 타임아웃

중간 프록시가 HTTP/2 연결을 끊거나 idle timeout을 짧게 잡는 경우, 클라이언트는 재연결 비용을 치르며 deadline을 소진합니다.

(2) 패킷 드랍/재전송

일시적인 네트워크 품질 저하로 RTT가 튀면, 짧은 deadline에서 바로 DEADLINE_EXCEEDED로 이어집니다.

(3) Kubernetes 오토스케일/리소스 부족

트래픽이 늘었는데 HPA가 제대로 확장되지 않아 서버가 포화되면 지연이 증가합니다. 메트릭 수집 문제로 HPA가 안 늘어나는 케이스도 흔합니다. 참고: Kubernetes HPA가 안 늘 때 metrics-server 0값 해결

관측(Observability): DEADLINE_EXCEEDED를 ‘원인’으로 바꾸기

DEADLINE_EXCEEDED를 줄이려면 “어느 구간에서 시간이 소진됐는지”를 쪼개야 합니다.

1) 클라이언트 지표

  • RPC latency histogram (p50/p95/p99)
  • error code별 카운트 (DeadlineExceeded, Unavailable 등)
  • attempt 수(재시도 횟수)
  • name resolver / LB 상태 변화 로그

2) 서버 지표

  • handler latency
  • in-flight requests
  • queue length(있다면)
  • DB/외부 의존성 latency
  • ctx.Err() 발생 빈도(취소/만료)

3) 분산 트레이싱

OpenTelemetry로 다음을 분리해서 보세요.

  • 클라이언트 span: name resolution, connect, TLS handshake(가능하면)
  • 서버 span: interceptor에서 시작 시각 기록
  • downstream span: DB/외부 API

타임아웃(Deadline) 설계 원칙

원칙 1) “타임아웃 = SLO 예산”으로 취급

타임아웃을 감으로 정하지 말고, 최소한 다음 기준으로 잡습니다.

  • p99 처리시간 + 네트워크 변동폭 + 재시도 여유
  • 사용자 요청의 전체 예산(예: 1초)에서 각 hop에 예산을 배분

예:

  • 전체 API SLO: 800ms
  • 내부 gRPC 2-hop 호출
    • A→B: 250ms
    • B→C: 250ms
    • 나머지(직렬화/큐잉/여유): 300ms

원칙 2) hop마다 deadline을 “줄여서” 전달

상위 요청의 context를 그대로 하위 호출에 전달하면, 하위 호출이 상위 예산을 과도하게 소비할 수 있습니다. 하위 호출에는 별도 예산을 잘라서 주는 것이 안전합니다.

// 상위 HTTP 요청 ctx에서 내부 호출 예산을 잘라 쓰는 예시
func (s *Server) Handle(ctx context.Context, req *Request) (*Response, error) {
    // 상위 요청이 800ms라면 내부 호출은 250ms만 사용
    childCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
    defer cancel()

    out, err := s.grpcClient.DoSomething(childCtx, &pb.DoReq{Id: req.Id})
    if err != nil {
        return nil, err
    }
    return &Response{Value: out.Value}, nil
}

원칙 3) 서버는 취소를 “빨리” 존중

서버는 ctx.Done()을 주기적으로 확인하고, DB/외부 호출에도 context를 넘겨야 합니다.

func (s *Svc) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    // 예: DB 쿼리에 ctx를 전달
    user, err := s.repo.FindUser(ctx, req.Id)
    if err != nil {
        // ctx 만료인지 구분
        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            return nil, status.Error(codes.DeadlineExceeded, "deadline exceeded")
        }
        return nil, status.Error(codes.Internal, err.Error())
    }

    select {
    case <-ctx.Done():
        // 클라이언트가 이미 취소했다면 즉시 종료
        return nil, status.Error(codes.Canceled, "request canceled")
    default:
    }

    return &pb.GetUserResponse{Id: user.ID, Name: user.Name}, nil
}

원칙 4) 재시도는 “항상” 위험하다: 멱등성부터 확인

재시도는 지연/장애 상황에서 트래픽을 증폭시키는 양날의 검입니다.

  • 안전한 재시도 대상: 멱등(idempotent) 읽기, 조건부 쓰기, 토큰 기반 중복 방지
  • 위험한 재시도 대상: 결제/주문 생성 같은 비멱등 쓰기

가능하면 쓰기 요청에는 다음 중 하나를 도입하세요.

  • Idempotency-Key(요청 고유 키) 저장
  • 서버 측 dedup 테이블
  • 조건부 업데이트(compare-and-swap)

원칙 5) 재시도는 “총 예산” 안에서만

각 attempt에 동일한 타임아웃을 주면 총 시간이 폭발합니다. 상위 deadline을 기준으로 남은 시간에서 attempt별 예산을 계산해야 합니다.

func withAttemptTimeout(ctx context.Context, perAttempt time.Duration) (context.Context, context.CancelFunc, error) {
    deadline, ok := ctx.Deadline()
    if !ok {
        // 상위 deadline이 없다면 perAttempt를 그대로 사용
        return context.WithTimeout(ctx, perAttempt)
    }

    remaining := time.Until(deadline)
    if remaining <= 0 {
        return nil, nil, context.DeadlineExceeded
    }

    if remaining < perAttempt {
        perAttempt = remaining
    }
    return context.WithTimeout(ctx, perAttempt)
}

Go gRPC 재시도 구현: client interceptor로 제어하기

Go gRPC는 언어/버전/설정에 따라 “서비스 config 기반 자동 재시도”가 제한적이거나 운영에서 통제하기 어려운 경우가 많습니다. 실무에서는 클라이언트 unary interceptor로 재시도를 명시적으로 구현하고, 대상 메서드/코드만 제한하는 패턴이 많이 쓰입니다.

아래 예시는 다음 정책을 구현합니다.

  • 최대 3회 시도
  • Unavailable, DeadlineExceeded에만 재시도(상황에 따라 ResourceExhausted는 제외 권장)
  • 지수 백오프 + 지터
  • 상위 deadline을 초과하지 않도록 attempt별 timeout 제한
package retry

import (
    "context"
    "math/rand"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type Policy struct {
    MaxAttempts    int
    PerAttemptTO   time.Duration
    BaseBackoff    time.Duration
    MaxBackoff     time.Duration
}

func UnaryClientInterceptor(p Policy) grpc.UnaryClientInterceptor {
    if p.MaxAttempts <= 0 {
        p.MaxAttempts = 1
    }
    if p.BaseBackoff <= 0 {
        p.BaseBackoff = 20 * time.Millisecond
    }
    if p.MaxBackoff <= 0 {
        p.MaxBackoff = 200 * time.Millisecond
    }

    return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn,
        invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {

        var lastErr error

        for attempt := 1; attempt <= p.MaxAttempts; attempt++ {
            attemptCtx, cancel, err := func() (context.Context, context.CancelFunc, error) {
                if p.PerAttemptTO <= 0 {
                    // per-attempt timeout을 강제하지 않음
                    return ctx, func() {}, nil
                }
                c, cancel := context.WithTimeout(ctx, p.PerAttemptTO)
                // 상위 ctx가 이미 만료되면 여기서도 빠르게 실패
                if err := ctx.Err(); err != nil {
                    cancel()
                    return nil, nil, err
                }
                return c, cancel, nil
            }()
            if err != nil {
                return err
            }

            lastErr = invoker(attemptCtx, method, req, reply, cc, opts...)
            cancel()

            if lastErr == nil {
                return nil
            }

            st, ok := status.FromError(lastErr)
            if !ok {
                // gRPC status가 아니라면 재시도하지 않음
                return lastErr
            }

            if !isRetryable(st.Code()) {
                return lastErr
            }

            // 마지막 attempt면 종료
            if attempt == p.MaxAttempts {
                break
            }

            // 상위 deadline이 남아있는지 확인
            if err := ctx.Err(); err != nil {
                return err
            }

            // backoff with jitter
            backoff := expBackoff(p.BaseBackoff, p.MaxBackoff, attempt)
            jitter := time.Duration(rand.Int63n(int64(backoff / 5))) // 0~20%
            sleep := backoff + jitter

            t := time.NewTimer(sleep)
            select {
            case <-ctx.Done():
                t.Stop()
                return ctx.Err()
            case <-t.C:
            }
        }

        return lastErr
    }
}

func isRetryable(c codes.Code) bool {
    switch c {
    case codes.Unavailable, codes.DeadlineExceeded:
        return true
    default:
        return false
    }
}

func expBackoff(base, max time.Duration, attempt int) time.Duration {
    // attempt=1이면 1배, 2면 2배, 3이면 4배...
    b := base << (attempt - 1)
    if b > max {
        return max
    }
    return b
}

적용 예:

conn, err := grpc.NewClient(
    target,
    grpc.WithTransportCredentials(creds),
    grpc.WithChainUnaryInterceptor(
        retry.UnaryClientInterceptor(retry.Policy{
            MaxAttempts:  3,
            PerAttemptTO: 150 * time.Millisecond,
            BaseBackoff:  30 * time.Millisecond,
            MaxBackoff:   200 * time.Millisecond,
        }),
    ),
)
if err != nil {
    panic(err)
}

재시도 설계 체크리스트

  • 재시도 대상 메서드 제한(전체 RPC에 일괄 적용 금지)
  • 멱등성 보장(특히 쓰기)
  • 백오프/지터 적용(동시 재시도 폭주 방지)
  • DeadlineExceeded를 무조건 재시도하지 말 것
    • 이미 “시간이 부족”하다는 신호일 수 있음
  • 서버가 과부하(ResourceExhausted)일 때는 재시도가 더 악화시킬 수 있음

타임아웃과 재시도의 흔한 안티패턴

1) per-attempt timeout을 상위 timeout보다 크게 설정

상위 요청이 300ms인데 attempt마다 500ms면 의미가 없습니다. “총 예산” 기준으로 설계하세요.

2) 서버에서 긴 작업을 하면서 취소를 무시

클라이언트는 timeout으로 끊고 재시도하는데 서버는 계속 일하면, 장애 시점에 서버 CPU가 터집니다. 긴 루프/배치/스트리밍 처리에서는 반드시 ctx.Done()을 확인하세요.

3) 재시도 + 짧은 deadline의 조합

짧은 deadline(예: 100ms)에서 재시도 3회는 대부분 실패하며, 오히려 부하만 늘립니다. 이 경우는 재시도보다 타임아웃 재설계가 우선입니다.

4) 로드밸런서/Ingress 503과 DEADLINE_EXCEEDED를 혼동

클라이언트는 DeadlineExceeded로 보지만, 실제로는 Ingress/LB에서 503/연결 종료가 발생했을 수 있습니다. EKS 환경에서 Ingress 503 트러블슈팅이 필요할 때는 다음 글도 함께 보면 원인 분리가 빨라집니다: EKS Ingress 503인데 Pod 정상일 때 점검 가이드

운영에서의 권장 접근 순서

  1. 타임아웃 값이 합리적인지: p95/p99 기준으로 재산정
  2. 서버가 취소를 존중하는지: ctx 전파/DB ctx 적용/루프 중단
  3. 재시도 정책이 안전한지: 멱등성/백오프/대상 제한
  4. 인프라 병목인지: CPU/메모리/큐잉/HPA/Ingress 설정
  5. 트레이싱으로 구간 분해: connect vs handler vs downstream

마무리

DEADLINE_EXCEEDED는 단순히 “서버가 느리다”가 아니라, 클라이언트의 시간 예산이 어디선가 소진되었다는 신호입니다. 따라서 해결도 “타임아웃 늘리기” 한 줄로 끝나지 않고, (1) 예산 기반 deadline 설계, (2) 서버의 취소 존중, (3) 멱등성 기반 재시도, (4) 인프라/큐잉/오토스케일 관측을 함께 맞물려야 합니다.

운영 중이라면 우선 재시도를 무작정 늘리기보다, 현재의 p99 지연과 실패 코드를 분해해 “재시도로 회복 가능한 실패”인지부터 분류해보는 것을 권합니다.