본문으로 건너뛰기

context

이 챕터에서 다루는 것

7-4에서 done 채널과 time.After로 취소와 타임아웃을 만들었다. 그런데 함수가 다섯 단계로 중첩되면 done 채널을 다섯 번 넘겨야 하고, 타임아웃은 단계마다 다시 계산해야 한다.

context취소 신호와 데드라인과 요청 스코프 값을 하나의 값으로 묶어 호출 체인 전체에 흘려보내는 표준 방법이다. 표준 라이브러리의 거의 모든 블로킹 API가 이것을 받는다.

문제 — 취소가 전파되지 않는다

func Handler(w http.ResponseWriter, r *http.Request) {
data := fetchFromDB() // 3초 걸린다
enriched := callAPI(data) // 5초 걸린다
render(w, enriched)
}

클라이언트가 1초 만에 연결을 끊었다. 그런데 이 코드는 8초를 다 쓴다. 아무도 그 사실을 모르기 때문이다.

취소를 전파하려면 모든 함수가 "그만"이라는 신호를 받을 수 있어야 한다. done 채널을 쓰면 이렇게 된다.

func fetchFromDB(done <-chan struct{}) (Data, error)
func callAPI(done <-chan struct{}, d Data) (Result, error)

여기에 타임아웃도 걸고 싶고, 요청 ID도 로그에 남기고 싶다면 파라미터가 계속 늘어난다. 그리고 모든 라이브러리가 같은 규약을 써야 한다. context.Context가 그 규약이다.

Context 인터페이스

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

메서드 넷뿐이다.

  • Done() — 취소되면 닫히는 채널. 7-2에서 본 "닫기로 브로드캐스트"가 그대로 쓰였다. 대기하는 고루틴이 몇이든 close 한 번에 전부 깨어난다.
  • Err() — 아직 살아 있으면 nil, 취소면 context.Canceled, 시간 초과면 context.DeadlineExceeded.
  • Deadline() — 마감 시각. 없으면 okfalse.
  • Value(key) — 요청 스코프 값.

컨텍스트는 불변이다. WithXxx 함수는 부모를 감싼 새 컨텍스트를 만든다. 그래서 트리가 생기고, 취소는 부모에서 자식으로만 흐른다.

취소 — WithCancel

examples/07-concurrency/07-context-basics/main.go
// worker는 ctx가 취소될 때까지 tick마다 카운트를 올린다.
// context를 쓰는 함수의 표준 모양이다: 첫 인자가 ctx, 루프 안에 Done 케이스.
func worker(ctx context.Context, tick <-chan struct{}) (int, error) {
count := 0
for {
select {
case <-ctx.Done():
// Err()는 왜 끝났는지, Cause()는 누가 무슨 이유로 끝냈는지 알려 준다.
return count, ctx.Err()
case <-tick:
count++
}
}
}

for + select + case <-ctx.Done(). 이 세 줄이 Go 동시성 코드의 가장 흔한 골격이다. 7-1에서 말한 "고루틴을 띄울 때 어떻게 끝나는지 같이 적어라"의 표준 답이기도 하다.

examples/07-concurrency/07-context-basics/main.go
// 1. WithCancel — 명시적으로 취소한다.
ctx, cancel := context.WithCancel(context.Background())
tick := make(chan struct{})

var wg sync.WaitGroup
var got int
var err error
wg.Go(func() { got, err = worker(ctx, tick) })

for range 3 {
tick <- struct{}{} // 버퍼 없는 채널이므로 워커가 받은 것이 확실하다
}
cancel()
wg.Wait()

버퍼 없는 채널로 tick을 보낸 덕에 "정확히 3"이 보장된다. 랑데부 의미론이 결정적인 출력을 만드는 또 하나의 예다.

cd examples/07-concurrency
go run ./07-context-basics
센 횟수: 3
ctx.Err(): context canceled
errors.Is(err, context.Canceled): true
Err(): context canceled
Cause(): 충분히 셌다
errors.Is(Cause, errEnough): true
자식 Err(): context canceled
자식 데드라인이 부모와 같은가: true

cancel은 반드시 부른다

ctx, cancel := context.WithCancel(parent)
defer cancel()

성공 경로에서도 부른다. cancel을 안 부르면 부모 컨텍스트가 자식을 계속 참조하고, 타이머가 있으면 그것도 살아 있다. 전형적인 누수다.

go vetlostcancel 분석기가 이것을 잡는다. cancel_로 버리거나 어떤 경로에서도 안 부르면 경고가 뜬다.

