본문으로 건너뛰기

Go context 완전 정리 — 취소 전파, 시간 예산, 그리고 4가지 함정

이 글의 순서10개 절

ctx를 첫 인자로 받아서 넘기는 것까지는 하고 있었다. 그런데 취소하면 고루틴이 정말 멈추는지는 확인해 본 적이 없었다. 면접에서 그 질문에 말이 막힌 뒤 context를 처음부터 다시 봤다. 취소는 멈추라는 신호를 보낼 뿐이고, 실제로 멈추는 건 받는 쪽이었다.

왜 필요한가: 유령 작업

요청 하나가 들어오면 서버는 그 안에서 여러 갈래로 뻗는다. DB를 치고, 외부 API를 부르고, 파일을 읽고, 그 각각이 또 고루틴을 띄운다. 그런데 클라이언트가 3초 만에 연결을 끊어 버리면?

알려 줄 수단이 없으면 서버는 아무도 기다리지 않는 결과를 계속 만든다. 이게 유령 작업이다. 평소엔 티가 안 나다가, 장애로 응답이 느려져 클라이언트들이 타임아웃으로 끊기 시작하면 버려진 작업이 쌓여 자원을 다 먹고 연쇄 장애로 번진다. context는 그 상황에서 그만해도 된다는 신호를 호출 체인 전체에 전파하는 표준 수단이다.

정체는 의외로 단순하다. 메서드 네 개짜리 인터페이스다.

context.Context
type Context interface {
Deadline() (deadline time.Time, ok bool) // 언제까지인가
Done() <-chan struct{} // 끝났음을 알리는 채널 (닫히면 신호)
Err() error // 왜 끝났나 (Canceled / DeadlineExceeded)
Value(key any) any // 요청 범위 값
}

쓰는 법은 만드는 함수 넷과 읽는 방법 둘이면 거의 끝난다.

만들기 언제
Background() 트리의 뿌리. main, 초기화, 테스트, 그리고 들어오는 요청의 최상단
WithCancel 내가 원할 때 직접 끊고 싶을 때
WithTimeout / WithDeadline 시간 상한을 걸 때. 실무에서 가장 많이 쓴다
WithValue 요청 ID, 트레이스 같은 요청 범위 메타데이터만 (뒤에서 자세히)
읽기 무엇을
select + ctx.Done() 끝났는지 감시한다
ctx.Err() 끝난 이유. Canceled인가 DeadlineExceeded인가

중요한 건 이들이 트리를 만든다는 점이다. WithXxx는 부모를 받아 파생 context를 만들고, 취소는 위에서 아래로만 흐른다.

Background()트리의 뿌리WithTimeout(3s)요청 하나의 수명DB 조회WithTimeout(1s) · 자기만 1초외부 API부모 ctx를 그대로 받는다캐시 갱신고루틴으로 갈라진다요청이 취소되면 아래 세 갈래가 함께 취소된다
요청 하나가 context 트리 하나를 만들고, 취소는 위에서 아래로만 흐른다

트리라서 생기는 규칙이 둘 있다. 첫째, 자식만 취소하면 부모는 멀쩡하다. DB 조회가 1초 타임아웃으로 끊겨도 요청 전체가 끊기지는 않는다. 둘째, deadline은 짧아지기만 한다. 부모가 3초인데 자식이 10초를 걸어도 실제 상한은 3초다. 늘릴 수 없다는 게 안전장치다.

취소는 협조 요청이다: 멈추는 건 받는 쪽

면접에서 context로 취소하면 고루틴이 죽느냐는 질문을 받고 말이 막힌 적이 있다. 답은 아니오다.

Go에는 고루틴을 밖에서 강제 종료하는 방법이 아예 없다. 스레드를 kill하는 API도, 인터럽트도 없다. 그래서 cancel()은 채널을 닫아 그만해도 된다고 알리기만 하고, 실제로 멈추는 건 받는 쪽의 몫이다. 공식 문서도 CancelFunc가 작업이 멈추기를 기다리지 않는다고 못 박는다.

✓Done()을 보는 루프

for _, vm := range vms {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
copyDisk(ctx, vm)
}

다음 반복에서 빠져나온다.

✕Done()을 안 보는 루프

for _, vm := range vms {
copyDisk(vm)
}

신호가 와도 확인하지 않으니 끝까지 돈다. 이게 유령 작업이다.

취소했는데 왜 안 멈추냐는 질문의 답은 대부분 Done()을 안 보는 쪽 코드다. 그래서 취소 체크를 어디에 넣느냐가 실제 설계 포인트가 된다. 원칙은 오래 걸리는 일을 시작하기 직전이다.

