Go context.Context 제대로 다루기: 취소, 타임아웃, 그리고 값

Go의 컨텍스트는 데이터 저장이 아닌 제어 흐름입니다.

Page content

Go의 context.Context는 잘못 사용할 정도로 간단하며 — 바로 이것이 문제입니다.

대부분의 Go 개발자는 표면적인 규칙들을 빠르게 익힙니다: context를 첫 번째 인자로 전달하기, ctx.Done() 확인하기, context.WithTimeout 사용하기, 그리고 nil을 절대 전달하지 않기.

func DoSomething(ctx context.Context) error {
	// ...
}

이 규칙들은 유용하지만, 쉬운 부분만 다루고 있습니다. 프로덕션 서비스에서 context는 단순한 매개변수 관례가 아닙니다 — 그것은 요청 수명 주기에 대한 제어 평면(control plane)입니다.

Go context: cancellation propagates through the call chain

Context는 작업이 언제 멈춰야 하는지, 얼마만큼의 시간이 남았는지, 어떤 취소 경로를 거쳤는지, 그리고 어떤 요청 범위(request-scoped) 값들이 API 경계를 넘어 이동해야 하는지를 알려줍니다. 잘 사용하면 고루틴 누수를 방지하고, 낭비되는 작업을 피하며, 데드라인을 전파하고, 서비스의 정상적인 종료(shutdown)를 쉽게 만들어줍니다. 잘못 사용하면 숨겨진 의존성들의 그릇, 가짜 전역 변수, 잊어버린 타임아웃, 누수되는 타이머, 그리고 혼란스러운 취소 동작이 됩니다.

조금 의견이 담긴 버전으로 말하자면: 취소, 데드라인, 요청 범위 메타데이터를 위해 context를 사용하고, 의존성 컨테이너로 사용하지 않는 것입니다.

Context가 사용되는 목적

context 패키지에는 세 가지 주요 역할이 있습니다 — 취소, 데드라인 및 타임아웃, 그리고 요청 범위 값(request-scoped values) — 이 세 가지 역할은 context가 설계된 모든 것을 포함합니다.

Context는 다음과 같은 질문에 답해야 합니다:

이 요청이 취소되었는가?
이 작업에 남은 시간이 얼마나 되는가?
로그에 어떤 요청 ID를 첨부해야 하는가?
이 요청과 연관된 인증된 사용자는 누구인가?

Context는 다음과 같은 질문에 답해서는 안 됩니다:

내 데이터베이스 연결은 어디에 있는가?
내 로거는 어디에 있는가?
내 설정은 어디에 있는가?
어떤 서비스 구현체를 사용해야 하는가?

이것들은 의존성(dependencies)입니다 — 함수 매개변수를 통해 명시적으로 전달해야 합니다 (이것을 깔끔하게 하는 패턴에 대해서는 Go에서의 의존성 주입를 참고하세요). Context는 애플리케이션 배선(wiring)이 아닌, 요청 수명 주기와 요청 메타데이터를 위한 것입니다.

기본 Context 구조

핵심 인터페이스는 작습니다:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

중요한 부분들은 다음과 같습니다:

  • context가 취소되거나 그 데드라인이 만료되었을 때 Done()은 닫힙니다.
  • Err()는 context가 왜 종료되었는지 설명합니다.
  • Deadline()은 context에 데드라인이 있는지 알려줍니다.
  • Value()는 요청 범위 데이터를 저장합니다.

대부분의 코드는 이 인터페이스를 구현하지 않습니다. 그것은 단순히 context를 받아서 아래로 전달할 뿐입니다.

첫 번째 규칙: Context를 명시적으로 전달하기

요청 범위나 취소 가능한 작업을 수행하는 함수의 경우, context를 첫 번째 매개변수로 전달하십시오 — 이것이 표준 Go 관례이며, 생태계의 모든 라이브러리와 도구가 이를 기대합니다:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

다음과 같은 함수에는 이것을 수행하십시오:

  • 데이터베이스 호출
  • 다른 서비스 호출
  • 대기열(queue) 대기
  • 백그라운드 작업 시작
  • I/O 블로킹
  • 타임아웃 사용
  • 요청 범위 값 필요
  • 취소 필요

context가 필요 없는 작은 순수(pure) 함수에는 context를 추가하지 마십시오.

이것은 괜찮습니다:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

모든 함수에 context가 필요한 것은 아닙니다. 모든 곳에 context를 추가하면 코드가 소음(noisy)이 생깁니다.

구조체에 Context를 저장하지 않기

