Published on

Go에서 context deadline exceeded 장애 패턴 7가지

Authors

서버 로그에 context deadline exceeded가 찍히면 대부분은 “타임아웃이구나” 하고 넘어가지만, 실제 장애에서는 이 에러가 병목의 결과이자 장애 전파의 트리거로 동작합니다. 특히 Go는 context가 표준 라이브러리 전반에 깊게 스며 있어, 타임아웃 전파가 잘 되면 빠르게 실패하고, 반대로 전파가 끊기면 리소스가 누수되며 장애가 커집니다.

이 글은 “왜 타임아웃이 났는지”를 넘어, 운영에서 반복되는 장애 패턴 7가지를 중심으로 재현 포인트, 로그/메트릭에서 보이는 징후, 그리고 코드 레벨의 개선책을 정리합니다.

관련해서 타임아웃 진단 관점은 gRPC에서도 유사합니다. 필요하면 이 글도 함께 보면 좋습니다: Spring Boot gRPC DEADLINE_EXCEEDED 타임아웃 진단

context deadline exceeded의 의미를 운영 관점으로 해석하기

Go에서 context deadline exceeded는 보통 아래 조건을 만족할 때 발생합니다.

  • context.WithTimeout 또는 context.WithDeadline으로 만든 컨텍스트가
  • 지정된 시간 안에 완료되지 못했고
  • 취소 신호가 전파되어, 대기 중이던 작업이 ctx.Err()를 반환

중요한 점은, 이 에러가 “원인”이 아니라 원인의 결과인 경우가 많다는 것입니다. 예를 들어 DB 커넥션 풀이 고갈되어 대기하다가 타임아웃이 나면, 로그에는 DB 쿼리 시간이 아니라 context deadline exceeded만 남기도 합니다.

아래 7가지 패턴은 대부분 이런 형태로 나타납니다.

패턴 1) HTTP 클라이언트에 타임아웃이 없거나, 컨텍스트가 전파되지 않음

증상

  • 특정 외부 API가 느려지면 고루틴이 계속 쌓임
  • context deadline exceeded가 아니라 오히려 “응답이 영원히 안 옴”으로 장애가 커짐
  • 혹은 상위 컨텍스트는 타임아웃인데, 실제 요청은 context.Background()로 보내서 취소가 안 됨

흔한 원인 코드

req, _ := http.NewRequest("GET", url, nil)
// ctx를 안 넣음
resp, err := http.DefaultClient.Do(req)

개선 코드

  • 요청 생성 시 NewRequestWithContext
  • http.Client{Timeout: ...}도 함께 설정 (DNS/커넥트/바디 읽기까지 포함한 상한)
client := &http.Client{Timeout: 3 * time.Second}

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

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
    return err
}

resp, err := client.Do(req)
if err != nil {
    // 여기서 ctx.Err()가 deadline이면 외부 지연/네트워크/서버 지연을 의심
    return err
}
defer resp.Body.Close()

운영 팁

  • 외부 호출은 “내부 타임아웃”과 “클라이언트 상한”을 둘 다 두는 편이 안전합니다.
  • 외부 API별로 타임아웃 예산을 다르게 잡고, 공통 RoundTripper에 트레이싱을 붙이면 원인 추적이 빨라집니다.

패턴 2) DB 커넥션 풀 고갈로 쿼리 실행 전 대기하다 타임아웃

증상

  • DB가 느린 게 아니라, 커넥션을 못 얻어서 기다리다 타임아웃
  • APM에는 쿼리 시간이 짧게 보이는데 요청은 타임아웃
  • database/sql 사용 시 피크 트래픽에서 급증

재현 포인트

  • MaxOpenConns가 너무 작거나
  • 트랜잭션/Rows/Stmt를 닫지 않아 커넥션이 반환되지 않거나
  • 느린 쿼리로 커넥션이 오래 점유됨

개선 코드

쿼리 호출은 QueryContext/ExecContext를 사용하고, Rows.Close()를 반드시 보장합니다.

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

rows, err := db.QueryContext(ctx, `SELECT id, name FROM users WHERE status = ?`, "active")
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    // scan...
}
if err := rows.Err(); err != nil {
    return err
}

운영 팁

  • db.Stats()를 주기적으로 노출해 WaitCount, WaitDuration, InUse를 봅니다.
  • 타임아웃이 “쿼리 실행 시간” 때문인지 “풀 대기” 때문인지 분리해야 합니다.
  • DB 레벨 최적화가 필요하면 N+1 같은 패턴도 같이 점검하세요. (스택은 다르지만 병목 형태는 유사합니다) Spring Boot JPA N+1 최적화 - Fetch Join·BatchSize