오래 걸리는 일 직전에 확인한다
func migrate(ctx context.Context, vms []VM) error {
for _, vm := range vms {
select {
case <-ctx.Done():
return ctx.Err() // Canceled 또는 DeadlineExceeded
default: // default가 있으니 막히지 않고 지나간다
}
if err := copyDisk(ctx, vm); err != nil { // 하위에도 그대로 넘긴다
return err
}
}
return nil
}

다행히 표준 라이브러리는 알아서 봐 준다. net/http나 database/sql처럼 ctx를 받는 API는 안에서 Done()을 감시하다가 취소되면 중단하고 에러를 돌려준다. 내가 직접 체크를 넣어야 하는 건 내가 짠 긴 루프와 CPU를 오래 쓰는 작업이다.

서버가 ctx를 넘기고 있는데도 취소 뒤에 부하가 안 줄어든다면, 체인 어딘가에 Done()을 안 보는 구간이 있을 가능성이 크다. 나는 이럴 때 오래 도는 루프부터 찾는다.

Done()이 브로드캐스트가 되는 이유

여기서 궁금해진 게 있었다. 고루틴이 100개인데 취소 신호를 어떻게 100개 전부에게 동시에 보낼까. 채널에 값을 보내면 하나만 받아 가는데.

답은 값을 보내지 않는다는 것이다. context는 채널을 닫는다. 닫힌 채널에서 수신하면 기다리던 모두가 즉시 제로값을 받고 깨어난다. 채널 하나로 몇 명에게든 알릴 수 있는 셈이다.

값을 보내면: 한 명만 받는다ch <- v고루틴 1받았다고루틴 2대기 중고루틴 3대기 중채널을 닫으면: 모두가 깨어난다close(ch)고루틴 1깨어남고루틴 2깨어남고루틴 3깨어남
값 하나를 보내면 하나만 깨어나고, 채널을 닫으면 기다리던 모두가 깨어난다

Done()의 타입이 <-chan struct{}인 것도 여기서 이해된다. 수신 전용이라 받는 쪽이 실수로 닫을 수 없고(컴파일 에러다), struct{}는 0바이트라 신호 자체에 비용이 없다.

이 성질 덕에 select에서 두 가지 패턴이 자연스럽게 나온다. 상황에 따라 골라 쓰면 된다.

논블로킹 체크와 블로킹 대기
// ① 논블로킹 체크: 계산 루프 중간에 취소됐는지만 확인한다
select {
case <-ctx.Done():
return ctx.Err()
default: // default가 있어서 막히지 않고 지나간다
}
// ② 블로킹 대기: 결과가 오거나 취소되거나, 먼저 오는 쪽을 받는다
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return nil, ctx.Err() // 결과를 영영 기다리며 막히는 일을 막는다
}

시간 예산: deadline은 짧아지기만 한다

context에서 가장 늦게 이해한 게 이거였다. WithTimeout을 몇 초 뒤에 끊는 장치로만 알았는데, 실제로는 요청 전체에 시간 예산을 배정하고 그 예산을 체인이 나눠 쓰게 하는 장치였다.

파생 context의 deadline은 부모보다 늦어질 수 없다. 부모가 3초 뒤에 끝나는데 자식이 10초를 걸어도 실제 상한은 여전히 3초다. 아래로 내려갈수록 남은 시간이 줄어들기만 한다.

체인을 내려갈수록 남은 예산이 줄어든다
  • API 게이트웨이3.0s 남음
  • 주문 서비스2.7s 남음
  • 재고 서비스2.2s 남음
  • DB 쿼리1.7s 남음

앞 단계가 쓴 시간만큼 뒤가 덜 받는다. 타임아웃을 서비스마다 따로 계산하지 않아도 되는 이유다

이 예산은 프로세스 경계도 넘어간다. gRPC는 deadline을 요청 메타데이터에 실어 보내고, 받는 서버의 ctx에 같은 deadline이 걸린다. 그 ctx를 다음 호출에 그대로 넘기면 서비스 체인 전체가 한 예산을 나눠 쓴다. 맨 앞에서 3초를 걸면 맨 뒤 DB까지 그 상한이 따라간다.

부모가 상한을 정한다
parent, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 10*time.Second) // 의미 없다: 실제 상한은 3초
defer cancel2()
남은 시간 보고 판단하기
// 무거운 작업 전에 남은 시간을 직접 볼 수도 있다
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 500*time.Millisecond {
return ErrNotEnoughTime // 어차피 못 끝낸다면 시작하지 않는다
}
HTTP 서버의 요청 ctx
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // 클라이언트 연결이 끊기면 이 ctx가 취소된다
if err := doWork(ctx); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

함정 ① cancel을 안 부르면 샌다

defer cancel()을 관용구로 외워 쓰다가, 안 부르면 정확히 무엇이 새는지는 몰랐다. 공식 문서가 짧게 답해 준다. cancel을 부르면 세 가지가 일어난다.

  1. 자식과 그 자식들을 취소한다.
  2. 부모가 자식을 가리키던 참조를 끊는다.
  3. 관련 타이머를 멈춘다.

