Go context 사용법과 실전 예제 (WithCancel, Timeout 등) – 업무에 사용하는 Go 언어 응용편 7

Go context
Go context

Go 언어의 기본 문법을 익혔다면, 이제 동시성 프로그래밍에서 필수적인 context 패키지를 다룰 시간입니다. Go context는 고루틴 간의 취소 신호, 타임아웃, 그리고 요청 범위 값을 전달하는 표준 방법을 제공합니다. 실무에서 안정적인 동시성 프로그래밍을 위해 반드시 알아야 할 핵심 개념들을 살펴보겠습니다.

context 패키지의 기본 개념

Context는 Go 1.7부터 표준 라이브러리에 포함된 패키지로, 고루틴의 생명주기를 관리하는 중요한 역할을 합니다. 가장 기본적인 형태는 다음과 같습니다:

Go
package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 기본 컨텍스트 생성
    ctx := context.Background()
    
    // 컨텍스트와 함께 함수 호출
    doSomething(ctx)
}

func doSomething(ctx context.Context) {
    fmt.Println("작업을 시작합니다")
    
    select {
    case <-time.After(2 * time.Second):
        fmt.Println("작업이 완료되었습니다")
    case <-ctx.Done():
        fmt.Println("작업이 취소되었습니다:", ctx.Err())
    }
}

위 코드에서 context.Background()는 최상위 컨텍스트를 생성하며, 이는 일반적으로 메인 함수나 요청 시작점에서 사용됩니다. ctx.Done() 채널은 컨텍스트가 취소되거나 타임아웃이 발생했을 때 신호를 받을 수 있게 해줍니다.

WithCancel을 이용한 취소 처리

WithCancel 함수는 수동으로 취소할 수 있는 컨텍스트를 생성합니다:

Go
package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 리소스 해제를 위해 반드시 호출
    
    var wg sync.WaitGroup
    
    // 여러 고루틴 실행
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(ctx, id)
        }(i)
    }
    
    // 3초 후 모든 고루틴 취소
    time.Sleep(3 * time.Second)
    fmt.Println("모든 작업을 취소합니다")
    cancel()
    
    wg.Wait()
    fmt.Println("모든 고루틴이 종료되었습니다")
}

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: 작업이 취소되었습니다\n", id)
            return
        default:
            fmt.Printf("Worker %d: 작업 중...\n", id)
            time.Sleep(1 * time.Second)
        }
    }
}

이 예제에서 context.WithCancel은 원본 컨텍스트와 취소 함수를 반환합니다. 취소 함수를 호출하면 해당 컨텍스트와 그 하위 컨텍스트들이 모두 취소됩니다. 각 워커 고루틴은 ctx.Done() 채널을 주기적으로 확인하여 취소 신호를 받으면 즉시 종료됩니다.

Go context의 타임아웃 처리

실무에서 가장 자주 사용되는 패턴 중 하나는 타임아웃 처리입니다:

Go
package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func main() {
    // 5초 타임아웃 설정
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    // HTTP 요청에 타임아웃 적용
    if err := makeHTTPRequest(ctx, "https://httpbin.org/delay/3"); err != nil {
        fmt.Printf("요청 실패: %v\n", err)
    }
    
    // 더 짧은 타임아웃으로 다시 시도
    ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel2()
    
    if err := makeHTTPRequest(ctx2, "https://httpbin.org/delay/3"); err != nil {
        fmt.Printf("두 번째 요청 실패: %v\n", err)
    }
}

func makeHTTPRequest(ctx context.Context, url string) error {
    // 컨텍스트를 포함한 HTTP 요청 생성
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return fmt.Errorf("요청 생성 실패: %w", err)
    }
    
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("HTTP 요청 실패: %w", err)
    }
    defer resp.Body.Close()
    
    fmt.Printf("요청 성공: %s (상태: %d)\n", url, resp.StatusCode)
    return nil
}

context.WithTimeout을 사용하면 지정된 시간 내에 작업이 완료되지 않으면 자동으로 취소됩니다. HTTP 클라이언트에서 NewRequestWithContext를 사용하여 요청에 타임아웃을 적용할 수 있습니다.

실무에서의 활용 사례

필자는 go언어를 활용하여 웹 사이트를 구성한 경험이 있으며, 이때 컨텍스트를 통한 요청 추적과 타임아웃 관리가 매우 중요했습니다. 특히 데이터베이스 연결이나 외부 API 호출 시 적절한 타임아웃 설정이 시스템의 안정성에 큰 영향을 미쳤습니다.

데이터베이스 연산에서의 컨텍스트 활용

Go
package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"
    
    _ "github.com/lib/pq" // PostgreSQL 드라이버 예시
)

type UserService struct {
    db *sql.DB
}

func NewUserService(db *sql.DB) *UserService {
    return &UserService{db: db}
}

func (s *UserService) GetUserByID(ctx context.Context, userID int) (*User, error) {
    // 데이터베이스 쿼리에 컨텍스트 적용
    query := "SELECT id, name, email FROM users WHERE id = $1"
    
    row := s.db.QueryRowContext(ctx, query, userID)
    
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, fmt.Errorf("사용자를 찾을 수 없습니다: %d", userID)
        }
        return nil, fmt.Errorf("데이터베이스 조회 실패: %w", err)
    }
    
    return &user, nil
}

