# GORM에서 WithContext를 빼먹으면 무엇이 안 되는가 - 타임아웃, 취소, 그리고 커넥션 풀

HTTP 핸들러에 5초 타임아웃을 걸어 두었습니다. 그런데 부하가 걸리면 요청은 5초에 실패로 돌아가는데 DB 커넥션은 계속 늘어나고, 결국 풀이 고갈되어 멀쩡한 요청까지 막힙니다. 애플리케이션 로그에는 타임아웃이 잘 찍혀 있어서 타임아웃은 동작하는 것처럼 보입니다.

원인은 두 가지입니다. 쿼리에 컨텍스트가 전달되지 않았거나, 전달됐지만 취소가 실제로 쿼리를 멈추지는 못하는 경우입니다. 이 글에서는 GORM에서 컨텍스트가 흘러가는 경로, 빠뜨리기 쉬운 지점, 그리고 취소가 어디까지 보장되는지 정리합니다.

# 1. 두 가지 사용 모드

GORM은 컨텍스트를 세션에 실어 전달합니다. 문서가 두 모드를 구분합니다.

단일 세션 모드 - 호출마다 컨텍스트를 붙입니다.

db.WithContext(ctx).Find(&users)

연속 세션 모드 - 컨텍스트가 붙은 세션을 만들어 재사용합니다.

tx := db.WithContext(ctx)
tx.First(&user, 1)
tx.Model(&user).Update("role", "admin")

여기서 중요한 성질이 하나 있습니다. WithContext는 원본 *gorm.DB를 바꾸지 않고 새 세션을 반환합니다. 전역에 두는 db는 컨텍스트가 없는 상태로 계속 남아 있고, 반환값을 받아 써야만 컨텍스트가 실립니다.

db.WithContext(ctx)        // 반환값을 안 받으면 아무 효과도 없다
db.Find(&users)            // 컨텍스트 없이 나간다

이 코드는 컴파일되고 정상 동작합니다. 타임아웃만 안 걸립니다.

# 2. 조용히 실패하기 때문에 테스트로 안 잡힌다

WithContext 누락이 위험한 이유는 증상이 정상 동작과 구별되지 않기 때문입니다.

  • 컴파일 오류가 없습니다.
  • 결과가 똑같이 나옵니다.
  • 응답 시간도 평소에는 같습니다.
  • 통합 테스트도 통과합니다. 테스트에서는 쿼리가 빠르니 타임아웃이 걸릴 일이 없습니다.

차이가 드러나는 것은 DB가 느려진 순간, 즉 이미 장애가 시작된 뒤입니다. 그리고 그때 이 누락은 장애를 완화하는 대신 증폭시킵니다. 상류가 포기한 요청의 쿼리가 계속 살아 있으면서 커넥션과 DB 자원을 점유하기 때문입니다.

리포지터리 계층이 있는 코드베이스에서는 누락이 사실상 예정된 일입니다. 메서드마다 WithContext를 반복해야 하고, 새 메서드를 추가하는 사람이 그 규칙을 알아야 합니다. 규칙을 기억에 의존시키는 설계가 원인이므로, 해법도 규칙 강화가 아니라 구조 변경이어야 합니다.

type userRepo struct {
    gdb *gorm.DB          // 이 필드는 밖으로 노출하지 않는다
}

// 모든 쿼리가 반드시 지나가는 관문
func (r *userRepo) db(ctx context.Context) *gorm.DB {
    return r.gdb.WithContext(ctx)
}

func (r *userRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    var u User
    if err := r.db(ctx).First(&u, id).Error; err != nil {
        return nil, err
    }
    return &u, nil
}

관문을 하나 두면 누락 가능성이 "메서드 개수"에서 "관문 하나"로 줄어듭니다. 그리고 gdb를 비공개로 두면 우회 경로가 막힙니다. 컨텍스트를 인터페이스 계약으로 만드는 일반적인 방법은 Go에서 context를 인터페이스 계약으로 만들기 (opens new window)에 정리했습니다.

