Published on

Go gRPC context deadline exceeded 원인 9가지

Authors

서버와 클라이언트가 모두 Go인 gRPC 환경에서 context deadline exceeded는 흔하지만, 원인을 단순히 “타임아웃 늘리면 된다”로 끝내면 장애가 반복됩니다. 이 에러는 클라이언트 컨텍스트의 데드라인이 만료되었음을 의미하지만, 실제 원인은 애플리케이션 코드부터 네트워크, 로드밸런서, 인프라까지 폭이 넓습니다.

이 글에서는 실무에서 자주 맞닥뜨리는 원인 9가지를 증상 패턴, 확인 방법, 해결책 중심으로 정리합니다. (중간중간 코드 예제 포함)


먼저: context deadline exceeded가 의미하는 것

gRPC 호출은 보통 다음과 같이 컨텍스트에 타임아웃을 걸고 실행합니다.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
if err != nil {
    // status.Code(err) == codes.DeadlineExceeded 인지 확인
    log.Printf("rpc error: %v", err)
}

여기서 context deadline exceeded클라이언트가 기다릴 수 있는 시간이 끝났다는 뜻입니다. 즉,

  • 서버가 실제로 늦었을 수도 있고
  • 서버는 응답했지만 네트워크/프록시에서 막혔을 수도 있고
  • 서버가 응답을 만들었어도 클라이언트가 이미 취소했을 수도 있습니다

그래서 원인 분석은 “서버가 느린가?”만 보면 놓치는 게 많습니다.

에러 코드를 반드시 확인하기

문자열 비교 대신 gRPC status code로 분기하세요.

st, ok := status.FromError(err)
if ok {
    switch st.Code() {
    case codes.DeadlineExceeded:
        // 타임아웃
    case codes.Unavailable:
        // 연결/라우팅 문제 가능성이 큼
    default:
    }
}

원인 1) 클라이언트 타임아웃이 너무 짧거나 잘못 전파됨

전형적인 패턴

  • 로컬에서는 잘 되는데 운영에서만 간헐적으로 발생
  • 특정 API만 유독 DeadlineExceeded
  • 호출 체인이 길어질수록 발생률 증가

체크 포인트

  • 상위 요청(예: HTTP 요청)의 컨텍스트를 그대로 gRPC에 전달하는 경우, 상위 컨텍스트 데드라인이 이미 촉박할 수 있습니다.
  • context.WithTimeout을 중첩으로 걸면서 실제 남은 시간이 매우 짧아지는 경우도 많습니다.

해결

  • “요청 전체 예산(time budget)”을 정하고, 하위 호출별로 슬라이스 하세요.
// 상위 ctx의 deadline을 존중하되, 최소 예산을 확보
func withBudget(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
    if deadline, ok := ctx.Deadline(); ok {
        remain := time.Until(deadline)
        if remain < d {
            // 이미 예산이 부족하면 그대로 사용하거나, 빠른 실패를 선택
            return context.WithTimeout(ctx, remain)
        }
    }
    return context.WithTimeout(ctx, d)
}

원인 2) 서버 핸들러가 실제로 느림 (DB, 외부 API, 락 경합)

전형적인 패턴

  • 서버 로그에 핸들러 시작은 찍히는데 응답 로그가 없음
  • 특정 쿼리/특정 테넌트에서만 지연
  • CPU는 낮은데 latency만 튀는 경우(대개 DB 락/IO)

확인 방법

  • 서버에 인터셉터로 처리 시간을 로깅
  • DB 쿼리 타임/락 대기 시간 측정
func unaryTimingInterceptor(logger *log.Logger) grpc.UnaryServerInterceptor {
    return func(
        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)
        logger.Printf("method=%s dur=%s err=%v", info.FullMethod, dur, err)
        return resp, err
    }
}

해결

  • DB 쿼리 최적화, 인덱스, 타임아웃, 커넥션 풀 점검
  • 외부 API 호출은 재시도/서킷브레이커/타임아웃 분리

외부 API 재시도 설계는 중복 부작용을 막는 게 핵심인데, 이 주제는 아래 글의 “Idempotency” 관점이 그대로 적용됩니다.


원인 3) gRPC 커넥션/리졸버/로드밸런싱 문제로 연결이 늦게 잡힘

전형적인 패턴

  • 첫 호출만 느리고 이후는 빠름
  • 배포 직후, 스케일아웃 직후 timeout 급증
  • codes.Unavailable과 섞여 나타남

