2025-12-28
Go 언어로 서버를 작성하다 보면 어느 순간부터 모든 함수 첫 번째 인자가 ctx context.Context로 시작하는 것을 보게 됩니다. 처음에는 “왜 이걸 매번 넘겨야 하지?”라는 의문이 생기지만, 실제로 서비스를 운영하면서 그 이유를 몸으로 깨닫게 됩니다.
이 글에서는 context 패키지의 설계 철학과 내부 구조부터 시작하여 WithCancel, WithTimeout, WithDeadline, WithValue의 동작 원리, 그리고 HTTP 핸들러, DB 쿼리, 고루틴 취소에서의 실무 패턴까지 깊게 다룹니다.
🛠️ context 없는 코드는 의심하라
필자가 마이크로서비스를 운영하면서 직접 경험한 일입니다. 외부 API 호출 코드에 context를 설정하지 않은 채로 배포했는데, 외부 서비스가 응답하지 않자 고루틴이 무한 대기 상태로 누적되었습니다. 결국 메모리 사용량이 서서히 증가하다가 수 시간 후 OOM(Out of Memory)으로 서버가 다운되었습니다. context는 단순한 관례가 아니라, 서버 안정성을 지키는 필수 방어선입니다.
context.Context는 Go 표준 라이브러리에서 제공하는 인터페이스입니다.
type Context interface {
Deadline() (deadline time.Time, ok bool) // 만료 시각
Done() <-chan struct{} // 취소/만료 신호 채널
Err() error // 취소 이유 (nil: 정상)
Value(key any) any // 키-값 데이터 조회
}
이 인터페이스를 통해 세 가지 기능을 제공합니다.
| 기능 | 메서드 | 설명 |
|---|---|---|
| 취소 신호 | Done() | 채널이 닫히면 작업 중단 |
| 마감 시간 | Deadline() | 처리 시간 제한 |
| 값 전달 | Value(key) | 요청 범위 데이터 공유 |
context는 부모-자식 트리 구조로 연결됩니다. 부모가 취소되면 모든 자식도 자동으로 취소됩니다.
context.Background()
└── WithCancel → ctx1
├── WithTimeout(ctx1) → ctx2 ← 타임아웃 or 부모 취소로 종료
└── WithValue(ctx1) → ctx3 ← 값 전달 (취소 X)
이 트리 덕분에 HTTP 요청 하나가 들어왔을 때 그 요청에서 파생된 모든 고루틴과 DB 쿼리를 단 한 번의 cancel() 호출로 중단할 수 있습니다.
// 루트 context: 절대 취소되지 않는 최상위 컨텍스트
ctx := context.Background()
// 임시 placeholder: 아직 어떤 context를 써야 할지 모를 때
ctx := context.TODO()
두 함수는 기능이 동일합니다. 차이는 의미적 표시입니다.
Background(): 프로그램의 최상위 진입점, 서버 시작, 테스트 기반 contextTODO(): “나중에 올바른 context로 교체해야 한다”는 개발자 표시func main() {
ctx := context.Background()
srv := NewServer(ctx)
srv.Start()
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // HTTP 요청의 context (이미 제공됨)
doWork(ctx)
}
🛠️ 실무 규칙: context.Background()는 main 또는 테스트에서만
요청 처리 코드 내부에서
context.Background()를 직접 생성하면 요청 취소 신호가 전달되지 않습니다. 항상 인자로 받은ctx를 기반으로 파생시켜야 합니다.
ctx, cancel := context.WithCancel(parent)
defer cancel() // 반드시 호출 — 리소스 누수 방지
cancel()을 호출하면 ctx.Done() 채널이 닫히며, 이 ctx를 감시하는 모든 고루틴에게 취소 신호가 전달됩니다.
func main() {
ctx, cancel := context.WithCancel(context.Background())
// 여러 고루틴에 동일한 ctx 전달
go worker(ctx, "worker-1")
go worker(ctx, "worker-2")
go worker(ctx, "worker-3")
time.Sleep(2 * time.Second)
cancel() // 세 고루틴 모두에게 취소 신호 전달
time.Sleep(time.Millisecond * 100)
}
func worker(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("%s 종료: %v\n", name, ctx.Err())
return
default:
fmt.Printf("%s 작업 중...\n", name)
time.Sleep(500 * time.Millisecond)
}
}
}
// ❌ cancel을 호출하지 않으면 리소스 누수
func badFunction(parent context.Context) {
ctx, _ := context.WithCancel(parent)
doWork(ctx)
// cancel이 호출되지 않아 ctx가 parent에 연결된 채로 남음
}
// ✅ defer로 반드시 호출
func goodFunction(parent context.Context) {
ctx, cancel := context.WithCancel(parent)
defer cancel()
doWork(ctx)
}
cancel()을 호출하지 않으면 부모 context가 살아 있는 동안 자식 context가 메모리에 유지됩니다. defer cancel()은 관용적 패턴입니다.
// 3초 후 자동 취소
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
// 특정 시각에 자동 취소
deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
두 함수는 내부적으로 동일합니다. WithTimeout(parent, d)는 WithDeadline(parent, time.Now().Add(d))의 편의 함수입니다.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if deadline, ok := ctx.Deadline(); ok {
fmt.Printf("작업 마감: %v (남은 시간: %v)\n",
deadline.Format("15:04:05"),
time.Until(deadline))
}
select {
case <-ctx.Done():
switch ctx.Err() {
case context.Canceled:
fmt.Println("수동으로 취소됨") // cancel() 호출
case context.DeadlineExceeded:
fmt.Println("타임아웃 초과") // 시간 만료
}
}
HTTP 요청은 r.Context()로 이미 context를 가지고 있습니다. 클라이언트가 연결을 끊으면 이 context가 자동으로 취소됩니다.
func searchHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // 요청 context
query := r.URL.Query().Get("q")
// DB 쿼리에 context 전달 (타임아웃 추가)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
results, err := searchDB(ctx, query)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
http.Error(w, "검색 시간 초과", http.StatusGatewayTimeout)
return
}
if ctx.Err() == context.Canceled {
// 클라이언트가 요청을 취소함 — 로그만 남기고 조용히 종료
log.Println("클라이언트 연결 종료:", query)
return
}
http.Error(w, "서버 오류", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(results)
}
func searchDB(ctx context.Context, query string) ([]string, error) {
// context를 DB 드라이버에 전달
rows, err := db.QueryContext(ctx, "SELECT title FROM articles WHERE title LIKE ?", "%"+query+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var results []string
for rows.Next() {
var title string
if err := rows.Scan(&title); err != nil {
return nil, err
}
results = append(results, title)
}
return results, rows.Err()
}
🛠️ HTTP 핸들러 context 활용 원칙
r.Context()를 기반으로 파생된 context를 사용한다.- DB 쿼리, 외부 API 호출에는 항상 타임아웃을 추가한다.
context.Canceled는 클라이언트 요청 취소이므로 에러 로그 없이 조용히 종료한다.
func fetchUserData(ctx context.Context, userID int) (*User, error) {
// context 기반 HTTP 요청 생성
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
fmt.Sprintf("https://api.example.com/users/%d", userID),
nil,
)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("요청 취소됨: %w", ctx.Err())
}
return nil, err
}
defer resp.Body.Close()
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
func main() {
// 2초 타임아웃으로 API 호출
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
user, err := fetchUserData(ctx, 42)
if err != nil {
log.Fatal(err)
}
fmt.Println(user.Name)
}
http.NewRequestWithContext는 Go 1.13에서 추가된 context 인식 요청 생성 함수입니다. context가 취소되면 진행 중인 HTTP 요청도 즉시 중단됩니다.
WithValue는 context에 키-값 쌍을 저장합니다.
// 타입 안전한 키 정의 (string 직접 사용 X)
type contextKey string
const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)
// 미들웨어: 요청 ID 주입
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := generateRequestID()
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 핸들러: 요청 ID 조회
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
requestID, ok := ctx.Value(requestIDKey).(string)
if !ok {
requestID = "unknown"
}
log.Printf("[%s] 요청 처리 시작", requestID)
// ...
}
// ❌ 잘못된 패턴: string 키 사용
ctx = context.WithValue(ctx, "userID", 42)
// ✅ 올바른 패턴: 전용 타입 키 사용
type contextKey string
ctx = context.WithValue(ctx, userIDKey, 42)
string을 키로 사용하면 다른 패키지에서 동일한 문자열로 충돌이 발생할 수 있습니다. 별도의 타입을 정의하면 컴파일 타임에 충돌을 방지합니다.
🛠️ WithValue 사용 3원칙
- 요청 범위 데이터만 저장한다: 요청 ID, 사용자 인증 정보, 분산 추적 ID
- 비즈니스 로직 데이터는 함수 인자로 전달한다: context로 DB 연결, 설정 등을 넘기면 테스트가 어려워진다
- 전용 타입 키를 사용한다: string 키는 충돌 위험이 있다
func runWorkers(ctx context.Context, numWorkers int, jobs <-chan Job) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errCh := make(chan error, numWorkers)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if err := processJob(ctx, job); err != nil {
errCh <- err
cancel() // 하나라도 실패하면 전체 취소
return
}
}
}
}(i)
}
wg.Wait()
close(errCh)
// 첫 번째 에러 반환
for err := range errCh {
return err
}
return nil
}
// context는 함수 호출 체인을 따라 전파되어야 함
func HandleOrder(ctx context.Context, orderID int) error {
// 전체 주문 처리: 10초 타임아웃
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
user, err := fetchUser(ctx, orderID) // ctx 전달
if err != nil {
return fmt.Errorf("사용자 조회 실패: %w", err)
}
if err := validateInventory(ctx, orderID); err != nil { // ctx 전달
return fmt.Errorf("재고 확인 실패: %w", err)
}
if err := processPayment(ctx, user, orderID); err != nil { // ctx 전달
return fmt.Errorf("결제 처리 실패: %w", err)
}
return notifyUser(ctx, user, orderID) // ctx 전달
}
context는 함수 인자를 통해 수직으로만 전달됩니다. 절대 구조체 필드나 전역 변수로 저장하지 않습니다.
// ❌ 절대 하지 말 것
type Server struct {
ctx context.Context // context를 구조체 필드로 저장 금지
}
// ✅ 메서드 인자로 전달
type Server struct{}
func (s *Server) HandleRequest(ctx context.Context, req Request) error {
return s.processData(ctx, req.Data)
}
context는 특정 요청이나 작업의 수명과 연결됩니다. 구조체 필드에 저장하면 수명 관리가 불가능해집니다.
// ❌ nil context는 패닉 유발
doWork(nil) // panic: runtime error: invalid memory address
// ✅ 불확실하면 Background 또는 TODO 사용
doWork(context.Background())
doWork(context.TODO())
// ❌ context를 무시한 긴 루프
func badProcess(ctx context.Context, items []Item) {
for _, item := range items {
heavyProcess(item) // ctx가 취소되어도 계속 실행
}
}
// ✅ 각 반복마다 취소 확인
func goodProcess(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
heavyProcess(item)
}
return nil
}
실제 서버에서는 여러 미들웨어가 context에 값을 추가하며 체인을 구성합니다.
type contextKey string
const (
requestIDKey contextKey = "requestID"
userKey contextKey = "user"
loggerKey contextKey = "logger"
)
// 1단계 미들웨어: 요청 ID 주입
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 2단계 미들웨어: 인증 & 사용자 정보 주입
func WithAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := validateToken(r.Context(), token)
if err != nil {
http.Error(w, "인증 실패", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 헬퍼 함수: 타입 안전한 값 추출
func GetRequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
func GetUser(ctx context.Context) *User {
user, _ := ctx.Value(userKey).(*User)
return user
}
// 핸들러: context에서 값 사용
func profileHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
requestID := GetRequestID(ctx)
user := GetUser(ctx)
log.Printf("[%s] 프로필 요청: userID=%d", requestID, user.ID)
json.NewEncoder(w).Encode(user)
}
context는 단순한 도구가 아닙니다. Go의 “명시적인 취소와 타임아웃 전파” 철학을 코드로 구현한 결과물입니다. 모든 IO 작업, 네트워크 호출, 장시간 연산에 context를 적용하면 서버의 안정성이 비약적으로 향상됩니다.
오늘 배운 핵심을 요약하면:
defer cancel()은 필수다. 누락 시 메모리 누수가 발생한다.r.Context()를 기반으로 파생하며, 직접 Background()를 생성하지 않는다.이것으로 Go 언어 기본편이 모두 마무리되었습니다. 변수와 타입부터 시작하여 구조체, 인터페이스, 에러 처리, 고루틴, 채널, 그리고 context까지 — Go 언어로 실무 서버를 작성하는 데 필요한 핵심 개념을 모두 다루었습니다.
다음 심화편에서는 Go 언어로 실제로 무엇을 만들 수 있는지, 웹 서버 구축, REST API 설계, 데이터베이스 연동 등 실전 주제들을 살펴보겠습니다.