- Published on
Go gRPC DEADLINE_EXCEEDED 원인과 재시도·타임아웃 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 간 통신을 gRPC로 바꾸면 “빠르고 안정적일 것”이라는 기대를 하게 되지만, 운영에서는 DEADLINE_EXCEEDED가 가장 자주(그리고 가장 애매하게) 마주치는 상태 코드 중 하나입니다. 특히 Go 환경에서는 context deadline exceeded 로그가 여기저기 흩어져 찍히며, 원인이 네트워크인지 서버 병목인지, 혹은 단순히 타임아웃 설계가 잘못된 것인지 판단이 어려워집니다.
이 글에서는 Go gRPC에서 DEADLINE_EXCEEDED가 발생하는 메커니즘을 먼저 정리한 뒤, 원인 분류(클라이언트/서버/네트워크/인프라), 관측 포인트, 그리고 재시도·타임아웃(Deadline) 설계 원칙을 실전 코드와 함께 설명합니다.
DEADLINE_EXCEEDED란 무엇인가
gRPC의 Deadline은 클라이언트가 “이 RPC는 언제까지 끝나야 한다”라는 시간 예산(time budget) 을 서버에 전달하는 개념입니다.
- 클라이언트는
context.WithTimeout/WithDeadline로 Deadline을 설정합니다. - 이 Deadline은 메타데이터로 서버에 전달되고, 서버는
ctx.Done()을 통해 취소/만료를 감지할 수 있습니다. - 시간이 초과되면 클라이언트는
codes.DeadlineExceeded로 RPC를 실패 처리합니다.
중요한 점은 Deadline 초과는 ‘서버가 늦었음’을 의미하지 않을 수도 있다는 것입니다.
- 요청이 서버에 도착하지 못했거나(네트워크/커넥션),
- 서버는 처리했지만 응답이 돌아오지 못했거나,
- 클라이언트 측 큐잉/스케줄링/커넥션 획득이 늦었거나,
- 서버는 취소를 감지하지 못하고 계속 일했을 수도 있습니다.
즉 DEADLINE_EXCEEDED는 “클라이언트 관점에서 시간 예산이 소진됨”입니다.
대표 원인 분류: 어디에서 시간이 새는가
1) 클라이언트 측 원인
(1) 타임아웃을 너무 짧게 잡음
가장 흔합니다. 특히 내부 호출인데도 “빠를 것”이라는 가정으로 50~100ms 같은 타임아웃을 박아두면, GC/스케줄링/일시적 네트워크 지연만으로도 쉽게 초과합니다.
(2) 커넥션/서브채널 준비 지연
gRPC는 내부적으로 connection을 만들고, LB 정책에 따라 sub-connection을 준비합니다. 첫 호출이거나, 연결이 끊겼다가 재수립되는 순간이라면 RPC 처리 이전에 이미 시간이 소진될 수 있습니다.
- DNS 조회 지연
- TCP handshake
- TLS handshake
- HTTP/2 설정
(3) 클라이언트 측 큐잉
애플리케이션 레벨에서 goroutine이 과도하게 늘어나거나, CPU가 포화되어 스케줄링이 밀리면 실제 네트워크 요청을 보내기 전에 context deadline이 만료될 수 있습니다.
2) 서버 측 원인
(1) 서버 핸들러/비즈니스 로직 지연
DB 쿼리, 외부 API 호출, 락 경합, 파일 I/O 등으로 처리 시간이 늘어납니다. 특히 DB 락/데드락/장기 트랜잭션은 gRPC 타임아웃과 결합되면 장애가 증폭됩니다. (DB 병목은 별도 진단이 필요합니다.)
(2) 서버가 취소를 무시함
클라이언트 deadline이 만료되어도 서버가 ctx.Done()을 체크하지 않으면, 서버는 계속 작업을 수행합니다. 이 경우:
- 클라이언트는 실패로 간주하고 재시도
- 서버는 이미 작업을 진행 중
- 결과적으로 중복 작업/부하 폭증
(3) 리소스 부족으로 인한 큐잉
서버 인스턴스 CPU/메모리 포화, goroutine 폭증, 스레드풀/커넥션풀 고갈로 요청이 “처리 시작”조차 늦어질 수 있습니다. Kubernetes 환경에서는 readiness가 애매하게 설정되어 트래픽이 과부하 Pod로 계속 들어가기도 합니다.
> 운영에서 “Pod는 Running인데 503/지연이 난다” 류의 문제는 readiness/endpoint 반영 문제와 함께 나타나는 경우가 많습니다. 참고: EKS에서 Pod는 Running인데 503가 뜰 때 점검
3) 네트워크/인프라 원인
(1) LB/Ingress/NAT/보안장비 타임아웃
중간 프록시가 HTTP/2 연결을 끊거나 idle timeout을 짧게 잡는 경우, 클라이언트는 재연결 비용을 치르며 deadline을 소진합니다.
(2) 패킷 드랍/재전송
일시적인 네트워크 품질 저하로 RTT가 튀면, 짧은 deadline에서 바로 DEADLINE_EXCEEDED로 이어집니다.
(3) Kubernetes 오토스케일/리소스 부족
트래픽이 늘었는데 HPA가 제대로 확장되지 않아 서버가 포화되면 지연이 증가합니다. 메트릭 수집 문제로 HPA가 안 늘어나는 케이스도 흔합니다. 참고: Kubernetes HPA가 안 늘 때 metrics-server 0값 해결
관측(Observability): DEADLINE_EXCEEDED를 ‘원인’으로 바꾸기
DEADLINE_EXCEEDED를 줄이려면 “어느 구간에서 시간이 소진됐는지”를 쪼개야 합니다.
1) 클라이언트 지표
- RPC latency histogram (p50/p95/p99)
- error code별 카운트 (
DeadlineExceeded,Unavailable등) - attempt 수(재시도 횟수)
- name resolver / LB 상태 변화 로그
2) 서버 지표
- handler latency
- in-flight requests
- queue length(있다면)
- DB/외부 의존성 latency
ctx.Err()발생 빈도(취소/만료)
3) 분산 트레이싱
OpenTelemetry로 다음을 분리해서 보세요.
- 클라이언트 span: name resolution, connect, TLS handshake(가능하면)
- 서버 span: interceptor에서 시작 시각 기록
- downstream span: DB/외부 API
타임아웃(Deadline) 설계 원칙
원칙 1) “타임아웃 = SLO 예산”으로 취급
타임아웃을 감으로 정하지 말고, 최소한 다음 기준으로 잡습니다.
- p99 처리시간 + 네트워크 변동폭 + 재시도 여유
- 사용자 요청의 전체 예산(예: 1초)에서 각 hop에 예산을 배분
예:
- 전체 API SLO: 800ms
- 내부 gRPC 2-hop 호출
- A→B: 250ms
- B→C: 250ms
- 나머지(직렬화/큐잉/여유): 300ms
원칙 2) hop마다 deadline을 “줄여서” 전달
상위 요청의 context를 그대로 하위 호출에 전달하면, 하위 호출이 상위 예산을 과도하게 소비할 수 있습니다. 하위 호출에는 별도 예산을 잘라서 주는 것이 안전합니다.
// 상위 HTTP 요청 ctx에서 내부 호출 예산을 잘라 쓰는 예시
func (s *Server) Handle(ctx context.Context, req *Request) (*Response, error) {
// 상위 요청이 800ms라면 내부 호출은 250ms만 사용
childCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
defer cancel()
out, err := s.grpcClient.DoSomething(childCtx, &pb.DoReq{Id: req.Id})
if err != nil {
return nil, err
}
return &Response{Value: out.Value}, nil
}
원칙 3) 서버는 취소를 “빨리” 존중
서버는 ctx.Done()을 주기적으로 확인하고, DB/외부 호출에도 context를 넘겨야 합니다.
func (s *Svc) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 예: DB 쿼리에 ctx를 전달
user, err := s.repo.FindUser(ctx, req.Id)
if err != nil {
// ctx 만료인지 구분
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, status.Error(codes.DeadlineExceeded, "deadline exceeded")
}
return nil, status.Error(codes.Internal, err.Error())
}
select {
case <-ctx.Done():
// 클라이언트가 이미 취소했다면 즉시 종료
return nil, status.Error(codes.Canceled, "request canceled")
default:
}
return &pb.GetUserResponse{Id: user.ID, Name: user.Name}, nil
}
원칙 4) 재시도는 “항상” 위험하다: 멱등성부터 확인
재시도는 지연/장애 상황에서 트래픽을 증폭시키는 양날의 검입니다.
- 안전한 재시도 대상: 멱등(idempotent) 읽기, 조건부 쓰기, 토큰 기반 중복 방지
- 위험한 재시도 대상: 결제/주문 생성 같은 비멱등 쓰기
가능하면 쓰기 요청에는 다음 중 하나를 도입하세요.
- Idempotency-Key(요청 고유 키) 저장
- 서버 측 dedup 테이블
- 조건부 업데이트(compare-and-swap)
원칙 5) 재시도는 “총 예산” 안에서만
각 attempt에 동일한 타임아웃을 주면 총 시간이 폭발합니다. 상위 deadline을 기준으로 남은 시간에서 attempt별 예산을 계산해야 합니다.
func withAttemptTimeout(ctx context.Context, perAttempt time.Duration) (context.Context, context.CancelFunc, error) {
deadline, ok := ctx.Deadline()
if !ok {
// 상위 deadline이 없다면 perAttempt를 그대로 사용
return context.WithTimeout(ctx, perAttempt)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, nil, context.DeadlineExceeded
}
if remaining < perAttempt {
perAttempt = remaining
}
return context.WithTimeout(ctx, perAttempt)
}
Go gRPC 재시도 구현: client interceptor로 제어하기
Go gRPC는 언어/버전/설정에 따라 “서비스 config 기반 자동 재시도”가 제한적이거나 운영에서 통제하기 어려운 경우가 많습니다. 실무에서는 클라이언트 unary interceptor로 재시도를 명시적으로 구현하고, 대상 메서드/코드만 제한하는 패턴이 많이 쓰입니다.
아래 예시는 다음 정책을 구현합니다.
- 최대 3회 시도
Unavailable,DeadlineExceeded에만 재시도(상황에 따라ResourceExhausted는 제외 권장)- 지수 백오프 + 지터
- 상위 deadline을 초과하지 않도록 attempt별 timeout 제한
package retry
import (
"context"
"math/rand"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type Policy struct {
MaxAttempts int
PerAttemptTO time.Duration
BaseBackoff time.Duration
MaxBackoff time.Duration
}
func UnaryClientInterceptor(p Policy) grpc.UnaryClientInterceptor {
if p.MaxAttempts <= 0 {
p.MaxAttempts = 1
}
if p.BaseBackoff <= 0 {
p.BaseBackoff = 20 * time.Millisecond
}
if p.MaxBackoff <= 0 {
p.MaxBackoff = 200 * time.Millisecond
}
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn,
invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
var lastErr error
for attempt := 1; attempt <= p.MaxAttempts; attempt++ {
attemptCtx, cancel, err := func() (context.Context, context.CancelFunc, error) {
if p.PerAttemptTO <= 0 {
// per-attempt timeout을 강제하지 않음
return ctx, func() {}, nil
}
c, cancel := context.WithTimeout(ctx, p.PerAttemptTO)
// 상위 ctx가 이미 만료되면 여기서도 빠르게 실패
if err := ctx.Err(); err != nil {
cancel()
return nil, nil, err
}
return c, cancel, nil
}()
if err != nil {
return err
}
lastErr = invoker(attemptCtx, method, req, reply, cc, opts...)
cancel()
if lastErr == nil {
return nil
}
st, ok := status.FromError(lastErr)
if !ok {
// gRPC status가 아니라면 재시도하지 않음
return lastErr
}
if !isRetryable(st.Code()) {
return lastErr
}
// 마지막 attempt면 종료
if attempt == p.MaxAttempts {
break
}
// 상위 deadline이 남아있는지 확인
if err := ctx.Err(); err != nil {
return err
}
// backoff with jitter
backoff := expBackoff(p.BaseBackoff, p.MaxBackoff, attempt)
jitter := time.Duration(rand.Int63n(int64(backoff / 5))) // 0~20%
sleep := backoff + jitter
t := time.NewTimer(sleep)
select {
case <-ctx.Done():
t.Stop()
return ctx.Err()
case <-t.C:
}
}
return lastErr
}
}
func isRetryable(c codes.Code) bool {
switch c {
case codes.Unavailable, codes.DeadlineExceeded:
return true
default:
return false
}
}
func expBackoff(base, max time.Duration, attempt int) time.Duration {
// attempt=1이면 1배, 2면 2배, 3이면 4배...
b := base << (attempt - 1)
if b > max {
return max
}
return b
}
적용 예:
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(creds),
grpc.WithChainUnaryInterceptor(
retry.UnaryClientInterceptor(retry.Policy{
MaxAttempts: 3,
PerAttemptTO: 150 * time.Millisecond,
BaseBackoff: 30 * time.Millisecond,
MaxBackoff: 200 * time.Millisecond,
}),
),
)
if err != nil {
panic(err)
}
재시도 설계 체크리스트
- 재시도 대상 메서드 제한(전체 RPC에 일괄 적용 금지)
- 멱등성 보장(특히 쓰기)
- 백오프/지터 적용(동시 재시도 폭주 방지)
DeadlineExceeded를 무조건 재시도하지 말 것- 이미 “시간이 부족”하다는 신호일 수 있음
- 서버가 과부하(
ResourceExhausted)일 때는 재시도가 더 악화시킬 수 있음
타임아웃과 재시도의 흔한 안티패턴
1) per-attempt timeout을 상위 timeout보다 크게 설정
상위 요청이 300ms인데 attempt마다 500ms면 의미가 없습니다. “총 예산” 기준으로 설계하세요.
2) 서버에서 긴 작업을 하면서 취소를 무시
클라이언트는 timeout으로 끊고 재시도하는데 서버는 계속 일하면, 장애 시점에 서버 CPU가 터집니다. 긴 루프/배치/스트리밍 처리에서는 반드시 ctx.Done()을 확인하세요.
3) 재시도 + 짧은 deadline의 조합
짧은 deadline(예: 100ms)에서 재시도 3회는 대부분 실패하며, 오히려 부하만 늘립니다. 이 경우는 재시도보다 타임아웃 재설계가 우선입니다.
4) 로드밸런서/Ingress 503과 DEADLINE_EXCEEDED를 혼동
클라이언트는 DeadlineExceeded로 보지만, 실제로는 Ingress/LB에서 503/연결 종료가 발생했을 수 있습니다. EKS 환경에서 Ingress 503 트러블슈팅이 필요할 때는 다음 글도 함께 보면 원인 분리가 빨라집니다: EKS Ingress 503인데 Pod 정상일 때 점검 가이드
운영에서의 권장 접근 순서
- 타임아웃 값이 합리적인지: p95/p99 기준으로 재산정
- 서버가 취소를 존중하는지: ctx 전파/DB ctx 적용/루프 중단
- 재시도 정책이 안전한지: 멱등성/백오프/대상 제한
- 인프라 병목인지: CPU/메모리/큐잉/HPA/Ingress 설정
- 트레이싱으로 구간 분해: connect vs handler vs downstream
마무리
DEADLINE_EXCEEDED는 단순히 “서버가 느리다”가 아니라, 클라이언트의 시간 예산이 어디선가 소진되었다는 신호입니다. 따라서 해결도 “타임아웃 늘리기” 한 줄로 끝나지 않고, (1) 예산 기반 deadline 설계, (2) 서버의 취소 존중, (3) 멱등성 기반 재시도, (4) 인프라/큐잉/오토스케일 관측을 함께 맞물려야 합니다.
운영 중이라면 우선 재시도를 무작정 늘리기보다, 현재의 p99 지연과 실패 코드를 분해해 “재시도로 회복 가능한 실패”인지부터 분류해보는 것을 권합니다.