패턴 3) 컨텍스트를 너무 짧게 잡아, 정상 지연에도 연쇄 실패

증상

  • 평소엔 괜찮다가 배포/GC/스케줄링 지연이 생기면 대량 타임아웃
  • “요청 타임아웃 300ms” 같은 공격적인 설정에서 자주 발생
  • 실패 재시도가 겹치며 트래픽이 더 증가하는 악순환

문제의 본질

타임아웃은 “기대 지연”이 아니라 “최악 지연”을 기준으로 잡아야 합니다. 특히 아래가 포함되면 꼬입니다.

  • 큐잉 지연 (예: 커넥션 풀 대기)
  • TLS 핸드셰이크
  • 콜드 스타트
  • GC stop-the-world

개선 가이드

  • 엔드투엔드 예산을 나누고, 하위 호출에 남은 시간을 전달합니다.
func callDownstream(ctx context.Context) error {
    deadline, ok := ctx.Deadline()
    if !ok {
        // 상위에서 deadline을 안 주면, 합리적인 기본값을 둠
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, 2*time.Second)
        defer cancel()
        return do(ctx)
    }

    // 남은 시간이 너무 적으면 조기 실패로 리소스 낭비 방지
    if time.Until(deadline) < 150*time.Millisecond {
        return context.DeadlineExceeded
    }
    return do(ctx)
}

운영 팁

  • 타임아웃은 평균이 아니라 p95~p99 지연과 오류 예산을 보고 조정합니다.
  • 타임아웃 단축은 “빠른 실패”를 만들지만, 재시도 정책이 있으면 “빠른 폭주”가 됩니다.

패턴 4) 재시도 폭주: 타임아웃 + 즉시 재시도 조합

증상

  • 외부 API가 느려지면 우리 서비스가 먼저 터짐
  • 로그에 context deadline exceeded가 폭증하면서 QPS가 더 올라감
  • 서킷 브레이커가 없거나, 백오프가 없음

흔한 문제 코드

for i := 0; i < 3; i++ {
    ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
    err := call(ctx)
    cancel()
    if err == nil {
        return nil
    }
}
return err

이 코드는 실패할수록 더 많은 호출을 만들고, 하위 시스템을 더 느리게 합니다.

개선 코드: 지수 백오프 + 지터 + 컨텍스트 인지

func retry(ctx context.Context, attempts int, fn func(context.Context) error) error {
    base := 80 * time.Millisecond
    for i := 0; i < attempts; i++ {
        if err := fn(ctx); err == nil {
            return nil
        } else {
            // deadline이면 재시도 가치가 낮을 때가 많음
            if errors.Is(err, context.DeadlineExceeded) && i == 0 {
                // 상황에 따라 즉시 반환도 고려
            }
        }

        // backoff with jitter
        d := base * time.Duration(1<<i)
        jitter := time.Duration(rand.Int63n(int64(d / 2)))
        sleep := d/2 + jitter

        t := time.NewTimer(sleep)
        select {
        case <-ctx.Done():
            t.Stop()
            return ctx.Err()
        case <-t.C:
        }
    }
    return context.DeadlineExceeded
}

운영 팁

  • 재시도는 “성공 확률이 높아지는 경우”에만 해야 합니다. 하위가 과부하라면 재시도는 독입니다.
  • 멱등성 없는 요청은 중복 실행이 또 다른 장애를 만듭니다. 분산 트랜잭션/보상 흐름이 있다면 중복 방지도 함께 설계하세요: MSA Saga 보상 트랜잭션 중복 실행 방지법

패턴 5) 고루틴 누수: select에서 ctx.Done()을 안 받음

증상

  • 타임아웃이 늘어날수록 고루틴 수가 계속 증가
  • CPU는 낮은데 메모리와 고루틴이 증가
  • 결국 스케줄링 지연으로 타임아웃이 더 늘어나는 악순환

흔한 누수 코드

ch := make(chan Result)
go func() {
    ch <- doWork() // 받는 쪽이 타임아웃으로 떠나면 여기서 영원히 블록
}()

select {
case r := <-ch:
    return r, nil
case <-time.After(200 * time.Millisecond):
    return Result{}, context.DeadlineExceeded
}

개선 코드

  • 버퍼 채널 사용 또는
  • 작업 고루틴에서도 ctx.Done()을 관찰
func work(ctx context.Context) (Result, error) {
    ch := make(chan Result, 1)

    go func() {
        ch <- doWork()
    }()

    select {
    case r := <-ch:
        return r, nil
    case <-ctx.Done():
        return Result{}, ctx.Err()
    }
}

