context 로 취소 전파하기
이 장에서 배우는 것
앞 장에서 파이프라인, 팬아웃, 워커 풀을 만들었다. 그 구조들은 일을 시작하는 방법은 갖췄지만 일을 멈추는 방법은 아직 부실하다. 손님이 주문을 취소하거나, 배달원 배정이 너무 오래 걸리거나, 서버가 종료되면 이미 출발한 고루틴들이 한꺼번에 손을 놓아야 한다. 이 장은 그 신호를 호출 사슬 전체에 전달하는 표준 수단인 context 패키지를 다룬다.
- WithCancel, WithTimeout, WithDeadline 의 차이를 구분하고 알맞은 것을 고른다.
- select 로 취소 신호와 작업 완료를 동시에 기다리는 코드를 작성한다.
- context 를 함수의 첫 번째 인자로 넘기는 관례와 그 이유를 설명한다.
- 요청 범위 값(request-scoped value)을 안전한 키와 접근 함수로 다룬다.
- HTTP 핸들러에서 r.Context() 를 기점으로 마감과 취소를 이어 붙인다.
문제 상황
중계 서비스는 주문 하나를 받으면 가까운 배달원을 찾는 배정 단계를 호출한다. 배정 단계는 외부 위치 조회 서버에 묻기 때문에 평소 수 밀리초지만 가끔 수 초가 걸린다. 손님은 기다리다 앱을 닫고, 서버는 닫힌 연결을 향해 계속 배정 작업을 돌린다. 이런 작업이 쌓이면 고루틴과 연결이 줄지 않고 늘기만 한다.
해결하려면 세 가지가 필요하다. 첫째, 호출자가 "이제 그만"이라고 말할 수 있어야 한다. 둘째, 말하지 않아도 시간이 다 되면 스스로 멈춰야 한다. 셋째, 그 신호가 호출 사슬의 맨 아래 고루틴까지 닿아야 한다. 채널을 직접 만들어 done 채널을 모든 함수에 끼워 넣을 수도 있지만, 함수마다 모양이 달라지고 마감 시각이나 오류 원인을 실어 나를 수 없다. context.Context 는 이 요구를 하나의 인터페이스로 묶는다.
Context 의 기본 구조
Context 는 네 가지 메서드를 가진 인터페이스다. Done 은 취소되면 닫히는 채널을 돌려주고, Err 는 닫힌 이유를 알려 주며, Deadline 은 마감 시각이 있으면 그 값을, Value 는 키에 묶인 값을 돌려준다. 이 장에서 쓰는 대부분의 동작은 Done 과 Err 두 가지로 설명된다.
컨텍스트는 나무를 이룬다. context.Background() 가 뿌리이고, WithCancel 같은 함수를 부를 때마다 부모에서 자식이 갈라진다. 부모가 취소되면 모든 자손이 취소되고, 자식만 취소하면 부모와 형제는 영향을 받지 않는다. 아래 그림이 이 방향성을 보여 준다.
세 가지 파생 함수
WithCancel 은 cancel 함수를 돌려주며 호출한 순간 취소된다. WithTimeout 은 지금부터 얼마 뒤라는 상대 시간으로, WithDeadline 은 몇 시 몇 분이라는 절대 시각으로 마감을 정한다. WithTimeout(parent, d) 는 내부에서 WithDeadline(parent, time.Now().Add(d)) 를 부르는 것과 같다. 세 함수 모두 취소 함수를 돌려주고, 마감이 먼저 와도 이 함수를 호출하는 것이 규칙이다. 호출하면 타이머와 부모에 걸어 둔 등록이 즉시 해제된다.
| 함수 | 마감 지정 | 마감으로 닫힌 뒤 Err | cancel 로 닫힌 뒤 Err |
|---|---|---|---|
| WithCancel | 없음 | 해당 없음 | context.Canceled |
| WithTimeout | 지금부터 d | context.DeadlineExceeded | context.Canceled |
| WithDeadline | 절대 시각 t | context.DeadlineExceeded | context.Canceled |
부모의 마감이 자식이 요청한 마감보다 이르면 자식은 부모의 마감을 그대로 따른다. 자식이 마감을 늘려서 부모를 넘어설 수는 없다. 완성 코드의 세 번째 실험이 이 성질을 확인한다.
취소 신호를 받는 select
Done 채널은 값을 보내지 않고 닫기만 한다. 닫힌 채널은 수신이 언제나 즉시 성공하므로, select 의 한 갈래에 case <-ctx.Done(): 을 두면 "취소되면 이 갈래가 선택된다"는 의미가 된다. 나머지 갈래에는 실제 작업의 완료를 둔다.
time.Sleep 으로 기다리는 코드는 이 구조를 만들 수 없다. 잠든 고루틴은 취소 신호를 볼 수 없어 약속한 시간을 끝까지 채운다. 반면 타이머 채널과 Done 을 select 로 묶으면 둘 중 먼저 오는 쪽에서 즉시 깨어난다. 아래 그림은 같은 시점에 취소가 들어왔을 때 두 방식의 차이를 나타낸다.
반복문 안에서 일하는 고루틴도 같은 원리다. 앞 장의 생산자처럼 채널에 값을 보내는 곳에는 보내기와 Done 을 select 로 묶는다. 수신자가 떠난 뒤 보내기에서 영원히 막힌 고루틴이 남는 것을 이 갈래가 막아 준다.
취소를 확인한 뒤 돌려줄 오류는 ctx.Err() 를 그대로 쓴다. 호출자는 errors.Is 로 Canceled 와 DeadlineExceeded 를 구분해 대응할 수 있다. 오류를 감싸는 법은 앞서 오류 설계에서 다룬 방식을 따르면 된다.
context 를 넘기는 관례
표준 라이브러리와 거의 모든 외부 패키지는 같은 관례를 따른다. context 는 첫 번째 인자이고 이름은 ctx 이며, 구조체 필드에 저장하지 않는다. 첫 번째 위치에 고정하면 시그니처만 봐도 취소 가능한 함수인지 알 수 있고, 린터가 규칙을 검사하기도 쉽다. 구조체에 저장하면 그 구조체가 쓰이는 모든 호출이 하나의 취소 범위에 묶여 요청마다 다른 마감을 줄 수 없게 된다.
nil 을 넘기지 않는다. 아직 어떤 context 를 써야 할지 모르는 자리에는 context.TODO() 를 두어 "나중에 채울 곳"임을 코드에 남긴다. 프로그램의 진입점과 테스트처럼 뿌리가 필요한 곳에서만 context.Background() 를 쓴다.
요청 범위 값의 올바른 쓰임
WithValue 는 context 에 키와 값을 묶어 아래로 내려보낸다. 요청 ID, 인증된 사용자 식별자처럼 요청 하나의 수명과 함께하고 여러 계층을 가로지르는 값에 적합하다. 반대로 함수가 동작하는 데 꼭 필요한 입력, 예컨대 주문 번호나 가게 이름은 인자로 받아야 한다. context 에 넣으면 시그니처에서 의존성이 사라져 호출자가 무엇을 채워야 하는지 알 수 없고, 빠뜨려도 컴파일러가 알려 주지 않는다.
키는 string 같은 기본 타입으로 만들지 않는다. 다른 패키지가 우연히 같은 문자열을 쓰면 값이 덮이거나 읽힌다. 패키지 안에서만 보이는 자체 타입을 정의하고, 읽기와 쓰기를 함수 두 개로 감싸 키를 밖에 노출하지 않는다. 읽는 쪽은 타입 단언에서 ok 를 확인해 값이 없을 때도 안전하게 빈 값을 돌려준다.
| 값 | 수명 | 없으면 동작이 달라지는가 | 둘 곳 |
|---|---|---|---|
| 요청 ID | 요청 하나 | 아니오, 로그에만 쓰인다 | context |
| 주문 번호 | 함수 호출 | 예, 처리 대상 자체다 | 인자 |
| 데이터베이스 핸들 | 프로그램 전체 | 예 | 구조체 필드나 인자 |
| 호출 추적 정보 | 요청 하나 | 아니오 | context |
HTTP 핸들러와 이어 붙이기
net/http 서버는 요청마다 context 를 만들어 r.Context() 로 건넨다. 이 context 는 클라이언트가 연결을 끊거나 핸들러가 끝나면 취소된다. 핸들러는 이것을 뿌리로 삼아 WithTimeout 으로 자기 단계의 마감을 덧붙이고, 하위 호출에 그대로 넘긴다. 그러면 클라이언트의 이탈과 서버의 마감이 같은 신호 하나로 합쳐진다.
| ctx.Err() | 의미 | 응답 코드 | 비고 |
|---|---|---|---|
| DeadlineExceeded | 서버가 정한 마감 초과 | 504 | 재시도 안내 가능 |
| Canceled | 클라이언트가 떠남 | 499 | 비표준 코드, 로그용 |
| nil 이 아닌 다른 오류 | 배정 자체의 실패 | 500 | 원인은 로그에 남김 |
499 는 표준 상태 코드가 아니고 일부 프록시가 관습으로 쓰는 값이다. 연결이 이미 끊겼다면 응답이 전달되지 않으므로 이 값은 사실상 서버 쪽 기록용이다. 이 장의 예제는 핸들러를 직접 호출하기 때문에 값을 볼 수 있다.
완성 코드
다섯 가지 실험을 한 파일에 담았다. 각 실험은 시간에 따라 달라지는 값을 출력하지 않고, 마감 비교는 큰 간격(수 밀리초 대 수백 밀리초)으로 둬서 결과가 고정된다.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
)
type ctxKey int
const requestIDKey ctxKey = iota
// WithRequestID 는 요청 ID 를 담은 자식 context 를 만든다.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// RequestID 는 context 에서 요청 ID 를 꺼낸다. 없으면 빈 문자열이다.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
// assignCourier 는 delay 만큼 걸리는 배달원 배정을 흉내 내며 취소를 존중한다.
func assignCourier(ctx context.Context, orderID string, delay time.Duration) (string, error) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return "courier-" + orderID, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func produceOrders(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for id := 1; ; id++ {
select {
case out <- id:
case <-ctx.Done():
return
}
}
}()
return out
}
func demoCancel() {
fmt.Println("[1] WithCancel")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var got []int
for id := range produceOrders(ctx) {
got = append(got, id)
if len(got) == 3 {
cancel()
break
}
}
fmt.Println("받은 주문:", got)
fmt.Println("ctx.Err:", ctx.Err())
}
func tryAssign(timeout, delay time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return assignCourier(ctx, "A1", delay)
}
func demoTimeout() {
fmt.Println("[2] WithTimeout")
c, err := tryAssign(time.Second, 5*time.Millisecond)
fmt.Printf("빠른 배정: %s, 오류=%v\n", c, err)
_, err = tryAssign(20*time.Millisecond, 500*time.Millisecond)
fmt.Printf("느린 배정: 오류=%v, 시간 초과=%t\n", err, errors.Is(err, context.DeadlineExceeded))
}
func demoDeadline() {
fmt.Println("[3] WithDeadline")
parent, cancelParent := context.WithDeadline(context.Background(), time.Now().Add(20*time.Millisecond))
defer cancelParent()
child, cancelChild := context.WithTimeout(parent, time.Hour)
defer cancelChild()
pd, _ := parent.Deadline()
cd, ok := child.Deadline()
fmt.Println("자식 마감 존재:", ok)
fmt.Println("자식 마감이 부모와 같다:", pd.Equal(cd))
<-child.Done()
fmt.Println("자식 오류:", child.Err())
}
func demoTree() {
fmt.Println("[4] 취소 전파")
parent, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
results := make([]string, 3)
started := make(chan struct{}, 3)
for i := range 3 {
wg.Add(1)
go func() {
defer wg.Done()
child, stop := context.WithTimeout(parent, time.Hour)
defer stop()
started <- struct{}{}
<-child.Done()
results[i] = fmt.Sprintf("가게 %d: %v", i, child.Err())
}()
}
for range 3 {
<-started
}
cancel()
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
}
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = "none"
}
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
})
}
func dispatchHandler(timeout time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
q := r.URL.Query()
delay := time.Millisecond
if q.Get("slow") == "1" {
delay = 500 * time.Millisecond
}
courier, err := assignCourier(ctx, q.Get("order"), delay)
switch {
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "배정 시간 초과 요청="+RequestID(ctx), http.StatusGatewayTimeout)
case errors.Is(err, context.Canceled):
http.Error(w, "요청 취소됨", 499)
case err != nil:
http.Error(w, "배정 실패", http.StatusInternalServerError)
default:
fmt.Fprintf(w, "%s 배정 요청=%s\n", courier, RequestID(ctx))
}
})
}
func call(ctx context.Context, h http.Handler, target, id string) string {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if id != "" {
req.Header.Set("X-Request-ID", id)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return fmt.Sprintf("%d %s", rec.Code, strings.TrimSpace(rec.Body.String()))
}
func demoHTTP() {
fmt.Println("[5] HTTP 핸들러")
h := withRequestID(dispatchHandler(50 * time.Millisecond))
bg := context.Background()
fmt.Println("정상:", call(bg, h, "/dispatch?order=A1", "req-1"))
fmt.Println("지연:", call(bg, h, "/dispatch?order=A2&slow=1", "req-2"))
gone, cancel := context.WithCancel(bg)
cancel()
fmt.Println("취소:", call(gone, h, "/dispatch?order=A3&slow=1", "req-3"))
}
func main() {
demoCancel()
demoTimeout()
demoDeadline()
demoTree()
demoHTTP()
}
줄별 해설
키와 접근 함수
ctxKey 는 이 패키지 밖에서 만들 수 없는 타입이므로 같은 정수 값을 가진 다른 키와 충돌하지 않는다. WithRequestID 와 RequestID 두 함수만 키를 안다. RequestID 의 id, _ := ...(string) 은 값이 없거나 타입이 다르면 빈 문자열을 돌려주는 안전한 단언이다.
assignCourier
time.Sleep 대신 time.NewTimer 를 쓰고 select 로 타이머와 Done 을 묶었다. 함수가 어느 쪽으로 반환하든 defer timer.Stop() 이 타이머를 정리한다. 취소로 깨어나면 빈 문자열과 ctx.Err() 를 돌려준다.
demoCancel 과 produceOrders
생산자는 보내기와 Done 을 select 로 묶는다. 소비자는 세 건을 받자마자 cancel() 을 부르고 반복을 빠져나온다. 이후 소비자가 수신하지 않으므로 보내기 갈래는 준비되지 않고, Done 갈래만 남아 생산자가 종료한다. 그래서 받은 주문은 항상 1, 2, 3 이다. 반복문 밖에서도 defer cancel() 을 두어 어떤 경로로 나가도 정리되게 했다.
demoTimeout
tryAssign 은 호출마다 새 WithTimeout 을 만들고 defer 로 cancel 을 부른다. 첫 호출은 마감 1초에 작업 5밀리초라 성공하고, 두 번째는 마감 20밀리초에 작업 500밀리초라 DeadlineExceeded 로 끝난다. errors.Is 로 원인을 확인한다.
demoDeadline
부모는 20밀리초 뒤의 절대 시각을 마감으로 갖는다. 자식은 1시간을 요청했지만 부모의 마감이 더 이르므로 Deadline() 이 부모와 같은 시각을 돌려준다. <-child.Done() 은 부모의 마감이 도래할 때까지 기다리고, 자식의 Err 는 DeadlineExceeded 다.
demoTree
세 고루틴이 각자 부모에서 파생한 자식 context 를 만들어 Done 을 기다린다. started 버퍼 채널로 세 고루틴이 모두 대기에 들어간 것을 확인한 뒤 부모를 취소한다. 결과는 인덱스 칸에 쓰므로 고루틴 실행 순서와 무관하게 출력 순서가 고정된다. 서로 다른 칸에 쓰므로 경쟁도 없고, 읽기는 wg.Wait() 뒤에 한다.
핸들러와 미들웨어
withRequestID 는 헤더에서 ID 를 읽어 r.WithContext 로 값이 실린 요청 사본을 다음 핸들러에 넘긴다. dispatchHandler 는 r.Context() 에서 파생해 50밀리초 마감을 덧붙이고, 같은 ctx 를 assignCourier 와 RequestID 에 쓴다. switch 는 오류 종류를 상태 코드로 옮긴다. 마감과 취소를 구분하는 이유는 응답 코드와 로그의 의미가 다르기 때문이다.
call 과 demoHTTP
call 은 주어진 context 로 요청을 만들고 httptest.NewRecorder 로 응답을 받아 상태 코드와 본문을 한 줄로 돌려준다. 포트를 열지 않으므로 실행이 바로 끝난다. 세 번째 요청은 호출 전에 이미 취소한 context 를 실어 클라이언트가 떠난 상황을 만든다. 배정에 500밀리초가 걸리므로 취소 갈래가 선택된다.
실행 결과
$ go run main.go
[1] WithCancel
받은 주문: [1 2 3]
ctx.Err: context canceled
[2] WithTimeout
빠른 배정: courier-A1, 오류=<nil>
느린 배정: 오류=context deadline exceeded, 시간 초과=true
[3] WithDeadline
자식 마감 존재: true
자식 마감이 부모와 같다: true
자식 오류: context deadline exceeded
[4] 취소 전파
가게 0: context canceled
가게 1: context canceled
가게 2: context canceled
[5] HTTP 핸들러
정상: 200 courier-A1 배정 요청=req-1
지연: 504 배정 시간 초과 요청=req-2
취소: 499 요청 취소됨
실무에서 자주 틀리는 것
cancel 을 부르지 않는다
WithTimeout 은 마감 전에 끝나도 타이머와 부모 등록을 유지한다. cancel 을 부르지 않으면 마감이 될 때까지 자원이 남는다. go vet 도 이 누락을 경고한다.
// 틀림
ctx, _ := context.WithTimeout(parent, 3*time.Second)
courier, err := assignCourier(ctx, id, d)
// 고침
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
courier, err := assignCourier(ctx, id, d)
취소를 보지 않고 기다린다
context 를 인자로 받고도 Done 을 확인하지 않으면 취소는 아무 효과가 없다. 기다림의 자리에는 select 를 쓴다.
// 틀림
func wait(ctx context.Context, d time.Duration) error {
time.Sleep(d)
return nil
}
// 고침
func wait(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
필수 입력과 문자열 키를 context 에 넣는다
주문 번호처럼 함수가 반드시 알아야 할 값을 context 에 숨기면 시그니처가 거짓말을 한다. 문자열 키는 다른 패키지와 충돌할 수 있다.
// 틀림
ctx = context.WithValue(ctx, "orderID", "A1")
func process(ctx context.Context) { id := ctx.Value("orderID").(string); _ = id }
// 고침
func process(ctx context.Context, orderID string) { _ = orderID }
// 요청 ID 처럼 부가 정보만 전용 키 타입과 접근 함수로 싣는다.
ctx = WithRequestID(ctx, "req-1")
핸들러 안에서 Background 로 새로 시작한다
핸들러가 context.Background() 에서 파생하면 클라이언트 이탈이 하위 작업에 전달되지 않고, 미들웨어가 실은 값도 사라진다.
// 틀림
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
// 고침
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
한눈에 보기
| 주제 | 규칙 | 이유 | 관련 코드 |
|---|---|---|---|
| 취소 함수 | 파생 직후 defer cancel() | 타이머와 부모 등록 해제 | WithCancel, WithTimeout |
| 기다림 | select 로 Done 과 함께 기다림 | 취소 즉시 반환 | assignCourier |
| 시그니처 | ctx 를 첫 인자로, 필드에 저장하지 않음 | 요청마다 다른 수명 | 모든 함수 |
| 값 | 전용 키 타입과 접근 함수, 부가 정보만 | 충돌 방지와 명시적 의존 | RequestID |
| HTTP | r.Context() 에서 파생 | 클라이언트 이탈 전파 | dispatchHandler |
| 오류 | errors.Is 로 Canceled 와 DeadlineExceeded 구분 | 응답과 로그 구분 | switch 문 |
연습 문제
- demoTree 에서 부모 대신 가게 1번의 자식 context 만 취소하도록 바꾸면 다른 가게의 결과는 어떻게 되는지 설명하라. 취소하지 않은 가게가 영원히 기다리지 않게 하려면 무엇을 해야 하는지도 쓰라.
- produceOrders 를 고쳐 WithCancel 대신 WithTimeout 으로 호출하는 main 을 작성하라. 마감이 지난 뒤 ctx.Err() 로 어떤 값이 나오는가.
- RequestID 를 쓰는 로그 함수
logf(ctx context.Context, format string, args ...any)를 작성하라. 요청 ID 가 없으면 "-" 를 쓴다. - dispatchHandler 의 delay 를 쿼리 값에서 읽도록 바꿀 때, 잘못된 입력은 어떤 상태 코드로 응답하는 것이 좋은가. 그 판단이 context 와 관련이 있는지도 답하라.
정답과 해설
1. 가게 1번만 context.Canceled 로 끝나고 나머지 둘은 아직 Done 이 닫히지 않아 대기를 계속한다. 자식은 WithTimeout(parent, time.Hour) 이므로 한 시간 뒤에는 DeadlineExceeded 로 끝난다. 그전에 끝내려면 부모를 취소하거나 더 짧은 마감을 주면 된다. 자식의 취소는 부모와 형제에게 닿지 않는다는 점이 핵심이다.
2. 예를 들어 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) 로 만들고 for id := range produceOrders(ctx) 안에서 시간이 지나기를 기다린다. 소비자가 계속 수신하는 동안 생산자는 보내기를 이어 가므로, 마감이 되면 생산자가 닫고 반복이 끝난다. 이때 ctx.Err() 는 context.DeadlineExceeded 다. 다만 얼마나 많은 주문을 받는지는 속도에 따라 달라지므로 개수를 출력하지 않는 것이 좋다.
3.
func logf(ctx context.Context, format string, args ...any) {
id := RequestID(ctx)
if id == "" {
id = "-"
}
fmt.Printf("[%s] "+format+"\n", append([]any{id}, args...)...)
}
요청 ID 는 부가 정보이므로 없어도 동작은 바뀌지 않는다. 그래서 context 에 두는 것이 알맞고, 빈 값 처리만 접근하는 쪽에서 맡는다.
4. 입력 오류는 클라이언트의 잘못이므로 400 Bad Request 가 알맞다. context 를 만들기 전에 검증하면 불필요한 타이머를 만들지 않아도 된다. 검증 실패는 취소나 마감과 무관하므로 context 오류와 섞어 처리하지 않는다. 500 으로 응답하면 서버 결함으로 오해되어 알림이 잘못 울린다.