Published on

Spring Boot gRPC DEADLINE_EXCEEDED 타임아웃 진단

Authors

서버가 멀쩡해 보이는데 gRPC 클라이언트에서 DEADLINE_EXCEEDED가 터지면, 대부분은 “타임아웃을 늘리면 되지 않나?”로 시작했다가 더 큰 장애로 번집니다. DEADLINE_EXCEEDED는 단순히 “느리다”가 아니라 요청이 deadline 안에 끝나지 못했다는 결과이며, 원인은 애플리케이션 코드부터 커넥션/큐잉/GC/로드밸런서/쿠버네티스 리소스까지 넓게 퍼져 있습니다.

이 글은 Spring Boot + gRPC(주로 grpc-java) 환경에서 DEADLINE_EXCEEDED재현 → 관측 → 원인 분리 → 수정 순서로 진단하는 체크리스트와 코드/설정 예시를 제공합니다.

1) DEADLINE과 timeout의 차이부터 정리

gRPC에서 흔히 말하는 “timeout”은 실질적으로 deadline(마감 시각) 입니다.

  • client deadline: 클라이언트가 “이 시각까지 응답이 안 오면 취소”를 선언
  • server: 서버는 deadline을 전달받아 남은 시간을 확인할 수 있고, 핸들링 중단/빠른 실패를 구현할 수 있음
  • 전파(propagation): 서비스 A → B → C로 호출이 이어지면, 상위 호출의 deadline이 하위 호출에도 반영되어야 전체 요청이 안정적으로 끝남

DEADLINE_EXCEEDED는 아래 중 하나로 발생합니다.

  1. 서버가 실제로 늦게 처리함(애플리케이션 병목)
  2. 서버는 빨리 처리했지만 응답이 네트워크/큐/로드밸런서에서 지연됨
  3. 클라이언트 측에서 연결/이름해석/리트라이/큐잉으로 시간을 소모함
  4. deadline이 너무 짧거나(요청 특성에 비해) 전파가 잘못됨

2) 먼저 “어디서 시간을 쓰는지”를 분리하는 3단계

2.1 클라이언트 관점: deadline 설정이 일관적인가?

가장 흔한 실수는 호출마다 deadline이 제각각이거나, 내부 호출에 deadline이 전파되지 않는 경우입니다.

Java 클라이언트 예시(권장: per-call deadline 명시 + 메타데이터/컨텍스트 전파):

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

public class GrpcClient {
  private final MyServiceGrpc.MyServiceBlockingStub stub;

  public GrpcClient(Channel channel) {
    this.stub = MyServiceGrpc.newBlockingStub(channel);
  }

  public MyResponse call(MyRequest req) {
    // 요청 특성에 맞게 deadline을 계층화(예: read 800ms, write 1500ms)
    return stub
        .withDeadlineAfter(800, TimeUnit.MILLISECONDS)
        .getSomething(req);
  }
}

진단 포인트

  • deadline이 너무 공격적이지 않은가? (p99 처리시간 + 네트워크 여유 + 큐잉 여유)
  • 호출 체인에서 상위 deadline을 하위 호출에 반영하는가?
  • retry를 사용한다면 “deadline 내에서만” 재시도하도록 설계했는가?

2.2 서버 관점: 서버가 실제로 늦은가, 아니면 응답이 못 나가는가?

서버에서 처리 로직이 끝났는데도 클라이언트가 DEADLINE_EXCEEDED를 받는다면, 다음을 의심해야 합니다.

  • 서버가 응답을 flush하기 전에 스트림이 끊김
  • 서버 이벤트 루프/스레드 풀 고갈로 응답 write가 지연
  • 로드밸런서/프록시가 특정 시간에 연결을 끊음(특히 L7)

서버 핸들러에서 deadline 남은 시간을 로그로 남기면 “서버에 도착했을 때 이미 늦었는지”를 빠르게 판단할 수 있습니다.

import io.grpc.*;
import net.devh.boot.grpc.server.service.GrpcService;

@GrpcService
public class MyGrpcService extends MyServiceGrpc.MyServiceImplBase {

  @Override
  public void getSomething(MyRequest request,
                           io.grpc.stub.StreamObserver<MyResponse> responseObserver) {
    Context ctx = Context.current();
    Deadline deadline = ctx.getDeadline();

    long remainingMs = deadline == null ? -1 : deadline.timeRemaining(java.util.concurrent.TimeUnit.MILLISECONDS);

    // remainingMs가 이미 0에 가깝다면: 네트워크/큐/클라이언트 지연을 의심
    System.out.println("remainingMs=" + remainingMs);

    // 비즈니스 로직...
    MyResponse resp = MyResponse.newBuilder().setOk(true).build();
    responseObserver.onNext(resp);
    responseObserver.onCompleted();
  }
}

2.3 네트워크/플랫폼 관점: 쿠버네티스/인그레스/프록시 타임아웃