구조체에 context를 저장하는 것은 Go 코드베이스에서 가장 흔한 실수 중 하나이며, 명시적으로 경고할 가치가 있습니다. 이렇게 하지 마십시오:

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

대신 이렇게 하십시오:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Context는 요청, 작업, 또는 태스크에 속하며, 서비스 구조체는 일반적으로 단일 요청보다 훨씬 긴 수명을 가집니다. 이러한 수명 주기를 혼합하면 취소를 명확하게 이해하기 어렵고, 특정 context가 어떤 작업에 속하는지 추론하기 어려워집니다.

실제로 단일 작업 수명 주기를 나타내는 타입을 위한 드문 예외는 있지만, 그것들은 충분히 드물기 때문에 기본 규칙은 단순해야 합니다:

Context를 전달하세요. 저장하지 마세요.

nil Context를 전달하지 않기

절대 context로 nil을 전달하지 마십시오.

나쁜 예:

err := svc.DoWork(nil)

기존 context가 없을 때는 context.Background()를 사용하십시오:

err := svc.DoWork(context.Background())

테스트에서는 가능한 경우 테스트 context를 사용하십시오:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

nil context는 코드가 그 메소드를 호출할 때 패닉(panic)을 일으킬 수 있습니다. Background context는 명시적이고 안전합니다.

Background, TODO, 및 Request Contexts

세 가지 일반적인 시작점이 있습니다.

context.Background

부모 context가 없는 프로그램의 최상위 수준에서 context.Background()를 사용하십시오 — 모든 자식 context가 파생되는 루트 context입니다:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

또는:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

context가 사용되어야 함을 알지만 아직哪一个를 사용할지 결정하지 못했을 때 context.TODO()를 사용하십시오.

ctx := context.TODO()

이것은 마이그레이션 중에 유용하지만, 실제 context가 존재하는 경우 영구적으로 남으면 안 됩니다.

Request Context

HTTP 서버에서 요청 context를 사용하십시오:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

요청 context는 클라이언트 연결이 닫힐 때, 요청이 취소될 때, 또는 서버가 요청 처리를 완료할 때 취소됩니다.

웹 서비스의 경우, 이것은 일반적으로 애플리케이션 코드로 내려보내야 할 context입니다.

context.WithCancel로 취소하기

작업을 명시적으로 중지하고 싶을 때 context.WithCancel을 사용하십시오.

ctx, cancel := context.WithCancel(parent)
defer cancel()

반환되는 cancel 함수는 자식 context를 취소하고 해당 context와 관련된 리소스를 해제합니다. 작업이 끝나면 항상 호출하십시오 — context가 결국 타임아웃 될 것이라 하더라도, cancel을 일찍 호출하면 리소스가 필요 이상으로 오래 살아있는 것을 방지합니다.

예시:

func RunWorker(parent context.Context) error {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	done := make(chan error, 1)

	go func() {
		done <- doBackgroundWork(ctx)
	}()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case err := <-done:
		return err
	}
}

패턴은 단순합니다:

  • 자식 context를 파생합니다.
  • cancel을 defer합니다.
  • 함께 멈추어야 할 작업에 자식 context를 전달합니다.
  • ctx.Done()을 관찰합니다.

context.WithTimeout로 타임아웃 설정하기

작업에 최대 지속 시간이 있을 때 context.WithTimeout을 사용하십시오.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

HTTP 클라이언트를 사용한 예시:

func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

이렇게 하면 타임아웃이 숨겨진 전역 설정이 아닌, 작업의 일부가 됩니다.

항상 cancel 호출하기

WithCancel, WithTimeout, 또는 WithDeadline을 호출할 때, 반환된 cancel 함수를 항상 호출하십시오 — 이것은 정확성에 중요합니다.

좋음:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

나쁨:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

cancel 호출을 누락하면 타이머와 자식 context가 필요 이상으로 오래 살아남을 수 있습니다.

타이머 채널 자체도 계약의 일부입니다: Go 1.27부터 time.After와 time.NewTimer 뒤의 채널들은 항상 버퍼리스(unbuffered)이며, 오래된 asynctimerchan 옵트아웃(opt-out)은 더 이상 1.23 이전 값으로 컴파일되지 않습니다. Go 1.27 업그레이드 가이드는 빌드를 실패시키는 다른 제거된 GODEBUG 값들과 함께 그 설정을 나열합니다.

데드라인 대 타임아웃

타임아웃은 상대적입니다:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

데드라인은 절대적입니다:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