체크 포인트

  • DNS 갱신 지연, 엔드포인트 변경, 서비스 디스커버리 문제
  • 커넥션이 준비되기 전에 RPC를 날림

해결

  • 클라이언트 생성 시 DialContext에 충분한 타임아웃
  • 준비 상태 확인(health check) 또는 warm-up
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

conn, err := grpc.DialContext(
    ctx,
    target,
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithBlock(),
)
if err != nil {
    return nil, err
}

grpc.WithBlock()은 연결이 준비될 때까지 대기하므로, 애플리케이션 시작 단계에서 실패를 빨리 드러내는 데 유용합니다.


원인 4) 서버 측 동시성 한계: 워커/고루틴/스레드풀/세마포어 병목

전형적인 패턴

  • QPS가 오르면 갑자기 timeout이 폭증
  • CPU가 100%가 아니어도 발생(락/큐 대기)
  • 서버는 살아 있는데 응답이 밀림

확인 방법

  • 서버에 in-flight 요청 수, 큐 대기 시간, 고루틴 수, GC 시간 등 지표 추가
  • pprof로 goroutine dump 확인

해결

  • 동시성 제한을 두었다면(세마포어), 제한값과 요청 비용을 재조정
  • 비싼 작업은 비동기화하거나 캐시
var sem = make(chan struct{}, 100) // 동시 처리 100 제한

func limit(next grpc.UnaryHandler) grpc.UnaryHandler {
    return func(ctx context.Context, req any) (any, error) {
        select {
        case sem <- struct{}{}:
            defer func() { <-sem }()
            return next(ctx, req)
        case <-ctx.Done():
            return nil, status.Error(codes.DeadlineExceeded, "queue timeout")
        }
    }
}

여기서 queue timeout이 많이 보이면, 실제 문제는 “서버가 느림”이 아니라 “서버가 바빠서 대기열에서 죽음”입니다.


원인 5) 메시지 크기/압축/직렬화 비용으로 인한 지연

전형적인 패턴

  • 큰 payload에서만 timeout
  • 네트워크 대역폭이 낮은 구간(다른 AZ, VPN, 프록시)에서 심해짐
  • CPU 프로파일에서 marshal/unmarshal 비중이 큼

확인 방법

  • 요청/응답 크기 로깅
  • marshal 시간 측정

해결

  • 불필요한 필드 제거, pagination/streaming으로 분할
  • MaxRecvMsgSize, MaxSendMsgSize 조정은 “해결”이 아니라 “증상 완화”일 수 있음
conn, err := grpc.Dial(
    target,
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultCallOptions(
        grpc.MaxCallRecvMsgSize(8*1024*1024),
        grpc.MaxCallSendMsgSize(8*1024*1024),
    ),
)

원인 6) HTTP/2 레벨 이슈: Keepalive, idle timeout, 중간 프록시의 연결 종료

전형적인 패턴

  • 한동안 유휴 상태였다가 다음 호출이 timeout
  • 특정 LB/Ingress 뒤에서만 발생
  • 패킷 캡처 시 RST, GOAWAY, idle timeout 흔적

체크 포인트

  • 로드밸런서/프록시의 idle timeout이 짧은데, 클라이언트는 커넥션을 재사용하려고 함
  • keepalive ping이 중간 장비 정책에 의해 차단/종료

해결

  • 클라이언트/서버 keepalive 파라미터를 인프라 정책에 맞춤
  • LB idle timeout 조정(가능하면)
ka := keepalive.ClientParameters{
    Time:                30 * time.Second,
    Timeout:             10 * time.Second,
    PermitWithoutStream: true,
}

conn, err := grpc.Dial(
    target,
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithKeepaliveParams(ka),
)

주의: PermitWithoutStream은 유휴 상태에서도 ping을 보내므로, 인프라 정책에 따라 오히려 연결이 끊길 수 있습니다. 반드시 환경에 맞춰 테스트하세요.


원인 7) 서버가 데드라인/취소를 무시하고 계속 작업함

전형적인 패턴

  • 클라이언트는 DeadlineExceeded인데 서버는 뒤늦게 작업 완료 로그가 찍힘
  • 비싼 작업이 계속 돌아 리소스를 잠식, 이후 요청까지 느려짐

