# Go에서 context를 인터페이스 계약으로 만들기

context.Context를 함수 첫 인자로 받으라는 관례는 널리 알려져 있습니다. 문제는 그 관례가 어디까지 적용돼야 하는가입니다. 핸들러는 ctx를 받는데 서비스 인터페이스는 안 받고, 리포지토리는 내부에서 context.Background()를 만들어 쓰는 코드베이스가 많습니다. 이 상태에서는 클라이언트가 연결을 끊어도 DB 쿼리가 끝까지 돕니다.

이 글에서는 취소가 실제로 전파되려면 어디에 ctx가 있어야 하는지, 왜 나중에 추가하기가 어려운지, 그리고 context.Value를 어디까지 써야 하는지를 정리합니다.

# 1. 사슬은 가장 약한 고리에서 끊어진다

취소 전파는 체인입니다. 문서의 규칙이 그 성질을 그대로 보여 줍니다.

When a Context is canceled, all Contexts derived from it are also canceled.

파생된 컨텍스트에만 전파됩니다. 중간에 누군가 context.Background()로 새 컨텍스트를 만들면, 그 지점부터 아래로는 부모의 취소가 도달하지 않습니다.

// 여기서 사슬이 끊긴다
func (r *repo) FindAll() ([]Item, error) {
    var items []Item
    err := r.db.WithContext(context.Background()).Find(&items).Error
    return items, err
}

이 코드는 컴파일되고 테스트도 통과합니다. 문제는 부하가 걸릴 때만 드러납니다. 클라이언트가 타임아웃으로 끊어도 서버는 쿼리를 계속 붙들고, 재시도가 겹치면 DB 커넥션이 고갈됩니다. 취소되지 않는 작업은 부하 상황에서 스스로를 증폭시킵니다.

끊긴 지점을 찾는 가장 빠른 방법은 검색입니다.

# 요청 경로에서 새 루트 컨텍스트를 만드는 곳
grep -rn "context.Background()\|context.TODO()" --include="*.go" \
  internal/ | grep -v "_test.go\|/cmd/\|main.go"

cmdmain, 백그라운드 워커의 시작점에는 있어야 정상입니다. 요청 처리 경로에 있으면 그게 끊긴 고리입니다.

# 2. 왜 나중에 추가하기가 어려운가

ctx를 인터페이스 시그니처에 넣는 일은 미루면 비용이 커집니다. 포트 인터페이스 하나에 메서드가 열 개면, 나중에 추가할 때 인터페이스·구현체·목·호출부를 전부 고쳐야 합니다. 구현체가 셋이면 그만큼 곱해집니다.

그래서 규칙을 인터페이스 설계 시점의 기본값으로 두는 편이 낫습니다. 외부와 닿는 모든 포트 메서드는 ctx를 첫 인자로 받는다고 계약으로 못 박습니다.

type WorkspaceRepository interface {
    FindByID(ctx context.Context, id string) (*Workspace, error)
    Save(ctx context.Context, w *Workspace) error
}

type Deployer interface {
    Apply(ctx context.Context, spec DeploySpec) error
}

당장 취소를 지원하지 않는 구현체라도 인자는 받아 둡니다. 나중에 지원하게 되면 시그니처를 바꾸지 않고 내부만 고치면 됩니다. 인자를 받는 비용은 0에 가깝고, 안 받아서 나중에 고치는 비용은 인터페이스 개수에 비례합니다.

문서가 정한 형태도 함께 지킵니다.

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.

구조체 필드에 ctx를 넣으면 그 객체가 재사용될 때 이미 취소된 컨텍스트를 계속 들고 다닙니다. 요청 스코프 값이 객체 수명에 묶이면서 생기는 문제입니다.

# 3. cancel을 호출하지 않으면 새는 것

타임아웃을 걸 때 반환된 cancel을 무시하는 코드가 종종 보입니다. 문서는 무엇이 새는지 정확히 말합니다.

Failing to call the CancelFunc leaks the child and its children until the parent is canceled.

부모가 취소될 때까지 자식 컨텍스트가 남습니다. 부모가 서버 수명 전체를 사는 컨텍스트라면 사실상 영구 누수입니다. 요청마다 하나씩 쌓입니다.

func (s *service) fetch(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()          // 정상 종료든 에러든 반드시 호출
    return s.client.Do(ctx)
}

go vetlostcancel 검사가 이 실수를 잡아 줍니다. CI에 넣어 두는 편이 확실합니다.

go vet ./...