대부분의 애플리케이션 코드는 타임아웃을 사용합니다. 요청이 여러 작업 간에 공유되어야 할 고정된 종료 시간이 있을 때 데드라인은 유용합니다 — 예를 들어, 요청에 900밀리초가 남아 있다면, 각 다운스트림 호출에 새로운 1초 타임아웃을 부여하는 대신 남은 예산을 전파하십시오.

서비스 레이어 간 타임아웃 예산

흔한 실수는 타임아웃을 무작정 쌓는 것입니다.

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	_ = service.DoWork(ctx)
}

func (s *Service) DoWork(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	return s.repo.Query(ctx)
}

이것은 해롭지 않아 보이지만, 실제 예산을 숨깁니다. 서비스 레이어는 일반적으로 타이머를 같은 값으로 재설정하는 대신 호출자의 데드라인을 존중해야 합니다.

더 나은 패턴은 다음과 같습니다:

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	if err := service.DoWork(ctx); err != nil {
		// 에러 처리
		return
	}
}

그리고 서비스 내부에서:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

하위 작업이 더 작은 예산이 필요할 때만 자식 타임아웃을 추가하십시오:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

올바른 멘탈 모델은 직관적입니다: 전체 요청에는 하나의 외부 예산이 있고, 특정 하위 작업은 그 예산에서 쪼개진 더 작은 예산을 가질 수 있으며, 어느 레이어도 호출자가 의도한 것 이상으로 요청을 조용히 연장해서는 안 됩니다.

ctx.Err()를 확인하여 취소와 타임아웃 구분하기

Context가 종료되면 ctx.Err()는 그 이유를 반환합니다.

보통 다음과 같은 것 중 하나입니다:

context.Canceled
context.DeadlineExceeded

예시:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

이것은 호출자가 취소와 타임아웃을 구분할 수 있게 해주며, 이 구별은 실제에서 중요합니다. 취소된 요청은 종종 클라이언트가 연결을 끊었음을 의미하는 반면, 데드라인 초과 에러는 대개 당신의 서비스가 너무 느렸음을 의미합니다 — 그것들은 항상 동일한 방식으로 기록, 재시도, 보고되어서는 안 됩니다.

더 나은 취소 이유를 위해 context.Cause 사용하기

현대 Go는 원인 인지형(cause-aware) 취소도 지원합니다.

유용한 함수에는 다음이 포함됩니다:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

단순 ctx.Err()는 광범위한 이유(취소됨 또는 데드라인 초과)를 알려줍니다.

context.Cause(ctx)는 더 구체적인 원인을 알려줄 수 있습니다.

예시:

var ErrShutdown = errors.New("server shutting down")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// 어떤 종료 시그널이 도착했습니다.
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

원인이 호출자, 로그, 또는 클리닝 동작에 중요할 때 원인 인지형 취소를 사용하고, 단순 ctx.Err()가 충분한 곳에서는 피하십시오 — 추가 세부 사항은 진단이 진정으로 그것을 필요로 할 때만 가치가 있습니다.

HTTP 서버 예시

일반적인 HTTP 핸들러는 r.Context()에서 시작해야 합니다. Go HTTP 서비스 구조화에 대한 전체 개요에 대해서는 Go에서의 REST API 구축를 참조하십시오. Gin, Echo, Fiber는 각각 자체 요청 context 타입 내부에 context.Context를 래핑하며, 스택 간의 차이는 2026년 Go 웹 프레임워크: net/http, chi, Gin, Echo, Fiber 비교에서 비교됩니다.

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		id := r.PathValue("id")

		user, err := svc.GetUser(ctx, id)
		if err != nil {
			writeError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

서비스는 context를 받아서 전파해야 합니다:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

리포지토리는 context 인지형 데이터베이스 메소드를 사용해야 합니다:

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		return nil, err
	}

	return &user, nil
}

중요한 것은 체인입니다 — 각 레이어가 동일한 context를 다음 레이어로 전달합니다:

flowchart TD A[HTTP request context] --> B[Handler] B --> C[Service] C --> D[Repository] D --> E[Database query]

context.Background()를 중간에 생성하여 체인을 끊지 마십시오.

context.Background() 실수: 취소 체인 끊기

이것은 흔한 버그입니다:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

이것은 호출자로부터의 모든 취소 및 데드라인 정보를 폐기합니다. 클라이언트가 연결을 끊으면, 데이터베이스 쿼리는 계속 실행됩니다. 요청이 타임아웃되면, 다운스트림 작업이 여전히 진행 중일 수 있습니다. 서버가 종료 중이라면, 이 코드는 그것을 완전히 무시합니다. 비즈니스 로직 내에서 수신된 context를 context.Background()으로 대체하는 것은 거의 항상 틀렸습니다.