gRPC는 HTTP/2 기반이라 L7 프록시/인그레스에서 설정이 어긋나면 “서버는 살아있는데 중간에서 끊기는” 형태가 자주 나옵니다.

  • Ingress/ALB/Nginx/Envoy의 idle timeout
  • L7이 gRPC를 제대로 프록시하지 못해 업그레이드/keepalive가 깨짐
  • 커넥션 드랍/재설정으로 재시도 → deadline 소모

EKS에서 타임아웃 계열 증상은 인그레스/타겟 타임아웃과 함께 나타나는 경우가 많습니다. HTTP 502/target timeout을 겪었다면 같은 축에서 gRPC도 영향을 받을 수 있으니 아래 글도 함께 보시면 원인 분리에 도움이 됩니다: EKS ALB Ingress 502 target timeout 원인·해결

3) 가장 흔한 원인 Top 7과 확인 방법

3.1 서버 스레드 풀/이벤트 루프 고갈

grpc-java는 Netty 이벤트 루프, 그리고 애플리케이션 실행(비즈니스 로직)을 위한 executor가 관여합니다. 다음 상황이면 서버가 “처리할 준비”가 안 되어 큐잉이 발생합니다.

  • 블로킹 I/O를 이벤트 루프에서 수행
  • DB 호출이 느린데 동시 요청이 많아 스레드가 잠김
  • CPU throttling으로 스레드가 돌지 못함

확인

  • 서버 p95/p99 latency 급증 + CPU 사용률이 높거나(또는 throttling)
  • 스레드 덤프에서 RUNNABLE/WAITING이 특정 지점(DB, lock)에서 쏠림

3.2 DB 병목(JPA N+1, 락, 느린 쿼리)

gRPC는 빠른 RPC처럼 보이지만, 결국 서버 내부에서 DB를 치면 DB가 병목이 됩니다. 특히 “어떤 요청만” 타임아웃 나는 경우, N+1이나 특정 쿼리 플랜 문제가 많습니다.

  • 특정 엔드포인트만 DEADLINE_EXCEEDED
  • p99가 특정 데이터 조건에서만 튐

JPA 기반이라면 N+1로 인해 호출당 쿼리 수가 폭발하는지 먼저 보세요: Spring Boot 3 JPA N+1 폭발을 끝내는 법

3.3 클라이언트 채널/이름해석/커넥션 재사용 문제

클라이언트에서 매 요청마다 Channel을 생성하면 TLS 핸드셰이크/이름해석/커넥션 설정으로 시간을 낭비합니다.

안티패턴

// 매 요청마다 channel 생성 (비추)
ManagedChannel channel = ManagedChannelBuilder.forAddress(host, port)
    .useTransportSecurity()
    .build();

권장

  • 애플리케이션 수명 동안 Channel을 재사용
  • 커넥션 풀처럼 다루지 말고, gRPC Channel 자체를 long-lived로 유지

3.4 Keepalive/idle timeout 불일치로 중간에서 커넥션이 죽음

로드밸런서/프록시가 idle timeout을 60초로 끊는데, 클라이언트는 5분 동안 아무 keepalive도 안 보내면 다음 요청에서 커넥션이 깨져 재연결/재시도로 deadline을 소모할 수 있습니다.

클라이언트 keepalive 예시

ManagedChannel channel = NettyChannelBuilder.forAddress(host, port)
    .keepAliveTime(30, TimeUnit.SECONDS)
    .keepAliveTimeout(10, TimeUnit.SECONDS)
    .keepAliveWithoutCalls(true)
    .build();

주의: keepalive는 인프라/프록시 정책과 충돌할 수 있으니(과도한 핑) 운영 환경에 맞게 조정해야 합니다.

3.5 리소스 고갈: Pod CPU throttling, 메모리 압박, GC

CPU limit이 낮으면 평균은 버티는데 p99에서 급격히 밀립니다. gRPC는 동시성이 높을수록 tail latency가 민감합니다.

확인

  • kubectl top pod에서 CPU가 limit 근처에 붙어있음
  • container_cpu_cfs_throttled_seconds_total 증가
  • GC 로그에서 stop-the-world가 deadline을 잠식

CrashLoopBackOff처럼 명확한 크래시가 아니어도, 리소스 압박은 타임아웃으로 먼저 나타나곤 합니다. 쿠버네티스 관점의 빠른 진단 루틴은 아래 글의 방식이 그대로 적용됩니다: Kubernetes CrashLoopBackOff 원인별 10분 진단

3.6 관측 부재: “어느 구간이 느린지” 모르는 상태

DEADLINE_EXCEEDED만 보고 서버 timeout을 늘리면, 근본 원인이 숨습니다. 최소한 아래 3가지는 남겨야 합니다.

  • 클라이언트: 호출 시작/종료 시간, deadline 값, 재시도 횟수
  • 서버: 수신 시점의 remaining deadline, 핸들러 처리시간
  • 공통: trace id(분산 추적)

