열 줄로 보는 타임아웃
context.Context의 일은 코드에 언제 멈출지 알려 주는 것입니다. 여기서 느린 작업은 50ms를 받고, 컨텍스트가 그만하라고 하면 포기합니다:
첫 번째 호출은 10ms 만에 끝나서 rows <nil>을 반환합니다. 두 번째는 200ms가 필요하지만 컨텍스트가 (만들어진 시점부터 세어) 50ms에 만료되므로 context deadline exceeded를 반환합니다.
강제로 멈추는 것은 없습니다. Go에는 바깥에서 고루틴을 죽일 방법이 없습니다. 컨텍스트는 신호이며, 코드가 그것을 확인해야 합니다. ctx.Done()에 대해 select하거나, 단계 사이에 ctx.Err()를 확인하거나, 대신 확인해 주는 라이브러리 호출(http.NewRequestWithContext, db.QueryContext, exec.CommandContext)에 ctx를 넘기는 식입니다.
Context 인터페이스
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
| 메서드 | 반환 |
|---|---|
Done() | 컨텍스트가 취소되거나 시간이 다 되면 닫히는 채널(절대 취소될 수 없는 컨텍스트라면 nil) |
Err() | 활성 상태일 때는 nil, 그 뒤에는 context.Canceled 또는 context.DeadlineExceeded |
Deadline() | 마감 시간과 true, 없으면 ok == false |
Value(key) | 이 컨텍스트나 조상에서 key로 저장된 값, 없으면 nil |
컨텍스트는 불변입니다. 컨텍스트를 바꾸는 일은 없고, With 함수 중 하나로 자식을 파생시킵니다. 자식은 취소 신호, 마감 시간, 값 중 하나를 더합니다.
컨텍스트의 출처
모든 컨텍스트 트리는 루트에서 시작합니다:
context.Background()는main,init, 테스트, 서버의 최상위 설정용입니다.context.TODO()는 함수가 컨텍스트를 받아야 하는데 호출자에게 아직 없을 때 씁니다.Background와 똑같이 동작하며, 이름은 나중에 리팩터링할 곳이라는 표시입니다.
HTTP 핸들러 안에서는 루트를 만들지 않습니다. 클라이언트 연결이 끊기거나 핸들러가 반환될 때 서버가 취소하는 r.Context()를 씁니다.
WithCancel: 원할 때 멈추기
context.WithCancel은 자식 컨텍스트와 cancel 함수를 반환합니다. cancel을 호출하면 자식의 Done 채널과 그로부터 파생된 모든 것의 Done 채널이 닫힙니다.
생산자의 송신은 ctx.Done()과 나란히 select 안에 있습니다. 그래서 멈출 수 있습니다. 그냥 out <- i라고 쓰면 소비자가 읽기를 멈춘 뒤 영원히 블록되고 고루틴이 누수됩니다. 마지막 for range nums는 생산자가 채널을 닫을 때까지 기다립니다. 채널을 비우는 동안 생산자가 값을 한두 개 더 보낼 수도 있습니다. select의 두 case가 모두 준비되면 Go가 무작위로 고르기 때문입니다. 취소는 즉각적이지는 않고 신속할 뿐입니다.
cancel은 여러 번, 어느 고루틴에서 호출해도 안전합니다. 실제로 무언가를 하는 것은 첫 번째 호출뿐입니다.
WithTimeout과 WithDeadline
WithTimeout(parent, d)는 WithDeadline(parent, time.Now().Add(d))입니다. "최대 이만큼"에는 타임아웃을, 절대 시각이 있을 때는 마감 시간을 쓰세요.
시간이 지나면 Done이 닫히고 Err는 context.DeadlineExceeded를 반환합니다. cancel이 먼저 호출되면 Err는 context.Canceled를 반환합니다. 라이브러리는 대개 오류를 감싸므로 어느 쪽인지는 errors.Is로 확인하세요:
스스로 발동할 타임아웃이라도 항상 cancel을 호출하세요. 컨텍스트는 둘 중 하나가 일어날 때까지 타이머와 부모의 자리를 잡고 있으며, defer cancel()은 함수가 반환되는 즉시 둘 다 해제합니다. go vet은 버려진 cancel 함수를 보고합니다: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.
자식은 부모보다 오래 살 수 없다
컨텍스트는 트리를 이룹니다. 부모를 취소하면 모든 자손이 취소됩니다. 자식은 부모보다 짧은 마감 시간을 가질 수는 있지만 긴 마감 시간은 가질 수 없습니다. 항상 더 이른 마감 시간이 이깁니다.
이것이 컨텍스트를 계층을 넘나들며 유용하게 만드는 점입니다. HTTP 핸들러는 요청과 함께 끝나는 컨텍스트를 받고, 세 단계 아래의 데이터베이스 호출은 거기서 2초 타임아웃을 파생시킵니다. 클라이언트가 100ms 뒤에 연결을 끊으면 쿼리는 2초 뒤가 아니라 그 시점에 취소됩니다.
블록될 때는 항상 ctx.Done()에 대해 select하라
(채널 송신, 수신, 타이머에서) 기다리는 고루틴은 동시에 ctx.Done()도 기다려야 합니다. 절대 블록되지 않는 CPU 위주의 반복문이라면 가끔씩 ctx.Err()를 확인하세요:
for i, item := range items {
if i%1000 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
process(item)
}
단순한 대기라면 select 안에서 time.After를 쓰되, 대기가 자주 취소될 수 있다면 멈출 수 있는 타이머(또는 컨텍스트 타임아웃)를 쓰세요.
취소 원인 (Go 1.20과 1.21)
ctx.Err()는 canceled나 deadline exceeded만 알려 줍니다. 이유를 기록하려면 Cause 변형을 쓰세요:
WithCancelCause는 Go 1.20에, WithTimeoutCause와 WithDeadlineCause는 Go 1.21에 추가되었습니다. 기존 확인 코드가 계속 동작하도록 Err는 표준 값을 계속 반환하고, context.Cause가 세부 사항을 줍니다.
WithValue는 아껴서
context.WithValue(parent, key, value)는 값 하나를 붙입니다. ctx.Value(key)는 부모의 연쇄를 따라 그 값을 찾습니다.
값에 대한 규칙:
- 키에는 평범한
string이 아니라 비공개 타입을 쓰세요. 두 패키지가 모두"user"를 쓰면 서로를 덮어씁니다. (go vet은 이것을 잡지 못하고staticcheck는 잡습니다.) - 호출자가
any나 키를 볼 일이 없도록WithRequestID와RequestID같은 타입 있는 헬퍼 함수로 접근을 감싸세요. - API를 통과하는 요청 범위의 데이터만 저장하세요. 트레이스 ID와 요청 ID, 인증된 사용자, 로거입니다. 선택적 매개변수, 데이터베이스 핸들, 설정은 절대 넣지 마세요. 그런 것은 컴파일러가 확인하고 읽는 사람이 볼 수 있는 함수 인자나 구조체 필드에 속합니다.
- 조회는 부모를 하나씩 거슬러 올라가므로, 값을 하나 추가할 때마다 다른 값의 조회가 한 단계씩 길어집니다.
관례
ctx context.Context는 입출력을 하거나, 블록되거나, 그런 것을 호출하는 모든 함수의 첫 번째 매개변수입니다:func Fetch(ctx context.Context, url string) error.- 구조체에 컨텍스트를 저장하지 마세요. 메서드를 호출할 때마다 넘기세요. 컨텍스트는 작업 하나에 속하고, 구조체는 보통 그보다 오래 삽니다. (예외는
http.Request처럼 작업 하나를 나타내는 타입입니다.) - 컨텍스트로
nil을 절대 넘기지 마세요. 더 나은 것이 없다면context.TODO()를 쓰세요. - 컨텍스트 때문에 멈출 때는 호출자가 타임아웃과 진짜 실패를 구분할 수 있도록
ctx.Err()를 반환하거나%w로 감싸세요.
HTTP 서버와 클라이언트의 컨텍스트
서버 쪽: r.Context()는 클라이언트 연결이 끊길 때, 핸들러가 반환될 때, HTTP/2 스트림이 리셋될 때 취소됩니다. 클라이언트 쪽: http.NewRequestWithContext는 요청이 타임아웃이나 취소를 존중하게 만듭니다. 이 프로그램은 httptest로 양쪽을 모두 실행합니다:
클라이언트는 50ms에 포기하고 연결을 닫습니다. 서버가 이를 알아채면 요청 컨텍스트가 취소되고, 핸들러는 아무도 읽지 않을 보고서에 450ms를 더 쓰는 대신 멈춥니다. 실제 핸들러에서는 r.Context()를 모든 데이터베이스와 HTTP 호출로 넘기며, 그러면 모두 함께 멈춥니다.
그 밖의 헬퍼 (Go 1.21)
context.WithoutCancel(ctx)는 같은 값을 가지면서ctx가 취소되어도 취소되지 않는 컨텍스트를 반환합니다. 감사 로그 쓰기처럼 요청이 끝난 뒤에도 마쳐야 하는 작업에 쓰세요.context.AfterFunc(ctx, f)는ctx가 끝나면 자기 고루틴에서f를 실행하고, 등록을 해제하는stop함수를 반환합니다.
흔한 실수
cancel을 호출하지 않음.WithCancel,WithTimeout,WithDeadline직후에 항상defer cancel()을 쓰세요.ctx를 무시하는 고루틴을 시작함.ctx.Done()에 대해 select하지 않고 블록되면 취소가 아무 효과가 없고 고루틴이 누수됩니다.- 호출 연쇄 깊은 곳에서 새
context.Background()를 만듦. 호출자의 마감 시간 및 취소와의 연결이 끊깁니다. 받은ctx를 넘기세요. - 오류를
==로 비교함. 대부분의 라이브러리가 감싸므로errors.Is(err, context.DeadlineExceeded)를 쓰세요. - 의존성에
WithValue를 씀. 컨텍스트에 숨긴 데이터베이스 핸들은 컴파일러가 더 이상 확인할 수 없는 매개변수입니다. - 취소가 즉각적이라고 기대함. 코드는 다음 확인 지점에서야 알아챕니다. 확인 없는 긴 반복문은 계속 실행됩니다.
자주 묻는 질문
Go에서 context는 어디에 쓰나요?
context.Context는 함수와 그 함수가 호출하는 모든 것에게 언제 포기해야 하는지 알려 줍니다. 호출자가 취소했거나, 마감 시간이 지났거나, 클라이언트 연결이 끊겼을 때입니다. 요청 ID 같은 요청 범위의 값도 전달할 수 있습니다. 관례상 첫 번째 매개변수이며 이름은 ctx입니다.
context.Background와 context.TODO의 차이는 무엇인가요?
둘 다 절대 취소되지 않고 마감 시간도 값도 없는 빈 컨텍스트를 반환합니다. 동작은 똑같습니다. Background()는 main, 테스트, 최상위 설정의 루트입니다. TODO()는 실제 컨텍스트를 넘겨야 하지만 주변 코드에 아직 없는 곳을 표시하므로, 나중에 찾기 쉽게 해 줍니다.
context.WithTimeout 뒤에 cancel을 호출해야 하는 이유는 무엇인가요?
WithTimeout, WithDeadline, WithCancel은 새 컨텍스트를 부모에 등록하고 타이머를 시작할 수도 있습니다. cancel을 호출하면 타임아웃이 발동하거나 부모가 취소될 때가 아니라 작업이 끝나는 즉시 그 자원이 해제됩니다. 만든 직후에 defer cancel()을 쓰세요. cancel 함수를 버리면 go vet이 경고합니다.
Go에서 "context deadline exceeded"는 무슨 뜻인가요?
컨텍스트의 마감 시간이 지난 뒤 ctx.Err()가 반환하는 오류인 context.DeadlineExceeded의 텍스트입니다. HTTP 클라이언트와 데이터베이스 드라이버처럼 컨텍스트를 존중하는 함수는 시간이 다 되면 이를 (흔히 감싸서) 반환합니다. errors.Is(err, context.DeadlineExceeded)로 확인하세요.
매개변수를 넘기는 데 context.WithValue를 써야 하나요?
아니요. 트레이스 ID나 인증된 사용자처럼, API 경계를 넘나들고 중간의 함수들은 알 필요가 없는 요청 범위의 데이터에만 쓰세요. 함수가 일하는 데 필요한 것은 컴파일러가 확인할 수 있는 매개변수에 들어가야 합니다.