5. Cancellation with Context

context로 취소와 타임아웃 전파하기

  • Haram Lee
  • 2026-10-07
  • studies / Topics / Go

Go 서버 코드에서 거의 모든 함수의 첫 인자가 ctx context.Context다. 처음에는 의례적인 인자처럼 보이지만, 요청이 끊기거나 시간이 다 됐을 때 그 아래의 모든 작업을 함께 멈추게 하는 장치다. goroutine을 쉽게 띄울 수 있는 언어라서, 띄운 goroutine을 거두는 장치도 언어 관례로 자리 잡았다.

5.1 The Problem

클라이언트가 HTTP 요청을 보내고 2초 만에 연결을 끊었다고 하자. 서버의 핸들러는 그사이 DB 조회와 Kafka 전송을 시작했다. 요청이 끊겼으니 이 작업들은 더 할 필요가 없다. 그런데 핸들러가 띄운 goroutine들은 요청이 끊긴 줄 모른다.

context는 이 “그만해도 된다"는 신호를 호출 사슬 아래로 전달한다. 여러 goroutine이 같은 context를 받아 두면, 위에서 취소했을 때 모두 동시에 알 수 있다.

5.2 Core API

context.Context는 인터페이스이고, 주로 쓰는 메서드는 두 개다.

  • ctx.Done(): 취소되면 닫히는 channel이다. select에서 기다린다.
  • ctx.Err(): 취소된 이유다. 직접 취소면 context.Canceled, 시간 초과면 context.DeadlineExceeded다.

이미 만든 context는 고칠 수 없으므로, 기존 context에서 파생해 새 context를 만든다.

함수만드는 것
context.Background()뿌리 context. main이나 테스트에서 시작점으로 쓴다
context.WithCancel(parent)cancel()을 부르면 취소되는 context
context.WithTimeout(parent, 2*time.Second)2초 뒤 자동으로 취소되는 context
context.WithValue(parent, key, value)값을 실어 나르는 context. 요청 id, trace id 정도에만 쓴다
signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)종료 신호를 받으면 취소되는 context

부모가 취소되면 자식도 모두 취소된다. 반대로 자식을 취소해도 부모는 그대로다. 그래서 context는 위에서 아래로 내려가는 나무 모양이 된다.

go
func handle(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()   // 항상 부른다. 안 부르면 타이머가 남는다

    if err := producer.Produce(ctx, topic, body); err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "timeout", http.StatusGatewayTimeout)
            return
        }
        ...
    }
}

r.Context()는 HTTP 서버가 요청마다 만들어 주는 context다. 클라이언트가 연결을 끊으면 이 context가 취소된다. 여기에 타임아웃을 덧붙여 아래로 넘기는 것이 기본 형태다.

5.3 Conventions

  • 첫 번째 인자로 넘긴다. 이름은 ctx다. struct 필드에 저장하지 않는다.
  • nil을 넘기지 않는다. 모르면 context.TODO()를 넘긴다.
  • WithCancel, WithTimeout이 돌려준 cancel은 반드시 부른다. 보통 바로 아래 줄에 defer cancel()을 쓴다. go vet이 빠뜨린 것을 잡아 준다.
  • WithValue에 비즈니스 데이터를 넣지 않는다. 함수 인자로 넘길 수 있는 것은 인자로 넘긴다. context 값은 요청 범위의 부가 정보(trace id, 인증 정보)에만 쓴다.
  • 오래 도는 goroutine은 ctx.Done()을 확인한다. 4편의 배치 루프가 case <-ctx.Done():에서 남은 배치를 보내고 끝나는 것이 그 예다.

의존성을 클로저로 주입하는 코드에서도 같은 관례를 따른다. 의존성은 바깥 함수가 받고, 반환된 함수의 첫 인자는 항상 ctx다.

5.4 Graceful Shutdown

수집 서버에서 context가 가장 중요한 순간은 배포로 pod가 내려갈 때다. 쿠버네티스는 pod를 내리기 전에 SIGTERM을 보낸다. 서버는 이 신호를 받고 다음 순서로 끝나야 이벤트를 잃지 않는다.

  1. 새 요청을 받지 않는다(http.Server.Shutdown).
  2. 처리 중인 요청이 끝나기를 기다린다.
  3. channel에 쌓인 이벤트와 프로듀서 버퍼를 모두 보낸다.
  4. 종료한다.
go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()

go srv.ListenAndServe()
<-ctx.Done()                       // SIGTERM을 기다린다

shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)          // 1, 2
close(events)                      // 배치 goroutine이 남은 것을 보내고 끝나게 한다
wg.Wait()                          // 3

종료용 context를 context.Background()에서 새로 만드는 이유는, 이미 취소된 ctx를 넘기면 정리 작업도 바로 취소되기 때문이다. 종료 타임아웃은 쿠버네티스의 terminationGracePeriodSeconds(기본 30초)보다 짧게 잡는다.

이 흐름은 6편에서 전체 코드로 다시 본다.

Discussion