Published on

Go gRPC context deadline exceeded 원인·해결

Authors

서버와 클라이언트 모두 정상처럼 보이는데 gRPC 호출이 간헐적으로 실패하며 context deadline exceeded가 뜨면, 대부분은 “진짜로 서버가 느린” 문제가 아니라 타임아웃의 소유권(누가 언제 끊었는지)대기 구간(어디에서 시간을 소모했는지) 을 분리하지 못해서 진단이 길어집니다.

이 글은 Go gRPC에서 context deadline exceeded가 발생하는 대표 경로를 Dial 단계 / RPC 실행 단계 / 스트리밍 단계 로 나누고, 각 단계별로 무엇을 로그로 남기고 무엇을 조정해야 하는지, 그리고 재현 가능한 코드로 어떻게 확인할지 정리합니다.

에러의 의미부터 정확히: deadline과 cancellation

Go에서 context deadline exceeded는 말 그대로 컨텍스트에 설정된 마감 시간(deadline)까지 작업이 끝나지 않았다는 뜻입니다. 여기서 중요한 포인트는 아래 두 가지입니다.

  • deadline은 보통 클라이언트가 설정하지만, 서버도 내부 작업에 별도의 deadline을 둘 수 있습니다.
  • gRPC는 “서버 처리 시간”만 재는 게 아니라, 연결 수립, DNS, LB, TLS 핸드셰이크, 큐잉, HTTP/2 흐름 제어, 재시도 대기 등도 전부 포함한 “전체 경과 시간” 안에 들어갑니다.

즉, 같은 에러 문자열이라도 실제 원인은 매우 다양합니다.

어디서 시간이 새는지: 3단계로 분해

context deadline exceeded는 크게 다음 단계에서 발생합니다.

  1. Dial 단계: grpc.DialContext 또는 첫 RPC 전 연결 준비 과정에서 지연
  2. Unary RPC 실행 단계: 요청 전송, 서버 처리, 응답 수신 중 지연
  3. Streaming 단계: 스트림 생성 또는 Recv/Send가 블로킹되며 지연

각 단계별 대표 원인과 해결책을 아래에서 다룹니다.

1) Dial 단계에서의 deadline exceeded

대표 원인

  • DNS 조회 지연 또는 잘못된 레코드(특히 사내 DNS, split-horizon 환경)
  • LB까지의 네트워크 경로 문제, 방화벽, Security Group, NAT 포트 고갈
  • TLS 핸드셰이크 지연(인증서 체인 검증, OCSP, 프록시)
  • WithBlock()을 사용했는데 연결이 준비되지 않아 deadline까지 대기

재현 및 진단 코드

Dial에 deadline을 걸고, 실패 시 어디에서 막혔는지 로그를 남깁니다.

package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

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

	conn, err := grpc.DialContext(
		ctx,
		"example.internal:443",
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithBlock(),
	)
	if err != nil {
		log.Fatalf("dial failed: %v", err)
	}
	defer conn.Close()
}
  • WithBlock()을 쓰면 Dial이 “백그라운드로 돌아가며 나중에 붙는” 방식이 아니라, 지금 당장 연결이 ready가 될 때까지 대기합니다.
  • 따라서 Dial deadline exceeded는 “서버가 느리다”가 아니라 연결 준비가 안 됐다로 보는 게 맞습니다.

해결 체크리스트

  • Dial timeout을 RPC timeout과 분리하세요. 예를 들어 Dial은 1초, RPC는 3초처럼 목적이 다릅니다.
  • DNS가 의심되면, 애플리케이션 레벨에서 name resolver 로그를 켜거나, 인프라 측면에서 CoreDNS 및 노드 DNS 캐시를 점검합니다.
  • 쿠버네티스 환경이라면 ImagePullBackOff처럼 “원인이 네트워크/인증인데 증상은 타임아웃”인 케이스가 흔합니다. 네트워크 경로 점검 루틴을 갖춰두면 유사 장애 대응이 빨라집니다. 참고: K8s ImagePullBackOff - ErrImagePull·401 빠른 해결

2) Unary RPC 단계에서의 deadline exceeded

