Published on

Go gRPC context deadline exceeded 원인 7가지

Authors

서버와 클라이언트 모두 정상처럼 보이는데 gRPC 호출이 context deadline exceeded로 끊기면, 문제는 한 지점이 아니라 여러 계층에 걸쳐 있을 가능성이 큽니다. 특히 Go gRPC는 context.Context 기반으로 타임아웃과 취소가 전파되기 때문에, 애플리케이션 로직 지연뿐 아니라 DNS, LB, HTTP2, 프록시, 커넥션 재사용 방식까지 모두 원인이 될 수 있습니다.

이 글은 Go gRPC에서 context deadline exceeded가 발생하는 대표 원인 7가지를, 증상 패턴과 함께 재현 및 해결 방법 중심으로 정리합니다.

또한 네트워크 계층 이슈가 의심된다면 쿠버네티스 환경에서의 HTTPS 계열 장애 점검도 함께 참고하면 도움이 됩니다. 예를 들어 EKS Pod DNS는 되는데 HTTPS만 실패할 때 점검 같은 체크리스트는 gRPC도 결국 HTTP2 위에서 동작한다는 점에서 유사한 단서를 제공합니다.

빠른 진단 기준: 이 에러는 어디서 나왔나

context deadline exceeded는 크게 두 경우로 나뉩니다.

  • 클라이언트가 설정한 context.WithTimeout 또는 grpc.WithTimeout 계열 제한에 걸림
  • gRPC 내부에서 dial, name resolution, connection handshake, stream 생성 같은 단계가 컨텍스트 데드라인에 의해 중단됨

따라서 가장 먼저 해야 할 일은 다음을 로그로 남기는 것입니다.

  • 호출 시작 시각, 종료 시각, 경과 시간
  • RPC 메서드명, 대상 주소
  • 클라이언트 데드라인 값
  • gRPC 상태 코드 및 에러 문자열

아래는 클라이언트 측에서 최소한으로 남겨야 할 정보 예시입니다.

package main

import (
  "context"
  "log"
  "time"

  "google.golang.org/grpc"
  "google.golang.org/grpc/status"
)

func call(conn *grpc.ClientConn) {
  ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  defer cancel()

  start := time.Now()

  // 예시: pb.NewYourServiceClient(conn).YourRPC(ctx, req)
  err := doRPC(ctx)

  dur := time.Since(start)
  if err != nil {
    st, _ := status.FromError(err)
    deadline, _ := ctx.Deadline()
    log.Printf("rpc failed code=%v msg=%q dur=%v deadline=%v err=%v", st.Code(), st.Message(), dur, deadline, err)
    return
  }
  log.Printf("rpc ok dur=%v", dur)
}

func doRPC(ctx context.Context) error {
  // 실제 RPC 호출이 들어갈 자리
  <-ctx.Done()
  return ctx.Err()
}

이제부터는 원인별로 어떤 신호가 있는지, 어떻게 재현하고 고치는지 살펴보겠습니다.

원인 1: 클라이언트 타임아웃이 현실보다 너무 짧다

가장 흔하지만, 가장 자주 놓치는 원인입니다. 특히 다음 상황에서 잘 터집니다.

  • cold start 직후 첫 호출
  • TLS 핸드셰이크가 추가되는 환경
  • 리졸버가 DNS를 새로 조회하는 시점
  • 서버가 워밍업이나 캐시 미스로 느린 첫 쿼리를 수행

해결 포인트

  • 타임아웃을 “평균”이 아니라 “p99” 기준으로 잡기
  • 첫 호출만 별도 워밍업을 두거나, 초기 타임아웃을 더 길게
  • 서버 처리 시간과 네트워크 시간을 분리해 측정

아래는 “초기 1회는 길게, 이후는 짧게” 같은 전략 예시입니다.

func timeoutForAttempt(attempt int) time.Duration {
  if attempt == 0 {
    return 5 * time.Second
  }
  return 2 * time.Second
}

func callWithRetry(conn *grpc.ClientConn) error {
  for i := 0; i < 3; i++ {
    ctx, cancel := context.WithTimeout(context.Background(), timeoutForAttempt(i))
    err := doRealRPC(ctx, conn)
    cancel()
    if err == nil {
      return nil
    }
    // deadline 계열이면 다음 시도에 힌트를 주되, 무한 재시도는 금지
  }
  return context.DeadlineExceeded
}

func doRealRPC(ctx context.Context, conn *grpc.ClientConn) error {
  // pb.NewClient(conn).Method(ctx, req)
  return nil
}

원인 2: 서버 핸들러가 컨텍스트 취소를 무시하고 오래 걸린다

Go gRPC 서버 핸들러는 ctx를 받습니다. 그런데 내부에서 DB 쿼리, 외부 HTTP 호출, 메시지 큐 대기 등을 하면서 ctx.Done()을 체크하지 않거나, 하위 호출에 ctx를 전달하지 않으면 다음이 발생합니다.

  • 클라이언트는 타임아웃으로 끊김
  • 서버는 계속 일을 수행하며 리소스를 소모
  • 부하가 누적되면서 다음 요청들도 더 느려짐