취소 이유 — WithCancelCause

ctx.Err()context.Canceled밖에 못 준다. "왜?"를 알고 싶으면 WithCancelCause를 쓴다.

examples/07-concurrency/07-context-basics/main.go
// 2. WithCancelCause — 취소 이유를 실어 보낸다.
ctx2, cancel2 := context.WithCancelCause(context.Background())
cancel2(errEnough)
<-ctx2.Done()
fmt.Println("Err():", ctx2.Err())
fmt.Println("Cause():", context.Cause(ctx2))
fmt.Println("errors.Is(Cause, errEnough):", errors.Is(context.Cause(ctx2), errEnough))

Err()는 여전히 context canceled지만 context.Cause(ctx)가 진짜 이유를 준다. 4-7의 센티널 에러와 errors.Is가 그대로 쓰인다. WithDeadlineCause, WithTimeoutCause도 같은 짝이다.

취소는 아래로만 흐른다

examples/07-concurrency/07-context-basics/main.go
// 3. 취소는 자식 전체로 전파된다. 반대 방향은 없다.
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithTimeout(parent, time.Hour)
defer cancelChild()

cancelParent()
<-child.Done() // 부모를 취소했는데 자식이 깨어난다
fmt.Println("자식 Err():", child.Err())

부모를 취소하면 자식·손자가 전부 취소된다. 자식을 취소해도 부모는 멀쩡하다. HTTP 요청 하나가 부모, 그 안에서 띄운 DB 조회·캐시 조회·외부 API 호출이 자식이라고 생각하면 이 방향성이 정확히 원하는 동작이다.

데드라인과 타임아웃

ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
ctx, cancel := context.WithDeadline(parent, someTime)

WithTimeout(parent, d)WithDeadline(parent, time.Now().Add(d))의 편의 함수다. 절대 시각이 본질이고, 그래서 호출 체인을 따라 내려가도 남은 예산이 자동으로 줄어든다.

examples/07-concurrency/07-context-timeout/main.go
// fetch는 d만큼 걸리는 작업을 흉내 낸다.
// ctx를 존중한다는 것은 "중간에 Done을 확인한다"는 뜻이다.
func fetch(ctx context.Context, name string, d time.Duration) (string, error) {
t := time.NewTimer(d)
defer t.Stop()

select {
case <-t.C:
return name + " 완료", nil
case <-ctx.Done():
// 취소 이유를 감싸서 올린다. 호출자가 errors.Is로 판별할 수 있다.
return "", fmt.Errorf("%s 취소됨: %w", name, ctx.Err())
}
}

// pipeline은 세 단계를 순서대로 돈다. 하나의 ctx가 전체 예산을 관리한다.
func pipeline(ctx context.Context) error {
stages := []struct {
name string
d time.Duration
}{
{"1단계", 20 * time.Millisecond},
{"2단계", 20 * time.Millisecond},
{"3단계", 200 * time.Millisecond},
}
for _, s := range stages {
v, err := fetch(ctx, s.name, s.d)
if err != nil {
return err
}
fmt.Println(" ", v)
}
return nil
}
go run ./07-context-timeout
실행:
1단계 완료
2단계 완료
결과: 3단계 취소됨: context deadline exceeded
errors.Is(err, context.DeadlineExceeded): true
데드라인 결과: 느린 작업 취소됨: context deadline exceeded
과거 데드라인 Err(): context deadline exceeded

전체 예산 100ms를 세 단계가 나눠 쓴다. 1·2단계가 40ms를 쓰고, 3단계는 200ms가 필요하므로 남은 60ms에서 잘린다. 단계마다 타임아웃을 다시 계산할 필요가 없다.

이 출력은 20/20/200ms와 100ms 예산의 간격이 충분히 커서 안정적이지만, 타이밍에 의존한다는 점은 기억해 둔다.

%w로 감쌌기 때문에 호출자가 errors.Is(err, context.DeadlineExceeded)로 판별할 수 있다. 4-7의 래핑 체인이 그대로 작동한다.

:::tip 자식은 부모보다 오래 살 수 없다

base, _ := context.WithTimeout(ctx, 50*time.Millisecond)
longer, _ := context.WithTimeout(base, time.Hour) // 1시간이 아니다

longer.Deadline()base의 데드라인과 같다. 데드라인은 짧은 쪽이 이긴다. 예제 마지막 줄의 자식 데드라인이 부모와 같은가: true가 그것이다.

