Published on

gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결

Authors

서버 간 호출이 gRPC로 촘촘히 연결된 MSA에서 어느 날부터 DEADLINE_EXCEEDED가 폭증하고, p95/p99 지연이 급격히 튀며, 재시도까지 겹쳐 장애처럼 보이는 상황이 자주 발생합니다. 겉으로는 “타임아웃이 늘었다”지만, 실제로는 데드라인(deadline) 전파 누락이 트리거가 되어 대기열/스레드/커넥션이 연쇄적으로 잠기고 결국 상위 서비스가 먼저 타임아웃으로 쓰러지는 패턴이 많습니다.

이 글에서는 gRPC의 데드라인과 취소(cancellation)가 MSA에서 어떻게 전파되어야 하는지, 누락되면 어떤 형태로 타임아웃 폭풍(timeout storm)이 만들어지는지, 그리고 클라이언트/서버/프록시 레이어에서 재현·진단·해결하는 방법을 코드와 함께 정리합니다.

1) 증상: “상위는 타임아웃, 하위는 계속 일함”

데드라인 전파가 누락되면 보통 다음과 같은 현상이 함께 보입니다.

  • API Gateway/Frontend는 1~3초 타임아웃으로 504 또는 gRPC DEADLINE_EXCEEDED 급증
  • 중간 서비스(B)는 로그에 타임아웃이 찍히지만, 더 하위 서비스(C/D)는 요청이 계속 처리
  • 하위 서비스는 CPU/DB 커넥션 사용량이 올라가고, 큐/스레드풀이 고갈되며 지연이 더 커짐
  • 재시도 정책이 있으면 같은 요청이 중복으로 쌓여 상황 악화

핵심은 “상위 요청이 이미 포기했는데도 하위 작업이 멈추지 않는다”는 점입니다. gRPC는 이를 해결하기 위해 deadline + cancellation을 제공하지만, 체인 중간에서 이를 끊어먹으면 전체 시스템이 불안정해집니다.

2) gRPC 데드라인/취소의 기본 동작

  • Deadline: “이 시각까지 응답이 없으면 실패로 간주”라는 절대 시간 개념(대부분 클라이언트가 설정)
  • Timeout: 구현/문서에서는 timeout이라는 표현을 쓰지만, gRPC wire 레벨에서는 grpc-timeout 헤더로 전달되며 결국 deadline로 해석됩니다.
  • Cancellation: 상위 호출이 취소되면 서버는 context를 통해 이를 감지하고 작업을 중단해야 합니다.

중요 포인트:

  1. 클라이언트가 deadline을 설정해도, 서버가 그 deadline을 지키도록 강제되지 않습니다. 서버 핸들러가 ctx.Done()을 무시하면 계속 실행됩니다.
  2. 중간 서비스가 하위 호출을 만들 때, 상위 컨텍스트의 deadline을 하위 호출에 그대로 전달해야 합니다. 그렇지 않으면 하위 호출은 기본 무제한(또는 매우 긴)으로 실행될 수 있습니다.

3) 데드라인 전파 누락이 만드는 “타임아웃 폭풍” 메커니즘

전형적인 호출 체인:

  • A(Edge) → B(API) → C(Orchestrator) → D(DB/External)

A는 2초 deadline을 걸었는데, B가 C를 호출할 때 deadline을 설정하지 않으면:

  • A는 2초 후 타임아웃
  • B는 A의 취소를 감지하지 못하거나 무시하고 계속 C 호출을 기다림
  • C는 더 하위 D 호출을 계속 실행
  • 결과적으로 이미 실패한 요청이 하위 리소스를 계속 소비

이 상태에서 트래픽이 조금만 늘어도:

  • 동시 처리 슬롯(스레드, 이벤트 루프, DB 커넥션)이 “죽은 요청”에 점유
  • 새 요청이 대기 → 지연 증가 → 더 많은 타임아웃 → 더 많은 재시도
  • 시스템 전체가 타임아웃 중심으로 무너짐

이 현상은 NGINX/Ingress, Envoy, ALB 등 앞단 타임아웃과도 결합됩니다. 특히 gRPC 스트리밍이나 keepalive 설정이 엮이면 502/504가 섞여 보이기도 합니다. (프록시/인그레스 타임아웃 튜닝 관점은 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝도 함께 참고하면 좋습니다.)

4) 10분 진단 체크리스트

4.1 로그에서 “상위 타임아웃 vs 하위 정상 처리” 분리

  • 상위 서비스(A/B): DEADLINE_EXCEEDED, context deadline exceeded 증가
  • 하위 서비스(C/D): 같은 시각에 처리량/CPU는 증가하는데 에러는 적거나 늦게 발생

특히 하위에서 “늦게 성공” 로그가 찍히는지 확인하세요. 상위는 이미 응답을 반환(실패)했는데 하위는 성공 처리 후 DB 커밋까지 해버리면, 중복 처리/정합성 문제로 번질 수 있습니다.

4.2 분산 트레이싱에서 “남은 시간(budget)” 확인

