Published on

gRPC 데드라인 전파 실패, 원인과 진단법

Authors

마이크로서비스에서 gRPC를 쓰면 deadline(또는 timeout)은 단순한 타임아웃 옵션이 아니라, 요청의 예산(time budget) 을 하위 호출로 전달해 연쇄 지연을 막는 핵심 메커니즘입니다. 그런데 운영에서 자주 겪는 문제가 있습니다.

  • A 서비스는 deadline=200ms로 호출했다고 믿는데
  • B, C 서비스는 그 사실을 모르고 2초, 5초씩 처리하다가
  • 결국 A에서만 DEADLINE_EXCEEDED가 터지고, 하위 서비스는 계속 일을 하거나 DB 락을 잡고 있는 상황

이 글은 이런 데드라인 전파 실패를 “어디서 끊겼는지” 빠르게 찾는 체크리스트와, 코드/설정 레벨에서의 재현 및 해결 패턴을 정리합니다.

관련해서 분산 트랜잭션에서 타임아웃이 꼬이면 보상 트랜잭션 중복이나 순서 꼬임으로 이어질 수 있으니, SAGA 디버깅 관점은 아래 글도 함께 참고하면 도움이 됩니다.

데드라인 전파란 무엇인가

gRPC에서 클라이언트는 호출 시 데드라인을 설정할 수 있고, 이 값은 메타데이터 형태로 서버로 전달됩니다. 서버는 이를 기반으로 다음을 할 수 있습니다.

  • 남은 시간(time remaining)을 계산해 하위 호출의 데드라인을 더 짧게 설정
  • 남은 시간이 부족하면 빠르게 실패(fail-fast)
  • 서버 내부 작업(쿼리, 외부 API 호출)에 취소 신호를 전달

중요한 점은 데드라인이 자동으로 모든 곳에 “마법처럼” 전파되지 않는다는 것입니다. 프레임워크/언어/스레딩 모델/프록시 설정에 따라 쉽게 끊깁니다.

전파 실패의 대표 증상

운영에서 다음 패턴이면 데드라인 전파 실패를 강하게 의심할 수 있습니다.

  1. 상위 서비스만 DEADLINE_EXCEEDED, 하위 서비스는 정상 처리 로그가 남음
  2. 상위 요청이 타임아웃으로 끝났는데도 하위 서비스 CPU/DB 부하가 계속 유지
  3. 트레이싱에서 상위 span은 짧게 끝났는데 하위 span이 더 길게 이어짐
  4. 동일 요청에 대해 재시도(retry)가 겹치며 중복 처리 발생(특히 쓰기 요청)

원인 분류: “데드라인이 끊기는 지점” 6가지

1) 하위 호출에서 데드라인을 아예 설정하지 않음

가장 흔한 실수는 “클라이언트에서만 데드라인을 걸고, 서버에서 다른 서비스를 호출할 때는 기본 설정으로 호출”하는 것입니다.

  • A client200ms
  • A server는 B 호출 시 데드라인 미설정
  • B는 기본 무한대(또는 매우 긴)로 실행

해결의 핵심은 서버 핸들러에서 현재 컨텍스트의 남은 시간을 읽고, 하위 호출에 데드라인을 다시 설정하는 것입니다.

2) 컨텍스트(Context) 분리: 스레드/코루틴/리액티브 경계에서 유실

Java/Kotlin에서 특히 자주 나옵니다.

  • gRPC 서버 핸들러는 Context를 갖고 있는데
  • 내부에서 CompletableFuture, ExecutorService, Reactor 체인, 코루틴 디스패처로 넘어가며 컨텍스트가 끊김
  • 그 상태에서 하위 gRPC 호출을 하면 데드라인/취소가 전달되지 않음

이 경우 “데드라인이 전파되지 않는다”기보다, 전파의 원천이 되는 컨텍스트를 잃어버린 것입니다.