주어진 context를 사용하십시오:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

부모 context가 없는 가장자리(edge)에서만 context.Background()를 사용하십시오.

HTTP 클라이언트 예시

출력 HTTP 요청의 경우, 요청에 context를 첨부하십시오.

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

이렇게 하지 마십시오:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

이것은 작업 context 없는 요청을 생성합니다.

또한 http.Client.Timeout에만 의존하는 것도 피하십시오. 이것은 안전 한계로 유용할 수 있지만, 요청 context는 호출 체인 간의 더 나은 전파를 제공합니다.

흔한 패턴은 다음과 같습니다:

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

다운스트림 API 호출이 더 큰 요청 내에서 특정 예산이 있을 때 이것을 사용하십시오.

데이터베이스 예시

대부분의 Go 데이터베이스 API에는 context 인지형 메소드가 있습니다. Go 데이터 접근 라이브러리가 context를 처리하는 방법에 대한 더 넓은 시각 — GORM, Ent, Bun, sqlc를 포함하여 — 에 대해서는 PostgreSQL용 Go ORM 비교를 참조하십시오.

그것을 사용하십시오.

좋음:

rows, err := db.QueryContext(ctx, query, args...)

좋음:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

좋음:

result, err := db.ExecContext(ctx, query, args...)

나쁨:

rows, err := db.Query(query, args...)

Context 인지형 형태는 요청이 취소되거나 타임아웃될 때 데이터베이스 작업을 멈출 수 있게 하여, 특히 느린 쿼리, 과부하 상태의 데이터베이스, 그리고 지연이 사용자 경험에 직접 영향을 미치는 사용자 직면 API에서 특히 중요합니다.

트랜잭션과 Context

트랜잭션은 careful한 context 처리가 필요합니다.

트랜잭션은 일반적으로 작업 context로 시작해야 합니다:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

그리고 트랜잭션 작업에 동일한 context를 사용하십시오:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

트랜잭션 주변의 타임아웃에 유의하십시오. Context가 Commit 전에 취소되면, 트랜잭션이 롤백될 수 있습니다. 그것이 원하는 것일 수 있지만, 의도적이어야 합니다.

긴 트랜잭션의 경우, 더 나은 답은 보통 더 긴 타임아웃이 아니라 — 단위당 더 적은 작업을 수행하는 더 짧은 트랜잭션입니다.

백그라운드 워커와 Context

백그라운드 워커는 그 수명 주기를 나타내는 context를 수신해야 합니다.

예시:

type Worker struct {
	logger *slog.Logger
}

func (w *Worker) Run(ctx context.Context) error {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()

		case <-ticker.C:
			if err := w.doOnce(ctx); err != nil {
				w.logger.Error("worker iteration failed", "err", err)
			}
		}
	}
}

이 워커는 context가 취소되면 깨끗하게 멈추고, 그 티커는 defer ticker.Stop()을 통해 적절하게 정리됩니다. main에서, OS 시그널에 연결된 루트 context를 생성합니다:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	worker := &Worker{logger: slog.Default()}

	if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("worker stopped", "err", err)
	}
}

이것이 context의 올바른 사용입니다: 그것은 프로세스 작업의 수명 주기를 묘사하며, OS가 시그널을 보낼 때 이 context를 공유하는 고루틴 트리 전체가 함께 멈춥니다.

Context 취소로 고루틴 누수 방지하기

고루틴 누수는 고루틴이 더 이상 유용하지 않은 후에 영원히 블로킹된 채 남아 있을 때 발생합니다.

Context는 이것을 방지하는 데 도움이 됩니다.

나쁨:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

이 고루틴에는 종료 경로가 없습니다.

더 좋음:

func StartWorker(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				doWork()
			}
		}
	}()
}

루프를 돌리는 고루틴은 거의 항상 취소 경로가 있어야 합니다.

그것은 모든 고루틴이 context를 직접 수신해야 함을 의미하지는 않지만, 시스템은 그것을 멈추는 명확한 방법이 있어야 합니다.

context.AfterFunc

context.AfterFunc는 context가 취소된 후에 함수를 실행합니다.

클리닝, 작업 언블로킹, 또는 context를 기본적으로 지원하지 않는 API를 연결하는 데 유용할 수 있습니다.

예시:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// 필요한 경우 깨우거나 정리합니다.
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

