Published on

Go gRPC 데드라인 초과 원인 7가지와 해결

Authors

gRPC를 운영에 올리면 가장 흔하게 마주치는 에러 중 하나가 DeadlineExceeded입니다. 겉으로는 “시간이 초과됐다” 한 줄이지만, 실제 원인은 네트워크부터 서버 리소스, HTTP/2 흐름 제어, 커넥션 재사용, 로드밸런서 설정, 데이터베이스 지연까지 다양합니다.

이 글은 Go 기반 gRPC 클라이언트·서버에서 데드라인 초과가 발생하는 대표 원인 7가지를 재현 포인트, 확인 방법, 해결책으로 나눠 정리합니다. 특히 “가끔만 터지는” 타임아웃을 줄이려면 관측(메트릭·로그·트레이싱)과 데드라인 전파가 핵심입니다.

먼저 확인: 데드라인 초과의 정확한 의미

DeadlineExceeded는 보통 아래 중 하나입니다.

  • 클라이언트 컨텍스트의 데드라인이 먼저 만료됨
  • 서버가 응답을 만들었지만 네트워크/전송 계층에서 완료되기 전에 데드라인 만료
  • 서버가 데드라인을 인지하지 못하고 계속 처리하다가 클라이언트가 취소함

즉 “서버가 느리다”만이 아니라, 클라이언트 설정/네트워크/프록시/HTTP/2 상태까지 포함한 종합 증상입니다.

아래 코드는 클라이언트 데드라인을 명시하고, 에러를 상태 코드로 분해하는 기본 패턴입니다.

ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

resp, err := c.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
if err != nil {
  st, ok := status.FromError(err)
  if ok {
    // st.Code() == codes.DeadlineExceeded 인지 확인
    log.Printf("grpc error code=%s msg=%s", st.Code(), st.Message())
  }
  return
}
_ = resp

원인 1) 데드라인 전파 누락 또는 계층별 타임아웃 불일치

증상

  • 외부 API 호출은 300ms 제한인데, 내부 gRPC는 무제한 혹은 5초로 설정
  • 서버는 계속 일을 하다가 클라이언트가 먼저 끊음
  • 로그에는 서버 처리 시간이 길게 남고, 클라이언트는 DeadlineExceeded

확인 방법

  • 서버 핸들러에서 ctx.Deadline()를 로깅
  • downstream 호출(다른 gRPC/DB/HTTP)에 같은 ctx를 전달했는지 점검

해결

  • “최상위 요청”에서 받은 컨텍스트를 끝까지 전달
  • 계층별 타임아웃을 정렬: 예) 전체 800ms, 내부 RPC 600ms, DB 450ms
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
  // downstream도 동일 ctx 사용
  user, err := s.repo.LoadUser(ctx, req.Id)
  if err != nil {
    return nil, status.Error(codes.Internal, err.Error())
  }
  return &pb.GetUserResponse{User: user}, nil
}

func (r *Repo) LoadUser(ctx context.Context, id string) (*pb.User, error) {
  // DB도 ctx 사용 (예: database/sql)
  row := r.db.QueryRowContext(ctx, "select ... where id=$1", id)
  // ...
  return &pb.User{Id: id}, nil
}

원인 2) 커넥션 재사용 실패로 매 요청마다 핸드셰이크/리졸브 비용 발생

증상

  • 트래픽이 늘면 갑자기 타임아웃 증가
  • p95/p99가 튀고, 특히 첫 요청이 느림
  • 클라이언트가 grpc.Dial을 요청마다 수행

확인 방법

  • 코드에서 grpc.Dial이 핫패스에 있는지 검색
  • 커넥션 수가 비정상적으로 많아지는지(클라이언트/서버 양쪽) 확인

해결

  • 프로세스 생명주기 동안 ClientConn을 재사용
  • 리졸버/밸런서 설정을 명확히 하고, 불필요한 재연결을 줄임