대표 원인

  • 서버 핸들러가 느림(외부 API, DB, 락 경합)
  • 서버가 컨텍스트 취소를 무시하고 계속 작업함(고루틴 누수, 불필요한 작업 지속)
  • 클라이언트 deadline이 지나치게 짧음(특히 cold start, 캐시 미스, GC 직후)
  • 메시지가 커서 전송/수신 시간이 길어짐(압축 미사용, 대용량 payload)
  • 서버 측 큐잉(동시성 제한, worker pool 포화)

서버에서 컨텍스트를 제대로 존중하기

서버 핸들러는 반드시 ctx.Done()을 관찰해 취소되면 빨리 빠져야 합니다.

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
	select {
	case <-time.After(3 * time.Second):
		// 실제로는 DB 조회, 외부 API 호출 등
		return &pb.GetUserResponse{Id: req.Id}, nil
	case <-ctx.Done():
		// 클라이언트가 deadline으로 끊었거나, 상위에서 취소됨
		return nil, ctx.Err()
	}
}

핵심은 “서버가 느려서 타임아웃”인 상황에서도, 서버가 취소를 인지하지 못하면 이미 의미 없는 작업을 계속하며 리소스를 더 태웁니다. 이게 누적되면 다음 요청들이 더 느려져 타임아웃이 폭발합니다.

클라이언트에서 deadline 설정 패턴

Unary 호출마다 context.WithTimeout을 걸되, 근거 없는 짧은 값은 피합니다.

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

resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
if err != nil {
	// status 코드로 분해해서 관찰
	st, _ := status.FromError(err)
	log.Printf("grpc error: code=%v msg=%q", st.Code(), st.Message())
}

여기서 status.FromError로 코드가 DeadlineExceeded인지, 혹은 이미 서버에서 다른 에러를 반환했는지 분리해야 합니다.

DB/락 경합이 원인이라면

서버가 DB를 물고 늘어지는 경우, 애플리케이션 로그만으로는 “그냥 느리다”로 보입니다. 이때는 DB 레벨에서 어떤 쿼리가 병목인지 추적해야 합니다. 특히 InnoDB 데드락이나 락 대기가 길어지면 gRPC는 결국 deadline exceeded로 터집니다. 참고: MySQL InnoDB Deadlock 원인 쿼리 추적·해결 가이드

3) Streaming에서의 deadline exceeded

스트리밍은 unary보다 “어디서 막혔는지”가 더 헷갈립니다. 스트림 생성은 됐는데 Recv가 안 오거나, Send가 블로킹될 수 있습니다.

대표 원인

  • 서버가 메시지를 보내지 못하고 대기(생산자 지연)
  • 클라이언트가 읽지 않아 서버가 HTTP/2 flow control에 막힘
  • keepalive 설정 부재로 중간 장비가 idle 연결을 끊음
  • 장시간 스트림에 unary처럼 짧은 deadline을 걸어버림

스트리밍에서 deadline 설계

  • “스트림 전체”에 대한 deadline과 “각 메시지 수신”에 대한 timeout은 성격이 다릅니다.
  • 실무에서는 스트림 전체는 길게 두고, Recv 대기에는 별도의 타이머를 두는 패턴을 씁니다.
stream, err := client.Watch(ctx, &pb.WatchRequest{})
if err != nil {
	return err
}

for {
	recvCh := make(chan error, 1)
	go func() {
		_, err := stream.Recv()
		recvCh <- err
	}()

	select {
	case err := <-recvCh:
		if err != nil {
			return err
		}
		// 메시지 처리
	case <-time.After(5 * time.Second):
		return context.DeadlineExceeded
	case <-ctx.Done():
		return ctx.Err()
	}
}

고루틴을 매번 만드는 방식은 부담이 될 수 있으니, 실제 서비스에서는 메시지 수신 루프 구조를 더 정교하게 만들거나, 서버가 주기적으로 heartbeat 메시지를 보내도록 설계하는 편이 낫습니다.

자주 하는 실수 6가지

1) Dial과 RPC timeout을 동일하게 둠

연결 수립은 환경에 따라 변동성이 큽니다. cold path에서 TLS 핸드셰이크까지 포함하면 1초는 너무 짧을 수 있습니다. 반대로 RPC는 비즈니스 SLA에 맞춰야 합니다.

2) 서버가 ctx.Err()를 무시함