해결 포인트

  • DB, HTTP, 캐시 호출에 반드시 ctx 전달
  • 긴 루프나 스트리밍 처리에서는 주기적으로 ctx.Err() 확인
  • 서버 측에도 적절한 타임아웃을 두고, 초과 시 빠르게 실패
func (s *Server) GetUser(ctx context.Context, req *GetUserRequest) (*GetUserResponse, error) {
  // 나쁜 예: context.Background 사용
  // row := s.db.QueryRowContext(context.Background(), "SELECT ...")

  // 좋은 예: ctx를 전달
  row := s.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", req.Id)

  var id int64
  var name string
  if err := row.Scan(&id, &name); err != nil {
    return nil, err
  }

  select {
  case <-ctx.Done():
    return nil, ctx.Err()
  default:
  }

  return &GetUserResponse{Id: id, Name: name}, nil
}

원인 3: DNS 또는 서비스 디스커버리 지연으로 dial 단계에서 타임아웃

context deadline exceeded가 RPC 호출 자체가 아니라 dial 과정에서 발생하는 경우가 있습니다.

  • 쿠버네티스 CoreDNS 지연, NXDOMAIN 재시도
  • 노드의 /etc/resolv.conf 설정 문제
  • 서비스 디스커버리 시스템 장애

증상은 보통 “특정 시간대에만”, “특정 노드에서만” 발생합니다.

해결 포인트

  • 주소가 DNS 이름이면, DNS 응답 시간과 실패율을 측정
  • 클라이언트에서 커넥션을 매번 새로 만들지 말고 재사용
  • 필요 시 grpc.DialContext에 충분한 dial 타임아웃을 분리해서 부여
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
// dial은 별도 컨텍스트로 길게, RPC는 더 짧게 같은 전략도 가능

defer cancel()

conn, err := grpc.DialContext(
  ctx,
  "dns:///your-svc.default.svc.cluster.local:50051",
  grpc.WithInsecure(),
  grpc.WithBlock(),
)
if err != nil {
  // 여기서 deadline이면 DNS 또는 connect 단계 가능성이 큼
  return err
}
defer conn.Close()

쿠버네티스에서 네트워크 계층이 의심되면, DNS는 되는데 실제 연결이 실패하는 케이스도 많습니다. gRPC는 HTTP2 기반이라 MTU, SNAT, NACL 같은 이슈에 민감할 수 있으니 EKS Pod DNS는 되는데 HTTPS만 실패할 때 점검도 함께 확인해보는 편이 좋습니다.

원인 4: LB, Ingress, 프록시의 idle timeout 또는 HTTP2 설정 불일치

gRPC는 기본적으로 HTTP2 장기 커넥션을 재사용합니다. 그런데 중간 장비가 다음을 수행하면 문제가 됩니다.

  • 일정 시간 동안 트래픽이 없으면 커넥션을 조용히 끊음
  • HTTP2를 다운그레이드하거나, 특정 헤더를 제거
  • 최대 스트림 수 제한으로 새 스트림 생성이 지연

이때 클라이언트는 기존 커넥션을 재사용하려다가, 실제로는 끊긴 소켓 위에서 요청을 보내며 대기하다 데드라인에 걸릴 수 있습니다.

해결 포인트

  • LB idle timeout을 늘리거나, gRPC keepalive를 설정
  • 프록시가 gRPC를 완전히 지원하는지 확인
  • 서버와 클라이언트의 keepalive 정책을 함께 조정

Go gRPC keepalive 설정 예시입니다.

import (
  "time"
  "google.golang.org/grpc"
  "google.golang.org/grpc/keepalive"
)

conn, err := grpc.Dial(
  target,
  grpc.WithInsecure(),
  grpc.WithKeepaliveParams(keepalive.ClientParameters{
    Time:                30 * time.Second,
    Timeout:             10 * time.Second,
    PermitWithoutStream: true,
  }),
)

서버도 정책을 맞춰야 합니다.

import (
  "google.golang.org/grpc"
  "google.golang.org/grpc/keepalive"
  "time"
)

grpcServer := grpc.NewServer(
  grpc.KeepaliveParams(keepalive.ServerParameters{
    Time:    60 * time.Second,
    Timeout: 20 * time.Second,
  }),
  grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
    MinTime:             15 * time.Second,
    PermitWithoutStream: true,
  }),
)

원인 5: 커넥션 생성 남발로 인한 포트 고갈 또는 커넥션 큐 지연

요청마다 grpc.DialClose를 반복하면, 다음 문제가 누적됩니다.

  • TCP/TLS 핸드셰이크 비용 증가
  • ephemeral port 고갈 및 TIME_WAIT 누적
  • 서버 accept 큐가 밀리면서 연결이 늦어짐

결과적으로 dial 또는 첫 RPC에서 context deadline exceeded가 증가합니다.

해결 포인트

  • 프로세스 단위로 ClientConn을 재사용
  • 커넥션 풀링이 필요하면 “대상별로 몇 개만” 유지
  • 애플리케이션 종료 시점에만 Close
type Clients struct {
  conn *grpc.ClientConn
  // svc pb.YourServiceClient
}

