고 중급 #5 context.Context 깊이

6 분 소요

#4 select와 타임아웃에서 봤듯 — 동시성 코드에는 취소타임아웃이 거의 필연적으로 붙습니다. Go의 표준 도구가 context.Context입니다. 이번 글은 context의 쓰임을 모두 정리합니다.

context가 표준이 됐나 #

요청 하나가 처리되는 동안 — 여러 고루틴, 여러 서비스 호출이 일어납니다. 그 모든 곳에:

  1. 취소 신호를 전파
  2. 데드라인을 흘림
  3. 요청 범위 데이터 (사용자 ID, trace ID 등)를 전달

직접 done 채널이나 deadline 변수를 매번 짜는 건 번거롭고 일관성도 깨집니다. Go 표준 라이브러리에 context가 들어와 — 이 세 가지를 한 인터페이스로 표준화했습니다.

Context 인터페이스 #

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

네 메서드만 가진 매우 작은 인터페이스. 우리는 거의 다음 세 가지만 신경 씁니다.

  • Done() — 취소되거나 타임아웃되면 close 되는 채널
  • Err() — Done 후의 사유 (context.Canceled 또는 context.DeadlineExceeded)
  • Value(key) — 저장된 값 꺼내기

기본 컨텍스트 — BackgroundTODO #

기본 컨텍스트
ctx := context.Background()    // 빈 root 컨텍스트 — 절대 취소 안 됨
ctx := context.TODO()           // 무엇을 써야 할지 아직 모름 — 임시
  • Background — 메인 함수, 초기화, 테스트 등 root 컨텍스트의 출발점
  • TODO — 나중에 어떤 컨텍스트를 쓸지 결정해야 할 경우 (의도가 보이는 자리 표시자)

둘은 동작이 같지만 의도가 다릅니다. 평소엔 Background()를 씁니다.

자식 컨텍스트 만들기 #

기존 컨텍스트에 새 동작(취소, 타임아웃 등)을 더해 — 자식 컨텍스트를 만듭니다.

WithCancel — 명시적 취소 #

WithCancel
ctx, cancel := context.WithCancel(context.Background())
defer cancel()    // 자원 누수 방지

go work(ctx)

// 어느 시점에
cancel()    // ctx.Done()이 close 됨

cancel() 함수를 부르면 — ctx.Done() 채널이 close 되어 그것을 듣는 모든 고루틴이 알아차립니다.

defer cancel()이 핵심입니다 — 함수가 끝날 때 자식 컨텍스트가 정리되도록. 호출 안 하면 컨텍스트와 그것이 추적하는 자원이 GC 안 될 수 있습니다.

WithTimeout — 일정 시간 뒤 자동 취소 #

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

result, err := slowOperation(ctx)

5초 안에 끝나야 — 안 끝나면 ctx가 자동 취소되어 slowOperation이 일찍 종료될 수 있습니다.

WithDeadline — 절대 시점에 취소 #

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

상대적 시간이 아닌 절대 시점. timeout과 의미는 같지만 표현이 다릅니다. 시계상의 특정 시간 (예: 자정까지) 같은 경우에 어울립니다.

취소를 듣는 함수 #

select로 취소 듣기
func work(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()    // context.Canceled 또는 DeadlineExceeded
		default:
			// 한 단위 일하기
			doOneStep()
		}
	}
}

<-ctx.Done()이 case로 들어가는 게 표준입니다. 취소되면 즉시 빠져나갑니다.

시간 걸리는 IO도 ctx 받기 #

ctx를 IO로 전파
func fetch(ctx context.Context, url string) ([]byte, error) {
	req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return io.ReadAll(resp.Body)
}

http.NewRequestWithContext가 ctx를 받아 — 요청이 진행 중일 때 ctx가 취소되면 자동으로 요청도 중단합니다. 표준 라이브러리의 거의 모든 IO API가 ctx를 받게 발전했습니다.

Context 전파 규칙 #

첫 번째 매개변수로 ctx를 받고, 모든 하위 호출에 그대로 전달

전파
func handleRequest(ctx context.Context, req Request) error {
	user, err := loadUser(ctx, req.UserID)         // 전파
	if err != nil {
		return err
	}

	posts, err := loadPosts(ctx, user.ID)            // 전파
	if err != nil {
		return err
	}

	return saveAudit(ctx, user, posts)               // 전파
}

매 함수가 첫 인자로 ctx context.Context를 받는 게 표준 컨벤션입니다. 호출자가 취소하면 — 하위 모든 호출이 자동으로 중단됩니다.

절대로 ctx를 struct에 저장하지 마세요 #

안티 패턴
type Service struct {
	ctx context.Context    // ✗ 안 됨
}

ctx는 함수 매개변수로만 전파해야 합니다. 객체에 저장하면 — 어떤 ctx가 어디서 쓰이는지 추적이 어려워지고, 라이프사이클이 꼬입니다.

이 룰을 어기는 경우는 정말 드뭅니다. 거의 항상 첫 매개변수 패턴입니다.

컨텍스트에 값 담기 — WithValue #

요청 범위 데이터(사용자, trace ID 등)를 전달하는 도구.

