2026-01-01
기본편에서 context 패키지의 구조와 WithCancel, WithTimeout, WithValue 사용법을 다뤘습니다. 이번 글에서는 실제 서버 코드에서 context를 어떻게 연결하는지, 즉 실전 패턴에 집중합니다.
이 글에서는 요청 하나가 들어왔을 때 context가 미들웨어 → 핸들러 → 서비스 → DB 쿼리를 어떻게 관통하는지, 외부 API 병렬 호출에서 하나라도 실패하면 나머지를 어떻게 일괄 취소하는지, 그리고 흔히 저지르는 context 오용 패턴까지 실무 코드로 직접 살펴봅니다.
🛠️ 이 글의 전제
기본편의 context 트리 구조,
WithCancel/WithTimeout/WithValue동작 원리를 이해하고 있다고 가정합니다. 처음 접한다면 기본편을 먼저 읽어보세요.
Go HTTP 서버에서 모든 context는 r.Context()에서 시작합니다. 클라이언트가 연결을 끊으면 이 context가 자동으로 취소됩니다. 올바른 설계는 이 수명을 체인 전체에 전파하는 것입니다.
HTTP 요청 도착
↓
r.Context() ─── 미들웨어(RequestID 주입, 타임아웃 설정)
↓
핸들러(ctx 수신)
↓
서비스 레이어(ctx 전달)
↓
DB QueryContext(ctx) / http.NewRequestWithContext(ctx)
↓
클라이언트 연결 종료 or 타임아웃 → 취소 신호 전파 → 모든 단계 즉시 중단
이 흐름을 유지하면 클라이언트가 중간에 끊더라도 불필요한 DB 쿼리나 외부 API 호출이 즉시 중단됩니다.
요청 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를 유지할 수 있어 전체 인프라 트레이싱이 연결됩니다.
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)
}
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)
// ...
}
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에서 불필요한 쿼리가 계속 실행되는 것을 막습니다.
외부 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
}
여러 외부 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
}
앞서 만든 미들웨어들을 조합하면 다음과 같습니다.
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))
}
// ❌ 잘못된 패턴
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는 함수 파라미터로
}
// ❌ 잘못된 패턴 — 요청 취소가 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, ...)
}
// ❌ 잘못된 패턴 — 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에 저장하기 적합한 데이터
- 요청 ID, 트레이싱 ID
- 인증된 사용자 정보 (JWT 클레임)
- 데이터베이스 트랜잭션 객체 (미들웨어 패턴)
- 로케일/언어 정보
비즈니스 로직 파라미터, 설정값, 옵션 등은 함수 인자로 전달하세요.
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는 단순히 취소 신호를 전달하는 도구가 아닙니다. 요청 수명 전체를 관통하는 공유 버스입니다.
오늘 배운 핵심을 정리하면:
r.Context()에서 시작한다. 핸들러 내부에서 Background()를 새로 생성하면 클라이언트 취소가 전파되지 않는다.WithValue로 요청 ID·인증 정보를 주입하고, 헬퍼 함수로 타입 안전하게 꺼낸다.QueryContext, ExecContext, BeginTx에 항상 ctx를 전달한다. 클라이언트 이탈 시 DB 쿼리도 즉시 취소된다.http.NewRequestWithContext(ctx, ...)로 호출한다. ctx 취소 시 진행 중인 HTTP 연결이 중단된다.errgroup.WithContext()를 사용한다. 하나가 실패하면 나머지 ctx가 자동 취소된다.다음 심화편 글에서는 Go의 errors 패키지 고급 활용을 다룹니다. errors.Is / errors.As의 내부 동작, 커스텀 에러 타입 설계, 멀티 에러 처리, 그리고 대규모 서비스에서 계층별 에러 변환 전략을 살펴보겠습니다.