Go 언어 context 실전 활용 미들웨어 DB HTTP 가이드

Go 언어 context 실전: 미들웨어, DB 쿼리, HTTP 클라이언트 완전 활용 가이드

기본편에서 context 패키지의 구조와 WithCancel, WithTimeout, WithValue 사용법을 다뤘습니다. 이번 글에서는 실제 서버 코드에서 context를 어떻게 연결하는지, 즉 실전 패턴에 집중합니다.

이 글에서는 요청 하나가 들어왔을 때 context가 미들웨어 → 핸들러 → 서비스 → DB 쿼리를 어떻게 관통하는지, 외부 API 병렬 호출에서 하나라도 실패하면 나머지를 어떻게 일괄 취소하는지, 그리고 흔히 저지르는 context 오용 패턴까지 실무 코드로 직접 살펴봅니다.

🛠️ 이 글의 전제

기본편의 context 트리 구조, WithCancel / WithTimeout / WithValue 동작 원리를 이해하고 있다고 가정합니다. 처음 접한다면 기본편을 먼저 읽어보세요.


1. context 흐름 설계: 요청 수명과 context 수명 일치

Go HTTP 서버에서 모든 context는 r.Context()에서 시작합니다. 클라이언트가 연결을 끊으면 이 context가 자동으로 취소됩니다. 올바른 설계는 이 수명을 체인 전체에 전파하는 것입니다.

HTTP 요청 도착

r.Context() ─── 미들웨어(RequestID 주입, 타임아웃 설정)

핸들러(ctx 수신)

서비스 레이어(ctx 전달)

DB QueryContext(ctx) / http.NewRequestWithContext(ctx)

클라이언트 연결 종료 or 타임아웃 → 취소 신호 전파 → 모든 단계 즉시 중단

이 흐름을 유지하면 클라이언트가 중간에 끊더라도 불필요한 DB 쿼리나 외부 API 호출이 즉시 중단됩니다.


2. 요청 ID 추적 미들웨어 (net/http 표준 라이브러리)

요청 ID는 분산 로깅과 트레이싱의 출발점입니다. context에 주입하면 서비스·DB 레이어에서도 로그에 자동으로 포함할 수 있습니다.

package middleware

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "net/http"
)

type contextKey string

const (
    KeyRequestID contextKey = "requestID"
    KeyUserID    contextKey = "userID"
)

// 요청 ID 생성 (암호학적 난수 8바이트 → 16자리 hex)
func newRequestID() string {
    b := make([]byte, 8)
    rand.Read(b)
    return hex.EncodeToString(b)
}

// RequestID 미들웨어 — 모든 요청에 고유 ID 부여
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 업스트림 게이트웨이(Nginx, Caddy)에서 전달한 ID 재사용
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            reqID = newRequestID()
        }

        // context에 주입
        ctx := context.WithValue(r.Context(), KeyRequestID, reqID)
        r = r.WithContext(ctx)

        // 응답 헤더에도 포함 (클라이언트 디버깅용)
        w.Header().Set("X-Request-ID", reqID)

        next.ServeHTTP(w, r)
    })
}

// context에서 요청 ID 꺼내기 — 타입 단언 안전하게
func GetRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(KeyRequestID).(string); ok {
        return id
    }
    return "unknown"
}

🛠️ time.Now().UnixNano()가 아닌가?

UnixNano()는 같은 나노초에 두 요청이 들어오면 충돌합니다. 암호학적 난수 기반 hex가 실무 표준입니다. 또한 X-Request-ID 헤더를 먼저 확인하면 Caddy/Nginx가 이미 부여한 ID를 유지할 수 있어 전체 인프라 트레이싱이 연결됩니다.


3. 타임아웃 미들웨어

func Timeout(d time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()

            r = r.WithContext(ctx)

            // 타임아웃 감지를 위한 ResponseWriter 래퍼
            tw := &timeoutWriter{ResponseWriter: w}

            done := make(chan struct{})
            go func() {
                next.ServeHTTP(tw, r)
                close(done)
            }()

            select {
            case <-done:
                // 정상 완료 — 래퍼에 버퍼링된 응답 실제 전송
                tw.flush()
            case <-ctx.Done():
                // 타임아웃 발생
                if !tw.wroteHeader {
                    http.Error(w, `{"error":"request timeout"}`, http.StatusGatewayTimeout)
                    w.Header().Set("Content-Type", "application/json")
                }
            }
        })
    }
}

type timeoutWriter struct {
    http.ResponseWriter
    buf         []byte
    status      int
    wroteHeader bool
}

func (tw *timeoutWriter) WriteHeader(code int) {
    tw.status = code
    tw.wroteHeader = true
}