OpenTelemetry를 쓰고 있다면 span attribute로 deadline/timeout budget을 남기는 것이 좋습니다.

  • 들어온 요청의 deadline
  • 하위 호출에 설정한 timeout
  • 실제 latency

이 3개가 맞물리지 않으면 전파가 끊긴 겁니다.

4.3 gRPC 메타데이터(grpc-timeout) 확인

프록시(Envoy)나 서버 인터셉터에서 grpc-timeout 헤더 유무를 확인합니다.

  • A→B에는 있는데
  • B→C에서 사라진다면

B의 클라이언트 코드가 deadline을 설정하지 않았을 가능성이 큽니다.

5) 해결 전략: “전파 + 강제 + 예산 분배”

해결은 보통 3단계로 접근합니다.

  1. 클라이언트에서 deadline 전파(필수): 상위 컨텍스트를 하위 호출에 그대로 사용
  2. 서버에서 cancellation을 존중(필수): ctx.Done()을 감지하고 즉시 중단
  3. 예산(budget) 분배(권장): 전체 deadline 중 일부를 하위 호출에 할당하고, 재시도/큐잉에 쓸 시간을 남김

아래는 언어별로 자주 터지는 포인트와 함께 예제를 제공합니다.

6) Go: context 전파 누락 패턴과 수정

6.1 잘못된 예: Background 컨텍스트로 하위 호출

func (s *ServiceB) Handle(ctx context.Context, req *pb.Request) (*pb.Response, error) {
    // ❌ 상위 ctx를 버리고 Background를 사용하면 deadline/cancel 전파가 끊김
    childCtx := context.Background()

    resp, err := s.clientC.Do(childCtx, &pb.ChildRequest{Id: req.Id})
    if err != nil {
        return nil, err
    }
    return &pb.Response{Value: resp.Value}, nil
}

이 코드는 A가 2초 deadline으로 호출해도 C 호출은 무제한으로 대기할 수 있습니다.

6.2 올바른 예: 상위 ctx를 그대로 전달 + 하위 timeout 상한

func (s *ServiceB) Handle(ctx context.Context, req *pb.Request) (*pb.Response, error) {
    // ✅ 상위 ctx를 기본으로 사용
    // ✅ 단, 하위 호출에 별도 상한을 두어 예산을 확보(예: 70%)

    // 상위 deadline이 없을 수도 있으니 기본값도 준비
    const defaultTimeout = 1500 * time.Millisecond

    childCtx := ctx
    if _, ok := ctx.Deadline(); !ok {
        var cancel context.CancelFunc
        childCtx, cancel = context.WithTimeout(ctx, defaultTimeout)
        defer cancel()
    } else {
        // 상위 deadline이 있다면, 남은 시간의 일부만 하위에 할당
        dl, _ := ctx.Deadline()
        remaining := time.Until(dl)
        budget := time.Duration(float64(remaining) * 0.7)
        if budget > 0 {
            var cancel context.CancelFunc
            childCtx, cancel = context.WithTimeout(ctx, budget)
            defer cancel()
        }
    }

    resp, err := s.clientC.Do(childCtx, &pb.ChildRequest{Id: req.Id})
    if err != nil {
        return nil, err
    }
    return &pb.Response{Value: resp.Value}, nil
}

포인트:

  • ctx를 버리지 않는다.
  • 상위 deadline이 없을 때도 서비스 내부는 무제한이 되지 않도록 기본 timeout을 둔다.
  • 상위 deadline이 있을 때는 전체를 하위에 몰빵하지 말고 일부만 할당한다(큐잉/직렬화/응답 처리 시간을 남김).

6.3 서버 핸들러에서 cancellation 존중

func (s *ServiceC) Do(ctx context.Context, req *pb.ChildRequest) (*pb.ChildResponse, error) {
    // 긴 작업/외부 호출 전후로 ctx 확인
    select {
    case <-ctx.Done():
        return nil, status.Error(codes.Canceled, ctx.Err().Error())
    default:
    }

    // 예: DB 쿼리도 ctx를 반드시 전달
    row := s.db.QueryRowContext(ctx, "SELECT value FROM items WHERE id=$1", req.Id)

    var v string
    if err := row.Scan(&v); err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }

    return &pb.ChildResponse{Value: v}, nil
}

DB/HTTP 호출에 ctx를 넘기지 않으면, gRPC 레벨에서 cancel이 와도 내부 작업이 멈추지 않습니다.

7) Node.js: deadline을 “자동 전파”로 오해하기 쉬운 지점

Node gRPC 라이브러리(@grpc/grpc-js)는 호출 옵션으로 deadline을 넣을 수 있지만, 프레임워크(Express/Fastify)에서 들어온 요청 타임아웃과 자동 연결되지 않습니다.

7.1 잘못된 예: 하위 호출에 deadline 미설정

// ❌ 상위 HTTP 요청은 2초 제한인데, gRPC 하위 호출은 무제한
clientC.do({ id }, (err, resp) => {
  // ...
});

