Go의 Context: timeout, cancel, 그리고 값 전달
1. 개요
여러 goroutine을 처리하는 Go 서비스를 작성할 때, 자식 goroutine에게 “멈춰라, 나는 더 이상 결과가 필요 없다” 또는 “2초 동안만 실행하고 포기하기”라고 말할 방법이 필요합니다. context.Context는 정확히 이 문제를 해결하기 위해 만들어졌습니다.
이 글은 context를 사용하는 세 가지 주요 작업을 다룹니다: 수동 취소 (cancel), timeout/deadline 설정, 그리고 함수 호출 계층을 통해 request-scoped 값 전달. 이 글을 다 읽으면 올바른 시점에 자동으로 멈출 수 있는 HTTP handler, DB를 호출하는 함수, 그리고 자식 goroutine을 호출하는 함수를 작성할 수 있으며 goroutine 누수를 방지할 수 있습니다.
2. Context란 무엇이고 왜 필요한가
context.Context는 처리 스레드의 컨텍스트(일반적으로 요청)를 설명하는 인터페이스입니다. 다음을 포함합니다:
- 취소 신호 (cancel signal).
- Deadline/timeout.
- 요청에 연결된 작은 key-value 쌍.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Done()은 채널을 반환합니다. 컨텍스트가 취소되거나 만료될 때 닫힙니다.Err()는 이유를 알려줍니다:context.Canceled또는context.DeadlineExceeded.Value()는 컨텍스트에 연결된 값을 검색합니다.
주의: Context는 선택적 매개변수를 전달하는 데 사용하지 않습니다. 제어 신호와 request ID, 현재 사용자 같은 request-scoped 데이터만 전달해야 합니다 — config나 중요한 비즈니스 객체가 아닌.
3. WithCancel을 사용한 수동 취소
여러 goroutine 중 하나가 이미 결과를 얻었을 때 나머지 goroutine을 취소하는 것 같이, 언제 취소할지 직접 결정하고 싶을 때 사용합니다.
func fetchFromMultipleSources(ctx context.Context) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 항상 cancel을 호출하여 리소스 해제
resultCh := make(chan string, 1)
for _, src := range []string{"cache", "db", "api"} {
go func(source string) {
select {
case <-time.After(100 * time.Millisecond):
resultCh <- source
cancel() // 결과를 얻었으므로 다른 소스 취소
case <-ctx.Done():
return // 취소되었으므로 일찍 종료
}
}(src)
}
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
context.WithCancel(ctx)는 context 자식과 그것을 취소할 cancel 함수를 만듭니다.defer cancel()은 필수입니다: 호출하지 않으면 자식 context는 부모가 취소될 때까지 유지되어ctx.Done()을 선택 중인 goroutine 누수를 일으킵니다.- 먼저 완료된 goroutine이
cancel()을 호출하면 나머지 goroutine은ctx.Done()을 통해 신호를 받고 멈춥니다.
자주 발생하는 오류: defer cancel() 잊음. 함수가 일찍 반환되지만 context를 취소하지 않으면 자식 goroutine은 간접적인 garbage collection이 될 때까지 계속 실행됩니다 — 실제로는 누수입니다.
4. WithTimeout, WithDeadline을 사용한 timeout과 deadline
4.1. 상대적 시간 제한을 위한 WithTimeout
func callPaymentService(ctx context.Context, orderID string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://payment.local/orders/"+orderID, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // context.DeadlineExceeded일 수 있습니다
}
defer resp.Body.Close()
return nil
}
context.WithTimeout(ctx, 2*time.Second)는 2초 후에 context를 자동으로 취소합니다.http.NewRequestWithContext는 context를 요청에 연결합니다; context가 만료되면http.Client는 실행 중인 요청을 자동으로 취소합니다.- 2초를 초과하면
resp, err := http.DefaultClient.Do(req)는context.DeadlineExceeded를 포함하는 err을 반환합니다.
timeout 오류 확인:
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("결제 서비스 timeout")
}
return err
}
4.2. 절대적 시간 마크를 위한 WithDeadline
func processBeforeMidnight(ctx context.Context) error {
deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
select {
case <-time.After(5 * time.Hour):
return errors.New("처리가 완료되었습니다")
case <-ctx.Done():
return ctx.Err()
}
}
WithDeadline은 특정 시간 마크가 있을 때 사용합니다(예: 하루 끝).WithTimeout과 달라서 현재부터의 상대적 시간입니다.- 본질적으로
WithTimeout(ctx, d)는 단지WithDeadline(ctx, time.Now().Add(d))을 호출하는 얇은 래퍼입니다.
5. WithValue를 사용한 context를 통한 값 전달
요청에 연결된 데이터, 비즈니스 매개변수가 아닌 것에 사용합니다.
type requestIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey{}).(string)
return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
if id, ok := RequestIDFromContext(r.Context()); ok {
log.Printf("[%s] 주문 처리 중", id)
}
}
type requestIDKey struct{}를 별도의 key로 사용합니다 — 다른 패키지의 key와 충돌하기 쉽기 때문에 문자열을 직접 사용하지 않습니다.WithRequestID와RequestIDFromContext는 set/get을 캡슐화합니다. 다른 패키지는 실제 key가 무엇인지 알 필요가 없습니다.r.WithContext(ctx)는 값이 연결된 새로운 요청을 만들고, middleware chain을 통해 전달합니다.
주의: 함수가 올바르게 작동하기 위해 필요한
*sql.DB, config 또는 기타 항목을 전달하기 위해context.WithValue를 사용하지 마세요. 이들은 명확한 매개변수 또는 구조체의 필드여야 합니다. Context value는 request ID, trace ID, 로그인한 사용자 같은 ‘있으면 좋은’ 데이터에만 적합합니다.
6. Context.TODO와 context.Background
func main() {
ctx := context.Background() // 루트 context, 절대 취소되지 않음
run(ctx)
}
func legacyFunctionBeingRefactored() {
ctx := context.TODO() // placeholder, 나중에 실제 context로 대체할 것
_ = ctx
}
context.Background()는 루트 context입니다. main, test 또는 top-level 초기화 위치에서 사용됩니다.context.TODO()는 refactoring 중일 때, 어떤 context를 전달해야 할지 확실하지 않을 때 사용합니다 — 기능상Background()와 유사하지만 코드 리더와 linter 도구에 의도를 명확하게 신호합니다.
7. 함수 호출 계층을 통한 context 전달
규약: ctx context.Context는 항상 첫 번째 매개변수입니다.
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
if err := s.validate(ctx, order); err != nil {
return err
}
return s.repo.Save(ctx, order)
}
func (r *OrderRepository) Save(ctx context.Context, order Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
return err
}
ExecContext는 context를 받습니다. 위의 HTTP 요청이 취소되면 (클라이언트가 연결을 닫으면) DB driver는 실행 중인 쿼리를 즉시 취소하고 불필요한 연결 유지를 피합니다.- 규칙: I/O(HTTP, DB, gRPC)를 호출하는 함수는 context를 받아서 context를 지원하는 API(*Context suffix)로 전달해야 합니다.
8. 테스트로 동작 확인
func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
ctx := context.Background()
result, err := fetchFromMultipleSources(ctx)
require.NoError(t, err)
assert.Contains(t, []string{"cache", "db", "api"}, result)
}
func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
slowServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
}))
defer slowServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
_, err := http.DefaultClient.Do(req)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: assertion 라이브러리 추가 (XML이 아님, 단지 메모) -->
go get github.com/stretchr/testify
- 첫 번째 테스트는
context.Background()를 사용합니다. 일반적인 단위 테스트에서 timeout을 제어할 필요가 없기 때문입니다. - 다음 테스트는 의도적으로 timeout을 500ms로 설정하여 서버 처리 시간(3s)보다 짧게 합니다. 반환된 오류가 정확히
context.DeadlineExceeded인지 확인합니다. httptest.NewServer는 모의 서버를 구축합니다. 복잡한 mock이 필요하지 않습니다.
9. 자주 발생하는 오류
- cancel() 호출 안 함:
WithCancel/WithTimeout/WithDeadline호출 직후에 항상defer cancel()을 사용하세요. context가 자동으로 만료되는 경우에도 마찬가지입니다 — Go vet는 그것을 잊으면 경고합니다. - context를 구조체 필드에 저장: context는 함수 매개변수로 전달해야 합니다. 구조체의 필드로 저장하지 마세요 (
http.Request와 같은 몇 가지 특수한 경우 제외). 필드에 저장하면 오래되거나 stale 된 context가 됩니다. - context를 사용하여 필수 매개변수 전달: 함수가 해당 값이 없으면 제대로 작동하지 않으면 일반 매개변수로 전달하세요. context에 숨기지 마세요.
- 부모 context를 사용하는 대신 새로운 context 만들기: caller로부터
ctx를 받는 함수 내에서context.Background()를 호출하면 위에서 취소/timeout 기능을 잃어버립니다 — 항상 받은 context에서 파생시키세요:context.WithTimeout(ctx, ...),context.WithTimeout(context.Background(), ...)이 아니라.
Context가 필요하지 않은 경우: I/O가 없고 goroutine이 없는 순수 함수(pure function)의 경우 매개변수로 context가 필요하지 않습니다 — 추가하면 노이즈만 됩니다.
10. 결론
Context는 Go concurrency의 세 가지 핵심 작업을 해결합니다: 취소 신호, 시간 제한 적용, 함수 호출 계층을 통해 작은 값을 전달. 가장 안전한 규칙은 항상 ctx를 첫 번째 매개변수로 받고, 항상 defer cancel()을 사용하고, request-scoped 데이터에만 WithValue를 사용하는 것입니다. 이러한 원칙을 숙달하면 goroutine 누수가 없는 Go 서비스를 작성할 수 있으며 클라이언트 timeout 또는 요청 취소에 올바르게 응답합니다.