Spring Boot에서는 Micrometer + OpenTelemetry로 gRPC를 계측해 p50/p95/p99와 에러율을 같이 봐야 합니다.

3.7 Deadline 전파 실패(다운스트림은 더 긴 작업을 하는데 상위는 짧음)

A(800ms) → B(800ms) → C(800ms)처럼 동일 deadline을 걸면, B가 C를 호출할 때 이미 시간이 줄어든 상태입니다. B가 무심코 withDeadlineAfter(800ms)를 다시 걸어버리면(리셋) 전체 시스템이 불안정해집니다.

권장 패턴

  • 상위 요청의 deadline을 “남은 시간”으로 하위 호출에 전달
  • 하위 호출에 별도 budget을 두되, 상위 remaining을 넘기지 않기

(구현은 프레임워크/컨텍스트 전파 방식에 따라 다르므로, 핵심은 “리셋 금지” 원칙입니다.)

4) 재현 가능한 진단 루틴(운영에서 바로 쓰는 순서)

4.1 1단계: 클라이언트 로그로 deadline/재시도 확인

  • 실패한 요청의 deadline 값이 얼마였는지
  • 재시도가 있었는지(있다면 몇 번, 어떤 상태코드에서)
  • 이름해석/커넥션 생성이 매번 일어났는지

4.2 2단계: 서버에서 remaining deadline + 처리시간 기록

서버 로그에 아래 두 값이 같이 있어야 합니다.

  • remainingMs at start
  • handlerDurationMs

이 둘을 비교하면 원인이 크게 갈립니다.

  • remainingMs가 충분한데 handlerDuration이 길다 → 서버/DB/락/CPU 병목
  • remainingMs가 거의 0인데 handlerDuration은 짧다 → 네트워크/큐잉/클라이언트 지연

4.3 3단계: 네트워크 계층(인그레스/서비스 메시/LB) 타임아웃 매트릭스 점검

다음 “타임아웃 매트릭스”를 표로 만들어 정렬하면, 불일치가 즉시 보입니다.

  • 클라이언트 deadline
  • 서버 최대 처리 기대시간
  • Ingress/LB idle timeout
  • 프록시 upstream timeout
  • 서버 keepalive 정책

특히 gRPC는 스트리밍/롱커넥션이 섞이면 idle timeout이 더 자주 문제를 일으킵니다.

5) 해결 전략: 단순히 deadline을 늘리기 전에 할 것

5.1 빠른 실패 + 부분 결과/비동기화 고려

deadline을 늘리면 사용자 경험은 잠깐 좋아질 수 있지만, 서버는 더 오래 일하고 더 많이 쌓여서 결국 p99가 무너집니다.

  • 오래 걸리는 작업은 비동기(큐/이벤트)로 분리
  • gRPC는 서버 스트리밍으로 진행률/부분 결과를 주는 패턴도 가능

5.2 서버 병목이면: DB/락/스레드/캐시부터 최적화

  • 느린 쿼리/인덱스/플랜 확인
  • JPA N+1 제거, 배치/페치 조정
  • 락 경합 줄이기(트랜잭션 범위 축소)
  • CPU throttling 완화(리소스 limit 재조정)

5.3 인프라 병목이면: 타임아웃/keepalive 정합성 맞추기

  • LB idle timeout ≥ (최대 예상 호출 시간 + 여유)
  • 클라이언트 keepalive는 LB 정책과 충돌하지 않게
  • gRPC를 지원하는 L7 설정(HTTP/2, backend protocol 등) 재확인

6) 체크리스트(요약)

  • 클라이언트가 per-call deadline을 명시하고, Channel을 재사용하는가?
  • 서버에서 요청 시작 시 remaining deadline을 로깅했는가?
  • handlerDuration vs remainingMs로 “서버 지연/도착 지연”을 구분했는가?
  • DB 병목(N+1/느린 쿼리/락)이 없는가?
  • CPU throttling/GC로 tail latency가 튀지 않는가?
  • Ingress/LB/proxy idle timeout과 gRPC keepalive가 정합적인가?
  • 재시도가 deadline budget을 갉아먹지 않는가?

7) 마무리: DEADLINE_EXCEEDED는 증상이고, 진단은 분해부터

DEADLINE_EXCEEDED는 “서버가 느리다”가 아니라 “deadline 안에 끝나지 못했다”는 결과입니다. 따라서 해결도 “시간 늘리기”가 아니라, (1) 어디서 시간을 쓰는지 분해하고 (2) 그 구간의 병목을 제거하거나 (3) 시스템 설계를 deadline-friendly하게 바꾸는 것이 정석입니다.

운영에서 가장 효과가 큰 한 가지를 꼽자면, 서버에서 remaining deadlinehandler duration을 반드시 남기고, 클라이언트에서 deadline/재시도/채널 재사용을 표준화하는 것입니다. 이 두 가지만 갖춰도 원인 추적 시간이 체감상 절반 이하로 줄어듭니다.