Published on

gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계

Authors

서론

마이크로서비스 환경에서 gRPC를 쓰다 보면 “타임아웃 지옥”을 겪기 쉽습니다. 어떤 클라이언트는 30초를 기다리는데 중간 서비스는 2초에 끊고, 또 다른 서비스는 아예 무한 대기(또는 매우 긴 대기)를 해버립니다. 결과는 뻔합니다.

  • 사용자 요청은 이미 끊겼는데 서버는 계속 일을 함(낭비)
  • 재시도가 겹치며 트래픽이 폭증하고, 풀/스레드/커넥션이 고갈됨
  • NGINX/Ingress 계층에서는 499(Client Closed Request)나 upstream timeout이 급증
  • gRPC 레벨에서는 DEADLINE_EXCEEDED, CANCELLED가 연쇄적으로 발생

이 글은 “각 서비스가 알아서 타임아웃을 걸자”가 아니라, 요청 전체의 시간 예산을 한 번 정하고(end-to-end deadline) 그 예산이 모든 hop으로 전파되도록 설계하는 방법을 다룹니다. 인프라 레벨에서의 499 증상과도 연결되므로, 필요하면 EKS NGINX Ingress 499 폭주 원인과 해결 글도 함께 보면 디버깅이 빨라집니다.

gRPC 타임아웃/데드라인의 핵심 개념

Timeout vs Deadline

  • Timeout: “지금부터 N초 안에 끝내라” (상대 시간)
  • Deadline: “절대 시각 T까지 끝내라” (절대 시간)

gRPC API에서는 보통 timeout을 주면 내부적으로 deadline으로 변환됩니다. 중요한 점은 gRPC가 데드라인을 메타데이터로 전파하고, 서버는 그 값을 보고 남은 시간을 계산할 수 있다는 것입니다.

전파가 깨질 때 생기는 전형적인 문제

  1. 상위 요청은 2초 데드라인인데, 하위 호출은 5초 타임아웃
  • 하위 호출이 5초 동안 리소스를 점유
  • 상위는 이미 실패 처리 → 재시도 → 더 많은 하위 호출 생성
  1. 상위는 무한 대기, 하위는 짧은 타임아웃
  • 상위는 “가끔씩만” 실패한다고 착각
  • 실제로는 하위가 지속적으로 timeout → 상위에서 재시도/폴백 로직이 폭주
  1. 게이트웨이/Ingress 타임아웃 < 앱 타임아웃
  • 클라이언트는 이미 연결을 끊음(499)
  • 서버는 계속 계산/DB/외부 API 호출 수행

데드라인 전파 설계 원칙 7가지

1) End-to-End 시간 예산을 먼저 정한다

요청 종류별로 “사용자가 체감 가능한 최대 응답 시간”을 정의합니다.

  • 예: 검색 800ms, 결제 1500ms, 백오피스 5s

이 값은 단순히 SLO가 아니라, 모든 하위 호출이 공유하는 예산입니다.

2) Hop Budget(구간 예산)을 나누되, 상위 데드라인을 절대 초과하지 않는다

각 서비스는 자기 내부 처리 + 하위 호출에 시간을 배분하지만, 핵심은:

  • 하위 호출 데드라인 = min(상위 남은 시간 - safety margin, 서비스 정책 상한)

3) Safety Margin(여유분)을 강제한다

네트워크 지터, 직렬화/큐잉, GC, 스케줄링 지연을 고려해 항상 여유분을 남깁니다.

  • 예: 남은 시간에서 30~50ms 또는 10%를 빼고 하위 호출

4) 서버는 “클라이언트 데드라인”을 신뢰하고, 내부 작업도 중단 가능해야 한다

gRPC 서버는 클라이언트가 보낸 데드라인/취소를 감지할 수 있습니다.

  • Java: Context.current().isCancelled()
  • Go: ctx.Done()
  • Python: cancellation/timeout 예외

중요한 건 DB 쿼리/외부 HTTP 호출에도 동일한 ctx를 연결해 실제로 중단되게 만드는 것입니다.

5) 재시도는 데드라인 내에서만, 그리고 예산을 깎아먹는다는 사실을 반영한다

재시도는 “성공률”을 올리지만 “지연/부하”를 악화시킵니다.

  • 재시도 1회당 남은 예산이 크게 줄어듦
  • 데드라인이 짧은 요청에는 재시도 금지 또는 매우 제한

6) 스트리밍 RPC는 idle timeout과 메시지 단위 예산을 분리한다