func (s *UserService) CreateUser(ctx context.Context, user *User) error {
    // 트랜잭션에서 컨텍스트 사용
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("트랜잭션 시작 실패: %w", err)
    }
    defer tx.Rollback()
    
    query := "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id"
    err = tx.QueryRowContext(ctx, query, user.Name, user.Email).Scan(&user.ID)
    if err != nil {
        return fmt.Errorf("사용자 생성 실패: %w", err)
    }
    
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("트랜잭션 커밋 실패: %w", err)
    }
    
    return nil
}

type User struct {
    ID    int
    Name  string
    Email string
}

func main() {
    // 데이터베이스 연결 (실제 구현 시 적절한 설정 필요)
    db, err := sql.Open("postgres", "user=username dbname=mydb sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    service := NewUserService(db)
    
    // 5초 타임아웃으로 사용자 조회
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    user, err := service.GetUserByID(ctx, 1)
    if err != nil {
        log.Printf("사용자 조회 실패: %v", err)
        return
    }
    
    fmt.Printf("조회된 사용자: %+v\n", user)
}

이 예제에서 QueryRowContextBeginTx를 사용하여 데이터베이스 연산에 컨텍스트를 적용했습니다. 만약 지정된 시간 내에 쿼리가 완료되지 않으면 자동으로 취소되어 리소스 누수를 방지할 수 있습니다.

컨텍스트 값 전달과 미들웨어 패턴

필자는 추후에 gin 웹 프레임워크를 통해 고도화된 사이트를 만들었는데, 이때 go언어 context를 활용한 미들웨어 패턴이 매우 유용했습니다:

Go
package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
    
    "github.com/gin-gonic/gin"
)

type contextKey string

const (
    UserIDKey    contextKey = "user_id"
    RequestIDKey contextKey = "request_id"
)

// 요청 ID 생성 미들웨어
func RequestIDMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        requestID := generateRequestID()
        
        // 컨텍스트에 요청 ID 저장
        ctx := context.WithValue(c.Request.Context(), RequestIDKey, requestID)
        c.Request = c.Request.WithContext(ctx)
        
        // 응답 헤더에도 포함
        c.Header("X-Request-ID", requestID)
        
        c.Next()
    }
}

// 타임아웃 미들웨어
func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
        defer cancel()
        
        c.Request = c.Request.WithContext(ctx)
        
        // 타임아웃 체크를 위한 채널
        done := make(chan bool, 1)
        
        go func() {
            c.Next()
            done <- true
        }()
        
        select {
        case <-done:
            // 정상 완료
        case <-ctx.Done():
            // 타임아웃 발생
            c.JSON(http.StatusRequestTimeout, gin.H{
                "error": "요청 처리 시간이 초과되었습니다",
            })
            c.Abort()
        }
    }
}

func getUserHandler(c *gin.Context) {
    // 컨텍스트에서 값 추출
    requestID := c.Request.Context().Value(RequestIDKey).(string)
    
    fmt.Printf("요청 ID %s: 사용자 정보 조회 시작\n", requestID)
    
    // 시뮬레이션을 위한 지연
    time.Sleep(2 * time.Second)
    
    c.JSON(http.StatusOK, gin.H{
        "user_id":    123,
        "name":       "홍길동",
        "request_id": requestID,
    })
}

func generateRequestID() string {
    return fmt.Sprintf("req_%d", time.Now().UnixNano())
}

func main() {
    r := gin.Default()
    
    // 미들웨어 적용
    r.Use(RequestIDMiddleware())
    r.Use(TimeoutMiddleware(5 * time.Second))
    
    r.GET("/user", getUserHandler)
    
    r.Run(":8080")
}

이 예제에서 컨텍스트를 통해 요청 ID를 전달하고, 타임아웃 미들웨어를 구현했습니다. context.WithValue를 사용하여 요청 범위 데이터를 저장하고, 다운스트림 핸들러에서 이 값을 활용할 수 있습니다.

컨텍스트 사용 시 주의사항

컨텍스트를 올바르게 사용하기 위해서는 몇 가지 중요한 원칙을 지켜야 합니다:

  1. 컨텍스트는 구조체에 저장하지 않기: 컨텍스트는 함수 매개변수로 전달해야 합니다.
  2. nil 컨텍스트 전달 금지: 컨텍스트가 확실하지 않으면 context.TODO()를 사용합니다.
  3. 적절한 타임아웃 설정: 너무 짧으면 정상 요청이 실패하고, 너무 길면 리소스 낭비가 발생합니다.
  4. 컨텍스트 값 남용 방지: 요청 범위 데이터만 저장하고, 선택적 매개변수로 사용하지 않습니다.

Go context는 동시성 프로그래밍에서 안전하고 효율적인 고루틴 관리를 위한 필수 도구입니다. 취소 처리, 타임아웃 설정, 그리고 요청 범위 데이터 전달을 통해 더 견고한 애플리케이션을 구축할 수 있습니다. 실무에서는 특히 웹 서버, 데이터베이스 연산, 외부 API 호출 등에서 컨텍스트를 적극 활용하여 시스템의 안정성과 성능을 크게 향상시킬 수 있습니다.