Published on

Go gRPC DEADLINE_EXCEEDED 원인별 해결 7가지

Authors

프로덕션에서 gRPC 에러 중 가장 골치 아픈 축이 DEADLINE_EXCEEDED 입니다. 표면적으로는 “시간 초과”지만, 실제로는 클라이언트 타임아웃 설정, 서버 처리 지연, 로드밸런서/프록시의 유휴 타임아웃, 커넥션 재사용 실패, 리소스 고갈로 인한 큐잉, 스트리밍 백프레셔 등 원인이 겹쳐서 나타납니다.

이 글은 Go 기반 gRPC에서 DEADLINE_EXCEEDED원인별로 분해하고, 각 케이스에서 무엇을 먼저 확인해야 하는지, 그리고 어떤 설정/코드로 해결하는지 7가지로 정리합니다.

0) 먼저 확인할 것: “누가” 데드라인을 걸었나

DEADLINE_EXCEEDED 는 대개 클라이언트가 걸어둔 데드라인이 만료되어 발생합니다. 그런데 실제 체감은 “서버가 응답을 안 한다”로 보이기 때문에, 원인 추적이 꼬입니다.

아래를 먼저 로그에 남기면 진단 속도가 빨라집니다.

  • 클라이언트: RPC 시작 시각, 데드라인 값, 재시도 여부
  • 서버: 요청 도착 시각, 핸들러 처리 시간, DB/외부 호출 시간
  • 네트워크 계층: LB/Ingress 타임아웃, keepalive, 커넥션 재사용 여부

Go에서 데드라인 로깅 예시

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

if dl, ok := ctx.Deadline(); ok {
    log.Printf("rpc deadline=%s (in %s)", dl.Format(time.RFC3339Nano), time.Until(dl))
}

resp, err := client.Foo(ctx, &pb.FooRequest{Id: "123"})
_ = resp
if err != nil {
    s, _ := status.FromError(err)
    log.Printf("grpc code=%s msg=%q", s.Code(), s.Message())
}

이제부터는 원인별 해결로 들어갑니다.

1) 원인: 클라이언트 데드라인이 “너무 짧다” 또는 경로별로 불일치

가장 흔합니다. 특히 다음 상황에서 자주 터집니다.

  • 모바일/웹 게이트웨이에서 이미 짧은 타임아웃을 걸고 있고, 내부 gRPC도 동일하게 짧게 설정
  • 특정 API만 외부 호출이나 DB 쿼리가 길어지는데, 공통 인터셉터에서 일괄 1s 같은 값 적용
  • 배치/리포트성 요청인데도 인터랙티브 API와 동일한 데드라인

해결

  • RPC 메서드 성격별로 데드라인을 분리
  • 서버 처리 시간의 p95, p99 를 기준으로 데드라인 산정
  • “서버 내부에서 또 다른 gRPC 호출”이 있다면, 상위 데드라인에서 하위 호출에 쓸 수 있는 예산을 계산

메서드별 타임아웃 테이블 적용 예시

var timeoutByMethod = map[string]time.Duration{
    "/svc.UserService/GetUser":   800 * time.Millisecond,
    "/svc.ReportService/Export":  15 * time.Second,
}

func withMethodTimeoutUnary() grpc.UnaryClientInterceptor {
    return func(
        ctx context.Context,
        method string,
        req, reply any,
        cc *grpc.ClientConn,
        invoker grpc.UnaryInvoker,
        opts ...grpc.CallOption,
    ) error {
        d, ok := timeoutByMethod[method]
        if !ok {
            d = 2 * time.Second
        }
        ctx, cancel := context.WithTimeout(ctx, d)
        defer cancel()
        return invoker(ctx, method, req, reply, cc, opts...)
    }
}

2) 원인: 서버 핸들러가 느리다 (DB 쿼리, 락, 외부 API)

서버가 실제로 느린 케이스입니다. gRPC 자체 문제가 아니라, 핸들러 내부의 병목이 원인입니다.

대표 패턴

  • DB 인덱스 미비로 특정 조건에서 풀스캔
  • 트랜잭션 락 대기
  • 외부 HTTP API 지연 또는 간헐 장애
  • 캐시 미스 폭증

해결

  • 서버에서 핸들러 구간별 타이밍을 구조화 로그로 남기기
  • DB 쿼리 플랜/인덱스 점검, 커넥션 풀 고갈 여부 확인
  • 외부 호출에는 별도의 타임아웃과 서킷 브레이커/재시도 정책 적용