호출 체인 어딘가에서 "여기는 무조건 30초 준다"고 늘리는 것은 불가능하다. 정말 부모와 수명을 끊어야 하면 context.WithoutCancel(ctx)로 값만 물려받는 새 컨텍스트를 만든다 — 요청이 끝난 뒤에도 계속해야 하는 비동기 작업에 쓴다. :::

관례 — 첫 인자, 구조체 금지

func DoSomething(ctx context.Context, arg Arg) error

세 가지가 관례로 굳어 있다.

  1. 첫 번째 파라미터이고 이름은 ctx다.
  2. 구조체 필드로 넣지 않는다.
  3. nil을 넘기지 않는다. 모르겠으면 context.TODO().

2번의 이유가 중요하다. 컨텍스트는 요청 하나의 수명을 나타낸다. 구조체는 보통 그보다 오래 산다.

type Client struct {
ctx context.Context // 하지 않는다
}

이렇게 하면 Client 하나를 여러 요청이 공유할 때 어느 요청의 컨텍스트인지 알 수 없다. 취소가 엉뚱한 요청을 죽인다. 컨텍스트는 함수 인자로 흐른다.

:::note context.Background() vs context.TODO() 동작은 완전히 같다. 둘 다 취소되지 않고 데드라인이 없는 빈 컨텍스트다.

  • Background()main, 초기화, 최상위 요청의 뿌리.
  • TODO() — "여기 컨텍스트가 있어야 하는데 아직 배선을 안 했다."

TODO()는 정적 분석 도구가 찾을 수 있는 표식이다. 의미상 구분해서 쓴다. :::

:::info 예전 코드에서 만나는 것 golang.org/x/net/contextcontext가 Go 1.7에서 표준 라이브러리로 들어오기 전의 자리다. 지금은 표준 context의 별칭일 뿐이니, 보이면 그냥 context로 바꾸면 된다. :::

WithValue — 아껴 쓴다

ctx = context.WithValue(ctx, key, value)
v := ctx.Value(key)

두 가지가 즉시 문제가 된다. 키가 아무 타입이나 될 수 있어서 충돌한다. 그리고 값이 any라서 타입 안전성이 없다.

두 문제를 다 막는 표준 패턴이 있다.

examples/07-concurrency/07-context-value/main.go
// 키 타입을 비공개로 만든다.
// 다른 패키지가 같은 키를 만들 수 없으므로 충돌이 구조적으로 불가능하다.
// string이나 int를 키로 쓰면 다른 패키지와 충돌할 수 있다.
type ctxKey int

const (
keyRequestID ctxKey = iota
keyUser
)

// WithRequestID / RequestID 쌍으로 감싼다.
// 호출자는 context.WithValue도 ctxKey도 볼 필요가 없다.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, keyRequestID, id)
}

// 두 번째 반환값으로 "있었는가"를 준다. 없을 때 패닉하지 않는다.
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(keyRequestID).(string)
return id, ok
}

비공개 키 타입 + 접근자 함수 쌍. 이 두 가지가 세트다.

  • ctxKey가 비공개라서 다른 패키지가 같은 키 값을 만들 수 없다. keyRequestID0이어도, 다른 패키지의 0은 다른 타입이므로 충돌하지 않는다.
  • WithValueValue가 패키지 밖으로 새지 않으므로 타입 실수가 불가능하다.

6-5에서 본 "공개 API를 최소로"가 여기서도 그대로 적용된다.

go run ./07-context-value
[(없음)] 익명 요청: /health
[req-001] Alice 요청: /orders
바깥: req-001 / 안쪽: req-002
모르는 키: <nil>

바깥: req-001 / 안쪽: req-002가 보여 주듯 WithValue는 덮어쓰기가 아니라 감싸기다. 원래 컨텍스트는 그대로 남는다. Value 조회는 트리를 위로 거슬러 올라가는 선형 탐색이므로, 값이 많으면 조회가 느려진다.

무엇을 넣고 무엇을 넣지 않는가

넣어도 되는 것넣지 말아야 할 것
요청 ID, 트레이스 ID함수가 동작하는 데 필요한 인자
인증된 사용자DB 커넥션, 로거 같은 의존성
로케일, 요청 시작 시각설정값
미들웨어가 만든 요청 스코프 메타데이터반환값을 대신하는 출력 저장소

기준은 하나다.

함수 시그니처에 적을 수 있으면 시그니처에 적는다.

ctx에 넣는 순간 컴파일러가 검사할 수 없고, 문서에도 안 나오고, 호출자가 무엇을 넣어야 하는지 알 수 없다. 의존성은 6-5에서 본 대로 생성자로 주입한다.

