Go context로 타임아웃·취소 처리하기 | 실전 패턴 가이드
이 글의 핵심
분산 시스템에서는 언제까지 기다릴지와 상위 작업이 실패했을 때 하위 작업을 멈출지가 곧 안정성입니다. 이 글은 WithTimeout을 쓰고도 defer cancel()을 호출해야 하는 이유, 컨텍스트에 값을 넣을 때의 주의점, 취소가 전파되지 않는 흔한 실수를 코드 리뷰 기준으로 삼을 수 있게 정리합니다.
들어가며
분산 시스템과 HTTP 서비스에서는 “언제까지 기다릴지”와 “상위 단계가 실패했을 때 하위 작업을 멈출지”가 곧 안정성입니다. Go는 이를 context.Context로 표준화했으며, net/http·database/sql 등이 요청 단위로 컨텍스트를 받도록 설계되어 있습니다.
이 글은 타임아웃·취소·데드라인을 코드로 연결하는 패턴에 초점을 맞춥니다. Go context 취소·타임아웃 패턴을 한 곳에 모아 두면, 팀 코드 리뷰에서도 기준이 맞춰집니다. 시리즈의 context·우아한 종료 심화와 맞닿지만, 여기서는 API 선택과 HTTP 서버·클라이언트에 바로 붙이는 레시피를 압축해 정리합니다.
다루는 내용: WithCancel / WithTimeout / WithDeadline 차이, 취소 전파 규칙, http.Server·http.Client와의 조합, 고루틴 누수를 피하는 습관입니다.
개념 설명
context.Context는 취소 신호(Done), 마감 시각(Deadline), 요청 범위 값(Value)을 묶은 불변 인터페이스입니다. 하위 함수로 같은 줄기를 넘기면, 한곳에서 취소·타임아웃이 걸렸을 때 트리 전체에 일관되게 전달됩니다.
- 취소(
WithCancel): 사용자가 요청을 취소했거나, 상위 로직이 실패해 더 이상 의미 없는 작업을 멈출 때. - 상대 타임아웃(
WithTimeout): “지금부터 N초 안에” 같은 기간 제한. - 절대 데드라인(
WithDeadline): “오늘 23:59까지”처럼 시각이 정해진 경우. 취소 가능한 컨텍스트를 만들면 반드시cancel함수를 호출해야 합니다(리소스 누수 방지).defer cancel()패턴이 사실상 표준입니다.
실전 구현 (단계별 코드)
WithTimeout: DB·외부 API 호출 상한
package main
import (
"context"
"database/sql"
"errors"
"time"
)
func UserByID(ctx context.Context, db *sql.DB, id int64) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
var name string
err := db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, id).Scan(&name)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", err
}
return "", err
}
return name, nil
}
호출부의 ctx는 보통 http.Request.Context()에서 온 요청 루트입니다.
WithTimeout(ctx, 800ms)는 “부모의 남은 시간과 800ms 중 짧은 쪽”을 마감으로 씁니다. 요청 전체에 이미 300ms만 남았다면 이 쿼리의 한도도 300ms가 되므로, 함수 안에서 고정 값을 걸어도 상위 SLA를 넘지 않습니다. 반대로 이 함수 안에서 부모보다 긴 타임아웃을 걸어도 늘어나지 않는다는 뜻이기도 합니다. 위 코드의 if errors.Is(...) 분기는 두 경로가 똑같이 err를 반환해 사실상 의미가 없는데, 실무에서는 이 자리에서 fmt.Errorf("user %d 조회 시간 초과: %w", id, err)처럼 %w로 감싸 호출자가 errors.Is(err, context.DeadlineExceeded)로 여전히 판별할 수 있게 하면서 맥락을 더하는 것이 일반적입니다. %v로 감싸면 원인 에러 체인이 끊겨 상위에서 타임아웃인지 알 수 없게 됩니다.
컨텍스트 취소가 실제로 DB 쿼리를 멈추는지는 드라이버 구현에 달려 있다는 점도 알아 두세요. database/sql은 컨텍스트가 취소되면 호출자에게 즉시 에러를 돌려주지만, 서버에서 쿼리를 중단시키는 것은 드라이버가 취소 요청(PostgreSQL의 cancel request, MySQL의 KILL QUERY 등)을 보내야 가능합니다. 드라이버가 이를 지원하지 않으면 Go 쪽은 타임아웃으로 빠져나왔는데 DB에서는 무거운 쿼리가 계속 돌아, 타임아웃이 잦을수록 DB 부하가 오히려 쌓이는 상황이 생깁니다. 저는 이 차이를 DB의 실행 중 쿼리 목록(pg_stat_activity 등)을 보고서야 알게 됐는데, 그 뒤로는 컨텍스트 타임아웃과 별개로 DB 쪽 statement_timeout도 함께 설정합니다.
WithCancel: 한 단계 실패 시 나머지 워커 중단
func work(ctx context.Context, id int) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errs := make(chan error, 2)
go func() { errs <- fetchA(ctx, id) }()
go func() { errs <- fetchB(ctx, id) }()
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
cancel() // 실패 시 다른 고루틴에 취소 전파
return err
}
}
return nil
}
func fetchA(ctx context.Context, id int) error {
select {
case <-time.After(100 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func fetchB(ctx context.Context, id int) error {
select {
case <-time.After(200 * time.Millisecond):
return errors.New("upstream")
case <-ctx.Done():
return ctx.Err()
}
}
errs 채널의 버퍼를 고루틴 수(2)만큼 잡은 것이 이 패턴의 핵심입니다. 첫 에러를 받자마자 return하면 나머지 고루틴의 결과를 아무도 받지 않는데, 버퍼가 없다면 그 고루틴은 errs <- ...에서 영원히 막혀 고루틴 누수가 됩니다. 버퍼가 있으면 결과를 채널에 넣고 종료할 수 있습니다. 같은 일을 표준처럼 쓰는 것이 golang.org/x/sync/errgroup으로, g, ctx := errgroup.WithContext(ctx)로 만든 뒤 g.Go(func() error { return fetchA(ctx, id) })를 등록하고 g.Wait()하면 첫 에러에서 공유 컨텍스트가 취소되고 모든 고루틴이 끝날 때까지 기다려 줍니다. 직접 짠 위 코드는 에러가 나면 나머지 고루틴을 기다리지 않고 반환한다는 점이 다르므로, 고루틴이 정리 작업을 해야 한다면 errgroup이 더 안전합니다.
fetchA의 time.After는 예제용입니다. Go 1.22 이하에서는 time.After가 만든 타이머가 발화할 때까지 GC되지 않아, 긴 타임아웃을 루프 안에서 반복 생성하면 메모리가 쌓였습니다(Go 1.23부터는 참조가 없으면 수거됨). 루프에서 쓴다면 time.NewTimer를 만들고 defer t.Stop()하는 편이 버전과 무관하게 안전합니다.
HTTP 서버: 요청 컨텍스트와 클라이언트 타임아웃
package main
import (
"context"
"io"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1/x", nil)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(w, resp.Body)
}
http.Client.Timeout은 전체 요청(연결+TLS+바디) 상한이며, NewRequestWithContext의 ctx는 취소·데드라인을 전달합니다. 둘을 함께 두면 “느린 업스트림”과 “클라이언트가 떠남”을 동시에 다루기 쉽습니다.
r.Context()는 클라이언트가 연결을 끊거나, HTTP/2 스트림이 취소되거나, 핸들러가 반환될 때 취소됩니다. 그래서 사용자가 브라우저를 닫으면 업스트림 호출도 곧바로 context canceled로 끝나고, 쓸모없는 응답을 기다리는 고루틴이 남지 않습니다. Client.Timeout에는 응답 바디를 읽는 시간도 포함되므로, 위 io.Copy가 큰 응답을 스트리밍하다 10초가 지나면 중간에 Client.Timeout exceeded while reading body 에러로 끊깁니다. 대용량 다운로드를 중계하는 핸들러라면 전체 타임아웃 대신 Transport의 ResponseHeaderTimeout(헤더까지)과 컨텍스트를 조합하는 편이 맞습니다.
요청마다 &http.Client{}를 새로 만드는 것 자체는 Transport를 지정하지 않으면 공유 http.DefaultTransport를 쓰므로 연결 풀이 재사용되지만, 커스텀 Transport까지 요청마다 만들면 연결 재사용이 사라지고 소켓이 TIME_WAIT로 쌓입니다. 클라이언트는 패키지 수준이나 서버 구조체에 한 번 만들어 공유하는 것이 관례입니다. 또 http.Error(w, err.Error(), 502)처럼 내부 에러 문자열을 그대로 응답에 넣으면 업스트림 주소 같은 내부 정보가 노출되므로, 사용자에게는 일반적인 메시지를 주고 상세 내용은 로그로 남기세요.
서버 종료: 별도의 Shutdown 컨텍스트
운영에서는 요청 단위 ctx와 프로세스 종료 신호를 분리하는 경우가 많습니다. 우아한 종료 예시는 Go 심화 #09의 Shutdown 패턴을 참고하면 됩니다.
func main() {
srv := &http.Server{Addr: ":8080", Handler: http.HandlerFunc(handler)}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
// SIGINT/SIGTERM이 오면 sigCtx가 취소됨
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-sigCtx.Done()
// 종료용 컨텍스트는 이미 취소된 sigCtx가 아니라 새 루트에서 만든다
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown 실패: %v", err)
}
}
Shutdown에 넘기는 컨텍스트를 이미 취소된 시그널 컨텍스트에서 파생하면 Shutdown이 진행 중인 요청을 기다리지 않고 즉시 context canceled로 반환해, 우아한 종료가 강제 종료로 바뀝니다. 그래서 종료 대기용 컨텍스트는 context.Background()에서 새로 만듭니다. 20초라는 값은 쿠버네티스의 terminationGracePeriodSeconds(기본 30초)보다 짧게 잡아, 파드가 SIGKILL을 받기 전에 정리가 끝나도록 맞춘 것입니다. Shutdown은 새 연결을 받지 않고 진행 중인 요청이 끝나기를 기다리지만, 핸들러 안의 긴 작업이 r.Context()를 확인하지 않으면 끝날 때까지 기다려야 하므로 결국 핸들러의 컨텍스트 전파가 종료 시간을 좌우합니다.
고급 활용: 전파·값·계층
- 전파: 부모가 취소되면 자식도 취소됩니다. 자식만 취소하려면 그 가지에서 파생된
WithCancel의cancel()만 호출하면 됩니다. - 값(
context.WithValue): 추적 ID·인증 주체처럼 요청 스코프 메타데이터만 넣으며, 비즈니스 입력은 함수 인자로 두는 편이 테스트에 유리합니다. - 중첩 타임아웃: 바깥이 5초, 안이 800ms처럼 더 짧은 쪽이 먼저 발화합니다. 각 계층이 자신의 SLA만 알면 됩니다.
- 취소 끊기(Go 1.21+): 요청이 끝난 뒤에도 감사 로그 기록처럼 끝까지 해야 하는 작업은
context.WithoutCancel(ctx)로 값은 유지하되 부모의 취소만 끊은 컨텍스트를 넘깁니다. 요청 컨텍스트를 그대로 넘긴 고루틴은 응답이 나가는 순간 취소되어 작업이 중간에 끊기는 것이 흔한 버그입니다. - 취소 원인(Go 1.20+):
context.WithCancelCause와context.Cause(ctx)를 쓰면context canceled라는 막연한 에러 대신 “어떤 단계의 어떤 에러 때문에 취소되었는지”를 전달할 수 있어 로그 분석이 쉬워집니다.
WithValue의 키로 string을 쓰면 다른 패키지와 키가 충돌할 수 있어 go vet와 staticcheck가 경고합니다. type ctxKey struct{}처럼 패키지 안에서만 보이는 전용 타입을 키로 쓰고, 값을 꺼내는 접근 함수(UserFrom(ctx))를 함께 제공하는 것이 관용적인 방법입니다.
성능·비교: 세 API를 언제 쓰나
| API | 용도 | 메모 |
|---|---|---|
WithCancel | 수동 취소, 파이프라인 조기 종료 | cancel() 호출 필수 |
WithTimeout(d) | 상대 기한(지금부터 d) | 내부적으로 WithDeadline |
WithDeadline(t) | 절대 시각 | 클럭 동기화가 중요한 배치·스케줄에 적합 |
비용 자체는 가볍지만, 컨텍스트 없이 블로킹 호출을 남겨 두면 고루틴·연결이 쌓입니다. 성능 이슈는 대부분 “취소가 안 닿는 I/O”에서 옵니다.
실무 사례
- 게이트웨이: 업스트림 호출마다
WithTimeout을 걸고, 클라이언트가 끊기면r.Context()로 빠져나오게 합니다. - 배치 작업: 한 건 실패 시 전체를 멈추려면
WithCancel루트 하나를 두며, 워커에 같은ctx를 넘깁니다. - gRPC/DB:
PingContext,QueryContext등*ContextAPI를 쓰면 동일한 모델을 유지할 수 있습니다.
트러블슈팅
증상: 타임아웃이 안 걸린다
→ 블로킹 호출이 컨텍스트를 무시하는지 확인하세요. 표준 라이브러리는 *Context 변형을 써야 합니다.
증상: cancel을 안 불러서 리소스 누수 경고
→ defer cancel()을 습관화하세요. WithTimeout도 동일합니다.
증상: Err()가 Canceled인지 DeadlineExceeded인지 헷갈린다
→ errors.Is(err, context.DeadlineExceeded)처럼 값으로 비교하세요.
증상: 테스트가 느리다
→ context.Background() 대신 짧은 WithTimeout을 쓰거나, 취소 가능한 ctx로 끊으세요.
마무리
context는 Go에서 동시성과 마감 정책을 팀 전체가 같은 방식으로 표현하게 해 주는 핵심 도구입니다. WithTimeout·WithCancel·WithDeadline을 상황에 맞게 고르고, HTTP에서는 요청 ctx + Client 타임아웃을 함께 설계하면 운영 환경에서 재현하기 어려운 “느린 누수”를 많이 줄일 수 있습니다. 고루틴과 채널 기본기는 고루틴·채널과 REST 실습 API 프로젝트와 이어 읽으면 좋습니다.
자주 묻는 질문 (FAQ)
Q. WithTimeout을 썼는데도 defer cancel()을 꼭 호출해야 하나요?
A. 호출해야 합니다. 타임아웃이 지나면 컨텍스트는 결국 취소되지만, 작업이 일찍 끝났을 때 cancel을 부르지 않으면 타이머와 부모 컨텍스트에 연결된 자원이 만료 시점까지 남습니다. go vet도 cancel 함수를 버리는 코드를 경고하므로, 컨텍스트를 만든 바로 다음 줄에 defer cancel()을 두는 것을 습관으로 삼는 편이 좋습니다.
같이 보면 좋은 글
- [Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교
- [Go 2주 완성 #08] Day 14: 실전 미니 프로젝트 - REST API 서버 구축
- C++ HTTP 클라이언트 직접 만들기