Go의 context는 하나의 작업과 관련된 취소 신호, 제한 시간, 종료 시각, 요청 범위 값을 함수와 goroutine 사이에 전달하기 위한 표준 패키지입니다.
context.Context는 함수의 파라미터로 전달되지만, 사용자 ID나 조회 조건과 같은 일반적인 업무 데이터를 전달하는 목적과는 다릅니다. 하나의 요청을 처리하는 여러 함수와 goroutine이 작업의 취소 여부와 남은 시간을 공통으로 확인할 수 있도록 만드는 역할을 합니다.
func process(ctx context.Context, itemID int) error {
// itemID: 처리 대상 데이터
// ctx: 작업 취소와 시간 제한 정보
return nil
}
Go 공식 문서에서는 Context를 필요한 함수의 첫 번째 파라미터로 전달하고, 일반적으로 ctx라는 이름을 사용하도록 안내합니다.
context가 필요한 이유
서버가 하나의 요청을 처리하면서 데이터베이스 조회와 외부 API 호출을 실행한다고 가정하겠습니다.
HTTP 요청
└─ handler
└─ service
├─ 데이터베이스 조회
└─ 외부 API 호출
클라이언트가 연결을 끊었거나 요청 제한 시간이 초과되었다면, 더 이상 결과가 필요하지 않은 하위 작업도 중단하는 것이 좋습니다. 그렇지 않으면 불필요한 데이터베이스 조회나 네트워크 요청이 계속 실행될 수 있습니다.
context를 함수 호출 경로에 전달하면 하위 함수와 goroutine이 같은 취소 신호를 확인할 수 있습니다.
func handler(ctx context.Context) error {
return service(ctx)
}
func service(ctx context.Context) error {
return repository(ctx)
}
func repository(ctx context.Context) error {
// 데이터베이스 작업
return nil
}
context는 작업을 직접 강제 종료하지 않습니다. 작업을 수행하는 함수나 goroutine이 ctx.Done()을 확인하고 스스로 반환해야 합니다.
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return result
}
context는 구조체가 아닌 인터페이스
context.Context는 다음 메서드를 정의한 인터페이스입니다.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
각 메서드의 역할은 다음과 같습니다.
| 메서드 | 역할 |
|---|---|
Deadline() |
작업이 종료되어야 하는 시각을 반환 |
Done() |
취소되거나 제한 시간이 끝나면 닫히는 채널 반환 |
Err() |
취소 원인을 반환 |
Value() |
요청 범위에 연결된 값을 조회 |
비슷한 기능을 구조체로 직접 만들 수도 있습니다.
type TaskContext struct {
done chan struct{}
once sync.Once
}
func NewTaskContext() *TaskContext {
return &TaskContext{
done: make(chan struct{}),
}
}
func (c *TaskContext) Cancel() {
c.once.Do(func() {
close(c.done)
})
}
func (c *TaskContext) Done() <-chan struct{} {
return c.done
}
그러나 직접 구현하면 취소 원인, 타임아웃, 부모·자식 관계, 타이머 정리, 동시성 안전성 등을 모두 처리해야 합니다. 또한 database/sql, net/http처럼 context.Context를 받는 표준 API와 바로 연결할 수 없습니다.
context의 중요한 의미는 단순히 기능을 제공하는 데 있지 않습니다. 여러 패키지가 취소와 제한 시간을 동일한 인터페이스로 주고받을 수 있도록 표준화한 데 있습니다.
기본 context 만들기
context.Background
context.Background()는 취소 신호, 제한 시간, 값이 없는 최상위 context입니다.
func main() {
ctx := context.Background()
if err := process(ctx); err != nil {
log.Fatal(err)
}
}
일반적으로 main, 초기화 코드, 테스트의 시작점에서 사용합니다.
context.TODO
context.TODO()도 취소 신호, 제한 시간, 값이 없는 context입니다. 어떤 context를 사용해야 할지 아직 정하지 못했거나, 기존 코드에 context 전달을 추가하는 중일 때 임시로 사용할 수 있습니다.
ctx := context.TODO()
실제 요청을 처리하는 코드에서는 새로 Background()를 만드는 것보다 상위 함수에서 받은 context를 그대로 전달하는 것이 중요합니다.
func service(ctx context.Context) error {
// 권장
return repository(ctx)
}
func service(ctx context.Context) error {
// 상위 취소 신호가 끊어지므로 일반적으로 권장하지 않음
return repository(context.Background())
}
취소 가능한 context
context.WithCancel은 부모 context에서 파생된 취소 가능한 context와 CancelFunc를 반환합니다.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cancel()을 호출하면 ctx.Done() 채널이 닫힙니다.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("작업 종료:", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Println("작업 처리 중")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
time.Sleep(2 * time.Second)
cancel()
time.Sleep(100 * time.Millisecond)
}
실행 결과는 다음과 비슷합니다.
작업 처리 중
작업 처리 중
작업 처리 중
작업 종료: context canceled
ctx.Done()으로 실제 값이 전달되는 것은 아닙니다. 취소될 때 채널이 닫히며, 닫힌 채널은 즉시 수신할 수 있으므로 취소 신호로 사용됩니다.
부모 context와 자식 context
WithCancel, WithTimeout, WithDeadline, WithValue는 기존 context를 부모로 받아 새로운 자식 context를 만듭니다.
parent := context.Background()
child, cancel := context.WithCancel(parent)
defer cancel()
부모가 취소되면 부모에서 파생된 자식 context도 취소됩니다.
parent
└─ child
└─ grandchild
parent가 취소되면 child와 grandchild도 취소됩니다. 반대로 child를 취소해도 parent는 취소되지 않습니다.
여기서 context의 부모·자식 관계와 goroutine의 실행 관계는 서로 다른 개념입니다. Go 런타임은 go worker()로 시작한 goroutine을 자동으로 부모 goroutine과 연결해 취소하지 않습니다.
부모 context를 그대로 전달하는 경우
자식 goroutine이 부모의 취소를 알기 위해 반드시 새로운 자식 context를 만들 필요는 없습니다. 부모 context를 그대로 전달해도 됩니다.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
select {
case <-ctx.Done():
fmt.Println("worker 종료:", ctx.Err())
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
time.Sleep(time.Second)
cancel()
time.Sleep(100 * time.Millisecond)
}
worker가 전달받은 ctx는 main에서 취소한 동일한 context이므로 취소를 감지할 수 있습니다.
자식 context를 새로 만드는 경우
자식 작업에 별도의 취소 제어나 더 짧은 제한 시간을 추가해야 할 때 자식 context를 만듭니다.
func loadData(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
select {
case <-time.After(5 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
이 context는 다음 중 하나가 먼저 발생하면 취소됩니다.
- 부모 context가 취소됨
- 자식의 2초 제한 시간이 끝남
- 자식의
cancel()이 호출됨
따라서 관계를 다음과 같이 이해할 수 있습니다.
부모 context: 전체 요청의 생명주기
└─ 자식 context: 특정 하위 작업의 더 짧은 생명주기
부모 취소를 알 수 없는 경우
부모와 관계없는 context를 새로 만들면 부모의 취소가 전파되지 않습니다.
func worker() {
ctx := context.Background()
// main에서 만든 context와 관계가 없음
<-ctx.Done()
}
context.Background()의 Done()은 nil을 반환하므로 위 수신은 계속 대기합니다.
정리하면 다음과 같습니다.
| 전달 방식 | 상위 context 취소 감지 |
|---|---|
상위 ctx를 그대로 전달 |
가능 |
상위 ctx에서 자식 context를 만들어 전달 |
가능 |
별도의 context.Background() 사용 |
불가능 |
| context를 전달하지 않음 | 불가능 |
제한 시간 설정
context.WithTimeout은 지정한 시간이 지나면 자동으로 취소되는 자식 context를 만듭니다.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
다음 예제에서 작업 완료에는 3초가 필요하지만 제한 시간은 2초입니다.
package main
import (
"context"
"fmt"
"time"
)
func run(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
fmt.Println("작업 완료")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
err := run(ctx)
fmt.Println("결과:", err)
}
실행 결과:
결과: context deadline exceeded
context.WithDeadline은 지속 시간이 아니라 종료 시각을 직접 지정합니다.
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
부모와 자식이 모두 제한 시간을 가지고 있다면 더 이른 제한 시간이 적용됩니다. 자식 context가 부모보다 늦게 끝나도록 지정해도 부모가 먼저 취소되면 자식도 함께 취소됩니다.
cancel을 호출해야 하는 이유
WithCancel, WithTimeout, WithDeadline은 context와 함께 취소 함수를 반환합니다.
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
작업이 제한 시간보다 먼저 끝나더라도 cancel()을 호출하는 것이 좋습니다. 취소 함수는 부모가 자식을 참조하는 관계를 제거하고, 연결된 타이머 등의 자원을 정리합니다.
일반적으로 context를 만든 직후 defer cancel()을 작성합니다.
func process(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
return execute(ctx)
}
cancel()은 goroutine을 강제로 종료하지 않습니다. 취소 신호만 전달하므로 실행 중인 코드가 Done()을 확인하거나 context를 지원하는 API를 사용해야 실제 작업을 중단할 수 있습니다.
취소 원인 확인
ctx.Err()은 context가 종료된 이유를 반환합니다.
| 상황 | 반환값 |
|---|---|
| 아직 취소되지 않음 | nil |
cancel() 호출 |
context.Canceled |
| 제한 시간 또는 종료 시각 초과 | context.DeadlineExceeded |
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
fmt.Println("직접 취소됨")
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
fmt.Println("제한 시간 초과")
}
}
취소 사유를 별도의 오류로 기록해야 한다면 context.WithCancelCause와 context.Cause를 사용할 수 있습니다.
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("상위 작업에서 결과가 더 이상 필요하지 않음"))
fmt.Println(context.Cause(ctx))
ctx.Err()은 일반적인 취소 상태를 반환하고, context.Cause(ctx)는 지정한 구체적인 취소 원인을 반환합니다.
여러 goroutine에 같은 context 전달하기
하나의 context는 여러 goroutine에서 동시에 안전하게 사용할 수 있습니다.
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
select {
case <-time.After(5 * time.Second):
fmt.Printf("worker %d 완료\n", id)
case <-ctx.Done():
fmt.Printf("worker %d 취소: %v\n", id, ctx.Err())
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Add(1)
go worker(ctx, id, &wg)
}
time.Sleep(time.Second)
cancel()
wg.Wait()
}
실행 결과는 다음과 비슷합니다.
worker 1 취소: context canceled
worker 2 취소: context canceled
worker 3 취소: context canceled
cancel()이 같은 context를 전달받은 goroutine을 직접 찾아 종료하는 것은 아닙니다. 모든 goroutine이 같은 Done() 채널의 종료를 감지하고 각자 반환하는 구조입니다.
요청 범위 값 전달
context.WithValue는 요청의 생명주기와 함께 전달되어야 하는 값을 연결할 때 사용합니다.
type contextKey string
const requestIDKey contextKey = "requestID"
ctx := context.WithValue(
context.Background(),
requestIDKey,
"req-20260721-001",
)
값은 Value로 조회하고 타입 단언을 사용합니다.
requestID, ok := ctx.Value(requestIDKey).(string)
if !ok {
fmt.Println("request ID 없음")
}
대표적인 사용 대상은 다음과 같습니다.
- 요청 ID
- 추적 ID
- 인증 또는 권한 확인에 필요한 요청 범위 정보
- 로깅에 필요한 요청 메타데이터
페이지 크기, 조회 조건, 상품 ID처럼 함수의 동작에 직접 필요한 데이터는 일반 파라미터로 전달하는 것이 명확합니다.
// 권장
func search(ctx context.Context, keyword string, pageSize int) error {
return nil
}
// 일반적인 함수 옵션 전달 용도로는 권장하지 않음
ctx = context.WithValue(ctx, "pageSize", 100)
키 충돌을 방지하기 위해 기본 타입인 string을 직접 키로 사용하는 것보다 패키지 내부의 별도 키 타입을 정의하는 것이 좋습니다.
HTTP와 데이터베이스에서 사용하기
Go HTTP 서버의 요청에는 context가 포함되어 있습니다.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result, err := loadData(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, result)
}
r.Context()는 클라이언트 연결이 끊어지거나 HTTP 요청이 취소되는 경우 취소될 수 있습니다. 이 context를 서비스와 저장소 계층으로 그대로 전달할 수 있습니다.
func loadData(ctx context.Context) (string, error) {
return findData(ctx)
}
데이터베이스 작업에서는 QueryContext, QueryRowContext, ExecContext처럼 context를 받는 메서드를 사용합니다.
func findName(ctx context.Context, db *sql.DB, id int64) (string, error) {
var name string
err := db.QueryRowContext(
ctx,
"SELECT name FROM items WHERE id = ?",
id,
).Scan(&name)
if err != nil {
return "", err
}
return name, nil
}
외부 HTTP 요청에도 context를 연결할 수 있습니다.
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://example.com/api/items",
nil,
)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
상위 HTTP 요청이 취소되면 전달된 context를 통해 데이터베이스 작업과 외부 HTTP 요청에도 취소 신호를 전파할 수 있습니다. 실제 취소 지원 범위는 사용하는 API와 드라이버의 구현에 따라 달라질 수 있습니다.
전체 흐름 예제
다음 예제는 HTTP 요청 context에서 2초 제한 시간을 가진 자식 context를 만들고 서비스와 저장소 계층으로 전달합니다.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
func findItem(ctx context.Context, itemID int64) (string, error) {
select {
case <-time.After(3 * time.Second):
return fmt.Sprintf("item-%d", itemID), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func loadItem(ctx context.Context, itemID int64) (string, error) {
return findItem(ctx, itemID)
}
func itemHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
item, err := loadItem(ctx, 100)
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "처리 시간이 초과되었습니다.", http.StatusGatewayTimeout)
case errors.Is(err, context.Canceled):
return
default:
http.Error(w, "처리 중 오류가 발생했습니다.", http.StatusInternalServerError)
}
return
}
fmt.Fprintln(w, item)
}
func main() {
http.HandleFunc("/items", itemHandler)
http.ListenAndServe(":8080", nil)
}
흐름은 다음과 같습니다.
HTTP 요청의 context
└─ 2초 제한 시간을 가진 자식 context
└─ loadItem
└─ findItem
findItem에는 3초가 필요하지만 자식 context의 제한 시간은 2초이므로 context.DeadlineExceeded가 반환됩니다. 클라이언트가 먼저 연결을 끊으면 부모인 r.Context()가 취소되고, 그로부터 파생된 자식 context도 함께 취소됩니다.
사용 원칙
context를 사용할 때는 다음 기준을 지키는 것이 좋습니다.
context.Context는 필요한 함수의 첫 번째 파라미터로 전달합니다.- 함수 안에서 상위 context를 버리고
context.Background()로 교체하지 않습니다. - 같은 context를 여러 goroutine에 전달해도 됩니다.
- 자식 작업에 별도 취소나 더 짧은 제한 시간이 필요할 때 파생 context를 만듭니다.
WithCancel,WithTimeout,WithDeadline이 반환한 취소 함수는 호출합니다.nilcontext를 전달하지 않습니다.- 업무 데이터와 선택적 함수 인자는 일반 파라미터로 전달합니다.
- context를 구조체 필드에 장기간 저장하기보다 작업을 수행하는 메서드에 명시적으로 전달합니다.
context의 취소는 goroutine 강제 종료 명령이 아닙니다. 취소 신호를 받은 코드가 반환하거나, context를 지원하는 API가 작업을 중단해야 실제 실행이 끝납니다.
정리
context도 함수의 파라미터로 전달되는 값입니다. 다만 일반적인 업무 데이터가 아니라 하나의 작업에 대한 취소, 제한 시간, 종료 시각, 요청 범위 정보를 전달합니다.
비슷한 기능을 구조체와 채널로 직접 구현할 수도 있지만, 표준 context.Context를 사용하면 Go 표준 라이브러리와 외부 라이브러리가 같은 방식으로 취소 신호를 주고받을 수 있습니다.
자식 goroutine이 상위 작업의 취소를 알기 위해 새로운 자식 context를 반드시 만들 필요는 없습니다. 상위 context를 그대로 전달해도 취소를 감지할 수 있습니다. 별도의 자식 context는 하위 작업에 더 짧은 제한 시간이나 독립적인 취소 범위를 추가할 때 만듭니다.
상위 context를 그대로 전달
→ 같은 취소 범위를 공유
상위 context에서 자식 context 생성
→ 상위 취소를 전달받으면서 하위 작업만의 제한 조건 추가
별도의 Background context 생성
→ 기존 상위 취소 관계가 끊어짐
따라서 context는 다음과 같이 이해할 수 있습니다.
함수와 goroutine이 하나의 작업 생명주기를 공유하도록 취소와 시간 제한을 전달하는 표준 인터페이스입니다.
참고 자료
- Go context 패키지 공식 문서: https://pkg.go.dev/context
- Go Blog - Go Concurrency Patterns: Context: https://go.dev/blog/context
- Go Blog - Contexts and structs: https://go.dev/blog/context-and-structs
- Go 공식 문서 - 진행 중인 데이터베이스 작업 취소: https://go.dev/doc/database/cancel-operations
'프로그래밍 > Go' 카테고리의 다른 글
| Go panic과 recover (0) | 2026.07.23 |
|---|---|
| Go defer (0) | 2026.07.22 |
| Go select, timeout, close, context (0) | 2026.07.20 |
| Goroutine과 channel (1) | 2026.07.18 |
| Go embedding과 메서드 승격 이해하기 (0) | 2026.07.15 |
댓글