# 3. 컨텍스트를 넘겨도 쿼리가 멈춘다는 보장은 없다

이제 더 근본적인 문제입니다. WithContext를 제대로 붙였다고 해서 타임아웃 시점에 DB 작업이 중단되는 것은 아닙니다. database/sql 문서가 명시합니다.

Drivers that do not support context cancellation will not return until after the query is completed.

드라이버가 취소를 지원하지 않으면 쿼리가 끝날 때까지 반환하지 않습니다. 컨텍스트가 만료돼도 그 고루틴은 계속 기다리고, 커넥션도 계속 점유됩니다.

즉 컨텍스트 타임아웃의 효과는 드라이버에 따라 둘로 나뉩니다.

애플리케이션 데이터베이스
취소 지원 드라이버 빠르게 반환 쿼리 취소 요청 전달
미지원 드라이버 쿼리 종료까지 대기 계속 실행

그리고 취소를 지원하는 드라이버라도 "취소 요청을 보낸다"는 것이지 "DB가 즉시 멈춘다"는 뜻은 아닙니다. 서버가 그 요청을 처리하기까지 시간이 걸리고, 이미 시작된 무거운 작업은 중간에 멈추기 어렵습니다.

결론은 이렇습니다. 애플리케이션 타임아웃은 데이터베이스 부하를 막아 주지 않습니다. 부하를 막으려면 DB 쪽에도 상한이 있어야 합니다.

-- PostgreSQL: 세션 단위 상한을 함께 건다
SET statement_timeout = '5s';
SET idle_in_transaction_session_timeout = '10s';

애플리케이션 타임아웃보다 DB 상한을 조금 크게 잡는 것이 보통입니다. 애플리케이션이 먼저 포기하고, 그래도 남은 쿼리는 DB가 정리하는 구조입니다.

# 4. 커넥션 풀이 실제로 고갈되는 경로

세 요소가 맞물립니다.

  1. 요청이 들어와 쿼리를 시작한다. 커넥션 하나를 점유한다.
  2. 컨텍스트가 만료된다. 3절 때문에 커넥션은 아직 안 돌아온다.
  3. 클라이언트가 재시도한다. 새 요청이 또 커넥션을 요구한다.

느려진 상태에서 재시도가 겹치면 필요한 커넥션 수가 곱으로 늘어납니다. 풀 상한에 닿으면 새 요청은 커넥션을 기다리고, 그 대기가 다시 타임아웃을 만듭니다. DB가 조금 느려진 것이 애플리케이션 전체 중단으로 번지는 표준 경로입니다.

여기서 풀 설정이 완충 장치가 됩니다.

sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(20)          // DB가 감당할 수 있는 총량에서 역산
sqlDB.SetMaxIdleConns(20)          // Open과 같게 두어 잦은 재연결을 피한다
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)

SetMaxOpenConns를 크게 잡는 것이 안전해 보이지만 반대입니다. 문서의 설명대로 상한이 없으면(n <= 0) 커넥션이 무제한으로 늘어납니다.

If n <= 0, then there is no limit on the number of open connections. The default is 0 (unlimited).

기본값이 무제한이라는 점이 중요합니다. 설정하지 않으면 애플리케이션이 DB를 밀어붙이다 DB 쪽 연결 상한에 먼저 닿고, 그러면 그 DB를 쓰는 다른 서비스까지 함께 죽습니다. 풀 상한은 "우리 서비스가 느려지는 지점"을 정하는 값이 아니라 **"DB가 무너지지 않게 하는 방벽"**입니다. 인스턴스 수 × 풀 크기가 DB의 최대 연결 수보다 확실히 작아야 합니다.

# 5. 트랜잭션에서는 컨텍스트가 롤백을 유발한다

트랜잭션은 성격이 다릅니다.

The provided context is used until the transaction is committed or rolled back. If the context is canceled, the sql package will roll back the transaction. Tx.Commit will return an error if the context provided to BeginTx is canceled.