func (tw *timeoutWriter) Write(b []byte) (int, error) {
    tw.buf = append(tw.buf, b...)
    return len(b), nil
}

func (tw *timeoutWriter) flush() {
    if tw.status != 0 {
        tw.ResponseWriter.WriteHeader(tw.status)
    }
    tw.ResponseWriter.Write(tw.buf)
}

4. 인증 미들웨어 — context에 사용자 정보 주입

type AuthClaims struct {
    UserID int
    Role   string
    Email  string
}

func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            writeJSON(w, http.StatusUnauthorized, map[string]string{
                "error": "인증 토큰이 없습니다",
            })
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")
        claims, err := validateJWT(token)
        if err != nil {
            writeJSON(w, http.StatusUnauthorized, map[string]string{
                "error": "유효하지 않은 토큰입니다",
            })
            return
        }

        // 인증 정보를 context에 주입
        ctx := context.WithValue(r.Context(), KeyUserID, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 핸들러에서 인증 정보 꺼내기
func GetAuthClaims(ctx context.Context) (*AuthClaims, bool) {
    claims, ok := ctx.Value(KeyUserID).(*AuthClaims)
    return claims, ok
}

// 실제 사용 예
func profileHandler(w http.ResponseWriter, r *http.Request) {
    claims, ok := GetAuthClaims(r.Context())
    if !ok {
        http.Error(w, "인증 정보 없음", http.StatusUnauthorized)
        return
    }

    reqID := GetRequestID(r.Context())
    log.Printf("[%s] 프로필 조회: userID=%d", reqID, claims.UserID)

    // DB 조회 시 context 전달
    user, err := userRepo.FindByID(r.Context(), claims.UserID)
    // ...
}

5. DB 쿼리에 context 연동 — database/sql

database/sql의 모든 쿼리 메서드에는 Context 버전이 있습니다. context가 취소되면 진행 중인 DB 쿼리도 즉시 중단됩니다.

type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    // QueryRowContext — context 취소 시 즉시 반환
    row := r.db.QueryRowContext(ctx,
        `SELECT id, name, email, created_at FROM users WHERE id = $1`,
        id,
    )

    var u User
    err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("사용자 %d를 찾을 수 없습니다", id)
    }
    if err != nil {
        return nil, fmt.Errorf("사용자 조회 실패: %w", err)
    }

    return &u, nil
}

func (r *UserRepository) ListActive(ctx context.Context, limit int) ([]User, error) {
    rows, err := r.db.QueryContext(ctx,
        `SELECT id, name, email FROM users WHERE active = true ORDER BY created_at DESC LIMIT $1`,
        limit,
    )
    if err != nil {
        return nil, fmt.Errorf("활성 사용자 목록 조회 실패: %w", err)
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        // rows.Next()도 context 취소를 감지함
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
            return nil, fmt.Errorf("행 스캔 실패: %w", err)
        }
        users = append(users, u)
    }

    return users, rows.Err()
}

// 트랜잭션에서 context 사용
func (r *UserRepository) TransferBalance(ctx context.Context, fromID, toID int, amount int64) error {
    tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    if err != nil {
        return fmt.Errorf("트랜잭션 시작 실패: %w", err)
    }
    defer tx.Rollback() // 커밋 성공 시 no-op

    // 출금
    _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE user_id = $2 AND balance >= $1`,
        amount, fromID,
    )
    if err != nil {
        return fmt.Errorf("출금 실패: %w", err)
    }

    // 입금
    _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE user_id = $2`,
        amount, toID,
    )
    if err != nil {
        return fmt.Errorf("입금 실패: %w", err)
    }

    return tx.Commit()
}

🛠️ context 취소 시 DB 동작

QueryContext 실행 중 context가 취소되면 드라이버가 DB 서버에 쿼리 취소 신호를 보내고 즉시 에러를 반환합니다. PostgreSQL의 경우 pg_cancel_backend()가 호출됩니다. 덕분에 클라이언트가 끊어도 DB에서 불필요한 쿼리가 계속 실행되는 것을 막습니다.


6. HTTP 클라이언트에서 context 연동

외부 API를 호출할 때 반드시 http.NewRequestWithContext(ctx, ...)를 사용해야 합니다. 클라이언트 측 타임아웃(http.Client.Timeout)과 context 취소는 별개로 동작합니다.

// 재사용 가능한 HTTP 클라이언트 (패키지 수준 싱글턴)
var apiClient = &http.Client{
    Timeout: 30 * time.Second, // 최대 안전망
    Transport: &http.Transport{
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    },
}

type ExternalAPIClient struct {
    baseURL string
    token   string
}

