Published on

Go gRPC 데드라인 초과? 컨텍스트·리트라이 튜닝

Authors

서버/클라이언트 로그에 rpc error: code = DeadlineExceeded desc = context deadline exceeded가 뜨면 대부분은 “타임아웃을 늘리면 되지 않을까?”로 시작합니다. 하지만 gRPC의 데드라인은 컨텍스트 전파, 서버 핸들러의 취소 처리, LB/프록시/서버리스의 상한, 리트라이 정책까지 한 번에 드러나는 신호입니다. 잘못 늘린 타임아웃은 대기열을 키워 더 큰 지연과 장애를 만들 수 있고, 반대로 너무 짧으면 정상 트래픽도 실패로 몰아넣습니다.

이 글은 Go gRPC에서 DeadlineExceeded를 재현→관측→원인 분해→튜닝 순서로 정리합니다. 특히 컨텍스트 데드라인을 “계약”으로 다루고, 리트라이를 “증폭기”가 아닌 “완충장치”로 만드는 방법에 집중합니다.

관련해서 서버리스/프록시의 상한 때문에 504가 섞여 보이는 경우도 많습니다. Cloud Run 같은 환경이라면 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드도 함께 확인해두면 원인 분리가 빨라집니다.

1) DeadlineExceeded의 정확한 의미

gRPC에서 DeadlineExceeded는 크게 두 갈래로 발생합니다.

  1. 클라이언트 컨텍스트 데드라인 만료

    • 클라이언트가 context.WithTimeout/WithDeadline으로 설정한 시간이 지나면 RPC가 취소됩니다.
    • 서버는 이미 작업을 시작했을 수 있으며, 서버가 취소를 감지하지 못하면 서버는 계속 일하고 클라이언트는 실패하는 낭비가 생깁니다.
  2. 중간 계층(프록시/게이트웨이/서버리스) 타임아웃

    • Envoy, API Gateway, Cloud Run/ALB 등에서 별도의 요청 상한으로 끊어버릴 수 있습니다.
    • 이때 클라이언트는 gRPC 에러로 보거나 HTTP 504로 보기도 합니다(브리지 구성에 따라 다름).

핵심은 “데드라인 초과”가 어디에서 발생했는지 먼저 분리하는 것입니다.

2) 가장 흔한 함정: 데드라인 미전파/불일치

2.1 클라이언트가 데드라인을 안 준다

Go gRPC는 데드라인이 없는 컨텍스트로 호출하면 사실상 무기한 대기할 수 있습니다(중간 계층 상한이 없다면). 이는 장애 시 커넥션/고루틴이 쌓이는 지름길입니다.

// 나쁜 예: 데드라인 없이 호출
resp, err := client.GetUser(context.Background(), &pb.GetUserRequest{Id: id})
// 좋은 예: 호출별 데드라인을 명시
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: id})

2.2 “전체 데드라인”과 “하위 호출 데드라인”이 충돌

상위 요청(예: HTTP 핸들러) 컨텍스트에 1초가 남았는데, 내부 gRPC 호출에 2초를 주는 코드는 의미가 없습니다. 반대로 내부 호출에 너무 짧게 주면 상위는 여유가 있어도 내부에서 먼저 실패합니다.

권장 패턴은 상위 컨텍스트를 부모로 두고, 남은 시간을 기준으로 버짓(budget) 을 나눠 쓰는 것입니다.

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 예: 외부 HTTP 요청 전체에 1500ms 예산
    ctx, cancel := context.WithTimeout(r.Context(), 1500*time.Millisecond)
    defer cancel()

    // 내부 gRPC에는 900ms만 할당(나머지는 직렬화/후처리/여유)
    grpcCtx, grpcCancel := context.WithTimeout(ctx, 900*time.Millisecond)
    defer grpcCancel()

    resp, err := h.userClient.GetUser(grpcCtx, &pb.GetUserRequest{Id: "42"})
    if err != nil {
        // ...
    }
    _ = resp
}

2.3 서버가 취소를 무시한다

서버 핸들러가 DB 쿼리/외부 호출을 할 때 ctx를 전달하지 않으면, 클라이언트가 취소해도 서버는 계속 작업합니다.

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    // 나쁜 예: ctx를 사용하지 않는 DB 호출
    user, err := s.repo.FindUser(req.Id) // 내부적으로 context.Background()를 쓰는 경우
    if err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }
    return &pb.GetUserResponse{Id: user.ID}, nil
}
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    // 좋은 예: ctx 전파
    user, err := s.repo.FindUser(ctx, req.Id)
    if err != nil {
        // ctx 만료/취소면 적절한 코드로 변환
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, status.Error(codes.DeadlineExceeded, "db timeout")
        }
        if errors.Is(err, context.Canceled) {
            return nil, status.Error(codes.Canceled, "request canceled")
        }
        return nil, status.Error(codes.Internal, err.Error())
    }
    return &pb.GetUserResponse{Id: user.ID}, nil
}