WithValue
type userKey struct{}

ctx := context.WithValue(parent, userKey{}, currentUser)

// 다른 곳에서 꺼내기
user, ok := ctx.Value(userKey{}).(*User)
if !ok {
	// 없음
}

키는 보통 struct{} 타입의 빈 값. string 키는 충돌 위험이 있어 권장 X. 자기 패키지의 unexported 타입을 키로 쓰는 게 표준입니다.

헬퍼로 감싸기 #

키 헬퍼
type contextKey int

const (
	userKey contextKey = iota
	requestIDKey
)

func WithUser(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, userKey, u)
}

func UserFrom(ctx context.Context) (*User, bool) {
	u, ok := ctx.Value(userKey).(*User)
	return u, ok
}

// 사용
ctx = WithUser(ctx, user)
if u, ok := UserFrom(ctx); ok {
	// ...
}

키와 타입을 한 곳에 모아 — 다른 곳에서는 헬퍼 함수만 사용. 안전성과 가독성이 같이 좋아집니다.

WithValue가 어울리는 경우 vs 아닌 경우 #

어울리는 경우 #

  • 요청 ID, trace ID
  • 인증된 사용자 정보
  • 로깅 컨텍스트 (logger 인스턴스)

요청 처리 동안 거의 모든 함수가 보고 싶어하는 메타데이터.

어울리지 않는 경우 #

  • 함수 매개변수로 명시되어야 할 도메인 데이터
안티 패턴
ctx := context.WithValue(parent, "amount", 1000)
ctx := context.WithValue(parent, "userID", "u1")

// 잘못된 호출
processPayment(ctx)
권장
processPayment(ctx, userID, amount)

매개변수로 명시되어야 할 데이터를 ctx에 숨기면 — 함수 시그니처만 봐서는 어떤 데이터가 필요한지 모르고, 디버깅이 어려워집니다.

규칙: ctx 안에는 “horizontal” 데이터, 매개변수에는 “vertical” 데이터. horizontal은 모든 계층이 공통으로 보는 것, vertical은 그 함수의 역할에 직접 관련된 것.

자주 만나는 패턴 #

1) HTTP 핸들러 #

HTTP 핸들러
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()    // 요청에 묶인 ctx (클라이언트가 끊으면 cancel)

	user, err := loadUser(ctx, getUserID(r))
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	// ...
}

r.Context()는 — HTTP 요청이 끊기면 자동 취소되는 컨텍스트. 클라이언트가 연결을 끊으면 (브라우저 탭 닫기 등) 서버 사이드의 작업도 자동 중단됩니다.

2) 다중 동시 요청 + 첫 실패에 모두 취소 #

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

results := make(chan Result, n)
for _, url := range urls {
	go func(u string) {
		r, err := fetch(ctx, u)
		results <- Result{r, err}
	}(url)
}

for i := 0; i < n; i++ {
	r := <-results
	if r.err != nil {
		cancel()    // 하나가 실패하면 다른 모두 중단
		return r.err
	}
}

이 패턴은 #1 동시성 패턴에서 더 정교한 형태(errgroup)로 다룹니다.

3) 데이터베이스 쿼리 #

DB 쿼리도 ctx
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE active = $1", true)

표준 database/sql API가 ctx를 받는 함수들을 가지고 있습니다 — QueryContext, ExecContext, BeginTx. ctx를 통해 쿼리도 취소될 수 있습니다.

4) Kubernetes / cloud SDK #

대부분의 Go SDK가 모든 RPC 호출에 ctx를 받습니다. 클라우드 API 호출도 — 사용자가 요청을 취소하면 서버에 보낸 호출도 자동으로 중단됩니다.

errgroup — 컨텍스트와 짝 #

자주 함께 쓰이는 패턴이라 #1 동시성 패턴에서 자세히 다루지만, 미리보기.

errgroup 미리보기
import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(parent)

g.Go(func() error {
	return fetch1(ctx)
})

g.Go(func() error {
	return fetch2(ctx)
})

if err := g.Wait(); err != nil {
	return err
}

여러 고루틴 + 첫 에러로 모두 취소 + 결과 모으기 — 이 패턴이 errgroup 한 줄로 정리됩니다. Go 동시성 코드의 가장 유용한 라이브러리 중 하나입니다.

마무리 #

이번 글에서 정리한 내용:

  • context.Context — 취소/타임아웃/요청 범위 값의 표준 도구
  • Background()가 root, TODO()는 자리 표시자
  • WithCancel / WithTimeout / WithDeadline으로 자식 컨텍스트
  • 항상 defer cancel() — 자원 누수 방지
  • <-ctx.Done()으로 select 안에서 취소 듣기
  • 함수 첫 매개변수로 ctx 전달이 표준 — struct에 저장 X
  • WithValue는 horizontal 메타데이터에만 (도메인 데이터는 매개변수)
  • HTTP 요청, DB 쿼리, 외부 API 모두 ctx 통합
  • 자식 컨텍스트는 부모가 취소되면 같이 취소

다음 글(#6 테스팅)에서는 표준 testing 패키지로 테스트와 벤치마크를 짜는 법, table-driven 테스트 패턴을 다룹니다.

X