고 중급 #5 context.Context 깊이
#4 select와 타임아웃에서 봤듯 — 동시성 코드에는 취소와 타임아웃이 거의 필연적으로 붙습니다. Go의 표준 도구가 context.Context입니다. 이번 글은 context의 쓰임을 모두 정리합니다.
왜 context가 표준이 됐나
#
요청 하나가 처리되는 동안 — 여러 고루틴, 여러 서비스 호출이 일어납니다. 그 모든 곳에:
- 취소 신호를 전파
- 데드라인을 흘림
- 요청 범위 데이터 (사용자 ID, trace ID 등)를 전달
직접 done 채널이나 deadline 변수를 매번 짜는 건 번거롭고 일관성도 깨집니다. Go 표준 라이브러리에 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)— 저장된 값 꺼내기
기본 컨텍스트 — Background와 TODO
#
ctx := context.Background() // 빈 root 컨텍스트 — 절대 취소 안 됨
ctx := context.TODO() // 무엇을 써야 할지 아직 모름 — 임시- Background — 메인 함수, 초기화, 테스트 등 root 컨텍스트의 출발점
- TODO — 나중에 어떤 컨텍스트를 쓸지 결정해야 할 경우 (의도가 보이는 자리 표시자)
둘은 동작이 같지만 의도가 다릅니다. 평소엔 Background()를 씁니다.
자식 컨텍스트 만들기 #
기존 컨텍스트에 새 동작(취소, 타임아웃 등)을 더해 — 자식 컨텍스트를 만듭니다.
WithCancel — 명시적 취소
#
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 자원 누수 방지
go work(ctx)
// 어느 시점에
cancel() // ctx.Done()이 close 됨cancel() 함수를 부르면 — ctx.Done() 채널이 close 되어 그것을 듣는 모든 고루틴이 알아차립니다.
defer cancel()이 핵심입니다 — 함수가 끝날 때 자식 컨텍스트가 정리되도록. 호출 안 하면 컨텍스트와 그것이 추적하는 자원이 GC 안 될 수 있습니다.
WithTimeout — 일정 시간 뒤 자동 취소
#
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := slowOperation(ctx)5초 안에 끝나야 — 안 끝나면 ctx가 자동 취소되어 slowOperation이 일찍 종료될 수 있습니다.
WithDeadline — 절대 시점에 취소
#
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()상대적 시간이 아닌 절대 시점. timeout과 의미는 같지만 표현이 다릅니다. 시계상의 특정 시간 (예: 자정까지) 같은 경우에 어울립니다.
취소를 듣는 함수 #
func work(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled 또는 DeadlineExceeded
default:
// 한 단위 일하기
doOneStep()
}
}
}<-ctx.Done()이 case로 들어가는 게 표준입니다. 취소되면 즉시 빠져나갑니다.
시간 걸리는 IO도 ctx 받기 #
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 등)를 전달하는 도구.
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 핸들러 #
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) 다중 동시 요청 + 첫 실패에 모두 취소 #
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) 데이터베이스 쿼리 #
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 동시성 패턴에서 자세히 다루지만, 미리보기.
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 테스트 패턴을 다룹니다.