- Published on
Go gRPC context deadline exceeded 원인 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버와 클라이언트가 모두 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) 관측/로깅 부재로 인해 “원인 미상 타임아웃”이 됨
전형적인 패턴
- 클라이언트에는 에러만 남고 서버에는 아무 흔적이 없음
- 분산 환경에서 어느 홉에서 늦었는지 모름
해결: 최소 관측 세트
- 요청 ID(또는 trace ID) 전파
- 클라이언트/서버 인터셉터로 latency, status code, payload size 기록
- 가능하면 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 및 오토스케일을 함께 튜닝하는 흐름을 추천합니다.