컨텍스트가 취소되면 트랜잭션이 롤백됩니다. 대개 원하는 동작입니다. 클라이언트가 떠났는데 쓰기가 커밋되면 안 되기 때문입니다.

그런데 이 성질이 두 가지 상황에서 문제가 됩니다.

첫째, 트랜잭션이 요청보다 오래 걸리는 경우. 배치성 작업을 요청 컨텍스트로 감싸면, 클라이언트가 연결을 끊거나 상류 타임아웃이 걸릴 때 작업이 통째로 롤백됩니다. 이런 작업은 요청 컨텍스트가 아니라 작업 수명에 맞는 별도 컨텍스트를 써야 합니다.

// 요청과 수명이 다른 작업은 컨텍스트를 갈아 끼운다
func (s *Service) StartExport(reqCtx context.Context, id int64) error {
    // 값(트레이스, 요청 ID)은 이어받되 취소는 분리한다
    jobCtx, cancel := context.WithTimeout(context.WithoutCancel(reqCtx), 30*time.Minute)
    go func() {
        defer cancel()
        s.runExport(jobCtx, id)
    }()
    return nil
}

context.WithoutCancel은 값은 유지하고 취소 신호만 끊습니다. 트레이스 컨텍스트를 잃지 않으면서 수명을 분리할 수 있습니다.

둘째, 세션을 오래 들고 있는 경우. 연속 세션 모드에서 만든 tx를 구조체 필드에 저장하면 그 안에 요청 컨텍스트가 박제됩니다. 요청이 끝난 뒤 그 세션으로 쿼리하면 이미 취소된 컨텍스트를 쓰게 되어 즉시 실패합니다. 원인이 "컨텍스트가 없어서"가 아니라 "낡은 컨텍스트라서"이므로 진단이 헷갈립니다.

규칙은 단순합니다. 컨텍스트가 실린 세션은 요청 범위를 벗어나 저장하지 않습니다.

# 6. 훅과 콜백에서도 컨텍스트를 이어야 한다

GORM 훅 안에서 외부 호출을 하는 경우가 있습니다. 감사 로그를 남기거나 캐시를 무효화하는 작업입니다. 이때 context.Background()를 쓰면 그 지점에서 전파가 끊깁니다. 타임아웃도 트레이스도 이어지지 않습니다.

훅에서는 현재 컨텍스트를 꺼낼 수 있습니다.

func (u *User) BeforeCreate(tx *gorm.DB) error {
    ctx := tx.Statement.Context        // context.Background()를 쓰지 않는다
    return audit.Record(ctx, "user.create", u.ID)
}

훅은 도메인 로직과 인프라가 섞이기 쉬운 자리라 이 누락이 자주 생깁니다. 트레이스에서 스팬이 갑자기 끊기거나 훅 안의 작업만 타임아웃을 무시한다면 여기를 봐야 합니다.

# 7. 직접 확인하는 방법

코드에서 누락 찾기. 정적으로 훑는 것이 가장 빠릅니다.

# gorm 호출 중 WithContext를 거치지 않는 것 찾기
grep -rn --include='*.go' -E '\b(gdb|db|DB)\.(Find|First|Take|Last|Create|Save|Updates?|Delete|Raw|Exec|Model|Where)\(' . \
  | grep -v 'WithContext' | grep -v '_test.go'

# 컨텍스트를 받지 않는 리포지터리 메서드
grep -rn --include='*.go' -E 'func \(r \*[a-zA-Z]+Repo\) [A-Z][A-Za-z]*\(' . \
  | grep -v 'ctx context.Context'

동작으로 확인하기. 짧은 타임아웃과 느린 쿼리를 붙여 보면 확실합니다.

