Go context로 타임아웃·취소 처리하기: WithTimeout, WithCancel, HTTP 서버 종료

이 글의 핵심

분산 시스템에서는 언제까지 기다릴지와 상위 작업이 실패했을 때 하위 작업을 멈출지가 곧 안정성입니다. 이 글은 WithTimeout을 쓰고도 defer cancel()을 호출해야 하는 이유, 컨텍스트에 값을 넣을 때의 주의점, 취소가 전파되지 않는 흔한 실수를 코드 리뷰 기준으로 삼을 수 있게 정리합니다.

들어가며

분산 시스템과 HTTP 서비스에서는 “언제까지 기다릴지”와 “상위 단계가 실패했을 때 하위 작업을 멈출지”가 곧 안정성입니다. Go는 이를 context.Context로 표준화했으며, net/http·database/sql 등이 요청 단위로 컨텍스트를 받도록 설계되어 있습니다.

이 글은 타임아웃, 취소, 데드라인을 실제 코드에 연결하는 방법에 집중합니다. 세 API를 고르는 기준과 DB 호출, HTTP 서버·클라이언트에 바로 붙이는 코드를 정리합니다.


개념

context.Context는 취소 신호(Done), 마감 시각(Deadline), 요청 범위 값(Value)을 묶은 불변 인터페이스입니다. 하위 함수로 같은 컨텍스트를 이어서 넘기면, 한곳에서 취소나 타임아웃이 일어났을 때 그 아래 트리 전체에 같은 신호가 전달됩니다.

Go context 트리에서 부모의 취소와 타임아웃이 자식에게 전파되는 구조

그림: 컨텍스트는 트리를 이룬다. 부모가 취소되거나 마감이 지나면 아래 전체의 Done 채널이 닫히고, 자식만 취소해도 부모는 그대로다.

파생 컨텍스트를 만드는 함수는 세 가지가 기본입니다. WithCancel은 사용자가 요청을 취소했거나 상위 로직이 실패해 더 이상 의미 없는 작업을 멈출 때 씁니다. WithTimeout은 “지금부터 N초 안에”처럼 기간으로 제한할 때, WithDeadline은 “23:59까지”처럼 마감 시각이 정해져 있을 때 씁니다.

세 함수 모두 cancel 함수를 함께 돌려주며, 이 함수는 반드시 호출해야 합니다. 호출하지 않으면 부모 컨텍스트가 끝날 때까지 자식 컨텍스트와 타이머가 해제되지 않습니다. 그래서 컨텍스트를 만든 바로 다음 줄에 defer cancel()을 두는 것이 사실상의 표준입니다.


WithTimeout: DB·외부 API 호출 상한

package main

import (
	"context"
	"database/sql"
	"fmt"
	"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 {
		// %w로 감싸면 호출자가 errors.Is(err, context.DeadlineExceeded)로 여전히 판별 가능
		return "", fmt.Errorf("user %d 조회: %w", id, err)
	}
	return name, nil
}

호출부의 ctx는 보통 http.Request.Context()에서 온 요청 루트입니다.

WithTimeout(ctx, 800ms)는 “부모의 마감과 지금부터 800ms 뒤 중 이른 쪽”을 마감으로 씁니다. 요청 전체에 이미 300ms만 남았다면 이 쿼리의 한도도 300ms가 되므로, 함수 안에서 고정 값을 걸어도 상위 SLA를 넘지 않습니다. 반대로 이 함수 안에서 부모보다 긴 타임아웃을 걸어도 늘어나지 않는다는 뜻이기도 합니다. 에러를 감쌀 때는 %w를 써야 합니다. %v로 감싸면 원인 에러 체인이 끊겨 상위에서 errors.Is(err, context.DeadlineExceeded)로 타임아웃인지 알 수 없게 됩니다.

컨텍스트 취소가 실제로 DB 쿼리를 멈추는지는 드라이버 구현에 달려 있다는 점도 알아 두세요. database/sql은 컨텍스트가 취소되면 호출자에게 에러를 돌려주지만, 서버에서 쿼리를 중단시키는 것은 드라이버가 취소 요청(PostgreSQL의 cancel request, MySQL의 KILL QUERY 등)을 보내야 가능합니다. 드라이버가 이를 지원하지 않으면 Go 쪽은 타임아웃으로 빠져나왔는데 DB에서는 무거운 쿼리가 계속 돌아, 타임아웃이 잦을수록 DB 부하가 오히려 쌓이는 상황이 생깁니다. 이런 차이는 애플리케이션 로그로는 보이지 않고 DB의 실행 중 쿼리 목록(PostgreSQL의 pg_stat_activity, MySQL의 SHOW PROCESSLIST)을 봐야 드러나므로, 컨텍스트 타임아웃과 별개로 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 (
	"io"
	"log"
	"net/http"
	"time"
)