// 앱 시작 시 1회 생성
conn, err := grpc.NewClient(
  "dns:///user-svc.default.svc.cluster.local:50051",
  grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil { panic(err) }

defer conn.Close()
client := pb.NewUserServiceClient(conn)

// 요청마다 conn을 새로 만들지 말 것

원인 3) 서버 처리 지연: DB 락, 느린 쿼리, 외부 API 지연

증상

  • 서버 애플리케이션 로그에서 특정 핸들러가 오래 걸림
  • 특정 엔드포인트/특정 테넌트에서만 타임아웃

확인 방법

  • 서버에 핸들러 레벨 latency 히스토그램 추가
  • DB slow query 로그, 락 대기, 커넥션 풀 고갈 여부 확인

해결

  • 쿼리/인덱스 최적화, 락 범위 축소
  • 풀 사이즈 및 타임아웃 조정
  • 캐시 도입 또는 읽기 전용 리플리카 분리

DB가 원인인 경우, VACUUM/블로트로 인한 지연도 흔합니다. PostgreSQL을 쓴다면 아래 글도 함께 보면 진단 속도가 빨라집니다.

원인 4) 리소스 병목: CPU 스로틀링, GC 압박, 메모리 부족, OOM 전조

증상

  • 노드/파드 CPU가 꽉 차거나 스로틀링 발생
  • GC 시간이 길어지고 stop-the-world가 늘어 p99가 튐
  • 컨테이너 메모리 압박으로 응답 지연 후 타임아웃

확인 방법

  • container_cpu_cfs_throttled_seconds_total, go_gc_duration_seconds 같은 메트릭
  • 파드 이벤트에 OOMKilled/재시작 징후

해결

  • CPU/메모리 requests·limits 재조정, 오토스케일 설정
  • 큰 객체 할당 줄이기, 스트리밍 처리로 메모리 피크 낮추기
  • 고비용 작업을 비동기로 분리

Kubernetes 환경에서 메모리로 인한 지연과 OOM은 타임아웃의 “전조 증상”으로 자주 나타납니다.

원인 5) HTTP/2 레벨 문제: 흐름 제어, 스트림 에러, 프록시 호환성

증상

  • 서버는 빨리 처리했는데 클라이언트는 타임아웃
  • 특정 환경(특정 LB/Ingress)에서만 재현
  • 간헐적으로 RST_STREAM류 에러가 섞여 나옴

확인 방법

  • gRPC debug 로그(필요 시)로 transport 레벨 이벤트 확인
  • Ingress/LB가 HTTP/2를 제대로 프록시하는지, idle timeout이 짧지 않은지 점검

해결

  • 중간 프록시가 gRPC를 “HTTP/2 end-to-end”로 지원하는지 확인
  • keepalive, max connection age, idle timeout을 인프라와 맞추기
  • HTTP/2 스트림 에러 원인을 별도로 제거

HTTP/2 스트림 계층 이슈는 DeadlineExceeded와 함께 나타나는 경우가 많습니다. 아래 글의 체크리스트가 도움이 됩니다.

원인 6) 메시지 크기/직렬화 비용: 큰 payload, 압축, 역직렬화 과부하

증상

  • 특정 요청(대용량 응답/파일 메타데이터 등)에서만 타임아웃
  • CPU는 낮지 않은데 네트워크 전송이 길어짐
  • protobuf marshal/unmarshal 시간이 눈에 띄게 큼

확인 방법

  • 요청/응답 바이트 크기 로그(샘플링)
  • pprof로 proto.Marshal, proto.Unmarshal 비중 확인

해결

  • 응답을 쪼개서 paging 또는 server streaming으로 전환
  • 불필요한 필드 제거, 반복 필드 크기 제한
  • 압축은 만능이 아님: CPU가 병목이면 오히려 느려질 수 있음

예: 대용량 리스트를 unary로 한 번에 보내지 말고 스트리밍으로 전환합니다.

// proto: rpc ListUsers(ListUsersRequest) returns (stream User);
func (s *Server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
  ctx := stream.Context()

  it := s.repo.IterUsers(ctx, req.PageSize)
  for it.Next() {
    if err := stream.Send(it.User()); err != nil {
      return err
    }
  }
  return it.Err()
}