서버가 취소를 무시하면 장애 시점에 “느린 요청”이 계속 쌓여 더 큰 장애로 번집니다.

3) 재시도와 deadline을 같이 고려하지 않음

클라이언트가 재시도를 켰는데 전체 deadline이 짧으면, 첫 시도에서 조금만 늦어도 두 번째 시도는 의미가 없어집니다. 재시도 정책을 쓸 때는 “총 예산 시간”을 기준으로 설계해야 합니다.

4) 관측 없이 timeout만 늘림

timeout을 늘리면 증상은 줄어들지만, 병목은 그대로 남습니다. 특히 DB 락, 외부 API 지연, 큐잉은 결국 더 큰 tail latency를 만듭니다.

5) keepalive 미설정으로 idle 연결이 끊김

중간 장비가 HTTP/2 idle 연결을 끊으면, 다음 요청에서 재연결이 필요해지고 그 순간 deadline exceeded가 튈 수 있습니다. keepalive는 인프라 정책과 충돌할 수 있으니, 서버와 LB 정책을 함께 봐야 합니다.

6) 에러를 문자열로만 비교함

반드시 gRPC status code로 분해하세요.

st, ok := status.FromError(err)
if ok {
	switch st.Code() {
	case codes.DeadlineExceeded:
		// timeout
	case codes.Unavailable:
		// 연결 문제, 서버 다운, LB 문제 등
	}
}

UnavailableDeadlineExceeded는 대응이 완전히 다릅니다.

실무용 해결 순서: “늘리기” 전에 이것부터

1) 클라이언트: deadline을 로그로 남기기

요청 시작 시각, deadline 시각, 실제 경과 시간을 구조화 로그로 남기면 “진짜 timeout인지”가 바로 보입니다.

  • start_time
  • deadline
  • elapsed_ms
  • method
  • target

2) 서버: handler latency와 큐잉을 분리

서버에서 아래를 분리해 관측하세요.

  • gRPC 인터셉터에서 측정한 전체 핸들러 시간
  • DB/외부 API 호출 시간
  • worker pool 대기 시간(있다면)

3) 네트워크: 연결 재사용과 경로를 점검

  • 커넥션이 매 요청마다 새로 생기지 않는지 확인
  • LB, 프록시, 서비스 메시가 있다면 타임아웃 계층이 몇 개인지 확인

특히 “중간 계층 타임아웃이 더 짧아서” gRPC가 deadline exceeded로 보이는 경우가 있습니다. 예를 들어 LB idle timeout, 프록시 upstream timeout 같은 값이 클라이언트 deadline보다 짧으면, 클라이언트는 원인을 오해하기 쉽습니다.

4) 서버 리소스: CPU throttling, GC, 동시성 제한

  • 쿠버네티스라면 CPU limit으로 인한 throttling이 tail latency를 키웁니다.
  • Go GC pause 자체는 짧지만, 높은 할당률은 p99를 흔듭니다.

권장 설정 예시(출발점)

서비스 특성에 따라 다르지만, 출발점으로 아래처럼 “역할”을 분리하면 진단이 쉬워집니다.

  • Dial timeout: 1초 내외(내부망) 또는 2초 내외(외부망)
  • Unary RPC timeout: SLA 기반(예: p99 300ms면 800ms 정도로 시작)
  • Streaming: 전체는 길게, 메시지 간 inactivity timeout 별도

그리고 무엇보다, timeout은 “늘리면 해결”이 아니라 병목을 드러내는 경보장치로 취급해야 합니다.

마무리

Go gRPC의 context deadline exceeded는 단일 원인이 아니라, deadline 예산 안에서 벌어진 모든 지연의 결과입니다. 따라서 해결도 “timeout 증가”가 아니라 다음 질문에 답하는 방식으로 접근해야 합니다.

  • 이 timeout은 Dial인가, RPC 실행인가, 스트리밍 Recv인가
  • 누가 deadline을 설정했고 값은 합리적인가
  • 서버가 취소를 존중하는가
  • 네트워크/LB/프록시의 타임아웃 계층이 더 짧지는 않은가
  • 병목은 DB, 외부 API, 큐잉, 리소스 제한 중 어디인가

이 순서대로 분해해 들어가면, 같은 에러 메시지라도 원인에 맞는 해결책으로 빠르게 수렴할 수 있습니다.