서버 핸들러 타이밍 측정 예시

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    start := time.Now()
    defer func() {
        log.Printf("GetUser total=%s", time.Since(start))
    }()

    t1 := time.Now()
    u, err := s.repo.FindUser(ctx, req.Id)
    log.Printf("FindUser=%s", time.Since(t1))
    if err != nil {
        return nil, status.Error(codes.Internal, "db error")
    }

    return &pb.GetUserResponse{User: u}, nil
}

DB/풀 이슈가 의심되면 애플리케이션 레벨에서는 503 이나 큐잉으로 드러나기도 합니다. 풀 고갈 패턴 자체는 언어를 가리지 않으니, 원인 파악 관점에서는 다음 글도 참고할 만합니다.

3) 원인: LB/Ingress 유휴 타임아웃 또는 HTTP2 설정 문제

gRPC는 HTTP/2 기반이라, 중간에 ALB/NLB/Envoy/Ingress 같은 계층이 있으면 타임아웃과 keepalive 정책의 영향을 강하게 받습니다.

자주 발생하는 현상

  • 요청이 “가끔”만 DEADLINE_EXCEEDED 로 실패한다
  • 첫 요청은 빠른데, 일정 시간 지나고 나서의 요청이 느리거나 실패한다
  • 서버 로그에 요청이 아예 도착하지 않거나, 도착이 늦게 찍힌다

해결

  • LB/Ingress의 idle timeout을 서비스 특성에 맞게 조정
  • 프록시가 HTTP/2를 제대로 유지하는지 확인
  • 클라이언트 keepalive를 서버/LB 정책에 맞게 설정

EKS 환경이라면 네트워크 경로 문제는 종종 “타임아웃”으로만 보입니다. 네트워크 계층 문제를 더 넓게 점검하려면 아래 글도 도움이 됩니다.

4) 원인: keepalive 미설정으로 인한 반쯤 죽은 커넥션(half-open) 재사용

NAT, 방화벽, LB가 유휴 커넥션을 조용히 끊어버리면 클라이언트는 끊긴 줄 모르고 기존 커넥션을 재사용하려고 하다가 지연 후 타임아웃이 납니다.

해결

  • 클라이언트 keepalive ping을 적절히 설정
  • 서버도 keepalive 정책을 명시하고, 너무 공격적인 ping은 차단

Go gRPC keepalive 설정 예시

import "google.golang.org/grpc/keepalive"

ka := keepalive.ClientParameters{
    Time:                30 * time.Second,
    Timeout:             10 * time.Second,
    PermitWithoutStream: true,
}

conn, err := grpc.Dial(
    target,
    grpc.WithTransportCredentials(creds),
    grpc.WithKeepaliveParams(ka),
)
if err != nil {
    return err
}
_ = conn

주의할 점은 keepalive는 만능이 아니라는 것입니다. LB idle timeout이 60초인데 keepalive를 5분으로 두면 효과가 없습니다. 반대로 너무 잦은 ping은 인프라 정책에 의해 차단될 수 있습니다.

5) 원인: 서버 리소스 고갈로 큐가 쌓인다 (goroutine 폭증, CPU throttling)

서버가 바쁘면 요청은 도착했지만 처리 시작이 늦어지고, 그 사이 클라이언트 데드라인이 만료됩니다.

대표 시나리오

  • 서버 동시성 제한이 없어서 goroutine이 폭증하고 스케줄링 지연
  • K8s에서 CPU limit로 throttling 발생
  • GC 압력이 커져 stop-the-world 시간이 늘어남
  • 다운스트림(DB/외부 API) 병목으로 상위 서비스 요청이 적체

해결

  • 서버에 동시성 제한(세마포어) 또는 워커 풀 적용
  • K8s 리소스 request/limit 재조정, HPA 기준 점검
  • pprof로 CPU, goroutine, block 프로파일 확인

간단한 동시성 제한 예시

type limiter struct {
    sem chan struct{}
}

func newLimiter(n int) *limiter {
    return &limiter{sem: make(chan struct{}, n)}
}