확인 방법

  • 서버 핸들러에서 ctx.Done()을 체크하는지 확인
  • DB/외부 API 호출에 ctx를 전달하는지 확인

해결

  • 모든 블로킹 호출에 ctx를 전달하고, 루프에서는 취소를 폴링
func (s *Server) Report(ctx context.Context, req *pb.ReportRequest) (*pb.ReportResponse, error) {
    for i := 0; i < 10_000; i++ {
        select {
        case <-ctx.Done():
            return nil, status.Error(codes.Canceled, "client canceled")
        default:
        }
        // 작업 수행
    }
    return &pb.ReportResponse{Ok: true}, nil
}

원인 8) 리소스 압박: CPU throttling, 메모리 부족, GC 스톱 더 월드

전형적인 패턴

  • 특정 노드/특정 파드에서만 timeout
  • p99 지연이 주기적으로 튐(특히 GC)
  • CPU 사용률은 낮아 보이는데 실제로는 throttling

확인 방법

  • 컨테이너 CPU throttling 지표 확인
  • Go runtime metrics(예: GC pause, goroutines)
  • 노드 이벤트/파드 eviction 여부

EKS 환경이라면 파드가 안정적으로 떠 있는지부터 확인해야 합니다. eviction/재스케줄이 반복되면 연결이 흔들리고 timeout이 늘어납니다.

또한 부하가 늘었는데 오토스케일이 안 되면(메트릭 오류 등) 서버가 과부하로 밀리면서 DeadlineExceeded가 급증할 수 있습니다.

해결

  • CPU limit 상향 또는 requests/limits 재조정
  • 메모리 여유 확보, 불필요한 할당 줄이기
  • 핫패스에서 큰 슬라이스/맵 재할당 줄이고, 스트리밍/배치 처리 고려

원인 9) 관측/로깅 부재로 인해 “원인 미상 타임아웃”이 됨

전형적인 패턴

  • 클라이언트에는 에러만 남고 서버에는 아무 흔적이 없음
  • 분산 환경에서 어느 홉에서 늦었는지 모름

해결: 최소 관측 세트

  1. 요청 ID(또는 trace ID) 전파
  2. 클라이언트/서버 인터셉터로 latency, status code, payload size 기록
  3. 가능하면 OpenTelemetry로 trace 수집

아래는 메타데이터로 요청 ID를 전파하는 간단 예시입니다.

// client
reqID := uuid.NewString()
ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", reqID)

// server
func requestIDFromContext(ctx context.Context) string {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return ""
    }
    vals := md.Get("x-request-id")
    if len(vals) == 0 {
        return ""
    }
    return vals[0]
}

관측이 갖춰지면 DeadlineExceeded가 “현상”이 아니라 “어느 계층에서 시간이 소모됐는지”로 분해됩니다.


빠른 트러블슈팅 체크리스트

  • 클라이언트 타임아웃이 실제 SLO에 맞나? 상위 컨텍스트 데드라인이 너무 짧지 않나?
  • status.Code(err)가 정말 codes.DeadlineExceeded인가? codes.Unavailable이 섞여 있나?
  • 서버 인터셉터로 method별 p50/p95/p99 처리 시간을 알고 있나?
  • DB/외부 API 호출에 ctx를 전달하고 취소를 존중하나?
  • 첫 호출만 느리면 Dial/DNS/리졸버/커넥션 준비 문제를 의심했나?
  • 메시지 크기와 직렬화 비용을 측정했나?
  • LB/Ingress idle timeout과 HTTP/2 keepalive 정책이 충돌하지 않나?
  • 컨테이너 CPU throttling, GC pause, eviction, 오토스케일 실패가 없는가?
  • 요청 ID/트레이싱으로 어느 홉에서 지연이 생겼는지 추적 가능한가?

마무리

context deadline exceeded는 “gRPC가 느리다”가 아니라 요청의 시간 예산이 어디선가 소진됐다는 신호입니다. 위 9가지를 순서대로 점검하면, 대부분의 케이스에서 원인을 네트워크/인프라/서버 코드/외부 의존성 중 하나로 좁힐 수 있습니다.

다음 단계로는 (1) 서버 인터셉터 기반의 latency 지표를 고정 설치하고, (2) 클라이언트에서 재시도 정책과 idempotency를 정리한 뒤, (3) 인프라 idle timeout 및 오토스케일을 함께 튜닝하는 흐름을 추천합니다.