3) 게이트웨이/프록시가 timeout 헤더를 덮어씀

Envoy, gRPC-Gateway, Ingress, L7 프록시에서 다음이 발생할 수 있습니다.

  • 외부에서 들어온 grpc-timeout을 내부로 전달하지 않음
  • 프록시가 자체 타임아웃을 더 길게 잡아서 서버는 오래 처리
  • 반대로 프록시가 너무 짧게 끊어 상위는 실패하지만 서버는 작업 지속

즉 “서비스 코드” 문제가 아니라 “네트워크 계층”에서 끊길 수 있습니다.

4) Retry 정책이 데드라인을 사실상 무력화

클라이언트가 데드라인을 200ms로 걸었더라도 다음 조합이면 이상해집니다.

  • per-retry timeout이 길거나
  • 재시도 횟수가 많고
  • 재시도 사이 backoff가 있고
  • 전체 데드라인보다 재시도 정책이 우선되는 구현/설정

결과적으로 상위는 200ms를 기대했는데 실제로는 더 오래 대기하거나, 반대로 짧게 끝나더라도 하위에서 중복 처리(특히 쓰기)가 발생할 수 있습니다.

5) 서버가 취소를 무시: 작업이 계속 진행됨

데드라인은 “요청을 중단하라”는 신호지만, 서버가 다음을 하지 않으면 효과가 없습니다.

  • 취소 신호를 감지하고 루프/블로킹 호출을 빠져나오기
  • DB 쿼리/HTTP 호출에 별도 타임아웃을 걸기
  • 스트리밍 응답에서 클라이언트 종료를 감지해 중단하기

특히 JDBC/HTTP 클라이언트에 타임아웃이 없으면, gRPC 요청은 끝났는데도 내부 작업은 계속됩니다.

6) 관측(Observability) 부재로 “전파 실패처럼 보이는” 경우

실제로는 전파가 되고 있는데도,

  • 로그에 데드라인이 출력되지 않음
  • 트레이싱에서 deadline 정보를 태그로 남기지 않음
  • 에러 매핑이 달라서 DEADLINE_EXCEEDED가 다른 에러처럼 보임

때문에 “끊겼다”가 아니라 “보이지 않는다”인 경우도 많습니다.

진단 전략: 10분 안에 어디서 끊겼는지 찾는 법

핵심은 각 홉(hop)에서 남은 시간grpc-timeout을 관측하는 것입니다.

1) 클라이언트: 호출 직전에 데드라인/남은 시간 로깅

언어별로 구현은 다르지만, 목표는 동일합니다.

  • 호출 시점의 데드라인 절대값
  • 남은 시간(ms)
  • 호출 대상 메서드

2) 서버: 인터셉터에서 들어온 데드라인을 로깅

서버가 받은 데드라인이 없다면, 이미 앞단에서 끊긴 것입니다.

  • 게이트웨이/프록시
  • 클라이언트 라이브러리
  • 호출 코드

3) 서버 내부 하위 호출: “현재 컨텍스트 기반”으로 데드라인 재설정

하위 호출에 데드라인을 명시하지 말고, 현재 요청 컨텍스트의 남은 시간을 기준으로 계산합니다.

4) 취소 전파 확인: 서버에서 취소 이벤트를 로그로 남김

서버가 cancelled를 감지했는데도 계속 처리한다면, 내부 작업이 취소를 반영하지 않는 것입니다.

5) 분산 트레이싱: span에 deadline_ms 태그 추가

트레이싱만으로도 “A는 200ms, B는 2s” 같은 불일치를 빠르게 찾을 수 있습니다.

코드 예제: Java gRPC에서 데드라인 전파/로깅 인터셉터

아래 예시는 서버에서 들어온 데드라인을 로깅하고, 하위 호출 시 남은 시간을 기준으로 데드라인을 설정하는 패턴입니다.

서버 인터셉터: 데드라인/남은 시간 로깅