func (c *ExternalAPIClient) FetchUserProfile(ctx context.Context, userID int) (*ExternalProfile, error) {
    url := fmt.Sprintf("%s/users/%d", c.baseURL, userID)

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("요청 생성 실패: %w", err)
    }

    // 요청 ID를 외부 서비스에도 전달 (분산 트레이싱)
    req.Header.Set("Authorization", "Bearer "+c.token)
    req.Header.Set("X-Request-ID", GetRequestID(ctx))
    req.Header.Set("Accept", "application/json")

    resp, err := apiClient.Do(req)
    if err != nil {
        // context 취소로 인한 에러인지 확인
        if ctx.Err() != nil {
            return nil, fmt.Errorf("요청 취소됨 (%s): %w", ctx.Err(), err)
        }
        return nil, fmt.Errorf("API 호출 실패: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("API 오류: status %d", resp.StatusCode)
    }

    var profile ExternalProfile
    if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
        return nil, fmt.Errorf("응답 파싱 실패: %w", err)
    }

    return &profile, nil
}

7. 병렬 API 호출과 일괄 취소

여러 외부 API를 병렬로 호출하되, 하나라도 실패하면 나머지를 즉시 취소하는 패턴입니다.

type DashboardData struct {
    User    *User
    Orders  []Order
    Reviews []Review
}

func (s *DashboardService) GetDashboard(ctx context.Context, userID int) (*DashboardData, error) {
    // 하나라도 실패하면 나머지 취소
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    type result[T any] struct {
        data T
        err  error
    }

    userCh := make(chan result[*User], 1)
    ordersCh := make(chan result[[]Order], 1)
    reviewsCh := make(chan result[[]Review], 1)

    // 병렬 실행
    go func() {
        u, err := s.userRepo.FindByID(ctx, userID)
        userCh <- result[*User]{u, err}
    }()

    go func() {
        orders, err := s.orderRepo.ListByUser(ctx, userID, 10)
        ordersCh <- result[[]Order]{orders, err}
    }()

    go func() {
        reviews, err := s.reviewRepo.ListByUser(ctx, userID, 5)
        reviewsCh <- result[[]Review]{reviews, err}
    }()

    // 결과 수집
    userRes := <-userCh
    if userRes.err != nil {
        cancel() // 나머지 취소
        return nil, fmt.Errorf("사용자 조회 실패: %w", userRes.err)
    }

    ordersRes := <-ordersCh
    if ordersRes.err != nil {
        cancel()
        return nil, fmt.Errorf("주문 조회 실패: %w", ordersRes.err)
    }

    reviewsRes := <-reviewsCh
    if reviewsRes.err != nil {
        return nil, fmt.Errorf("리뷰 조회 실패: %w", reviewsRes.err)
    }

    return &DashboardData{
        User:    userRes.data,
        Orders:  ordersRes.data,
        Reviews: reviewsRes.data,
    }, nil
}

errgroup 패키지를 사용하면 위 패턴을 더 간결하게 구현할 수 있습니다.

import "golang.org/x/sync/errgroup"

func (s *DashboardService) GetDashboardV2(ctx context.Context, userID int) (*DashboardData, error) {
    g, ctx := errgroup.WithContext(ctx) // 하나라도 에러나면 ctx 취소

    var data DashboardData

    g.Go(func() error {
        u, err := s.userRepo.FindByID(ctx, userID)
        if err == nil {
            data.User = u
        }
        return err
    })

    g.Go(func() error {
        orders, err := s.orderRepo.ListByUser(ctx, userID, 10)
        if err == nil {
            data.Orders = orders
        }
        return err
    })

    g.Go(func() error {
        reviews, err := s.reviewRepo.ListByUser(ctx, userID, 5)
        if err == nil {
            data.Reviews = reviews
        }
        return err
    })

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

    return &data, nil
}

8. 완전한 미들웨어 체인 구성

앞서 만든 미들웨어들을 조합하면 다음과 같습니다.

func setupRouter() http.Handler {
    mux := http.NewServeMux()

    // API 라우트 등록
    userHandler := NewUserHandler(userRepo, externalAPI)
    userHandler.Register(mux)

    // 미들웨어 체인 (바깥→안 순서로 래핑)
    handler := chain(mux,
        RequestID,                   // 1. 요청 ID 부여
        Timeout(10*time.Second),     // 2. 전체 요청 타임아웃
        Logger,                      // 3. 로깅 (요청 ID 포함)
        CORS,                        // 4. CORS 헤더
        Auth,                        // 5. 인증 (일부 경로만)
    )

    return handler
}

