context로 취소와 타임아웃 전파하기
고루틴은 스스로 멈추지 않는다
지금까지 다룬 예제는 대부분 고루틴이 자연스럽게 끝나는 상황이었다. 실무는 다르다.
HTTP 요청 하나를 처리하면서 DB 조회, 외부 API 호출, 캐시 조회를 각각 고루틴으로
띄웠는데, 클라이언트가 연결을 끊어버렸다면? 그 세 고루틴을 그냥 두면 클라이언트는
이미 떠났는데 서버 혼자 계속 일하는 상태가 된다. 요청량이 많아지면 이런 좀비
고루틴이 쌓여 메모리와 CPU를 갉아먹는다. context.Context는 정확히 이 문제 —
"위에서 아래로 취소 신호와 마감 시한을 전파하는 것" — 를 풀기 위해 만들어졌다.
Context는 트리 구조로 전파된다
context.Context는 부모-자식 관계를 이룬다. 부모가 취소되면 그 부모로부터 파생된
모든 자식 컨텍스트도 함께 취소된다. 반대는 성립하지 않는다 — 자식이 취소돼도 부모나
형제 컨텍스트는 영향받지 않는다.
flowchart TD R["context.Background()"] --> A["요청 컨텍스트 (WithCancel)"] A --> B["DB 조회 고루틴"] A --> C["외부 API 호출 고루틴"] A --> D["캐시 조회 고루틴"]
이 그림에서 클라이언트가 연결을 끊으면 A를 취소하고, 그 즉시 B·C·D 모두가 취소 신호를 받는다. 각 고루틴이 그 신호를 실제로 확인하고 작업을 중단하는지는 별개의 문제다 — Context는 신호를 전파할 뿐, 강제로 고루틴을 죽이지 못한다(Go에는 다른 언어의 스레드 kill 같은 기능이 없다). 신호를 받고도 무시하면 아무 일도 안 일어난다.
Done() 채널을 확인하지 않으면 취소는 그냥 무시된다
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
fmt.Println("취소됨:", ctx.Err())
return
case job, ok := <-jobs:
if !ok {
return
}
process(job)
}
}
}ctx.Done()은 컨텍스트가 취소되거나 타임아웃되면 닫히는 채널을 리턴한다(닫힌
채널이므로 3장에서 배운 규칙대로 즉시 제로값을 반환하며 통과한다). select로
이걸 다른 작업 채널과 나란히 두는 게 취소를 처리하는 표준 패턴이다. ctx.Err()는
Done()이 왜 닫혔는지 알려준다 — context.Canceled(명시적 취소)인지
context.DeadlineExceeded(시간 초과)인지 구분할 수 있다.
문제는 오래 걸리는 단일 연산 — 예를 들어 time.Sleep(10 * time.Second)이나
select 없이 그냥 도는 CPU 바운드 루프 — 안에서는 이 신호를 확인할 지점 자체가
없다는 것이다. 그런 코드는 select 안에 넣을 방법을 찾거나(예: time.Sleep 대신
time.After와 ctx.Done()을 select로 묶기), 루프 안에서 주기적으로
ctx.Err() != nil을 체크해야 한다.
WithCancel, WithTimeout, WithDeadline, WithValue
context.WithCancel(parent): 명시적으로cancel()을 호출해야 취소된다context.WithTimeout(parent, d):d가 지나면 자동 취소. 내부적으로WithDeadline을 감싼 것context.WithDeadline(parent, t): 특정 시각t에 자동 취소context.WithValue(parent, key, value): 취소와 무관하게, 요청 범위 데이터(예: 요청 ID)를 실어 나름
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 항상 defer로 호출 — 타임아웃 전에 함수가 끝나도 자원 정리cancel 함수를 defer로 호출하는 걸 잊으면 안 된다. WithTimeout/WithDeadline은
내부적으로 타이머를 갖고 있는데, 자연스럽게 타임아웃이 되기 전에 함수가 먼저
끝났다면 그 타이머를 명시적으로 정리해야 한다. cancel()을 안 부르면 타이머가
만료될 때까지 관련 자원이 메모리에 남는다 — go vet이 이걸 경고해 주긴 하지만,
습관으로 만들어 두는 게 낫다.
WithValue는 신중하게 써야 한다. 함수 인자로 명시적으로 넘겨야 할 값(설정값,
의존성)을 WithValue에 숨겨 넣으면 코드를 읽을 때 그 값이 어디서 왔는지 추적하기
어려워진다. 요청 ID나 트레이싱 스팬처럼 "요청 경계를 관통해서 흘러야 하지만 모든
함수 시그니처에 넣기는 부담스러운" 값에 한정해서 쓰는 걸 권한다.
라이브러리 함수를 만든다면 첫 인자로 받아라
Go 관용구는 ctx context.Context를 함수의 첫 번째 인자로 두는 것이다. 표준
라이브러리(net/http, database/sql 등)가 이 관용구를 따르고, 커뮤니티 코드도
거의 예외 없이 따른다.
func FetchUser(ctx context.Context, id int) (*User, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// ...
}구조체 필드에 context.Context를 저장해 두고 여러 메서드에서 재사용하는 패턴은
Go 팀이 공식적으로 권장하지 않는다 — 컨텍스트는 호출마다 다를 수 있는 요청 범위
값인데, 구조체 필드로 박아두면 그 구조체 인스턴스를 재사용하는 모든 호출이 같은
취소 신호를 공유하게 되어 버린다. 짧게 말해 context는 저장하지 말고 전달하라.