DB 드라이버/HTTP 클라이언트가 context.Context를 지원하는지, 내부에서 Background()로 끊어먹지 않는지 점검하세요.

3) 관측: “어디서 시간을 쓰는지” 먼저 보이게 만들기

3.1 gRPC 인터셉터로 데드라인/소요시간 로깅

클라이언트/서버 각각에 unary interceptor를 달아 남은 데드라인실제 소요시간, status code를 기록하면 원인 분해가 빨라집니다.

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

        start := time.Now()
        dl, hasDL := ctx.Deadline()
        err := invoker(ctx, method, req, reply, cc, opts...)
        dur := time.Since(start)

        code := status.Code(err)
        if hasDL {
            logger.Info("grpc client call",
                "method", method,
                "code", code.String(),
                "duration_ms", dur.Milliseconds(),
                "deadline_in_ms", time.Until(dl).Milliseconds(),
            )
        } else {
            logger.Info("grpc client call",
                "method", method,
                "code", code.String(),
                "duration_ms", dur.Milliseconds(),
                "deadline", "none",
            )
        }
        return err
    }
}

서버도 동일하게 만들어서 “서버 핸들러 진입~종료” 시간을 찍으면, 클라이언트에서 늦는지(큐잉/네트워크) vs 서버에서 늦는지(처리/락/DB) 대략 갈립니다.

3.2 분산 트레이싱: deadline은 span attribute로 남겨라

OpenTelemetry를 쓰고 있다면, 요청 시작 시점에 deadline_unix_nano 같은 속성을 span에 기록해두면 “데드라인이 짧아서 실패”인지 “서버가 느려서 실패”인지 대시보드에서 한눈에 보입니다.

4) 데드라인 설계: 늘리기 전에 ‘예산’을 정하라

데드라인을 튜닝할 때는 평균이 아니라 p95/p99를 기준으로 “성공률”과 “리소스”를 같이 봐야 합니다.

4.1 추천 접근

  • 엔드포인트별로 목표 SLO(예: p95 300ms, p99 800ms)를 정한다.
  • 클라이언트 데드라인은 p99 + 여유(직렬화/네트워크) 정도로 시작한다.
  • 서버 내부 의존성(DB/외부 API)에는 상위 데드라인보다 짧게 하위 타임아웃을 둔다.
  • 타임아웃을 늘렸을 때 동시성/큐 길이/CPU가 어떻게 변하는지 같이 본다.

4.2 “짧은 타임아웃 + 빠른 실패”가 유리한 경우

  • 사용자 인터랙션 경로(화면 렌더, 검색 자동완성)
  • 팬아웃 호출(한 요청이 여러 downstream을 동시에 호출)

이 경우 타임아웃을 늘리면 성공률이 오르는 대신, 동시에 더 많은 요청이 오래 붙잡혀 시스템 전체가 느려질 수 있습니다.

5) 리트라이 튜닝: DeadlineExceeded에서 가장 위험한 증폭기

리트라이는 실패를 가려주지만, 잘못 설정하면 장애 시 트래픽을 폭발시킵니다. 특히 DeadlineExceeded는 “서버가 느리다/혼잡하다” 신호일 수 있어 무작정 재시도하면 더 느려집니다.

5.1 원칙

  • 멱등(idempotent) 인 요청만 자동 재시도한다.
  • 재시도는 짧은 버짓 안에서만 한다(전체 데드라인을 초과하지 않게).
  • backoff + jitter를 반드시 넣는다.
  • DeadlineExceeded는 대개 재시도 이득이 낮다. (네트워크 단절이 아니라 “시간이 부족”인 경우가 많음)

5.2 Go에서 per-RPC 리트라이(서비스 컨픽) 예시

gRPC-Go는 서비스 config로 retry policy를 적용할 수 있습니다(환경/버전에 따라 제약이 있으니 사용 전 확인 필요). 아래는 개념 예시입니다.

serviceCfg := `{
  "methodConfig": [{
    "name": [{"service": "user.UserService", "method": "GetUser"}],
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.05s",
      "maxBackoff": "0.2s",
      "backoffMultiplier": 2.0,
      "retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
    },
    "timeout": "0.8s"
  }]
}`

conn, err := grpc.NewClient(
    target,
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultServiceConfig(serviceCfg),
)
if err != nil { panic(err) }

포인트:

  • retryableStatusCodesDEADLINE_EXCEEDED를 넣지 않는 구성이 보통 안전합니다.
  • 대신 UNAVAILABLE(일시 네트워크/서버 다운)나 RESOURCE_EXHAUSTED(과부하) 정도만 제한적으로 다룹니다.
  • 재시도 횟수는 2~3을 넘기기 전에, 먼저 서버 처리시간/큐잉을 해결하는 게 우선입니다.