취소 원인을 구분해야 할 때는 Err()를 봅니다. 기한 초과는 DeadlineExceeded, 그 외 취소는 Canceled입니다. 이 구분이 로깅에서 중요합니다. 클라이언트가 끊어서 취소된 요청을 서버 에러로 집계하면 알람이 잘못 울립니다.

if err != nil {
    switch {
    case errors.Is(err, context.DeadlineExceeded):
        // 우리 쪽 타임아웃 - 조사 대상
    case errors.Is(err, context.Canceled):
        // 클라이언트가 끊음 - 대개 정상
    }
}

# 4. context.Value의 경계

ctx가 모든 함수를 지나다니다 보니, 인자를 넘기기 귀찮을 때 Value에 넣고 싶어집니다. 문서가 선을 긋습니다.

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

기준은 "프로세스와 API 경계를 넘나드는 요청 스코프 데이터인가" 입니다. 추적 ID, 요청 ID, 인증 주체 정도가 여기 해당합니다. 페이지 크기나 정렬 옵션 같은 것은 명시적 인자로 받아야 합니다.

Value에 넣은 값은 타입 검사를 받지 못하고, 함수 시그니처만 봐서는 무엇을 요구하는지 알 수 없습니다. 호출자가 값을 안 넣어도 컴파일되고, 런타임에 nil로 터집니다. 이 위험을 줄이려면 키를 비공개 타입으로 정의하고 접근자를 함께 제공해야 합니다.

type ctxKey struct{}          // 비공개 타입 - 다른 패키지와 충돌하지 않는다

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, ctxKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(ctxKey{}).(string)
    return id, ok
}

키를 string으로 두면 다른 패키지가 같은 문자열을 쓸 때 조용히 덮어씁니다. 비공개 구조체 타입을 키로 쓰면 그 패키지 밖에서는 같은 키를 만들 수 없습니다.

# 5. 백그라운드 작업의 예외

요청 컨텍스트를 그대로 물려주면 안 되는 경우가 있습니다. 요청 처리 중에 시작해 응답 이후에도 계속돼야 하는 작업입니다. 요청 컨텍스트는 응답과 함께 취소되므로 그대로 쓰면 작업이 중간에 끊깁니다.

이때 필요한 것은 값은 물려받되 취소는 물려받지 않는 컨텍스트입니다. Go 1.21부터 표준 라이브러리에 들어왔습니다.

func (h *Handler) Submit(c *gin.Context) {
    reqCtx := c.Request.Context()

    // 추적 ID 등 값은 유지, 취소·기한은 분리
    bgCtx := context.WithoutCancel(reqCtx)
    bgCtx, cancel := context.WithTimeout(bgCtx, 10*time.Minute)

    go func() {
        defer cancel()
        h.svc.Process(bgCtx)
    }()

    c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
}

다만 이 패턴이 작업 유실까지 막아 주지는 않습니다. 프로세스가 죽으면 고루틴도 사라집니다. 정말 유실되면 안 되는 작업은 컨텍스트가 아니라 큐로 넘겨야 합니다.

# 6. 점검 목록

확인 방법
요청 경로에 새 루트 컨텍스트가 있는가 context.Background() 검색
cancel 누수가 있는가 go vet ./...의 lostcancel
포트 인터페이스가 ctx를 받는가 인터페이스 정의 검토
구조체에 ctx를 저장하는가 ctx context.Context 필드 검색
Value 키가 비공개 타입인가 context.WithValue 호출부 검토
취소 원인을 구분해 로깅하는가 DeadlineExceeded / Canceled 분기

취소가 실제로 전파되는지는 테스트로 확인하는 것이 가장 확실합니다.

func TestFindAll_취소되면_즉시_반환한다(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel()                                  // 미리 취소

    _, err := repo.FindAll(ctx)
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("취소가 전파되지 않았다: %v", err)
    }
}

# 7. 마무리

  • 취소는 파생된 컨텍스트로만 전파됩니다. 요청 경로 어딘가에 context.Background()가 있으면 그 아래는 절대 취소되지 않습니다.
  • ctx는 인터페이스 설계 시점에 넣어야 합니다. 나중에 추가하는 비용은 인터페이스와 구현체 개수에 비례합니다.
  • cancel을 호출하지 않으면 부모가 끝날 때까지 샙니다. go vet으로 자동 검사할 수 있습니다.
  • Value는 경계를 넘나드는 요청 스코프 데이터 전용입니다. 선택 인자를 여기 넣으면 타입 검사와 시그니처의 이점을 모두 잃습니다.

# 참고