Published on

Go gRPC 데드라인 초과 원인과 해결 가이드

Authors

gRPC를 운영 환경에 올리면 가장 자주 마주치는 에러 중 하나가 context deadline exceeded 입니다. 보통은 클라이언트가 설정한 타임아웃 안에 RPC가 끝나지 못했다는 뜻이지만, 실제 원인은 단순히 “서버가 느리다”로 끝나지 않습니다. 네트워크 계층에서 연결이 지연되거나, 서버에서 큐잉이 발생하거나, 인터셉터/리졸버/밸런서 설정이 잘못되어 재시도가 누적되는 등 다양한 경로로 같은 증상이 나타납니다.

이 글에서는 Go gRPC에서 데드라인 초과가 발생하는 대표 패턴을 분해해서 진단하는 방법과, 코드 및 운영 설정으로 재발을 줄이는 실전 처방을 정리합니다. 더 많은 원인 체크리스트가 필요하다면 내부 글인 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방도 함께 참고하세요.

1) 에러 의미를 정확히 구분하기

Go에서 흔히 보는 메시지는 두 가지 계열입니다.

  • context deadline exceeded
    • context.WithTimeout 혹은 context.WithDeadline로 만든 데드라인을 넘긴 경우
    • gRPC 레벨에서는 보통 상태 코드 DEADLINE_EXCEEDED로 매핑됨
  • rpc error: code = DeadlineExceeded desc = context deadline exceeded
    • gRPC가 상태 코드로 감싼 형태

중요한 점은 “서버가 실제로 응답을 늦게 준 것”과 “요청이 서버까지 제대로 도달하지 못한 것”이 동일한 에러로 보일 수 있다는 것입니다. 따라서 원인 분석은 아래 순서로 진행하는 것이 안전합니다.

  1. 클라이언트에서 데드라인이 얼마였는지 확인
  2. 서버 핸들러가 호출되었는지 확인(서버 로그/트레이스)
  3. 서버 내부에서 어디서 시간이 소비되었는지 확인(DB, 락, 외부 API)
  4. 네트워크/로드밸런서/프록시 구간에서 지연 또는 리셋이 있었는지 확인

2) 가장 흔한 원인 1: 타임아웃 값이 비현실적으로 짧음

마이크로서비스에서 “기본 100ms 타임아웃” 같은 규칙을 넣어두면, 평상시에는 빠르다가도 GC, 콜드 캐시, DB 커넥션 재수립, 노이즈로 인해 쉽게 데드라인을 넘깁니다.

처방

  • RPC별로 SLO와 P95/P99를 기준으로 타임아웃을 설계
  • 전체 타임아웃을 한 번에 크게 잡기보다, 하위 작업에 예산을 배분

예시: RPC 예산 배분 패턴

func (c *Client) GetOrder(ctx context.Context, id string) (*pb.Order, error) {
	// 전체 RPC 예산
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	// DB 조회 예산 500ms
	dbCtx, dbCancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer dbCancel()
	order, err := c.repo.FindOrder(dbCtx, id)
	if err != nil {
		return nil, err
	}

	// 외부 결제 조회 예산 800ms
	payCtx, payCancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer payCancel()
	pay, err := c.payments.Get(payCtx, order.PaymentID)
	if err != nil {
		return nil, err
	}

	return build(order, pay), nil
}

이렇게 하면 “전체가 2초인데 DB가 1.8초를 써서 외부 호출이 무조건 타임아웃” 같은 상황을 줄일 수 있습니다.

3) 원인 2: 서버가 요청을 받았지만 처리 중 블로킹

서버 핸들러가 호출되었는데도 데드라인이 초과된다면, 서버 내부에서 시간이 소비됩니다. 대표적으로는 다음이 많습니다.

  • DB 쿼리 느림, 인덱스 누락, 락 경합
  • 커넥션 풀 고갈(예: DB pool, HTTP client pool)
  • 외부 API 호출 지연
  • 고루틴 누수로 인한 스케줄링 지연
  • 큰 응답 직렬화 비용 증가(프로토 버퍼 메시지 과대)

처방: 서버에서 반드시 ctx를 전파