7.2 올바른 예: 상위 요청의 남은 시간을 deadline으로 변환

import grpc from '@grpc/grpc-js';

function callChildWithBudget(clientC, id, parentDeadlineMs = 2000) {
  const start = Date.now();
  const budgetMs = Math.floor(parentDeadlineMs * 0.7);

  const deadline = new Date(Date.now() + budgetMs);

  return new Promise((resolve, reject) => {
    clientC.do(
      { id },
      { deadline },
      (err, resp) => {
        if (err) return reject(err);
        resolve({ resp, elapsedMs: Date.now() - start });
      }
    );
  });
}

실무에서는 “부모 요청의 deadline을 어디서 얻느냐”가 관건입니다.

  • HTTP라면: 서버 타임아웃 정책(예: 2초)을 단일 소스로 두고, 내부 호출은 예산 분배
  • gRPC라면: 서버 핸들러에서 call.getDeadline()(라이브러리별 API 상이)로 추출 후 하위 호출에 반영

8) Interceptor로 데드라인 전파를 강제하기

코드 리뷰로 모든 호출 지점을 잡는 건 어렵습니다. 그래서 클라이언트 인터셉터로 “상위 ctx에 deadline이 있으면 반드시 하위에도 적용”을 강제하는 방식이 효과적입니다.

8.1 Go: UnaryClientInterceptor로 기본 timeout 강제

func DefaultTimeoutUnaryClientInterceptor(defaultTimeout time.Duration) grpc.UnaryClientInterceptor {
    return func(
        ctx context.Context,
        method string,
        req, reply any,
        cc *grpc.ClientConn,
        invoker grpc.UnaryInvoker,
        opts ...grpc.CallOption,
    ) error {
        // 상위 deadline이 없으면 기본 timeout 부여
        if _, ok := ctx.Deadline(); !ok {
            var cancel context.CancelFunc
            ctx, cancel = context.WithTimeout(ctx, defaultTimeout)
            defer cancel()
        }
        return invoker(ctx, method, req, reply, cc, opts...)
    }
}

// 사용 예
conn, _ := grpc.Dial(
    target,
    grpc.WithInsecure(),
    grpc.WithUnaryInterceptor(DefaultTimeoutUnaryClientInterceptor(1500*time.Millisecond)),
)

이렇게 하면 최소한 “무제한 대기”는 차단됩니다. 다만 예산 분배(70% 등)는 호출 경로별로 다를 수 있어, 핵심 경로부터 점진 적용하는 것을 권장합니다.

9) 재시도/서킷브레이커와의 궁합: 데드라인이 먼저다

데드라인 전파가 안 된 상태에서 재시도를 켜면, 타임아웃 폭풍이 훨씬 빨리 옵니다.

권장 순서:

  1. 데드라인 전파 및 서버 cancel 처리
  2. 그 다음에 재시도 정책을 “idempotent + 제한된 횟수 + jitter”로 조정
  3. 마지막으로 서킷브레이커/벌크헤드로 격리

특히 재시도는 남은 시간(budget) 안에서만 수행되어야 합니다. 남은 시간이 200ms인데 3회 재시도를 돌리면, 성공 가능성은 낮고 부하만 늘립니다.

10) 운영 팁: 관측 가능성(Observability) 개선 포인트

  • 로그에 remaining_ms, deadline을 구조화 필드로 남기기
  • gRPC status code별 카운터(DEADLINE_EXCEEDED, CANCELED)를 분리
  • “서버에서 취소를 무시한 시간”을 측정: 요청 종료 후에도 작업이 지속되는 시간을 메트릭화

추가로, 타임아웃이 겉으로는 503/504로 보일 때가 많습니다. 쿠버네티스 환경에서는 인그레스/서비스 디스커버리/노드 리소스 문제와 섞여 보이니, 빠른 원인 분리를 위해 EKS에서 503 Service Unavailable 원인 10분 진단 같은 체크리스트를 병행하면 탐지 속도가 올라갑니다.

11) 마무리: “데드라인은 계약, 전파는 의무”

gRPC MSA에서 데드라인 전파는 단순한 옵션이 아니라 상위 호출자가 요구한 응답 시간 계약을 하위 서비스가 함께 지키기 위한 메커니즘입니다. 전파가 누락되면 실패한 요청이 리소스를 계속 점유하고, 그 리소스 고갈이 다시 타임아웃을 낳는 악순환이 만들어집니다.

정리하면, 타임아웃 폭증을 멈추는 최소 조건은 다음입니다.

  • 모든 하위 호출은 상위 context를 전달한다(또는 인터셉터로 강제)
  • 서버는 ctx.Done()을 존중하고, DB/HTTP에도 ctx를 전달한다
  • 전체 deadline에서 하위 호출 예산을 분배해 “응답/직렬화/재시도” 시간을 남긴다

이 3가지를 적용하면 DEADLINE_EXCEEDED는 단순 에러가 아니라, 시스템이 스스로를 보호하는 정상적인 신호로 되돌아옵니다.