서버 스트리밍/양방향 스트리밍에서 “전체 데드라인”만 걸면 실제 운영에서 곤란합니다.

  • 연결 유지 목적의 idle timeout
  • 메시지 처리 단위의 per-message deadline

7) 관측 가능성(Observability): “남은 시간”을 로그/메트릭으로 남긴다

타임아웃 지옥은 대부분 “어디서 시간이 새는지”가 보이지 않아 생깁니다.

  • inbound에서 받은 deadline
  • outbound 호출 시점의 remaining time
  • 실제 처리 시간과 큐잉 시간

Ingress에서 499가 보이면 앱 레벨의 cancellation 대응이 부족한 경우가 많습니다. 499 자체의 의미와 상관관계는 EKS NGINX Ingress 499 폭주 원인과 해결에서 더 깊게 다룹니다.

구현: Java/Spring gRPC에서 데드라인 전파 강제하기

아래 예시는 개념을 단단히 보여주기 위해 서버 인터셉터 + 클라이언트 인터셉터로 “데드라인 전파”와 “하위 호출 데드라인 계산”을 강제합니다.

서버: inbound 데드라인/취소를 로깅하고 빠른 중단 지원

import io.grpc.*;
import io.grpc.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.TimeUnit;

public class DeadlineLoggingServerInterceptor implements ServerInterceptor {
  private static final Logger log = LoggerFactory.getLogger(DeadlineLoggingServerInterceptor.class);

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

    Deadline d = call.getDeadline();
    Long remainingMs = (d == null) ? null : d.timeRemaining(TimeUnit.MILLISECONDS);

    log.info("inbound method={}, deadlineRemainingMs={}", call.getMethodDescriptor().getFullMethodName(), remainingMs);

    // cancellation hook
    Context ctx = Context.current();
    ctx.addListener(context -> {
      if (context.isCancelled()) {
        log.warn("request cancelled: method={}, cause={}",
            call.getMethodDescriptor().getFullMethodName(), context.cancellationCause());
      }
    }, Runnable::run);

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

서버 인터셉터는 “전파가 되고 있는지”를 확인하는 1차 안전장치입니다. 하지만 핵심은 서버 핸들러가 ctx 취소를 실제 작업에 연결하는 것입니다.

예: DB/외부 호출이 취소 가능한 형태로 묶여야 합니다(예: HTTP client timeout, DB statement timeout 설정 등).

클라이언트: 상위 남은 시간을 기반으로 하위 호출 데드라인을 계산

import io.grpc.*;

import java.util.concurrent.TimeUnit;

public class PropagatingDeadlineClientInterceptor implements ClientInterceptor {
  private final long maxOutboundMs;      // 서비스 정책 상한
  private final long safetyMarginMs;     // 여유분

  public PropagatingDeadlineClientInterceptor(long maxOutboundMs, long safetyMarginMs) {
    this.maxOutboundMs = maxOutboundMs;
    this.safetyMarginMs = safetyMarginMs;
  }

  @Override
  public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
      MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {

    Deadline parent = Context.current().getDeadline();

    // 부모 데드라인이 없다면, 정책 상한으로 강제 (무한대 방지)
    long outboundMs;
    if (parent == null) {
      outboundMs = maxOutboundMs;
    } else {
      long remaining = parent.timeRemaining(TimeUnit.MILLISECONDS);
      outboundMs = Math.min(maxOutboundMs, Math.max(1, remaining - safetyMarginMs));
    }

    Deadline outboundDeadline = Deadline.after(outboundMs, TimeUnit.MILLISECONDS);
    CallOptions withDeadline = callOptions.withDeadline(outboundDeadline);

    return next.newCall(method, withDeadline);
  }
}

이 방식의 장점:

  • 상위가 300ms 남았는데 하위에 2초를 주는 실수를 원천 차단
  • 상위에 데드라인이 없어도 “최대 상한”으로 무한 대기 방지

운영에서 흔한 장애 패턴이 “특정 경로만 데드라인이 없다”인데, 이 인터셉터로 상당 부분 예방됩니다.

Spring Boot에서 적용(예시)

사용 중인 gRPC 프레임워크(예: grpc-spring-boot-starter)에 따라 등록 방식은 다르지만 개념은 동일합니다.

@Bean
public ServerInterceptor deadlineLoggingServerInterceptor() {
  return new DeadlineLoggingServerInterceptor();
}

@Bean
public ClientInterceptor propagatingDeadlineClientInterceptor() {
  return new PropagatingDeadlineClientInterceptor(
      800 /* maxOutboundMs */, 
      50  /* safetyMarginMs */
  );
}

구현: Go에서 컨텍스트 기반 데드라인 전파

Go는 context.Context가 표준이라 데드라인 전파가 자연스럽지만, “남은 시간 기반으로 하위 호출을 제한”하는 로직은 직접 넣어야 합니다.

package deadline

import (
	"context"
	"time"
)

func OutboundContext(parent context.Context, max time.Duration, safety time.Duration) (context.Context, context.CancelFunc) {
	deadline, ok := parent.Deadline()
	if !ok {
		return context.WithTimeout(parent, max)
	}

	remaining := time.Until(deadline) - safety
	if remaining <= 0 {
		remaining = time.Millisecond // 최소 1ms라도
	}
	if remaining > max {
		remaining = max
	}
	return context.WithTimeout(parent, remaining)
}

사용 예:

ctx2, cancel := deadline.OutboundContext(ctx, 800*time.Millisecond, 50*time.Millisecond)
defer cancel()
resp, err := client.GetUser(ctx2, req)

이렇게 하면 상위 요청이 이미 촉박할 때 하위 호출이 “불필요하게 오래” 붙잡고 있지 않습니다.

타임아웃 지옥을 부르는 운영 실수 체크리스트

1) L7(Ingress/Proxy) 타임아웃과 애플리케이션 데드라인 불일치

  • Ingress 1s, 앱 3s면 499/업스트림 에러가 먼저 터집니다.
  • 앱은 client cancel을 감지하고 즉시 중단해야 합니다.

Ingress에서 499가 늘었다면, 단순히 “클라이언트가 나갔다”로 끝내지 말고 앱이 취소 전파를 제대로 처리하는지까지 확인하세요. 관련 디버깅 힌트는 EKS NGINX Ingress 499 폭주 원인과 해결에 정리돼 있습니다.

2) 재시도 + 짧은 데드라인 조합

  • 짧은 데드라인에서 재시도는 성공률보다 실패율/부하를 올리는 경우가 많습니다.
  • 특히 “동일 요청이 동시에 여러 번” 날아가면 downstream이 먼저 무너집니다.

3) 풀 고갈(스레드/커넥션)로 인한 큐잉 지연

데드라인을 잘 설계해도 서버 내부에서 큐잉이 길어지면 남은 시간이 급격히 줄어듭니다.

  • Java라면 가상 스레드/블로킹 IO 조합에서 풀 고갈이 자주 나타납니다. 비슷한 병목 패턴은 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결도 참고할 만합니다(직접 gRPC는 아니지만 “대기열/풀 고갈 → 지연 폭증”이라는 구조가 같습니다).

4) 서버가 취소를 무시하고 끝까지 작업하는 경우

  • 클라이언트는 이미 deadline 초과로 떠났는데
  • 서버는 CPU/DB/외부 API를 끝까지 태움

이 경우 “타임아웃”이 아니라 “리소스 누수”에 가깝고, 장애 시 폭발력이 커집니다.

권장 정책 템플릿(실무용)

아래는 팀 합의를 빠르게 만들기 위한 현실적인 템플릿입니다.

  • 모든 inbound 요청은 데드라인 필수
    • 없으면 게이트웨이에서 기본값 부여(예: 1s/2s/5s)
  • 모든 outbound 호출은 min(상위 남은 시간 - margin, outbound 상한)
    • margin: 30~100ms 또는 10%
  • 데드라인 임박 시(예: remaining < 50ms) outbound 호출 금지
    • 즉시 폴백/빠른 실패
  • 서버 핸들러는 취소 신호를 주기적으로 체크
    • 긴 루프/배치/대용량 처리에서 특히 중요
  • 메트릭 표준화
    • grpc_client_deadline_remaining_ms(outbound 시점)
    • grpc_server_deadline_remaining_ms(inbound 시점)
    • status code별 카운트(DEADLINE_EXCEEDED, CANCELLED)

결론

gRPC의 타임아웃 문제는 “서비스마다 다른 값”이 아니라, 요청 전체 시간 예산이 분해/전파/강제되지 않는 구조적 문제로 발생합니다. 데드라인 전파를 제대로 설계하면 아래가 동시에 개선됩니다.

  • 불필요한 하위 호출/재시도 감소
  • 취소 전파로 리소스 낭비 감소
  • 499/DEADLINE_EXCEEDED 연쇄 완화
  • 장애 시 폭발 반경 축소

다음 단계로는 (1) 게이트웨이에서 기본 데드라인을 부여하고, (2) 클라이언트 인터셉터로 전파를 강제하며, (3) 취소 가능한 작업 모델로 서버 내부를 정리하는 순서가 가장 비용 대비 효과가 좋습니다.