서버 핸들러에서 만든 모든 I/O는 ctx를 받아야 합니다. 그렇지 않으면 클라이언트가 이미 타임아웃으로 끊었는데도 서버는 끝까지 일을 하며 리소스를 소모합니다.

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
	user, err := s.repo.FindUser(ctx, req.Id) // ctx 전파
	if err != nil {
		return nil, status.Error(codes.Internal, err.Error())
	}
	return &pb.GetUserResponse{User: user}, nil
}

DB 라이브러리나 HTTP 클라이언트도 ctx 기반 API를 사용하세요.

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := s.httpClient.Do(req)

처방: 서버 큐잉(동시성 제한)으로 타임아웃이 나는 경우

서버에 동시성 제한을 걸어둔 워커 풀 구조에서, 큐 대기가 길어져 데드라인이 초과될 수 있습니다. 이때는 “처리 시간”이 아니라 “대기 시간”이 대부분입니다.

  • 큐 길이, 대기 시간 메트릭을 노출
  • 동시성 제한 값을 트래픽과 리소스에 맞게 조정
  • 고비용 작업을 분리(비동기 처리, 배치)

4) 원인 3: 클라이언트 측 연결/리졸브/핸드셰이크 지연

grpc.Dial은 옵션에 따라 초기 연결이 지연될 수 있고, 첫 RPC에서 DNS 리졸브, TCP/TLS 핸드셰이크가 겹치면 짧은 데드라인에서 쉽게 초과됩니다.

처방: 연결 준비와 RPC 타임아웃을 분리

  • 앱 시작 시점에 커넥션을 미리 만들고 워밍업
  • 필요하면 DialContext에 별도의 타임아웃 적용
func dialGRPC(ctx context.Context, target string) (*grpc.ClientConn, error) {
	dialCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	conn, err := grpc.DialContext(
		dialCtx,
		target,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithBlock(),
	)
	return conn, err
}

func callRPC(ctx context.Context, client pb.UserServiceClient) error {
	rpcCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	_, err := client.GetUser(rpcCtx, &pb.GetUserRequest{Id: "42"})
	return err
}

WithBlock을 쓰면 연결이 준비될 때까지 대기하므로, “첫 호출이 가끔씩 타임아웃” 같은 현상을 더 빨리 드러내고 진단하기 쉬워집니다. 대신 시작 지연이 생길 수 있으니 서비스 성격에 맞게 선택하세요.

5) 원인 4: 재시도(retry)와 데드라인의 상호작용

클라이언트 재시도를 켜면, 한 번의 RPC가 내부적으로 여러 번 시도되면서 전체 시간이 늘어납니다. 특히 짧은 데드라인에서는 “첫 시도는 거의 성공했는데 재시도 정책 때문에 총합이 데드라인을 넘김” 같은 일이 생깁니다.

처방

  • 재시도는 멱등 요청에만 제한적으로
  • 재시도 백오프와 최대 시도 횟수를 데드라인 예산 내로 설계
  • 서버가 과부하일 때는 재시도가 오히려 폭발을 만든다는 점을 고려

또한 재시도와 캐시가 결합되면 “왜 특정 구간에서만 지연이 늘지” 같은 현상이 생깁니다. CI나 빌드 캐시 문제가 원인 분석을 어렵게 만드는 경우도 있으니, 재현 환경이 꼬였을 때는 GitHub Actions 캐시로 CI 꼬일 때 진단·해결 가이드처럼 캐시 계층을 정리하는 접근도 도움이 됩니다.

6) 원인 5: 로드밸런서/프록시의 타임아웃이 더 짧음

클라이언트 데드라인이 5초인데도 1초 근처에서 꾸준히 끊긴다면, 중간 프록시의 idle timeout, request timeout, keepalive 정책을 의심해야 합니다.

  • L4/L7 로드밸런서의 idle timeout
  • Ingress, Envoy, Nginx의 upstream timeout
  • Kubernetes 환경에서 노드/파드 간 네트워크 이슈

특히 EKS에서 ALB Ingress를 쓰는 경우 502나 리셋과 함께 체감상 “데드라인 초과”로 보이는 장애가 동반되기도 합니다. 인프라 구간까지 같이 보는 관점은 EKS ALB Ingress 502 Target reset 원인과 해결도 참고할 만합니다.