AfterFunc를 주의해서 사용하십시오 — 그것은 취소가 발생하면 논리를 시작하므로, 제어 흐름을 따라가기 어려울 수 있습니다. 대부분의 애플리케이션 코드에서는, ctx.Done()에 대한 일반적인 select가 더 명확하고 추론하기 쉽습니다. AfterFunc는 이미 context를 받지 않는 API에 context 취소를 적응시켜야 할 때 가장 가치 있습니다.

context.WithoutCancel

context.WithoutCancel은 부모가 취소될 때 취소되지 않는 context를 생성합니다.

이것은 유용하지만, 오용하기에도 쉽습니다.

예시 사용 사례:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// 요청 처리...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "request completed")
		}()
	}
}

사상은 감사(audit) 쓰기가 요청 context가 취소된 후에도 잠시 계속될 수 있어야 한다는 것입니다. 이것은 드물고 의도적이어야 하며 — WithoutCancel을 취소 문제를 회피하는 방법으로 사용하지 마십시오. 자식 작업이 진정으로 부모 취소보다 길게 지속되어야 할 때만 사용하고, 항상 새로운 타임아웃을 추가하십시오: 취소를 무시하지만 데드라인을 담지 않는 context는 쉽게 백그라운드 고루틴 누스를 생성할 수 있습니다.

Context Values를 올바르게 사용하기

Context values는 API 경계를 넘는 요청 범위 데이터를 위한 것입니다.

좋은 예시:

  • 요청 ID
  • 트레이스 ID
  • 인증된 사용자 ID
  • 테넌트 ID
  • 로케일
  • 보안 주체(security principal)
  • 상관성 메타데이터(correlation metadata)

나쁜 예시:

  • 데이터베이스 연결
  • 숨겨진 의존성으로서의 로거
  • 일반 제어 흐름을 위한 기능 플래그
  • 선택적 함수 매개변수
  • 설정
  • 서비스 클라이언트

유용한 규칙: 만약 그 값이 요청의 정체성 또는 가시성(observability) 컨텍스트의 일부라면, 그것은 context에 속할 수 있습니다. 만약 그것이 당신의 코드가 일을 수행하는 데 필요한 의존성이면, 명시적으로 전달하십시오.

Context Values에 타입 지정된 키 사용하기

단순 문자열을 context 키로 사용하지 마십시오.

나쁨:

ctx = context.WithValue(ctx, "userID", "123")

이것은 다른 패키지와의 충돌을 일으킬 수 있습니다.

비공개(비공개) 사용자 정의 키 타입을 사용하십시오:

type userIDKey struct{}

func WithUserID(ctx context.Context, userID string) context.Context {
	return context.WithValue(ctx, userIDKey{}, userID)
}

func UserIDFromContext(ctx context.Context) (string, bool) {
	userID, ok := ctx.Value(userIDKey{}).(string)
	return userID, ok
}

이 패턴은 패키지 경계에서 타입 안전성을 제공하고, 다른 패키지와의 키 충돌을 방지하며, 타입 지정된 접근자 함수를 통해 context API 표면을 깔끔하게 유지합니다.

선택적 매개변수를 위해 Context Values를 사용하지 않기

이것은 나쁨:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

이것은 함수 계약을 숨깁니다.

명시적 매개변수를 선호하십시오:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

Context values는 함수 인수를 대체해서는 안 됩니다. 숨겨진 입력은 코드를 이해하고, 테스트하고, 리뷰하는 것을 어렵게 만들며 — 함수 시그니처를 읽는 사람은 그 매개변수가 존재하는지조차 알 수 없을 것입니다.

로깅과 Context

Context와 함께 로깅하는 두 가지 일반적인 접근법이 있습니다. 여기의 예시는 Go의 log/slog 패키지를 사용합니다 — 프로덕션 서비스에서 slog를 사용한 구조화된 로깅에 대한 더 깊은 이해를 위해 slog를 사용한 Go 구조화된 로깅를 참조하십시오.

접근법 1: 값을 추출하여 로그에 첨부하기

func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
	if requestID, ok := RequestIDFromContext(ctx); ok {
		logger = logger.With("request_id", requestID)
	}

	logger.Info(msg)
}

이것은 로거를 적절한 의존성으로서 명시적으로 유지하고, API 경계를 넘어 legitimately 이동해야 하는 요청 범위 값에만 context를 사용합니다.

접근법 2: Context에 로거 저장하기

일부 코드베이스는 context에 로거를 저장합니다.