import io.grpc.*;
import java.util.concurrent.TimeUnit;

public class DeadlineLoggingServerInterceptor implements ServerInterceptor {
  @Override
  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
      ServerCall<ReqT, RespT> call,
      Metadata headers,
      ServerCallHandler<ReqT, RespT> next) {

    Context ctx = Context.current();
    Deadline d = ctx.getDeadline();

    if (d == null) {
      System.out.println("[grpc] no deadline for method=" + call.getMethodDescriptor().getFullMethodName());
    } else {
      long remainingMs = d.timeRemaining(TimeUnit.MILLISECONDS);
      System.out.println("[grpc] deadline remaining_ms=" + remainingMs +
          " method=" + call.getMethodDescriptor().getFullMethodName());
    }

    return next.startCall(call, headers);
  }
}

포인트는 Context.current().getDeadline()null인지 여부입니다.

  • null이면 상위에서 데드라인이 아예 안 왔습니다.
  • 값이 있는데도 하위 호출이 길어지면 “하위 호출 설정/취소 반영” 문제일 확률이 큽니다.

하위 호출 시: 남은 시간 기반으로 데드라인 재설정

import io.grpc.*;
import java.util.concurrent.TimeUnit;

public class DownstreamClient {
  private final MyServiceGrpc.MyServiceBlockingStub stub;

  public DownstreamClient(MyServiceGrpc.MyServiceBlockingStub stub) {
    this.stub = stub;
  }

  public MyResponse call(MyRequest req) {
    Deadline d = Context.current().getDeadline();

    if (d == null) {
      // 상위 데드라인이 없다면, 서비스 기본값을 강제하는 편이 안전합니다.
      return stub.withDeadlineAfter(300, TimeUnit.MILLISECONDS).myRpc(req);
    }

    long remainingMs = d.timeRemaining(TimeUnit.MILLISECONDS);

    // 네트워크/직렬화/큐잉을 고려한 안전 마진
    long budgetMs = Math.max(1, remainingMs - 20);

    return stub.withDeadlineAfter(budgetMs, TimeUnit.MILLISECONDS).myRpc(req);
  }
}

이렇게 하면 “상위 요청 예산 내에서만 하위 호출”이 강제됩니다. 특히 체인 호출이 깊을수록 효과가 큽니다.

비동기 경계에서 컨텍스트 유실을 재현하는 간단한 테스트

다음과 같은 코드 구조는 데드라인/취소가 기대대로 동작하지 않을 가능성이 큽니다.

  • 요청 핸들러에서 ExecutorService로 작업을 던짐
  • 던진 작업에서 하위 gRPC 호출

재현용 예시(의도적으로 위험한 형태):

ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(8);

public MyResponse handler(MyRequest req) throws Exception {
  // 상위에서 deadline=200ms로 들어왔다고 가정
  return pool.submit(() -> {
    // 여기서 Context.current()는 기대와 다를 수 있습니다.
    // 결과적으로 하위 호출에 deadline이 안 잡히거나, 취소를 못 받을 수 있습니다.
    return downstreamClient.call(req.toDownstream());
  }).get();
}

진단 팁:

  • 위 코드에서 작업 내부에 Context.current().getDeadline()를 찍어보세요.
  • 서버 인터셉터에서는 데드라인이 보이는데, 비동기 작업 내부에서는 null이거나 남은 시간이 이상하게 나오면 “컨텍스트 경계 문제”입니다.

해결은 프레임워크별로 다르지만, 공통 원칙은 현재 컨텍스트를 캡처해서 실행 구간에 바인딩하는 것입니다.

프록시/게이트웨이 구간 체크리스트