// 클라이언트는 한 번 만들어 공유한다
var upstream = &http.Client{Timeout: 10 * time.Second}

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 {
		log.Printf("요청 생성 실패: %v", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	resp, err := upstream.Do(req)
	if err != nil {
		log.Printf("업스트림 호출 실패: %v", err)
		http.Error(w, "upstream error", http.StatusBadGateway)
		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 or context cancellation while reading body)가 붙은 에러로 끊깁니다. 대용량 다운로드를 중계하는 핸들러라면 전체 타임아웃 대신 Transport의 ResponseHeaderTimeout(헤더까지)과 컨텍스트를 조합하는 편이 맞습니다.

요청마다 &http.Client{}를 새로 만들어도 Transport를 지정하지 않으면 공유 http.DefaultTransport를 쓰므로 연결 풀은 재사용되지만, 커스텀 Transport까지 요청마다 만들면 연결 재사용이 사라지고 소켓이 TIME_WAIT로 쌓입니다. 그래서 위 코드처럼 클라이언트를 패키지 수준이나 서버 구조체에 한 번 만들어 공유하는 것이 관례입니다. 또 err.Error()를 그대로 응답에 넣으면 업스트림 주소 같은 내부 정보가 노출되므로, 사용자에게는 일반적인 메시지를 주고 상세 내용은 로그로 남깁니다.


서버 종료: 별도의 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로 반환해, 우아한 종료가 강제 종료로 바뀝니다(진행 중인 요청이 없으면 이미 취소된 컨텍스트여도 nil을 돌려줍니다). 그래서 종료 대기용 컨텍스트는 context.Background()에서 새로 만듭니다. 20초라는 값은 쿠버네티스의 terminationGracePeriodSeconds(기본 30초)보다 짧게 잡아, 파드가 SIGKILL을 받기 전에 정리가 끝나도록 맞춘 것입니다. Shutdown은 새 연결을 받지 않고 진행 중인 요청이 끝나기를 기다리지만, 핸들러 안의 긴 작업이 r.Context()를 확인하지 않으면 끝날 때까지 기다려야 하므로 결국 핸들러의 컨텍스트 전파가 종료 시간을 좌우합니다.

500ms 걸리는 요청이 진행 중일 때 이미 취소된 컨텍스트로 Shutdown을 부르면 바로 context canceled가 반환되고, 새 타임아웃 컨텍스트를 쓰면 요청이 끝나기를 기다려 nil을 반환하는 실행 결과

그림: 실제 실행 결과 — 진행 중인 요청(500ms)이 있을 때 이미 취소된 컨텍스트로 Shutdown하면 0ms 만에 context canceled가 돌아오고, 새로 만든 타임아웃 컨텍스트로 하면 요청이 끝나길 기다린 뒤 nil을 반환한다. (환경: macOS 27.0.1 · Go 1.24.0 (go.mod 지정 툴체인))


전파, 값, 취소 끊기

부모가 취소되면 자식도 모두 취소되지만, 자식을 취소해도 부모와 형제에는 영향이 없습니다. 그래서 한 가지 작업만 멈추고 싶을 때는 그 가지에서 만든 WithCancel의 cancel()만 호출하면 됩니다. 타임아웃이 중첩되면 바깥이 5초, 안이 800ms일 때처럼 더 이른 마감이 먼저 발화하므로, 각 계층은 자기 SLA만 걸어 두면 됩니다.

context.WithValue에는 추적 ID나 인증 주체처럼 요청 범위의 메타데이터만 넣고, 함수의 실제 입력은 인자로 넘기는 편이 읽기 쉽고 테스트하기도 좋습니다. 키로 string 같은 내장 타입을 쓰면 다른 패키지와 키가 충돌할 수 있어 staticcheck(SA1029)가 경고합니다. type ctxKey struct{}처럼 패키지 안에서만 보이는 전용 타입을 키로 쓰고, 값을 꺼내는 접근 함수(UserFrom(ctx))를 함께 제공하는 것이 관용적인 방법입니다.

요청이 끝난 뒤에도 끝까지 해야 하는 작업(감사 로그 기록 등)에 요청 컨텍스트를 그대로 넘기면, 응답이 나가는 순간 컨텍스트가 취소되어 작업이 중간에 끊깁니다. Go 1.21부터는 context.WithoutCancel(ctx)로 값은 유지하면서 부모의 취소만 끊은 컨텍스트를 만들 수 있습니다. 또 Go 1.20부터는 context.WithCancelCause와 context.Cause(ctx)로 “어떤 단계의 어떤 에러 때문에 취소되었는지”를 전달할 수 있어, context canceled라는 막연한 에러보다 로그 분석이 쉬워집니다.


세 API의 선택 기준

API용도메모
WithCancel수동 취소, 파이프라인 조기 종료cancel() 호출 필수
WithTimeout(d)지금부터 d 안에 끝나야 하는 호출내부적으로 WithDeadline(time.Now().Add(d))
WithDeadline(t)이미 정해진 마감 시각을 하위로 전달상위에서 받은 마감(gRPC 데드라인 등)을 그대로 이어 갈 때

컨텍스트를 만드는 비용 자체는 가볍습니다. 실제 문제는 컨텍스트를 받지 않는 블로킹 호출이 남아 있을 때 생기며, 취소가 닿지 않는 I/O 때문에 고루틴과 연결이 쌓이는 것이 대부분의 원인입니다. 게이트웨이라면 업스트림 호출마다 WithTimeout을 걸고, 배치 작업이라면 WithCancel 루트 하나를 모든 워커에 넘겨 한 건 실패 시 전체를 멈추게 하는 식으로 쓰고, DB와 gRPC 호출은 QueryContext, PingContext처럼 컨텍스트를 받는 변형을 써야 같은 모델이 유지됩니다.


트러블슈팅

타임아웃이 걸리지 않는다

블로킹 호출이 컨텍스트를 무시하고 있는지 확인합니다. db.Query 대신 db.QueryContext, http.NewRequest 대신 http.NewRequestWithContext처럼 표준 라이브러리에서는 *Context 변형을 써야 합니다. 직접 만든 루프라면 반복마다 ctx.Err()를 확인하거나 select에 ctx.Done()을 넣어야 취소가 닿습니다.

go vet이 lostcancel을 경고한다

WithCancel, WithTimeout, WithDeadline이 돌려준 cancel을 버리거나 일부 경로에서 호출하지 않으면 go vet의 lostcancel 검사가 경고합니다. 컨텍스트를 만든 바로 다음 줄에 defer cancel()을 두면 됩니다.

일부 경로에서 cancel을 호출하지 않는 코드를 go vet이 lostcancel로 경고하는 실행 결과

그림: 실제 실행 결과 — return 경로에서 cancel이 빠진 함수를 go vet이 “the cancel function is not used on all paths”로 짚는다. (환경: macOS 27.0.1 · Go 1.24.0 (go.mod 지정 툴체인))

Canceled인지 DeadlineExceeded인지 구분이 안 된다

err == context.DeadlineExceeded 같은 직접 비교는 에러가 한 번이라도 감싸지면 실패합니다. errors.Is(err, context.DeadlineExceeded)와 errors.Is(err, context.Canceled)로 비교해야 감싼 에러에서도 원인을 찾을 수 있습니다.

직접 실행해 확인했습니다. macOS의 Go 1.24.0(go.mod에 go 1.24.0 지정)에서 fmt.Errorf("slow: %w", ctx.Err())로 컨텍스트 에러를 한 번 감싼 함수를 타임아웃(50ms)과 수동 취소 두 경우로 호출했습니다.

경우에러 문자열err == context.DeadlineExceedederrors.Is(err, DeadlineExceeded)errors.Is(err, Canceled)
타임아웃slow: context deadline exceededfalsetruefalse
수동 취소slow: context canceled-falsetrue

감싼 에러를 ==로 비교하면 타임아웃인데도 false가 나왔고, errors.Is는 두 경우를 정확히 구분했습니다. 추가로 context.WithCancelCause로 cancel(errors.New("사용자가 요청을 중단"))을 호출하면 ctx.Err()는 여전히 context canceled이지만 context.Cause(ctx)는 사용자가 요청을 중단이었습니다. 취소 이유를 로그에 남기고 싶을 때는 Err()가 아니라 Cause를 보면 됩니다. context.DeadlineExceeded는 Timeout() bool을 가진 에러라서 errors.As로 이 인터페이스를 꺼내 true를 얻었고, 부모를 취소하면 시간 제한이 1시간인 자식 컨텍스트도 즉시 context canceled가 됐습니다.

테스트가 느리거나 멈춘다

테스트에서 context.Background()를 그대로 넘기면 외부 호출이 멈췄을 때 테스트 전체 타임아웃까지 기다리게 됩니다. 테스트마다 짧은 WithTimeout을 걸거나, Go 1.24 이상이라면 테스트가 끝날 때 취소되는 t.Context()를 쓰면 됩니다.

같은 환경에서 t.Context()를 확인했습니다. 테스트가 시작될 때 ctx.Err()는 nil이었고, t.Cleanup으로 등록한 함수가 실행되는 시점에는 이미 context canceled가 되어 있었습니다. 테스트 함수가 끝나면 컨텍스트가 취소되므로, 테스트 안에서 띄운 고루틴이 t.Context()를 받으면 정리 단계에서 자연스럽게 멈춥니다. Go 1.24 미만에서는 t.Context()가 없으므로 context.WithTimeout으로 직접 걸어야 합니다.