이것은 편리할 수 있지만, 기본값으로 추천하지 않습니다. 그것은 context를 의존성 컨테이너로 만듭니다.

제 선호도:

  • 로거 의존성을 명시적으로 전달하십시오.
  • 트레이스 ID와 요청 ID를 context에 저장하십시오.
  • 경계 또는 미들웨어에서那些 값들을 로그에 추가하십시오.

이것은 의존성을 가시적으로 유지합니다.

Context와 트레이싱

트레이싱은 context values의 가장 강력한 사용 사례 중 하나이며, 진정으로 적합한 적합합니다. OpenTelemetry 및 유사한 시스템은 함수 호출 및 프로세스 경계를 넘어서 트레이스 스팬을 전파하기 위해 context를 사용하며, 트레이스 데이터는 context가 운반하도록 설계된 바로 그 종류의 요청 범위 메타데이터입니다.

전형적인 패턴은 다음과 같습니다:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

Context는 활성 트레이스 스팬을 운반하며, 리포지토리는 그로부터 자식 스팬을 생성할 수 있습니다. 각 레이어는 tracer 개체를 명시적으로 전달하지 않고도 자체 스팬을 추가합니다 — context는 전체 호출 트리 전체에서 그 작업을 투명하게 수행합니다.

Context와 에러 처리

작업이 context 취소로 인해 멈추면, 그 정보를 보존하십시오. 여기의 패턴은 Go 에러 처리 아키텍처에서 다루는 더 넓은 에러 설계 전략을 보완합니다.

예시:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// 클라이언트가 취소하거나 호출자가 작업을停止了.
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// 타임아웃.
		return err
	}

	return err
}

Context 에러를 그것들을 숨기는 방식으로 맹목적으로 랩(wrap)하지 마십시오.

%w를 사용한 랩핑은 errors.Is를 보존하므로, 호출자는 여전히 취소 또는 타임아웃을 감지할 수 있습니다:

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

에러를 완전히 교체하면 그 정보를 폐기하고, 특정 context 에러 타입을 확인하는 모든 호출자를 깨뜨립니다:

if err != nil {
	return errors.New("query user failed")
}

Context 에러를 HTTP 응답으로 매핑하기

Context 에러는 종종 다른 HTTP 결과로 매핑됩니다.

예시:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// 클라이언트가 떠난 것일 수 있습니다.
		// 어떤 시스템에서는 이것을 클라이언트 요청 종료를 로그로 기록합니다.
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)
		return

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

클라이언트 취소를 애플리케이션 실패로 취급하지 마십시오 — 사용자가 브라우저 탭을 닫았다면, 그것은 당신의 서비스가 잘못 행동한 것이 아니며, 그것을 에러로 기록하는 것은 신호 없이 소음만 추가합니다.

미들웨어에서의 Context

HTTP 미들웨어는 요청 범위 값을 추가하는 흔한 장소입니다.

요청 ID 미들웨어 예시:

type requestIDKey struct{}

func WithRequestID(ctx context.Context, requestID string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, requestID)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	requestID, ok := ctx.Value(requestIDKey{}).(string)
	return requestID, ok
}

func RequestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestID := r.Header.Get("X-Request-ID")
		if requestID == "" {
			requestID = newRequestID()
		}

		ctx := WithRequestID(r.Context(), requestID)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

이것은 context의 좋은 사용입니다. 요청 ID는 요청에 속하며, 전체 호출 체인을 따라가야 하며, 각 레이어에서 로그와 트레이스에 첨부하는 것은 context values가 지원하도록 설계된 바로 그 종류의 횡단(cross-cutting) 가시성 관심사입니다.

테스트에서의 Context

테스트에서 context.Background()을 맹목적으로 사용하지 마십시오.

작업이 테스트 수명 주기에 속할 때 t.Context()를 선호하십시오:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

타임아웃 동작에 대해서는, 타임아웃이 작고 의미 있을 때만 실제 타임아웃으로 테스트하십시오.

동시성 및 시간 의존 코드의 경우, testing/synctest 사용을 고려하십시오 — synctest를 사용한 동시 Go 코드 테스트가 이 도구를 심층적으로 다룹니다:

func TestTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
		defer cancel()

		time.Sleep(30 * time.Second)

		if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
			t.Fatalf("got %v, want deadline exceeded", ctx.Err())
		}
	})
}

이것은 실제 시간을 기다리지 않고 실제 타임아웃 값을 테스트할 수 있게 해줍니다.

Context와 errgroup

함께 취소되어야 하는 고루틴 그룹의 경우, errgroup은 보통 좋은 적합입니다.