데드라인 전파 실패는 코드보다 인프라에서 더 빨리 찾는 경우도 많습니다. 다음을 순서대로 확인하세요.

  1. 외부 클라이언트가 grpc-timeout을 보내는지 패킷/로그로 확인
  2. 게이트웨이가 해당 헤더를 보존하는지 확인
  3. Envoy 사용 시 route timeout, idle timeout, max stream duration 설정 확인
  4. Ingress가 HTTP2를 제대로 패스스루하는지 확인(중간에 HTTP1.1로 다운그레이드되면 gRPC 자체가 깨질 수 있음)

이 단계에서 중요한 건 “어느 홉에서 grpc-timeout이 사라졌는지”를 찾는 것입니다.

Retry와 데드라인: 함께 설계하지 않으면 중복 처리로 번진다

데드라인이 짧을수록 재시도가 공격적으로 보일 수 있고, 특히 쓰기 요청에서 위험합니다.

  • 상위는 타임아웃으로 실패 처리
  • 실제로는 하위에서 처리 성공
  • 상위가 재시도하면서 동일 작업이 중복 실행

이 문제는 SAGA 보상 트랜잭션에서도 그대로 나타납니다. 재시도와 멱등성, 중복 방지는 아래 글이 실전적으로 연결됩니다.

권장 패턴:

  • 쓰기 요청은 가능하면 멱등 키(idempotency key) 도입
  • retry는 safe한 메서드에 제한하거나, 서버에서 중복 방지
  • 전체 데드라인과 per-retry timeout을 함께 정의하고, 관측 가능하게 로그로 남김

서버 내부 리소스(DB/외부 HTTP)까지 타임아웃을 “끝까지” 연결하기

gRPC 데드라인이 들어와도 DB 쿼리나 외부 HTTP가 무제한이면, 서버는 취소를 알아도 멈추지 못합니다.

권장 사항:

  • JDBC라면 statement timeout(드라이버/DB별 설정) 적용
  • 외부 HTTP 클라이언트(connect/read/call timeout) 설정
  • 긴 루프/배치 작업은 주기적으로 Context.current().isCancelled() 확인

DB 락/데드락 재현과 로그 기반 분석이 필요하다면, 아래 글의 접근 방식(재현 가능한 최소 케이스 만들기, 락 홀더 추적)은 gRPC 타임아웃 이슈에도 그대로 응용됩니다.

운영에서 바로 쓰는 “전파 실패” 판별표

관측 지점데드라인이 보임의미다음 액션
A 클라이언트A는 설정함A 서버에서 수신 여부 확인
A 서버 인터셉터아니오A->B 구간에서 끊김A의 호출 코드/프록시 확인
A 서버 인터셉터A까지는 정상A 내부 하위 호출 코드 점검
B 서버 인터셉터아니오A가 B로 전달 실패B 호출 stub에 데드라인 강제
B 서버는 취소 로그 있음취소는 전달됨DB/HTTP 타임아웃, 취소 반영 점검

표의 화살표는 본문에서 일반 텍스트로 쓰면 MDX에서 문제될 수 있어 A -> B처럼 인라인 코드로 표기했습니다.

마무리: 데드라인은 “옵션”이 아니라 “계약”이다

gRPC에서 데드라인 전파가 실패하면, 단순히 타임아웃이 안 걸리는 문제가 아니라 다음으로 확장됩니다.

  • 리소스 낭비(취소된 요청이 계속 DB/외부 API를 때림)
  • tail latency 악화(긴 작업이 워커를 점유)
  • 재시도에 의한 중복 처리 및 데이터 정합성 이슈

가장 효과적인 접근은 다음 3가지를 동시에 하는 것입니다.

  1. 서버 인터셉터로 “들어온 데드라인”을 강제 관측
  2. 하위 호출은 “현재 컨텍스트 남은 시간” 기반으로 데드라인 재설정
  3. DB/HTTP 등 내부 작업에 타임아웃과 취소 반영을 끝까지 연결

이 세 가지를 적용하면, 데드라인 전파 실패는 대부분 “어느 홉에서 끊겼는지”가 바로 드러나고, 재현 가능한 형태로 안정적으로 해결할 수 있습니다.