func (l *limiter) Acquire(ctx context.Context) error {
    select {
    case l.sem <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (l *limiter) Release() { <-l.sem }

이런 리소스 문제는 결국 Pod 재시작, CrashLoopBackOff 로 이어지기도 합니다. 장애 시나리오를 넓게 보는 체크리스트로는 다음 글이 유용합니다.

6) 원인: 재시도 정책이 잘못되어 “데드라인 예산”을 소모한다

gRPC 호출에 재시도를 붙이면 성공률은 좋아질 수 있지만, 잘못 붙이면 총 소요 시간이 늘어 데드라인을 초과합니다.

특히 다음이 위험합니다.

  • 데드라인은 2s 인데, 재시도를 3회 하고 각 시도마다 백오프를 넣음
  • 서버가 이미 과부하인데 클라이언트가 재시도로 더 때림
  • 멱등하지 않은 요청에 재시도를 걸어 부작용 발생

해결

  • 재시도는 “총 데드라인 예산” 안에서만 수행
  • DEADLINE_EXCEEDED 에 대한 재시도는 보수적으로(대개는 하지 않거나 1회 이하)
  • 재시도는 멱등 요청에만 적용

데드라인 예산 기반 1회 재시도 예시

func callWithOneRetry(ctx context.Context, fn func(context.Context) error) error {
    dl, ok := ctx.Deadline()
    if !ok {
        return fn(ctx)
    }

    // 남은 시간이 너무 적으면 재시도하지 않음
    if time.Until(dl) < 300*time.Millisecond {
        return fn(ctx)
    }

    err := fn(ctx)
    if err == nil {
        return nil
    }

    s, _ := status.FromError(err)
    if s.Code() != codes.Unavailable {
        return err
    }

    // 짧은 백오프 후 1회만
    t := time.NewTimer(80 * time.Millisecond)
    defer t.Stop()
    select {
    case <-t.C:
        return fn(ctx)
    case <-ctx.Done():
        return ctx.Err()
    }
}

재시도/백오프 설계는 gRPC뿐 아니라 전체 API 호출에서 공통 주제입니다. 백오프와 큐잉 관점은 아래 글이 참고가 됩니다.

7) 원인: 스트리밍에서 수신/송신이 막혀 데드라인이 초과된다

서버 스트리밍, 클라이언트 스트리밍, 바이디렉셔널 스트리밍에서는 “처리는 끝났는데 전송이 안 끝나는” 형태로 타임아웃이 나기도 합니다.

대표 패턴

  • 수신 측이 Recv 를 늦게 호출하거나, 애플리케이션 처리 속도가 느려 TCP 윈도우가 줄어듦
  • 송신 측이 큰 메시지를 계속 보내며 backpressure가 걸림
  • 스트림을 닫지 않아 리소스가 누수되고 나중에 전체 지연으로 이어짐

해결

  • 스트림 루프에서 ctx.Done() 을 항상 감시
  • 메시지 크기/빈도 제한, 배치 전송
  • 스트림 종료 시그널을 명확히 하고 CloseSend 또는 SendAndClose 를 확실히 호출

스트리밍 루프에서 컨텍스트 취소 처리 예시

for {
    select {
    case <-ctx.Done():
        return status.Error(codes.DeadlineExceeded, "stream deadline")
    default:
    }

    msg, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }

    // 처리 후 응답
    if err := stream.Send(&pb.EventAck{Id: msg.Id}); err != nil {
        return err
    }
}
return nil

실전 점검 체크리스트(요약)

현장에서 DEADLINE_EXCEEDED 를 만나면 아래 순서로 보면 빠릅니다.

  1. 클라이언트 데드라인 값과 메서드별 기대 처리 시간 p95/p99 비교
  2. 서버에서 요청이 도착했는지(로그/메트릭) 확인
  3. 서버 핸들러 내부 구간별 시간 측정(DB, 외부 호출)
  4. LB/Ingress idle timeout 및 HTTP/2 유지 정책 확인
  5. keepalive로 half-open 커넥션 방지
  6. 서버 리소스(큐잉, CPU throttling, goroutine) 점검
  7. 재시도/백오프가 데드라인 예산을 초과하지 않는지 검증

마무리

DEADLINE_EXCEEDED 는 단순히 타임아웃 값을 늘린다고 해결되는 문제가 아닙니다. “어디에서 시간이 새고 있는지”를 계층별로 분해해야 합니다. 특히 gRPC는 커넥션 재사용과 HTTP/2 특성 때문에, 애플리케이션 코드가 멀쩡해 보여도 LB/keepalive/리소스 큐잉에서 타임아웃이 터질 수 있습니다.

위 7가지를 기준으로 원인을 분류하고, 로그와 메트릭을 같은 축으로 맞춰보면 재현이 어려운 간헐 타임아웃도 훨씬 빨리 잡을 수 있습니다.