errgroup은 취소를 조정하지만, 그것 자체로 어떤 것도 제한하지는 않습니다: 제한이 없으면, 1000개 항목의 루프는 1000개 고루틴을 시작합니다. 용량 측면 — SetLimit, 시그널 채널, 워커 풀, 그리고 Wait을 살아남는 누수 형태 — 에 대해서는 Go에서 동시성 제한하기를 참조하십시오.

예시:

func FetchAll(ctx context.Context, ids []string, client *Client) error {
	g, ctx := errgroup.WithContext(ctx)

	for _, id := range ids {
		id := id

		g.Go(func() error {
			_, err := client.Fetch(ctx, id)
			return err
		})
	}

	return g.Wait()
}

한 고루틴이 에러를 반환하면, 그룹 context가 취소되고 ctx.Done()을 존중하는 다른 고루틴들은 일찍 멈출 수 있습니다. 이것은 여러 고루틴, 채널, 취소 경로를 수동으로 관리하는 것보다 훨씬 깔끔합니다. 여기의 핵심 구절은 “context를 존중한다"는 것입니다 — errgroup은 ctx.Done()을 무시하는 작업을 멈출 수 없습니다.

정상적인 종료 (Graceful Shutdown)

Context는 정상적인 종료에 중추적인 역할을 합니다.

전형적인 서버 설정은 다음을 가집니다:

  • OS 시그널에 의해 취소되는 루트 context
  • HTTP 서버
  • 백그라운드 워커
  • 종료 타임아웃
  • 클리닝 로직

예시:

func main() {
	root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	server := &http.Server{
		Addr:    ":8080",
		Handler: routes(),
	}

	go func() {
		<-root.Done()

		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		if err := server.Shutdown(shutdownCtx); err != nil {
			slog.Error("server shutdown failed", "err", err)
		}
	}()

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("server failed", "err", err)
		os.Exit(1)
	}
}

종료 context가 루트 context와 같지 않다는 점에 유의하십시오 — 루트는 OS 시그널이 도착할 때 이미 취소됩니다. 별도의 타임아웃 context는 강제 종료하기 전에 인플라이트(in-flight) 요청을 드레인할 수 있는 제한된 시간을 종료 프로세스에 부여하며, 이것이 정상적인 종료를 실제로 작동시키는 미묘하지만 중요한 구별입니다.

흔한 반패턴 (Anti-patterns)

반패턴 1: Context를 의존성 컨테이너로 사용하기

나쁨:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

의존성은 명시적으로 전달하십시오.

반패턴 2: 비즈니스 로직 내부에서 context.Background 생성하기

나쁨:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

이것은 취소 전파를 끊습니다.

반패턴 3: cancel 잊어버리기

나쁨:

ctx, _ := context.WithTimeout(parent, time.Second)

좋음:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

반패턴 4: 선택적 매개변수를 context에 넣기

나쁨:

ctx = context.WithValue(ctx, "includeDeleted", true)

명시적 옵션 구조체를 사용하십시오.

반패턴 5: 순수 코드로 너무 깊게 context 전달하기

나쁨:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

순수 계산은 길게 실행되거나 취소 가능하지 않는 한 context가 필요 없습니다.

반패턴 6: 루프에서 취소 무시하기

나쁨:

for item := range items {
	process(item)
}

더 좋음:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

반패턴 7: Context 에러 삼키기

나쁨:

if err != nil {
	return errors.New("operation failed")
}

좋음:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

취소 및 데드라인 에러를 보존하십시오.

실용적인 Context 체크리스트

Go 백엔드 코드에 대한 이 체크리스트를 사용하십시오.

함수 시그니처

  • Context는 첫 번째 매개변수입니다.
  • Context는 긴 수명의 구조체에 저장되지 않습니다.
  • Context는 필요 없는 순수 헬퍼 함수에 전달되지 않습니다.
  • Nil context는 절대 사용되지 않습니다.

취소

  • 긴 루프는 ctx.Done()을 확인합니다.
  • 고루틴은 종료 경로가 있습니다.
  • 워커 수명 주기는 부모 context에 연결됩니다.
  • Context 취소는 다운스트림 호출로 전파됩니다.

타임아웃

  • 외부 요청 타임아웃은 경계에서 설정됩니다.
  • 하위 작업 타임아웃은 외부 예산보다 작습니다.
  • Cancel 함수는 항상 호출됩니다.
  • 타임아웃은 각 레이어에서 맹목적으로 쌓이지 않습니다.