context.AfterFunc

취소됐을 때 정리 작업을 하나 걸어 두는 함수다.

stop := context.AfterFunc(ctx, func() {
conn.Close()
})
defer stop() // 정상 종료면 등록 해제

for/select 루프를 만들지 않고도 "취소되면 이것만 해라"를 표현한다. stop()은 아직 실행되지 않았으면 취소하고 true를 반환한다.

흔히 하는 실수

1. cancel을 안 부른다

defer cancel()이 기본이다. go vetlostcancel이 잡는다.

2. ctx.Done()을 확인하지 않는다

func slow(ctx context.Context) {
time.Sleep(10 * time.Second) // ctx를 받았지만 무시한다
}

컨텍스트를 받는 것과 존중하는 것은 다르다. 긴 루프라면 주기적으로 확인한다.

for i, item := range items {
if i%100 == 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
process(item)
}

3. 구조체에 넣는다

위에서 본 그대로다.

4. WithValue로 의존성을 넘긴다

db := ctx.Value("db").(*sql.DB) // 키도 틀렸고 발상도 틀렸다

문자열 키는 충돌하고, 없으면 패닉하고, 컴파일러는 아무것도 검사하지 못한다. DB는 생성자로 주입한다.

5. 라이브러리 안에서 타임아웃을 정한다

func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // 누가 정했나?
defer cancel()
...
}

타임아웃 정책은 호출자의 것이다. 라이브러리는 받은 ctx를 그대로 존중한다. 기본값을 주고 싶으면 옵션으로 노출한다.

6. ctx.Err()만 보고 원인을 판단한다

WithCancelCause를 쓴 곳이라면 context.Cause(ctx)를 봐야 진짜 이유가 나온다.

7. 취소가 곧 정리라고 생각한다

cancel()신호를 보낼 뿐이다. 워커가 Done()을 확인하고 실제로 반환할 때까지는 계속 돈다. "취소했으니 끝났겠지"라고 가정하고 자원을 정리하면 경합이다. 끝났음을 확인하려면 WaitGroup이 필요하다.

정리

  • context는 취소·데드라인·요청 스코프 값을 하나로 묶어 호출 체인에 흘려보낸다. Go 생태계 전체의 규약이다.
  • Done()은 닫히는 채널이다. close로 브로드캐스트하는 7-2의 기법이 그대로 쓰였다.
  • for + select + case <-ctx.Done()이 표준 골격이다.
  • defer cancel()은 성공 경로에서도 부른다. go vetlostcancel이 감시한다.
  • WithTimeoutWithDeadline의 편의 함수다. 절대 시각이 본질이라 체인을 따라 예산이 자동으로 줄어들고, 자식은 부모보다 오래 살 수 없다.
  • WithCancelCause + context.Cause로 취소 이유를 실어 보낸다.
  • 첫 인자, 이름은 ctx, 구조체에 넣지 않는다.
  • WithValue는 요청 스코프 메타데이터 전용이다. 비공개 키 타입과 접근자 함수 쌍으로 감싼다. 의존성은 생성자로 주입한다.
  • 타임아웃 정책은 호출자의 것이다. 라이브러리는 받은 ctx를 존중한다.

연습문제

  1. 07-context-timeoutpipeline에서 3단계의 소요 시간을 20ms로 줄여 보자. 전부 통과하는가? 그다음 전체 예산을 30ms로 줄이면 어느 단계에서 잘리는가? "단계마다 타임아웃을 다시 계산할 필요가 없다"는 말이 무슨 뜻인지 확인해 보자.

  2. 07-context-basicsworker에서 case <-ctx.Done()을 지우고 cancel()을 그대로 부른 뒤 runtime.NumGoroutine()을 찍어 보자. 무엇이 남는가? "컨텍스트를 받는 것과 존중하는 것은 다르다"를 이 실험으로 설명해 보자.

  3. 07-context-value에서 키 타입을 ctxKey가 아니라 string으로 바꾸고, 다른 "패키지"를 흉내 내는 함수가 같은 문자열 키로 값을 넣게 해 보자. 어느 값이 읽히는가? 비공개 키 타입이 이 문제를 왜 구조적으로 막는지 정리해 보자.

  4. context.AfterFunc를 써서 07-context-timeoutpipeline이 취소될 때 "정리 완료"를 출력하도록 해 보자. 정상 종료했을 때는 출력되지 않아야 한다. stop()의 반환값을 출력해서 두 경우가 어떻게 다른지 확인해 보자.