- Published on
gRPC 데드라인 전파 실패, 원인과 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 gRPC를 쓰면 deadline(또는 timeout)은 단순한 타임아웃 옵션이 아니라, 요청의 예산(time budget) 을 하위 호출로 전달해 연쇄 지연을 막는 핵심 메커니즘입니다. 그런데 운영에서 자주 겪는 문제가 있습니다.
- A 서비스는
deadline=200ms로 호출했다고 믿는데 - B, C 서비스는 그 사실을 모르고 2초, 5초씩 처리하다가
- 결국 A에서만
DEADLINE_EXCEEDED가 터지고, 하위 서비스는 계속 일을 하거나 DB 락을 잡고 있는 상황
이 글은 이런 데드라인 전파 실패를 “어디서 끊겼는지” 빠르게 찾는 체크리스트와, 코드/설정 레벨에서의 재현 및 해결 패턴을 정리합니다.
관련해서 분산 트랜잭션에서 타임아웃이 꼬이면 보상 트랜잭션 중복이나 순서 꼬임으로 이어질 수 있으니, SAGA 디버깅 관점은 아래 글도 함께 참고하면 도움이 됩니다.
데드라인 전파란 무엇인가
gRPC에서 클라이언트는 호출 시 데드라인을 설정할 수 있고, 이 값은 메타데이터 형태로 서버로 전달됩니다. 서버는 이를 기반으로 다음을 할 수 있습니다.
- 남은 시간(
time remaining)을 계산해 하위 호출의 데드라인을 더 짧게 설정 - 남은 시간이 부족하면 빠르게 실패(fail-fast)
- 서버 내부 작업(쿼리, 외부 API 호출)에 취소 신호를 전달
중요한 점은 데드라인이 자동으로 모든 곳에 “마법처럼” 전파되지 않는다는 것입니다. 프레임워크/언어/스레딩 모델/프록시 설정에 따라 쉽게 끊깁니다.
전파 실패의 대표 증상
운영에서 다음 패턴이면 데드라인 전파 실패를 강하게 의심할 수 있습니다.
- 상위 서비스만
DEADLINE_EXCEEDED, 하위 서비스는 정상 처리 로그가 남음 - 상위 요청이 타임아웃으로 끝났는데도 하위 서비스 CPU/DB 부하가 계속 유지
- 트레이싱에서 상위 span은 짧게 끝났는데 하위 span이 더 길게 이어짐
- 동일 요청에 대해 재시도(retry)가 겹치며 중복 처리 발생(특히 쓰기 요청)
원인 분류: “데드라인이 끊기는 지점” 6가지
1) 하위 호출에서 데드라인을 아예 설정하지 않음
가장 흔한 실수는 “클라이언트에서만 데드라인을 걸고, 서버에서 다른 서비스를 호출할 때는 기본 설정으로 호출”하는 것입니다.
- A
client는200ms - 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이거나 남은 시간이 이상하게 나오면 “컨텍스트 경계 문제”입니다.
해결은 프레임워크별로 다르지만, 공통 원칙은 현재 컨텍스트를 캡처해서 실행 구간에 바인딩하는 것입니다.
프록시/게이트웨이 구간 체크리스트
데드라인 전파 실패는 코드보다 인프라에서 더 빨리 찾는 경우도 많습니다. 다음을 순서대로 확인하세요.
- 외부 클라이언트가
grpc-timeout을 보내는지 패킷/로그로 확인 - 게이트웨이가 해당 헤더를 보존하는지 확인
- Envoy 사용 시 route timeout, idle timeout, max stream duration 설정 확인
- Ingress가 HTTP
2를 제대로 패스스루하는지 확인(중간에 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가지를 동시에 하는 것입니다.
- 서버 인터셉터로 “들어온 데드라인”을 강제 관측
- 하위 호출은 “현재 컨텍스트 남은 시간” 기반으로 데드라인 재설정
- DB/HTTP 등 내부 작업에 타임아웃과 취소 반영을 끝까지 연결
이 세 가지를 적용하면, 데드라인 전파 실패는 대부분 “어느 홉에서 끊겼는지”가 바로 드러나고, 재현 가능한 형태로 안정적으로 해결할 수 있습니다.