- Published on
Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 간 통신이 gRPC로 바뀌면 타임아웃은 더 이상 “느리다”의 문제가 아니라 SLO/에러율을 직접 올리는 장애 신호가 됩니다. 특히 Go에서 rpc error: code = DeadlineExceeded desc = context deadline exceeded는 흔하지만, 원인은 네트워크부터 애플리케이션 락, 커넥션 풀, 프록시 타임아웃까지 넓게 퍼져 있어 재현이 어렵습니다.
이 글은 Go gRPC에서 DEADLINE_EXCEEDED를 만드는 9가지 원인을 “어디서 시간을 잃는가” 기준으로 분류하고, 각 원인별로 **관측 포인트(로그/메트릭/트레이싱)**와 **바로 적용 가능한 처방(코드/설정)**을 제시합니다.
> 함께 읽기: 쿠버네티스 환경에서 웹훅/컨트롤러 타임아웃은 gRPC 타임아웃과 같은 패턴으로 나타납니다. EKS AWS Load Balancer Controller 500 Webhook 타임아웃 해결
DEADLINE_EXCEEDED를 제대로 해석하기
gRPC의 DEADLINE_EXCEEDED는 크게 두 갈래입니다.
- 클라이언트 관점: 내가 준 deadline 안에 응답을 못 받았다(네트워크/서버/중간 프록시/내 코드 모두 가능).
- 서버 관점: 서버 핸들러가
ctx.Done()을 받았고 작업을 중단하거나, 중단하지 못한 채 응답을 못 내보냈다.
따라서 “타임아웃을 늘리자”는 처방은 가장 마지막입니다. 먼저 어디서 시간이 소비되는지를 분해해야 합니다.
최소 진단 체크리스트(5분)
- 클라이언트 deadline 값은 얼마인가? (per-RPC? interceptor?)
- 서버 핸들러에서
ctx를 하위 호출(DB/HTTP/gRPC)에 전파하는가? - keepalive/idle timeout, LB timeout(Envoy/ALB/NLB)과 충돌하는가?
- 서버/클라이언트의 연결 수, 큐잉, 고루틴/FD 한계는?
- 트레이스에서 “DNS/커넥션/핸들러/다운스트림” 중 어디가 긴가?
1) 클라이언트 deadline이 너무 짧거나 일관되지 않음
가장 흔한 원인입니다. 특히 다음 패턴에서 자주 터집니다.
- 배치/리포트/대용량 응답인데 UI 요청과 같은 deadline을 사용
- interceptor에서 기본 deadline을 강제하지만 일부 호출에서 override 실패
- retry를 쓰는데 전체 예산(time budget) 없이 per-try deadline만 짧게 둠
처방
- “요청 유형별”로 deadline을 분리하고, 전체 예산을 기준으로 retry를 설계합니다.
// 요청 유형별로 명시적 deadline 부여
func callUserService(ctx context.Context, c pb.UserServiceClient, req *pb.GetUserReq) (*pb.GetUserResp, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
return c.GetUser(ctx, req)
}
// 배치성 호출은 더 큰 예산
func callReportService(ctx context.Context, c pb.ReportServiceClient, req *pb.ReportReq) (*pb.ReportResp, error) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
return c.Generate(ctx, req)
}
- 가능하면 서버 처리 시간 p95/p99를 기준으로 deadline을 산정하고, “증상”이 아니라 “SLO”에 맞춰 관리합니다.
2) 서버 핸들러가 ctx 취소를 무시(또는 전파하지 않음)
서버에서 deadline이 초과되면 ctx.Done()이 닫힙니다. 하지만 다음과 같은 코드가 있으면 타임아웃이 난 뒤에도 서버는 계속 일을 하며, 결국 큐잉/리소스 고갈로 연쇄 타임아웃이 발생합니다.
- DB 쿼리에
context.Background()사용 - 외부 HTTP 호출에 context 미전파
- 긴 루프/스트리밍 처리에서
ctx.Err()체크 누락
처방
- 모든 하위 호출에
ctx를 전달하고, 긴 작업은 주기적으로 취소를 체크합니다.
func (s *Server) Get(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
// BAD: ctx를 버림
// row := s.db.QueryRowContext(context.Background(), "SELECT ...")
// GOOD: ctx 전파
row := s.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id=?", req.Id)
var name string
if err := row.Scan(&name); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
// 긴 루프면 취소 체크
select {
case <-ctx.Done():
return nil, status.Error(codes.DeadlineExceeded, ctx.Err().Error())
default:
}
return &pb.GetResp{Name: name}, nil
}
3) 서버 측 큐잉(스레드/고루틴/워커풀/세마포어)로 대기 시간이 증가
서버가 CPU/IO를 못 따라가면 실제 처리 시간보다 **대기 시간(Queueing)**이 커져 deadline을 초과합니다. 흔한 형태:
- 글로벌 mutex 경합
- 제한된 워커풀(채널)에서 대기
- DB 커넥션 풀 부족으로 대기
관측 포인트
- pprof에서 goroutine blocking / mutex profile
- DB 풀 메트릭(대기 시간, in-use)
- gRPC server handler latency가 아니라 “request started → handler entered” 지연이 있는지
처방(예: 세마포어 대기에도 ctx 적용)
var sem = make(chan struct{}, 100) // 동시 처리 제한
func acquire(ctx context.Context) error {
select {
case sem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func release() { <-sem }
func (s *Server) Heavy(ctx context.Context, req *pb.HeavyReq) (*pb.HeavyResp, error) {
if err := acquire(ctx); err != nil {
return nil, status.Error(codes.DeadlineExceeded, "queue wait exceeded")
}
defer release()
// ... heavy work
return &pb.HeavyResp{}, nil
}
4) DNS/서비스 디스커버리 지연 또는 실패로 연결이 늦게 잡힘
특히 쿠버네티스에서 CoreDNS 부하/네트워크 이슈가 있으면, gRPC 호출이 “서버가 느린 것처럼” 보이지만 실제로는 이름 해석 단계에서 시간을 씁니다.
관측 포인트
- 클라이언트 측 트레이스에서
DNS lookup구간 - CoreDNS latency/error 메트릭
- 동일 노드/동일 파드에서만 재현되는지
처방
- 서비스 이름 해석이 잦다면 커넥션 재사용(아래 5번)과 함께, 디스커버리 계층의 병목을 먼저 제거합니다.
- 장애 시나리오가 “콜드스타트+DNS+스케일아웃”로 엮이면 503/타임아웃이 같이 튀는 패턴이 많습니다. GCP Cloud Run 503·콜드스타트 폭증 해결 가이드
5) 커넥션 재사용 실패(매 요청 Dial, 잦은 커넥션 생성)
gRPC는 HTTP/2 기반이라 연결 하나로 다중 스트림을 처리하는 것이 장점인데, 실수로 매 요청마다 grpc.Dial을 하면 다음 비용이 매번 발생합니다.
- TCP handshake
- TLS handshake
- HTTP/2 settings 교환
이 비용이 deadline을 갉아먹고, 피크에선 포트/FD 고갈까지 이어집니다.
처방
grpc.ClientConn은 프로세스 생명주기 동안 재사용합니다.
// 앱 시작 시 1회 생성
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(creds),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
}),
)
if err != nil { log.Fatal(err) }
client := pb.NewUserServiceClient(conn)
// 핸들러에서는 conn을 재사용
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel()
_, _ = client.GetUser(ctx, &pb.GetUserReq{Id: "1"})
}
- 연결 폭증이 FD 고갈로 이어지면 타임아웃이 연쇄적으로 발생합니다. 리눅스 FD 한계 진단은 Linux EMFILE(Too many open files) 원인과 해결도 함께 확인하세요.
6) keepalive/idle timeout/LB timeout 불일치로 중간에서 연결이 끊김
DEADLINE_EXCEEDED는 “서버가 늦다”가 아니라 중간에서 패킷이 드랍/연결이 유휴로 정리되어 응답이 안 오는 경우에도 발생합니다.
대표적인 불일치:
- L4/L7 LB의 idle timeout(예: 60s) < gRPC 장기 스트림 유지 시간
- 프록시(Envoy/Nginx)에서
grpc_read_timeout/stream_idle_timeout이 짧음 - NAT 게이트웨이/방화벽이 유휴 연결을 정리
처방
- 클라이언트 keepalive ping을 적절히 설정하고, LB/프록시 idle timeout과 정합성을 맞춥니다.
- 서버도 필요 시
keepalive.EnforcementPolicy와ServerParameters를 조정합니다.
s := grpc.NewServer(
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 10 * time.Second,
PermitWithoutStream: true,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 30 * time.Second,
Timeout: 5 * time.Second,
}),
)
7) 다운스트림(DB/외부 API/다른 gRPC) 지연이 상위 deadline을 소진
상위 서비스는 빠른데, 내부에서 호출하는 DB나 외부 API가 느려지면 결국 DEADLINE_EXCEEDED가 됩니다. 이때 흔한 실수는:
- 상위 deadline 500ms인데, 내부에서 DB 400ms + 외부 API 400ms를 순차 호출
- 각 하위 호출에 별도 timeout을 안 걸어 “끝까지 기다림”
처방: 하위 호출에 “부분 예산” 배분
func budget(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
if deadline, ok := ctx.Deadline(); ok {
remain := time.Until(deadline)
if remain < d {
d = remain
}
}
return context.WithTimeout(ctx, d)
}
func (s *Server) Aggregate(ctx context.Context, req *pb.AggReq) (*pb.AggResp, error) {
dbCtx, cancelDB := budget(ctx, 200*time.Millisecond)
defer cancelDB()
user, err := s.repo.GetUser(dbCtx, req.Id)
if err != nil { return nil, status.Error(codes.DeadlineExceeded, err.Error()) }
apiCtx, cancelAPI := budget(ctx, 250*time.Millisecond)
defer cancelAPI()
score, err := s.scorer.Fetch(apiCtx, user)
if err != nil { return nil, status.Error(codes.DeadlineExceeded, err.Error()) }
return &pb.AggResp{Score: score}, nil
}
8) 대용량 메시지/압축/직렬화 비용으로 시간 초과
Go gRPC에서 큰 payload는 다음 비용을 유발합니다.
- protobuf marshal/unmarshal CPU
- 압축(gzip 등) CPU
- HTTP/2 flow control로 인한 전송 지연
특히 “응답이 커졌는데 deadline은 그대로”인 상황에서 p99가 급격히 상승합니다.
관측 포인트
- payload size 분포(요청/응답 바이트)
- CPU 사용량(특히 user time)
- handler 내부는 빠른데 wire time이 긴지(클라이언트/서버 양쪽 트레이스)
처방
- 페이지네이션/필드 마스킹/서버 스트리밍으로 전환
- 정말 필요할 때만 압축 사용(또는 더 빠른 알고리즘)
MaxSendMsgSize/MaxRecvMsgSize는 “해결”이 아니라 “가드레일”로 설정
9) 클라이언트 측 리트라이/백오프가 deadline을 잠식(또는 폭주)
리트라이는 타임아웃을 “완화”하기도 하지만, 설계가 나쁘면 오히려 deadline 초과를 늘립니다.
- per-try timeout이 짧아 계속 실패 → backoff로 시간만 소비
- 동시 리트라이로 서버 부하 증가 → 큐잉 증가 → 더 많은 DEADLINE_EXCEEDED
- idempotent가 아닌 요청을 무분별하게 재시도
처방
- 전체 예산을 기준으로 “시도 횟수/백오프/최대 대기”를 제한
- 서버가 과부하일 때는 빠르게 실패(fail-fast)하거나, 클라이언트에서 rate limit 적용
// 간단한 예: 전체 예산 내에서만 재시도
func retryWithin(ctx context.Context, attempts int, perTry time.Duration, fn func(context.Context) error) error {
var last error
for i := 0; i < attempts; i++ {
tryCtx, cancel := context.WithTimeout(ctx, perTry)
err := fn(tryCtx)
cancel()
if err == nil {
return nil
}
last = err
if ctx.Err() != nil {
return ctx.Err()
}
time.Sleep(time.Duration(i+1) * 30 * time.Millisecond) // bounded backoff
}
return last
}
실전 트러블슈팅 순서(추천)
- 클라이언트 deadline 확인: 실제 값, 적용 위치(interceptor), 요청 유형별 분리 여부
- 트레이싱으로 구간 분해: DNS/Connect/Write/Server handler/Downstream
- 서버 큐잉/락/풀: DB 풀 대기, mutex 경합, 워커풀 대기
- 연결 재사용/keepalive/LB timeout 정합성
- payload/직렬화/압축
- retry 정책: 예산 기반, 폭주 방지
마무리
Go gRPC의 DEADLINE_EXCEEDED는 단순히 “타임아웃이 짧다”가 아니라, 큐잉·연결·중간 인프라·다운스트림·코드의 ctx 전파 중 한 곳에서 시간이 새고 있다는 신호입니다. 위 9가지 원인을 순서대로 배제하면, 대개 “늘리면 된다”가 아니라 “줄여야 할 지연이 어디인지”가 선명하게 보입니다.
운영 환경에서 특히 많이 놓치는 두 가지는 (1) grpc.ClientConn 재사용 실패로 인한 연결 비용, (2) LB/프록시 idle timeout과 keepalive 불일치입니다. 이 둘만 잡아도 p99 타임아웃이 눈에 띄게 줄어드는 경우가 많습니다.