안 부르면 이 셋이 일어나지 않는다. 문서 표현으로는 부모가 취소되거나 타이머가 울릴 때까지 자식이 샌다. 오래 사는 부모에서 WithCancel로 파생했다면 그 시점은 서버가 내려갈 때다. 한 요청에서는 티가 안 나고, 초당 수천 요청에서 드러난다.

타임아웃이 이미 지났다면 어떨까. 타이머가 울리면 context가 스스로 취소되면서 부모 참조도 함께 끊으니, 그때는 cancel이 할 일이 거의 없다. 문제는 일이 일찍 끝난 경우다. 2초 만에 끝났어도 cancel을 안 부르면 남은 1초 동안 타이머와 참조가 그대로 남는다. 어느 쪽이든 만든 직후 defer cancel()이면 신경 쓸 일이 없다.

🧪 직접 해보기

cancel을 빼먹으면 go vet이 잡는다

한 분기에서만 cancel을 부르는 코드를 일부러 만들어 go vet을 돌려 봤다.

leak.go
func leak(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
if ctx == nil {
cancel()
return
}
}
실행 결과
$ go vet ./...
./leak.go:9:2: the cancel function is not used on all paths (possible context leak)
./leak.go:14:1: this return statement may be reached without using the cancel var defined on line 9

모든 경로에서 cancel이 불리는지 검사하고, 빠지는 return 위치까지 알려 준다. CI에 go vet을 넣어 두면 이 함정은 사람이 기억할 필요가 없다.

함정 ② 취소되면 정리 로직도 같이 죽는다

이건 실제로 겪고 나서야 알았다. 작업이 실패해서 롤백을 하는데, 롤백 코드에도 같은 ctx를 넘기고 있었다. 이미 취소된 ctx다. 롤백이 시작하자마자 취소돼서, 지워야 할 스냅샷이 스토리지에 그대로 남아 비용이 계속 나갔다. 에러 로그도 context canceled뿐이라 원인이 정리 실패라는 걸 알아채기 어려웠다.

논리적으로는 당연한 일이었다. 취소 신호는 이 요청과 관련된 모든 작업을 멈추라는 뜻이고, 정리 작업도 그 안에 들어간다. 그래서 정리는 취소 신호에서 명시적으로 떼어 내야 한다.

✕같은 ctx로 정리한다

func rollback(ctx context.Context, id string) {
deleteSnapshot(ctx, id) // context canceled
}

정리가 시작하자마자 죽는다. 지워야 할 자원이 계속 과금된다.

✓떼어 내고 상한은 새로 준다

func rollback(ctx context.Context, id string) {
cctx := context.WithoutCancel(ctx)
cctx, cancel := context.WithTimeout(cctx, 30*time.Second)
defer cancel()
deleteSnapshot(cctx, id)
}

정리는 반드시 수행되고, 그렇다고 영원히 매달리지도 않는다 (Go 1.21+).

WithoutCancel은 값은 그대로 물려주면서 취소만 끊어 준다. 요청 ID나 트레이스 정보를 유지한 채 정리 작업의 로그를 남길 수 있다는 점이 좋다. 다만 deadline도 함께 사라지므로 새 타임아웃을 반드시 걸어야 한다. 안 그러면 이번엔 정리가 영원히 안 끝나는 반대 문제가 생긴다.

함정 ③④ WithValue 남용과 구조체 필드 저장

나머지 둘은 쓰지 말아야 할 곳에 쓰는 문제다. 공식 문서가 규칙을 명확히 적어 둬서, 헷갈릴 때 그대로 따르면 된다.

규칙 이유
구조체 안에 넣지 않는다 ctx는 객체의 수명이 아니라 호출 하나의 수명에 묶인다. 필드에 박아 두면 언제 취소되는지 알 수 없다
첫 번째 인자로, 이름은 ctx 문서가 정한 관례라 읽는 사람도 린터도 그 자리를 기대한다
nil을 넘기지 않는다 무엇을 넣을지 모르겠으면 context.TODO()를 넘긴다. 나중에 고칠 자리라는 표시가 된다
Value는 요청 범위 데이터만 함수의 선택적 파라미터를 넘기는 용도가 아니다
키는 비공개 타입으로 string 같은 기본 타입을 쓰면 다른 패키지와 키가 충돌한다

넷째 줄이 실무에서 가장 자주 어긋난다. WithValue가 편해 보여서 필수 파라미터를 여기에 숨기기 시작하면, 함수 시그니처만 봐서는 무엇이 필요한지 알 수 없고 타입 안전성도 사라진다. 값이 빠져 있어도 컴파일은 통과하고, 꺼내는 쪽의 타입 단언에서 실행 중에 panic이 난다.

