전달하신 문맥은 취소하는 문맥이 아닙니다.

작성자

카테고리:

← 피드로
DEV Community · MSakai · 2026-08-17 개발(SW)

MSakai

Two failure modes, opposite in effect, same root cause. Both compile, and both are quiet in development.

Failure one: the work dies with the request

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    order := parse(r)
    save(order)

    go func() {
        h.sendConfirmationEmail(r.Context(), order)   // cancelled the moment Create returns
    }()

    w.WriteHeader(http.StatusCreated)
}

Enter fullscreen mode Exit fullscreen mode

r.Context() is cancelled when the handler returns and the response is written. Your goroutine gets a context that is already dead, or dies milliseconds later. Emails silently don’t send — and under local testing, where the goroutine usually wins the race, everything looks fine.

Since Go 1.21 the fix is one call:

ctx := context.WithoutCancel(r.Context())
go h.sendConfirmationEmail(ctx, order)

Enter fullscreen mode Exit fullscreen mode

WithoutCancel keeps the values — trace IDs, request-scoped logger, auth subject — while detaching from the parent’s cancellation. Before 1.21 you had to hand-roll a context.Background() and re-attach every value you cared about, which is how those values usually got lost.

Give the detached work its own deadline, because it no longer has one:

ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
go func() {
    defer cancel()
    h.sendConfirmationEmail(ctx, order)
}()

Enter fullscreen mode Exit fullscreen mode

Failure two: the cancellation never arrives

func fetchAll(ctx context.Context, ids []string) ([]Item, error) {
    var items []Item
    for _, id := range ids {
        item, err := db.Get(context.Background(), id)   // ignores ctx entirely
        if err != nil {
            return nil, err
        }
        items = append(items, item)
    }
    return items, nil
}

Enter fullscreen mode Exit fullscreen mode

The caller cancels. Nothing stops. The loop runs to completion against a client that hung up, holding a database connection the whole time. Under load this is how a spike in cancelled requests turns into connection pool exhaustion.

The signature accepts a ctx and then ignores it, which is worse than not accepting one — every caller reasonably assumes cancellation works.

Making it visible

go vet catches a lost cancel, but not a lost ctx. Two things that do help:

contextcheck (available via golangci-lint) flags functions that receive a context and then pass context.Background() downstream — exactly failure two.

# .golangci.yml
linters:
  enable:
    - contextcheck
    - noctx        # flags http.NewRequest without a context

Enter fullscreen mode Exit fullscreen mode

A test that asserts cancellation propagates. If it matters, assert it:

func TestFetchAllRespectsCancellation(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel()

    _, err := fetchAll(ctx, []string{"a", "b", "c"})
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("expected context.Canceled, got %v", err)
    }
}

Enter fullscreen mode Exit fullscreen mode

Pre-cancelling before the call is the cheap version and catches most regressions.

The convention that prevents both

A context is scoped to an operation. When the operation’s lifetime changes, the context has to change with it — explicitly.

Concretely:

  • Work that must outlive the request: context.WithoutCancel, plus a fresh timeout
  • Work that is part of the request: pass ctx down, unchanged, to every call that accepts one
  • context.Background() appears in main, in tests, and essentially nowhere else

The third rule is the one worth grepping for. Every context.Background() inside a request path is either failure two, or a comment waiting to be written explaining why it isn’t.

These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.

원문에서 계속 ↗