운영 팁

  • pprof의 goroutine dump에서 chan send로 대기하는 스택이 반복되면 이 패턴을 의심합니다.
  • 타임아웃 경로가 “리턴”으로 끝나지 말고, 작업도 같이 정리되도록 만들어야 합니다.

패턴 6) 스트리밍/대용량 응답에서 바디 읽기가 느려 deadline 초과

증상

  • 헤더는 빨리 오는데 바디 읽기에서 타임아웃
  • 파일 다운로드, 대용량 JSON, SSE/스트리밍에서 발생
  • 프록시나 인그레스에서 클라이언트 중단과 섞여 관측되기도 함

포인트

http.Client.Timeout은 “요청 전체” 상한이라 스트리밍에는 불리할 수 있습니다. 반대로 컨텍스트만 두면 바디 읽기 중 취소가 잘 되지만, 너무 짧으면 정상 스트리밍도 죽습니다.

개선 코드

  • 스트리밍은 전체 상한 대신 Transport의 세부 타임아웃을 조정하거나
  • 읽기 루프에서 컨텍스트 취소를 관찰합니다.
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

buf := make([]byte, 32*1024)
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    n, rerr := resp.Body.Read(buf)
    if n > 0 {
        // process buf[:n]
    }
    if rerr == io.EOF {
        break
    }
    if rerr != nil {
        return rerr
    }
}
return nil

운영 팁

  • 인그레스에서 499 같은 “클라이언트 중단”과 섞이면 원인 분리가 어려워집니다. 엣지/인그레스 로그도 같이 봐야 합니다.

패턴 7) 락 경합 또는 단일 스레드 병목으로 내부 작업이 deadline을 넘김

증상

  • 외부 호출이 아니라 내부 로직에서 타임아웃
  • 특정 뮤텍스나 전역 락을 잡는 구간에서 지연
  • 단일 워커 큐가 밀려서 내부 큐잉이 발생

흔한 형태

  • 전역 캐시 갱신 락
  • 단일 플러셔 goroutine에 모든 요청이 의존
  • 로깅/메트릭 전송이 동기 방식

개선 코드: 락 대기에도 컨텍스트를 반영

Go의 sync.Mutex는 컨텍스트를 직접 지원하지 않으므로 설계를 바꾸거나 채널 세마포어로 제어합니다.

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore {
    s := make(Semaphore, n)
    for i := 0; i < n; i++ {
        s <- struct{}{}
    }
    return s
}

func (s Semaphore) Acquire(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-s:
        return nil
    }
}

func (s Semaphore) Release() { s <- struct{}{} }

이렇게 하면 “락 대기”가 곧바로 타임아웃으로 이어질 때, 대기열이 무한히 쌓이는 것을 막고 빠르게 실패시킬 수 있습니다.

운영 팁

  • mutex profileblock profile을 켜서 경합 지점을 찾습니다.
  • 단일 워커 병목은 QPS가 오를수록 지연이 선형이 아니라 기하급수적으로 악화됩니다.

장애 대응 체크리스트: 원인을 10분 안에 좁히는 방법

  1. 에러가 ctx.Err() 기반인지, 실제 네트워크 에러인지 구분
    • 래핑된 에러라면 errors.Is(err, context.DeadlineExceeded)로 분류
  2. 타임아웃이 난 지점이 어디인지 span 단위로 확인
    • HTTP 클라이언트, DB 풀 대기, 락 대기, 큐 대기
  3. 풀/큐/락 같은 “대기” 메트릭을 본다
    • db.Stats()WaitCount/WaitDuration
    • 고루틴 수, 런큐, GC 지표
  4. 재시도 정책을 점검
    • 백오프/지터/서킷 브레이커 유무
  5. 타임아웃 예산을 재설계
    • 상위 요청 타임아웃과 하위 호출 타임아웃의 합이 일관적인지

마무리: deadline exceeded는 “시간”이 아니라 “대기열” 문제일 때가 많다

현장에서 context deadline exceeded는 단순히 타임아웃 값을 늘리면 해결되는 문제가 아닌 경우가 더 많았습니다. 커넥션 풀 고갈, 재시도 폭주, 고루틴 누수, 락 경합처럼 대기열을 만드는 구조가 있으면, 타임아웃은 증상으로만 나타납니다.

권장 접근은 다음 순서입니다.

  • 어디서 기다리는지 관측 가능하게 만들고
  • 기다림을 줄이거나(병목 제거)
  • 기다림이 길어질 때 빠르게 실패하도록(격리/서킷/백오프)
  • 취소가 끝까지 전파되도록(컨텍스트 전파/리소스 정리)

이 4가지만 잡아도 context deadline exceeded가 “가끔 보이는 로그”가 아니라 “장애의 조기 경보”로 바뀝니다.