원인 7) 재시도/헤지 요청의 역효과: 타임아웃을 더 악화시키는 폭주

증상

  • 타임아웃이 늘자 재시도를 넣었더니 더 심해짐
  • 서버 QPS가 급증하고 tail latency가 폭발
  • 일부 요청은 성공하지만 전체적으로 불안정

확인 방법

  • 클라이언트 재시도 정책 여부 확인(라이브러리/서비스 메쉬 포함)
  • 실패 시도 횟수와 서버 부하의 상관관계 확인

해결

  • 무조건 재시도 금지: DeadlineExceeded는 이미 “시간이 없다”는 신호일 수 있음
  • 재시도는 짧은 지터 백오프, 상한, idempotent 요청에만 제한
  • 서버가 과부하일 때는 빠르게 실패하도록 codes.ResourceExhausted 등으로 보호

간단한 클라이언트 측 보호 패턴은 “남은 데드라인이 충분할 때만 재시도”입니다.

func shouldRetry(ctx context.Context, err error) bool {
  st, ok := status.FromError(err)
  if !ok { return false }
  if st.Code() != codes.Unavailable { // 보수적으로 제한
    return false
  }
  dl, ok := ctx.Deadline()
  if !ok { return false }
  return time.Until(dl) > 200*time.Millisecond
}

운영에서 바로 쓰는 진단 체크리스트

1) 클라이언트

  • WithTimeout 값이 현실적인지(네트워크+서버 처리+여유)
  • ClientConn 재사용하는지
  • 재시도 정책이 폭주를 만들지

2) 서버

  • 핸들러가 ctx 취소를 존중하는지
  • DB/외부 API 호출에 같은 ctx를 전달하는지
  • pprof와 메트릭으로 CPU/GC/락/풀 고갈을 확인했는지

3) 인프라

  • LB/Ingress의 HTTP/2 및 gRPC 지원이 확실한지
  • idle timeout, keepalive 정책이 서로 충돌하지 않는지

Go에서 관측 가능하게 만들기: 인터셉터로 데드라인과 지연 로그

문제가 “간헐적”일수록, 재현보다 관측이 빠릅니다. unary 서버 인터셉터로 처리 시간과 데드라인 정보를 남기면 원인 분리가 쉬워집니다.

func LoggingUnaryServerInterceptor() grpc.UnaryServerInterceptor {
  return func(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
  ) (any, error) {
    start := time.Now()

    var deadlineStr string
    if dl, ok := ctx.Deadline(); ok {
      deadlineStr = time.Until(dl).String()
    } else {
      deadlineStr = "none"
    }

    resp, err := handler(ctx, req)
    dur := time.Since(start)

    st, _ := status.FromError(err)
    log.Printf("method=%s dur=%s deadline_in=%s code=%s", info.FullMethod, dur, deadlineStr, st.Code())

    return resp, err
  }
}

이 로그만으로도 아래를 빠르게 구분할 수 있습니다.

  • deadline_in이 원래부터 너무 짧았는지
  • 서버 처리 시간 dur이 데드라인을 초과했는지
  • 특정 메서드에만 문제가 집중되는지

마무리: DeadlineExceeded는 “시간”이 아니라 “설계” 문제일 때가 많다

Go gRPC에서 데드라인 초과를 줄이려면 단순히 타임아웃을 늘리는 것보다,

  • 데드라인 전파와 계층별 타임아웃 정렬
  • 커넥션 재사용과 재시도 정책 절제
  • 서버 리소스/DB 병목 제거
  • HTTP/2 및 프록시 설정 정합성 확보

이 네 가지를 우선순위로 잡는 편이 재발 방지에 훨씬 효과적입니다.

운영 중 DeadlineExceeded와 함께 HTTP/2 스트림 계층 에러가 섞여 보인다면, 원인을 같은 선상에서 보지 말고 전송 계층 체크리스트도 병행하세요.