- Published on
Go gRPC DEADLINE_EXCEEDED 원인별 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
프로덕션에서 gRPC 에러 중 가장 골치 아픈 축이 DEADLINE_EXCEEDED 입니다. 표면적으로는 “시간 초과”지만, 실제로는 클라이언트 타임아웃 설정, 서버 처리 지연, 로드밸런서/프록시의 유휴 타임아웃, 커넥션 재사용 실패, 리소스 고갈로 인한 큐잉, 스트리밍 백프레셔 등 원인이 겹쳐서 나타납니다.
이 글은 Go 기반 gRPC에서 DEADLINE_EXCEEDED 를 원인별로 분해하고, 각 케이스에서 무엇을 먼저 확인해야 하는지, 그리고 어떤 설정/코드로 해결하는지 7가지로 정리합니다.
0) 먼저 확인할 것: “누가” 데드라인을 걸었나
DEADLINE_EXCEEDED 는 대개 클라이언트가 걸어둔 데드라인이 만료되어 발생합니다. 그런데 실제 체감은 “서버가 응답을 안 한다”로 보이기 때문에, 원인 추적이 꼬입니다.
아래를 먼저 로그에 남기면 진단 속도가 빨라집니다.
- 클라이언트: RPC 시작 시각, 데드라인 값, 재시도 여부
- 서버: 요청 도착 시각, 핸들러 처리 시간, DB/외부 호출 시간
- 네트워크 계층: LB/Ingress 타임아웃, keepalive, 커넥션 재사용 여부
Go에서 데드라인 로깅 예시
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if dl, ok := ctx.Deadline(); ok {
log.Printf("rpc deadline=%s (in %s)", dl.Format(time.RFC3339Nano), time.Until(dl))
}
resp, err := client.Foo(ctx, &pb.FooRequest{Id: "123"})
_ = resp
if err != nil {
s, _ := status.FromError(err)
log.Printf("grpc code=%s msg=%q", s.Code(), s.Message())
}
이제부터는 원인별 해결로 들어갑니다.
1) 원인: 클라이언트 데드라인이 “너무 짧다” 또는 경로별로 불일치
가장 흔합니다. 특히 다음 상황에서 자주 터집니다.
- 모바일/웹 게이트웨이에서 이미 짧은 타임아웃을 걸고 있고, 내부 gRPC도 동일하게 짧게 설정
- 특정 API만 외부 호출이나 DB 쿼리가 길어지는데, 공통 인터셉터에서 일괄
1s같은 값 적용 - 배치/리포트성 요청인데도 인터랙티브 API와 동일한 데드라인
해결
- RPC 메서드 성격별로 데드라인을 분리
- 서버 처리 시간의
p95,p99를 기준으로 데드라인 산정 - “서버 내부에서 또 다른 gRPC 호출”이 있다면, 상위 데드라인에서 하위 호출에 쓸 수 있는 예산을 계산
메서드별 타임아웃 테이블 적용 예시
var timeoutByMethod = map[string]time.Duration{
"/svc.UserService/GetUser": 800 * time.Millisecond,
"/svc.ReportService/Export": 15 * time.Second,
}
func withMethodTimeoutUnary() grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string,
req, reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
d, ok := timeoutByMethod[method]
if !ok {
d = 2 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
return invoker(ctx, method, req, reply, cc, opts...)
}
}
2) 원인: 서버 핸들러가 느리다 (DB 쿼리, 락, 외부 API)
서버가 실제로 느린 케이스입니다. gRPC 자체 문제가 아니라, 핸들러 내부의 병목이 원인입니다.
대표 패턴
- DB 인덱스 미비로 특정 조건에서 풀스캔
- 트랜잭션 락 대기
- 외부 HTTP API 지연 또는 간헐 장애
- 캐시 미스 폭증
해결
- 서버에서 핸들러 구간별 타이밍을 구조화 로그로 남기기
- DB 쿼리 플랜/인덱스 점검, 커넥션 풀 고갈 여부 확인
- 외부 호출에는 별도의 타임아웃과 서킷 브레이커/재시도 정책 적용
서버 핸들러 타이밍 측정 예시
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
start := time.Now()
defer func() {
log.Printf("GetUser total=%s", time.Since(start))
}()
t1 := time.Now()
u, err := s.repo.FindUser(ctx, req.Id)
log.Printf("FindUser=%s", time.Since(t1))
if err != nil {
return nil, status.Error(codes.Internal, "db error")
}
return &pb.GetUserResponse{User: u}, nil
}
DB/풀 이슈가 의심되면 애플리케이션 레벨에서는 503 이나 큐잉으로 드러나기도 합니다. 풀 고갈 패턴 자체는 언어를 가리지 않으니, 원인 파악 관점에서는 다음 글도 참고할 만합니다.
3) 원인: LB/Ingress 유휴 타임아웃 또는 HTTP2 설정 문제
gRPC는 HTTP/2 기반이라, 중간에 ALB/NLB/Envoy/Ingress 같은 계층이 있으면 타임아웃과 keepalive 정책의 영향을 강하게 받습니다.
자주 발생하는 현상
- 요청이 “가끔”만
DEADLINE_EXCEEDED로 실패한다 - 첫 요청은 빠른데, 일정 시간 지나고 나서의 요청이 느리거나 실패한다
- 서버 로그에 요청이 아예 도착하지 않거나, 도착이 늦게 찍힌다
해결
- LB/Ingress의 idle timeout을 서비스 특성에 맞게 조정
- 프록시가 HTTP/2를 제대로 유지하는지 확인
- 클라이언트 keepalive를 서버/LB 정책에 맞게 설정
EKS 환경이라면 네트워크 경로 문제는 종종 “타임아웃”으로만 보입니다. 네트워크 계층 문제를 더 넓게 점검하려면 아래 글도 도움이 됩니다.
4) 원인: keepalive 미설정으로 인한 반쯤 죽은 커넥션(half-open) 재사용
NAT, 방화벽, LB가 유휴 커넥션을 조용히 끊어버리면 클라이언트는 끊긴 줄 모르고 기존 커넥션을 재사용하려고 하다가 지연 후 타임아웃이 납니다.
해결
- 클라이언트 keepalive ping을 적절히 설정
- 서버도 keepalive 정책을 명시하고, 너무 공격적인 ping은 차단
Go gRPC keepalive 설정 예시
import "google.golang.org/grpc/keepalive"
ka := keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: true,
}
conn, err := grpc.Dial(
target,
grpc.WithTransportCredentials(creds),
grpc.WithKeepaliveParams(ka),
)
if err != nil {
return err
}
_ = conn
주의할 점은 keepalive는 만능이 아니라는 것입니다. LB idle timeout이 60초인데 keepalive를 5분으로 두면 효과가 없습니다. 반대로 너무 잦은 ping은 인프라 정책에 의해 차단될 수 있습니다.
5) 원인: 서버 리소스 고갈로 큐가 쌓인다 (goroutine 폭증, CPU throttling)
서버가 바쁘면 요청은 도착했지만 처리 시작이 늦어지고, 그 사이 클라이언트 데드라인이 만료됩니다.
대표 시나리오
- 서버 동시성 제한이 없어서 goroutine이 폭증하고 스케줄링 지연
- K8s에서 CPU limit로 throttling 발생
- GC 압력이 커져 stop-the-world 시간이 늘어남
- 다운스트림(DB/외부 API) 병목으로 상위 서비스 요청이 적체
해결
- 서버에 동시성 제한(세마포어) 또는 워커 풀 적용
- K8s 리소스 request/limit 재조정, HPA 기준 점검
- pprof로 CPU, goroutine, block 프로파일 확인
간단한 동시성 제한 예시
type limiter struct {
sem chan struct{}
}
func newLimiter(n int) *limiter {
return &limiter{sem: make(chan struct{}, n)}
}
func (l *limiter) Acquire(ctx context.Context) error {
select {
case l.sem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (l *limiter) Release() { <-l.sem }
이런 리소스 문제는 결국 Pod 재시작, CrashLoopBackOff 로 이어지기도 합니다. 장애 시나리오를 넓게 보는 체크리스트로는 다음 글이 유용합니다.
6) 원인: 재시도 정책이 잘못되어 “데드라인 예산”을 소모한다
gRPC 호출에 재시도를 붙이면 성공률은 좋아질 수 있지만, 잘못 붙이면 총 소요 시간이 늘어 데드라인을 초과합니다.
특히 다음이 위험합니다.
- 데드라인은
2s인데, 재시도를 3회 하고 각 시도마다 백오프를 넣음 - 서버가 이미 과부하인데 클라이언트가 재시도로 더 때림
- 멱등하지 않은 요청에 재시도를 걸어 부작용 발생
해결
- 재시도는 “총 데드라인 예산” 안에서만 수행
DEADLINE_EXCEEDED에 대한 재시도는 보수적으로(대개는 하지 않거나 1회 이하)- 재시도는 멱등 요청에만 적용
데드라인 예산 기반 1회 재시도 예시
func callWithOneRetry(ctx context.Context, fn func(context.Context) error) error {
dl, ok := ctx.Deadline()
if !ok {
return fn(ctx)
}
// 남은 시간이 너무 적으면 재시도하지 않음
if time.Until(dl) < 300*time.Millisecond {
return fn(ctx)
}
err := fn(ctx)
if err == nil {
return nil
}
s, _ := status.FromError(err)
if s.Code() != codes.Unavailable {
return err
}
// 짧은 백오프 후 1회만
t := time.NewTimer(80 * time.Millisecond)
defer t.Stop()
select {
case <-t.C:
return fn(ctx)
case <-ctx.Done():
return ctx.Err()
}
}
재시도/백오프 설계는 gRPC뿐 아니라 전체 API 호출에서 공통 주제입니다. 백오프와 큐잉 관점은 아래 글이 참고가 됩니다.
7) 원인: 스트리밍에서 수신/송신이 막혀 데드라인이 초과된다
서버 스트리밍, 클라이언트 스트리밍, 바이디렉셔널 스트리밍에서는 “처리는 끝났는데 전송이 안 끝나는” 형태로 타임아웃이 나기도 합니다.
대표 패턴
- 수신 측이
Recv를 늦게 호출하거나, 애플리케이션 처리 속도가 느려 TCP 윈도우가 줄어듦 - 송신 측이 큰 메시지를 계속 보내며 backpressure가 걸림
- 스트림을 닫지 않아 리소스가 누수되고 나중에 전체 지연으로 이어짐
해결
- 스트림 루프에서
ctx.Done()을 항상 감시 - 메시지 크기/빈도 제한, 배치 전송
- 스트림 종료 시그널을 명확히 하고
CloseSend또는SendAndClose를 확실히 호출
스트리밍 루프에서 컨텍스트 취소 처리 예시
for {
select {
case <-ctx.Done():
return status.Error(codes.DeadlineExceeded, "stream deadline")
default:
}
msg, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
// 처리 후 응답
if err := stream.Send(&pb.EventAck{Id: msg.Id}); err != nil {
return err
}
}
return nil
실전 점검 체크리스트(요약)
현장에서 DEADLINE_EXCEEDED 를 만나면 아래 순서로 보면 빠릅니다.
- 클라이언트 데드라인 값과 메서드별 기대 처리 시간
p95/p99비교 - 서버에서 요청이 도착했는지(로그/메트릭) 확인
- 서버 핸들러 내부 구간별 시간 측정(DB, 외부 호출)
- LB/Ingress idle timeout 및 HTTP/2 유지 정책 확인
- keepalive로 half-open 커넥션 방지
- 서버 리소스(큐잉, CPU throttling, goroutine) 점검
- 재시도/백오프가 데드라인 예산을 초과하지 않는지 검증
마무리
DEADLINE_EXCEEDED 는 단순히 타임아웃 값을 늘린다고 해결되는 문제가 아닙니다. “어디에서 시간이 새고 있는지”를 계층별로 분해해야 합니다. 특히 gRPC는 커넥션 재사용과 HTTP/2 특성 때문에, 애플리케이션 코드가 멀쩡해 보여도 LB/keepalive/리소스 큐잉에서 타임아웃이 터질 수 있습니다.
위 7가지를 기준으로 원인을 분류하고, 로그와 메트릭을 같은 축으로 맞춰보면 재현이 어려운 간헐 타임아웃도 훨씬 빨리 잡을 수 있습니다.