func TestQueryRespectsContext(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    start := time.Now()
    err := repo.SlowQuery(ctx)         // 내부에서 pg_sleep(3) 같은 쿼리

    if err == nil {
        t.Fatal("타임아웃이 걸리지 않았다 - WithContext 누락 의심")
    }
    if elapsed := time.Since(start); elapsed > time.Second {
        t.Fatalf("반환이 늦다(%v) - 드라이버가 취소를 지원하지 않을 수 있다", elapsed)
    }
}

이 테스트가 두 문제를 구분해 줍니다. 오류가 아예 안 나면 2절(누락), 오류는 나는데 늦게 나면 3절(드라이버 취소 미지원)입니다.

DB 쪽에서 확인하기. 애플리케이션이 포기한 쿼리가 살아 있는지 봅니다.

-- 오래 도는 쿼리와 그 상태
SELECT pid, now() - query_start AS duration, state, left(query, 80)
FROM pg_stat_activity
WHERE state <> 'idle' AND now() - query_start > interval '5 seconds'
ORDER BY duration DESC;

-- 트랜잭션 안에서 놀고 있는 세션 - 5절의 증상
SELECT count(*) FROM pg_stat_activity WHERE state = 'idle in transaction';

애플리케이션 타임아웃이 5초인데 여기에 30초짜리 쿼리가 보인다면, 3절 상황이 실제로 일어나고 있는 것입니다.

풀 상태 확인하기.

s := sqlDB.Stats()
log.Printf("open=%d inUse=%d idle=%d waitCount=%d waitDuration=%s",
    s.OpenConnections, s.InUse, s.Idle, s.WaitCount, s.WaitDuration)

WaitCount가 늘고 WaitDuration이 커지면 풀이 이미 병목입니다. 이 두 값을 메트릭으로 내보내면 4절의 연쇄가 시작되기 전에 알 수 있습니다.

# 8. 트러블슈팅

증상 원인 조치
타임아웃이 전혀 안 걸림 WithContext 누락 관문 메서드로 강제
WithContext를 썼는데 안 걸림 반환값을 안 받음 반환된 세션으로 호출
타임아웃은 걸리는데 DB 부하 유지 드라이버가 취소 미지원 statement_timeout 병행
커넥션이 계속 늘어남 풀 상한 미설정(기본 무제한) SetMaxOpenConns 설정
다른 서비스까지 DB 연결 실패 인스턴스 수 × 풀 크기 초과 총량 기준 재계산
배치 작업이 중간에 롤백 요청 컨텍스트로 트랜잭션 작업 수명 컨텍스트 분리
저장해 둔 세션이 즉시 실패 낡은 컨텍스트가 박제됨 세션을 요청 범위 밖에 두지 않음
훅 안에서만 트레이스가 끊김 context.Background() 사용 tx.Statement.Context 사용
idle in transaction이 쌓임 롤백·커밋 누락 idle_in_transaction_session_timeout

# 9. 마무리

  • WithContext새 세션을 반환합니다. 반환값을 안 받으면 아무 일도 일어나지 않고, 코드는 정상 동작합니다.
  • 누락은 조용히 실패합니다. 테스트도 통과하고 평소 응답 시간도 같습니다. DB가 느려진 순간에만 드러나고, 그때 장애를 증폭시킵니다.
  • 규칙을 기억에 맡기지 않고 관문 메서드 하나를 지나가게 만드는 것이 실질적인 해법입니다.
  • 컨텍스트를 제대로 넘겨도 드라이버가 취소를 지원하지 않으면 쿼리는 끝까지 돕니다. 애플리케이션 타임아웃은 DB 부하를 막아 주지 않습니다. DB 쪽 상한이 따로 필요합니다.
  • 커넥션 풀 상한의 기본값은 무제한입니다. 상한은 우리 서비스가 아니라 DB를 지키는 방벽이므로 인스턴스 수를 곱해 계산해야 합니다.
  • 트랜잭션에서 컨텍스트 취소는 롤백을 부릅니다. 요청보다 오래 사는 작업은 컨텍스트를 갈아 끼워야 하고, 컨텍스트가 실린 세션은 요청 범위 밖으로 내보내지 않습니다.

# 참고