5.3 애플리케이션 레벨 리트라이(컨텍스트 버짓 기반)

서비스 config를 쓰지 않거나, 더 정교하게 “남은 시간”을 반영하고 싶다면 직접 구현합니다.

func CallWithRetry[T any](ctx context.Context, attempts int, base time.Duration, fn func(context.Context) (T, error)) (T, error) {
    var zero T
    for i := 0; i < attempts; i++ {
        if err := ctx.Err(); err != nil {
            return zero, err
        }

        // 각 시도에 짧은 서브 타임아웃을 부여(남은 시간보다 작게)
        perTry := 200 * time.Millisecond
        if dl, ok := ctx.Deadline(); ok {
            remain := time.Until(dl)
            if remain < perTry {
                perTry = remain
            }
        }
        tryCtx, cancel := context.WithTimeout(ctx, perTry)
        v, err := fn(tryCtx)
        cancel()

        if err == nil {
            return v, nil
        }

        c := status.Code(err)
        // DeadlineExceeded는 보통 재시도 가치가 낮으므로 제외
        if c != codes.Unavailable && c != codes.ResourceExhausted {
            return zero, err
        }

        // backoff + jitter
        sleep := base * time.Duration(1<<i)
        if sleep > 300*time.Millisecond {
            sleep = 300 * time.Millisecond
        }
        jitter := time.Duration(rand.Int63n(int64(sleep / 2)))
        timer := time.NewTimer(sleep/2 + jitter)
        select {
        case <-ctx.Done():
            timer.Stop()
            return zero, ctx.Err()
        case <-timer.C:
        }
    }
    return zero, status.Error(codes.Unavailable, "retry attempts exhausted")
}

이 방식은 “전체 데드라인을 지키는 리트라이”가 가능해지고, 재시도 가능한 코드만 엄격히 제한할 수 있습니다.

6) 서버 측 성능/혼잡: DeadlineExceeded의 진짜 원인들

데드라인 초과가 늘어나는 시점에 서버에서 아래를 같이 확인하세요.

  • 동시성 제한/큐잉: worker pool, semaphore, DB 커넥션 풀 고갈
  • 락 경합: 전역 mutex, 캐시 stampede
  • GC/메모리 압박: stop-the-world 증가, p99 급등
  • 의존성 지연: DB 슬로우쿼리, 외부 API 지연, DNS 지연

특히 메모리 압박으로 레이턴시가 튀다가 결국 타임아웃으로 이어지는 케이스가 많습니다. 컨테이너 환경이라면 OOM 직전 스로틀/스왑/GC로 지연이 먼저 나타날 수 있으니, Linux OOM Killer 원인추적 - dmesg·cgroup·로그처럼 메모리 관측도 같이 묶어보는 게 좋습니다.

7) 실전 체크리스트(우선순위 순)

7.1 1단계: 실패 지점 분리

  • 클라이언트 로그: 호출 시작/종료 시간, 데드라인 유무
  • 서버 로그: 핸들러 소요시간, 취소 감지 여부
  • 중간 계층: 504/stream reset/idle timeout 설정

7.2 2단계: 컨텍스트 전파 보장

  • 서버 핸들러에서 모든 I/O에 ctx 전달
  • DB/HTTP 클라이언트 타임아웃을 상위 데드라인보다 짧게
  • 취소 시 빠르게 리턴하도록 select 패턴 적용
select {
case <-ctx.Done():
    return nil, status.Error(codes.Canceled, "client canceled")
case res := <-workCh:
    return res, nil
}

7.3 3단계: 데드라인/리트라이 튜닝

  • 엔드포인트별 timeout을 “고정값 1개”로 통일하지 말 것
  • 재시도는 멱등 + 제한된 코드만(보통 UNAVAILABLE 위주)
  • backoff/jitter 필수, maxAttempts는 작게

8) 결론: 데드라인은 ‘늘리는 값’이 아니라 ‘지키는 계약’

Go gRPC에서 DeadlineExceeded를 줄이는 가장 좋은 방법은 타임아웃을 크게 주는 것이 아니라,

  • 컨텍스트 데드라인을 끝까지 전파하고
  • 서버가 취소를 즉시 존중하며
  • 리트라이는 엄격히 제한하고
  • 관측으로 어디서 시간이 쓰이는지를 먼저 드러내는 것

입니다.

타임아웃을 올려서 성공률이 잠깐 올라가는 것처럼 보여도, 혼잡/큐잉이 원인이라면 결국 p99를 더 악화시켜 더 큰 DeadlineExceeded를 부르게 됩니다. 먼저 “예산”을 설계하고, 그 예산 안에서 서버와 의존성이 일을 끝내도록 만드는 것이 정공법입니다.