- Published on
Spring Boot gRPC DEADLINE_EXCEEDED 타임아웃 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡해 보이는데 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는 아래 중 하나로 발생합니다.
- 서버가 실제로 늦게 처리함(애플리케이션 병목)
- 서버는 빨리 처리했지만 응답이 네트워크/큐/로드밸런서에서 지연됨
- 클라이언트 측에서 연결/이름해석/리트라이/큐잉으로 시간을 소모함
- 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 starthandlerDurationMs
이 둘을 비교하면 원인이 크게 갈립니다.
- 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 deadline과 handler duration을 반드시 남기고, 클라이언트에서 deadline/재시도/채널 재사용을 표준화하는 것입니다. 이 두 가지만 갖춰도 원인 추적 시간이 체감상 절반 이하로 줄어듭니다.