값 (Values)

  • Context values는 요청 범위입니다.
  • 키는 사용자 정의 타입을 사용하고, 단순 문자열을 사용하지 않습니다.
  • 의존성은 context에 저장되지 않습니다.
  • 선택적 매개변수는 context에 저장되지 않습니다.

에러

  • context.Canceled와 context.DeadlineExceeded는 보존됩니다.
  • Context 에러는 API 경계에서 올바르게 매핑됩니다.
  • 원인 인지형 취소는 원인이 중요할 때만 사용됩니다.

테스트

  • 테스트는 적절한 곳에서 t.Context()를 사용합니다.
  • 타임아웃 테스트는 느린 실제 슬리프를 피합니다.
  • 동시 타임아웃 동작은 유용할 때 testing/synctest로 테스트됩니다.
  • 고루틴 누수는 종료 경로가 존재하는지 확인하여 점검됩니다.

Go 코드베이스에서 Context 사용 감사하기

이 패턴들을 검색하십시오:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

그리고 질문하십시오:

  • context.Background()은 최상위 경계에서만 사용되는가?
  • Cancel 함수가 항상 호출되는가?
  • 타임아웃이 적절한 경계에 배치되었는가?
  • Context values가 정말로 요청 범위인가?
  • 의존성이 context values에 숨겨져 있는가?
  • 고루틴이 중지 가능한가?
  • Context 에러가 보존되는가?

이것은 좋은 코드 리뷰 습관이며, 많은 context 버그는 구문 버그가 아니라 — 취소, 부하, 또는 종료 조건 하에서만 표면화되는 수명 주기 버그이기 때문입니다.

제 의견이 담긴 규칙

이 규칙들은 지루하지만, 효과가 있습니다.

규칙 1: Context는 제어 흐름입니다

Context를 취소, 데드라인, 요청 메타데이터를 제어하는 데 사용하십시오.

의존성을 밀반(smuggle)하는 데 사용하지 마십시오.

규칙 2: 호출자가 예산을 소유합니다

함수는 일반적으로 수신한 context를 존중해야 합니다.

하위 작업이 특정 더 작은 예산이 필요할 때만 더 짧은 자식 타임아웃을 생성하십시오.

규칙 3: Background는 가장자리에서 사용

main, 테스트, 최상위 설정에서 context.Background()을 사용하십시오.

취소를 회피하기 위해 서비스 및 리포지토리 메소드 내부에서 사용하지 마십시오.

규칙 4: Values는 지루해야 합니다

요청 ID, 트레이스 ID, 사용자 ID, 테넌트 ID는 context에 속합니다. 데이터베이스 연결, 로거, 설정 구조체, 서비스 클라이언트는 속하지 않습니다 — 그것들은 의존성이며 명시적으로 전달되어야 합니다.

규칙 5: 모든 고루틴은 수명 주기가 필요

고루틴이 시작되면, 그것이 어떻게 멈추는지 정확히 알아야 합니다. Context는 종종 정답이며, context가 아니면 다른 명확한 메커니즘이 있어야 합니다 — 채널, 동기화 원시 변수(sync primitive), 또는 명시적 시그널.

최종 생각

context.Context는 API가 크기 때문에 복잡한 것이 아닙니다 — API는 작습니다. 그것이 수명 주기를 나타내기 때문에 복잡한 것이며, 수명 주기는 아키텍처입니다. Context가 어디로 흐르는지, 어디서 파생되는지, 어디서 멈추는지에 대한 모든 결정은 당신의 서비스가 실패, 부하, 종료를 어떻게 처리하는지에 대한 결정입니다.

잘 사용된 context는 Go 서비스를 더 쉽게 취소하고, 더 쉽게 종료하고, 더 쉽게 관찰하며, 고루틴을 더 적게 누수되게 합니다. 잘못 사용된 context는 의존성을 숨기고, 데드라인을 폐기하며, 압력 하에서 코드를 추론하기 어렵게 만듭니다.

실용적인 교훈은 단순합니다:

Context를 아래로 전달하세요.
저장하지 마세요.
명시적 매개변수를 값으로 대체하지 마세요.
취소를 존중하세요.
경계에서 타임아웃을 사용하세요.
항상 cancel을 호출하세요.

이것이 제대로 된 Go context입니다.

이 기사는 프로덕션 앱 아키텍처 클러스터의 일부로, 프로덕션 Go 및 Python 시스템을 위한 코드 구조, 데이터 접근, 통합 패턴, 테스트 아키텍처를 다룹니다.

출처

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.