처방

  • 클라이언트 데드라인 <= 프록시 타임아웃이 되지 않게 정렬
  • gRPC keepalive 설정으로 중간 장비의 idle 종료를 예방
  • 장거리 스트리밍은 별도 채널/별도 타임아웃 정책으로 분리

7) 원인 6: 서버에서 데드라인을 무시하거나, 취소를 늦게 감지

Go에서는 ctx.Done()을 확인하거나, ctx를 I/O에 연결해야 취소가 전파됩니다. CPU 바운드 루프에서 ctx를 확인하지 않으면, 클라이언트가 취소해도 서버는 계속 돌 수 있습니다.

처방: 긴 루프에서 ctx.Done() 체크

func heavyWork(ctx context.Context, items []Item) error {
	for i := range items {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		// CPU 작업
		do(items[i])
	}
	return nil
}

8) 관측(Observability)로 원인 좁히기

데드라인 초과는 “결과”라서, 관측 없이는 원인을 특정하기 어렵습니다. 최소한 아래 3가지는 갖추는 것을 권합니다.

8.1 서버 인터셉터로 지연 시간과 데드라인 로그 남기기

func unaryLoggingInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		start := time.Now()
		deadline, hasDeadline := ctx.Deadline()

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

		fields := []zap.Field{
			zap.String("method", info.FullMethod),
			zap.Duration("duration", dur),
		}
		if hasDeadline {
			fields = append(fields, zap.Duration("time_left_at_start", time.Until(deadline)+dur))
		}
		if err != nil {
			fields = append(fields, zap.String("error", err.Error()))
			logger.Warn("grpc_request_done", fields...)
			return resp, err
		}
		logger.Info("grpc_request_done", fields...)
		return resp, nil
	}
}

여기서 핵심은 “서버가 요청을 받았는지”와 “서버 내부 처리 시간이 얼마나 되는지”를 분리해 보는 것입니다.

8.2 클라이언트 측에서도 RPC별 타임아웃과 실제 소요 시간 기록

클라이언트는 재시도, 리졸브, 연결 지연 등 서버 밖의 시간을 포함하므로, 서버 로그만 보면 놓치는 구간이 생깁니다.

8.3 분산 트레이싱으로 구간별 병목 확인

OpenTelemetry를 붙이면 클라이언트 스팬과 서버 스팬의 간격으로 “네트워크/큐잉/프록시” 구간을 추정할 수 있습니다.

9) 실전 체크리스트: 어디서부터 볼까

증상별로 빠르게 분기하는 체크리스트입니다.

9.1 특정 메서드만 데드라인 초과

  • 해당 메서드의 DB 쿼리 플랜, 인덱스, 락 확인
  • 응답 메시지 크기 확인(직렬화 비용)
  • 외부 API 의존이 있는지 확인

9.2 첫 호출만 자주 실패

  • DNS 리졸브, TLS 핸드셰이크, 커넥션 워밍업 확인
  • grpc.DialContext와 RPC 타임아웃 분리

9.3 트래픽 피크에서만 실패

  • 서버 동시성 제한/큐 대기 시간 확인
  • DB 커넥션 풀 고갈, 외부 API rate limit 확인
  • 오토스케일링 지연, Pod Pending 여부 확인

9.4 클라이언트는 타임아웃인데 서버는 요청 로그가 없음

  • 프록시/Ingress 타임아웃 또는 리셋
  • 네트워크 경로 MTU, 보안 그룹, NAT 이슈
  • 서비스 디스커버리 리졸브 문제

10) 결론: 데드라인은 “설계”해야 줄어든다

context deadline exceeded는 단순한 에러 메시지지만, 실제로는 타임아웃 정책(예산 배분), 서버 내부 취소 전파, 네트워크/프록시 타임아웃 정렬, 재시도 전략, 관측 체계가 맞물린 결과로 발생합니다.

  • 타임아웃을 RPC별로 현실적인 값으로 설계하고
  • 서버는 ctx를 모든 I/O에 전파하며
  • 연결 준비와 호출 타임아웃을 분리하고
  • 재시도를 데드라인 예산 안에서 제한하고
  • 인터셉터/트레이싱으로 “어디서 시간이 새는지”를 보이게 만들면

데드라인 초과는 대부분 “원인 불명 장애”가 아니라 “재현 가능한 성능/설정 문제”로 바뀝니다.