func NewClients(target string) (*Clients, error) {
  conn, err := grpc.Dial(target, grpc.WithInsecure())
  if err != nil {
    return nil, err
  }
  return &Clients{conn: conn}, nil
}

func (c *Clients) Close() error { return c.conn.Close() }

원인 6: 서버 리소스 병목으로 처리 지연이 타임아웃을 초과

서버가 느려지는 원인은 다양하지만, gRPC에서는 특히 다음이 자주 문제를 만듭니다.

  • goroutine 폭증, GC 압박
  • DB 커넥션 풀 고갈
  • 동기 락 경합
  • 외부 API 호출이 느려져 tail latency 증가

이 경우 클라이언트는 DeadlineExceeded를 보지만, 근본 원인은 서버의 처리 시간이 타임아웃을 넘어선 것입니다.

해결 포인트

  • 서버에 인터셉터로 요청 처리 시간을 기록하고 p95, p99를 관측
  • DB 풀, 워커 풀 크기, 큐 길이를 메트릭화
  • 느린 의존성 호출에 타임아웃과 회로차단기 적용

서버 unary interceptor로 지연을 로깅하는 예시입니다.

import (
  "context"
  "log"
  "time"

  "google.golang.org/grpc"
  "google.golang.org/grpc/status"
)

func timingUnaryInterceptor(
  ctx context.Context,
  req any,
  info *grpc.UnaryServerInfo,
  handler grpc.UnaryHandler,
) (any, error) {
  start := time.Now()
  resp, err := handler(ctx, req)
  dur := time.Since(start)

  st, _ := status.FromError(err)
  log.Printf("grpc method=%s dur=%v code=%v err=%v", info.FullMethod, dur, st.Code(), err)
  return resp, err
}

grpcServer := grpc.NewServer(grpc.UnaryInterceptor(timingUnaryInterceptor))

서버 병목이 DB에서 발생하는 경우가 많기 때문에, ORM이나 쿼리 접근 패턴을 점검하는 습관이 중요합니다. 다른 스택이긴 하지만 N+1 문제를 어떻게 관측하고 줄이는지에 대한 사고방식은 공통점이 많습니다. 필요하면 Spring Boot 3에서 JPA N+1 잡는 fetch join·EntityGraph도 참고할 만합니다.

원인 7: 스트리밍 RPC에서 메시지 흐름 제어 또는 소비 지연

스트리밍에서는 다음 형태로 데드라인이 나타납니다.

  • 클라이언트가 Recv를 늦게 호출해 서버 send가 막힘
  • 서버가 Send를 늦게 호출하거나, 한 메시지에 너무 큰 페이로드
  • 메시지 압축, 역직렬화 비용이 커서 처리 시간이 늘어남

특히 “서버는 보냈는데 클라이언트가 못 받는” 형태는 디버깅이 까다롭습니다.

해결 포인트

  • 스트리밍은 송신과 수신을 별도 goroutine으로 분리
  • 백프레셔를 고려해 버퍼링과 처리량을 조절
  • 큰 메시지는 청크로 나누거나, 서버 측 페이지네이션으로 전환

클라이언트에서 수신을 별도 goroutine으로 처리하는 패턴 예시입니다.

stream, err := client.Subscribe(ctx, req)
if err != nil {
  return err
}

errCh := make(chan error, 1)

go func() {
  for {
    msg, recvErr := stream.Recv()
    if recvErr != nil {
      errCh <- recvErr
      return
    }
    // 수신 즉시 처리하지 말고 작업 큐로 넘기는 방식도 고려
    _ = msg
  }
}()

select {
case <-ctx.Done():
  return ctx.Err()
case err := <-errCh:
  return err
}

체크리스트: 재현 가능한 형태로 원인을 좁히는 순서

아래 순서로 보면 “감”이 아니라 “증거”로 원인을 좁히기 쉽습니다.

  1. 에러가 dial 단계인지, RPC 처리 단계인지 분리해서 로그로 확인
  2. 클라이언트 타임아웃 값을 p99 기준으로 재설정하고, 첫 호출만 별도 관측
  3. 서버 핸들러가 하위 호출에 ctx를 전달하는지 점검
  4. LB, Ingress, 프록시의 idle timeout과 HTTP2 지원 상태 확인
  5. ClientConn 재사용 여부 점검, 요청당 dial 금지
  6. 서버 지연 메트릭을 인터셉터로 수집하고 병목 지점을 수치로 확인
  7. 스트리밍이라면 send, recv 소비 속도와 메시지 크기부터 점검

마무리

context deadline exceeded는 “타임아웃이 났다”라는 결과일 뿐, 원인은 네트워크부터 서버 내부 병목까지 다양합니다. 중요한 것은 에러가 발생한 단계와 지연이 누적되는 지점을 구분해 관측하는 것입니다.

위 7가지 원인을 기준으로 dial, name resolution, 프록시, 커넥션 재사용, 서버 처리, 스트리밍 소비 지연을 차례대로 배제해 나가면, 대부분의 케이스는 재현 가능한 형태로 좁혀지고 해결책도 명확해집니다.