필수 값은 인자로, 키는 비공개 타입으로
// 나쁨: 필수 파라미터를 ctx에 숨긴다. 시그니처가 아무것도 말해 주지 않는다
func createOrder(ctx context.Context) error {
userID := ctx.Value("userID").(string) // 없으면 런타임 panic
// ...
}
// 좋음: 필수는 인자로, ctx에는 요청 범위 메타데이터만
func createOrder(ctx context.Context, userID string) error { /* ... */ }
// 키는 반드시 비공개 타입으로
type traceIDKey struct{} // 다른 패키지는 이 타입을 만들 수 없다
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey{}, id)
}
func TraceIDFrom(ctx context.Context) (string, bool) { // 타입 안전한 접근자를 같이 둔다
id, ok := ctx.Value(traceIDKey{}).(string)
return id, ok
}
// 구조체 필드가 아니라 메서드 인자로
type Server struct{ ctx context.Context } // 나쁨
func (s *Server) Handle(ctx context.Context) error // 좋음

알아 두면 좋은 최신 API

context 패키지는 조용히 계속 커졌다. 특히 왜 취소됐는지 알 수 없던 문제가 1.20에서 풀렸는데, 이걸 모르고 context canceled라는 의미 없는 로그만 보고 있었다.

  • WithCancelCause, Cause (1.20): 취소에 이유를 실어 보낸다. 디버깅이 훨씬 쉬워진다.
  • WithoutCancel (1.21): 정리, 감사 로그처럼 취소돼도 끝내야 하는 작업을 떼어 낸다.
  • AfterFunc (1.21): ctx가 끝나면 함수를 실행한다. Done()을 지켜보는 고루틴을 직접 띄우지 않아도 된다.
  • WithTimeoutCause, WithDeadlineCause (1.21): 타임아웃에도 이유를 붙인다. 어느 단계에서 예산이 터졌는지 알 수 있다.
취소 이유 남기기
ctx, cancel := context.WithCancelCause(parent)
cancel(fmt.Errorf("업스트림 5xx 연속 발생: %w", err)) // err는 마지막 502 응답
fmt.Println(ctx.Err(), "|", context.Cause(ctx))
실행 결과
context canceled | 업스트림 5xx 연속 발생: 502

Err()는 언제나 똑같이 context canceled라 쓸모가 적었다. 필요했던 정보는 Cause() 쪽에 있다.

여러 작업 중 하나라도 실패하면 전부 취소하는 패턴은 errgroup이 감싸 준다.

errgroup: 첫 에러가 나면 나머지를 취소한다
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchUser(gctx) })
g.Go(func() error { return fetchOrders(gctx) })
if err := g.Wait(); err != nil { // 첫 에러가 나면 gctx가 자동으로 취소된다
return err
}

에러를 판별할 때는 3편에서 본 방식이 그대로 쓰인다. 감싸진 에러 안에서도 원인을 찾아야 하니 == 대신 errors.Is를 쓴다.

원인별로 다르게 처리한다
if errors.Is(err, context.DeadlineExceeded) {
return status(504) // 시간 초과
}
if errors.Is(err, context.Canceled) {
return // 클라이언트가 이미 떠났다. 응답할 필요도 없다
}

정리

  • Go에는 고루틴을 밖에서 죽이는 방법이 없다. 긴 루프에는 Done() 체크를 직접 넣는다.
  • Done()은 값을 보내지 않고 채널을 닫아서, 기다리던 모두가 동시에 깨어난다.
  • 맨 앞에서 건 시간 예산이 체인 끝까지 따라가고, gRPC라면 프로세스 경계도 넘는다.
  • cancel을 안 부르면 자식 context와 타이머가 부모에 매달린 채 남으니 만든 직후 defer cancel()을 쓴다. 빠뜨리면 go vet이 잡는다.
  • 정리 로직은 WithoutCancel로 취소에서 떼어 내고 독립 타임아웃을 건다.

다섯 편을 쓰고 나서

돌아보니 각 편의 함정이 같은 모양이었다. 인터페이스 값이 두 칸이라는 것, 슬라이스가 배열을 들여다보는 창이라는 것, defer와 클로저가 무엇을 언제 붙잡는지, 임베딩의 승격이 호출을 되돌리지 않는다는 것, 그리고 취소가 명령이 아닌 신호라는 것. 다섯 번 모두 그게 실제로 무엇인지를 착각했을 때 함정에 빠졌다. 이 이야기는 정리 편에서 두 축으로 다시 묶었다.

Go가 편의를 위해 실체를 숨기지 않는 언어라는 게 처음엔 불친절해 보였는데, 지금은 학습 곡선의 대부분을 앞으로 당겨 놓은 설계였다는 생각이 든다.

참고 자료

GitHub에서 고치기
esc

최근 글

↑↓ 이동↵ 열기검색 페이지에서 보기 ›