// 실제 요청 처리 흐름
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // 미들웨어에서 주입된 context

    reqID := GetRequestID(ctx) // 요청 ID 꺼내기

    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeJSON(w, http.StatusBadRequest, errResponse("잘못된 ID 형식"))
        return
    }

    // DB 조회 + 외부 API 병렬 호출 — 모두 같은 ctx 사용
    user, err := h.repo.FindByID(ctx, id)
    if err != nil {
        log.Printf("[%s] DB 조회 실패: %v", reqID, err)
        writeJSON(w, http.StatusInternalServerError, errResponse("조회 실패"))
        return
    }

    profile, err := h.externalAPI.FetchUserProfile(ctx, id)
    if err != nil {
        log.Printf("[%s] 외부 API 실패: %v", reqID, err)
        // 외부 API 실패는 soft error로 처리
        profile = &ExternalProfile{}
    }

    writeJSON(w, http.StatusOK, mergeUserData(user, profile))
}

9. context 안티패턴

9-1. context를 구조체 필드에 저장 — 금지

// ❌ 잘못된 패턴
type Service struct {
    ctx context.Context // context는 요청 범위 — 구조체 수명과 불일치
    db  *sql.DB
}

// ✅ 올바른 패턴
type Service struct {
    db *sql.DB // 구조체에는 장기 의존성만
}

func (s *Service) Process(ctx context.Context, id int) error {
    return s.db.QueryRowContext(ctx, ...) // context는 함수 파라미터로
}

9-2. context.Background()를 핸들러 내부에서 새로 생성 — 금지

// ❌ 잘못된 패턴 — 요청 취소가 DB 쿼리에 전파되지 않음
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background() // 요청 context를 무시!
    result, _ := db.QueryRowContext(ctx, ...)
}

// ✅ 올바른 패턴
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // 항상 요청 context에서 파생
    result, _ := db.QueryRowContext(ctx, ...)
}

9-3. context에 비즈니스 로직 데이터 저장 — 금지

// ❌ 잘못된 패턴 — context는 선택적 매개변수 전달 통로가 아님
ctx = context.WithValue(ctx, "pageSize", 20)
ctx = context.WithValue(ctx, "sortOrder", "desc")

func query(ctx context.Context) {
    pageSize := ctx.Value("pageSize").(int) // 타입 단언 unsafe
}

// ✅ 올바른 패턴 — 함수 파라미터로 명시
func query(ctx context.Context, pageSize int, sortOrder string) {
    // ...
}

🛠️ context에 저장하기 적합한 데이터

비즈니스 로직 파라미터, 설정값, 옵션 등은 함수 인자로 전달하세요.


10. context 전파 체인 전체 예제

func main() {
    db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))

    repo := &UserRepository{db: db}
    extAPI := &ExternalAPIClient{
        baseURL: os.Getenv("EXT_API_URL"),
        token:   os.Getenv("EXT_API_TOKEN"),
    }

    handler := NewUserHandler(repo, extAPI)

    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users/{id}", handler.Get)
    mux.HandleFunc("POST /api/users", handler.Create)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      setupMiddleware(mux),
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
    }

    // Graceful Shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        log.Println("서버 시작: :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    <-quit
    log.Println("종료 신호 수신 — 진행 중인 요청 완료 대기 (최대 30초)")

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    server.Shutdown(ctx)

    log.Println("서버 정상 종료")
}

마치며: context 실전 사용 핵심 정리

context는 단순히 취소 신호를 전달하는 도구가 아닙니다. 요청 수명 전체를 관통하는 공유 버스입니다.

오늘 배운 핵심을 정리하면:

  1. 모든 context는 r.Context()에서 시작한다. 핸들러 내부에서 Background()를 새로 생성하면 클라이언트 취소가 전파되지 않는다.
  2. 미들웨어에서 WithValue로 요청 ID·인증 정보를 주입하고, 헬퍼 함수로 타입 안전하게 꺼낸다.
  3. DB의 QueryContext, ExecContext, BeginTx에 항상 ctx를 전달한다. 클라이언트 이탈 시 DB 쿼리도 즉시 취소된다.
  4. 외부 API는 http.NewRequestWithContext(ctx, ...)로 호출한다. ctx 취소 시 진행 중인 HTTP 연결이 중단된다.
  5. 병렬 API 호출에는 errgroup.WithContext()를 사용한다. 하나가 실패하면 나머지 ctx가 자동 취소된다.
  6. context에는 요청 메타데이터만 저장한다. 비즈니스 로직 파라미터는 함수 인자로 전달한다.

다음 심화편 글에서는 Go의 errors 패키지 고급 활용을 다룹니다. errors.Is / errors.As의 내부 동작, 커스텀 에러 타입 설계, 멀티 에러 처리, 그리고 대규모 서비스에서 계층별 에러 변환 전략을 살펴보겠습니다.