Go 컨텍스트 타임아웃은 느린 DB 호출과 외부 요청이 쌓이는 것을 막습니다. 데드라인 전파, 취소, 안전한 기본값을 배우세요.

단일 느린 요청은 드물게 "그저 느린" 상태로 끝나지 않습니다. 요청이 대기하는 동안 고루틴이 유지되고, 버퍼와 응답 객체를 위한 메모리를 점유하며, 종종 데이터베이스 연결이나 풀의 슬롯을 차지합니다. 느린 요청이 충분히 쌓이면 제한된 자원이 대기 상태에 묶여 유용한 작업을 할 수 없게 되어 API가 멈춥니다.
이를 보통 세 가지 지점에서 느낍니다. 고루틴이 누적되어 스케줄링 오버헤드가 증가하면 모두의 지연(latency)이 나빠집니다. 데이터베이스 풀의 사용 가능한 연결이 고갈되면 빠른 쿼리조차 느린 쿼리 뒤에 대기하게 됩니다. 진행 중인 데이터와 부분적으로 만들어진 응답으로 메모리가 증가하면 GC 작업도 늘어납니다.
서버를 더 많이 추가해도 해결되지 않는 경우가 많습니다. 각 인스턴스가 같은 병목(작은 DB 풀, 느린 업스트림, 공유된 레이트 리밋)에 닿는다면 단지 대기열을 옮기고 비용만 늘릴 뿐 오류는 여전히 발생합니다.
예를 들어 한 핸들러가 여러 작업을 벌려서(fan out) 사용자를 Postgres에서 불러오고, 결제 서비스를 호출한 다음 추천 서비스를 호출한다고 가정해 보세요. 추천 호출이 멈추고 아무것도 이를 취소하지 않으면 요청은 끝나지 않습니다. DB 연결은 반환될 수 있지만 고루틴과 HTTP 클라이언트 자원은 계속 묶입니다. 이런 상황이 수백 건의 요청에 곱해지면 시스템이 서서히 느려집니다.
목표는 단순합니다: 명확한 시간 제한을 정하고 시간이 다 되면 작업을 멈추어 자원을 해제하고 예측 가능한 오류를 반환하는 것입니다. Go 컨텍스트 타임아웃은 요청의 모든 단계에 데드라인을 주어 사용자가 더 이상 기다리지 않을 때 작업이 중지되게 합니다.
context.Context는 호출 체인에 전달하는 작은 객체로, 모든 레이어가 한 가지에 동의하게 합니다: 이 요청을 언제 멈춰야 하는가. 타임아웃은 한 느린 의존성이 서버를 점유하지 못하게 하는 일반적인 방법입니다.
컨텍스트는 세 가지 정보를 가질 수 있습니다: 데드라인(작업을 멈춰야 할 시각), 취소 신호(누군가 일찍 중단을 결정함), 그리고 소량의 요청 범위 값들(신중히 사용하고 큰 데이터는 절대 넣지 마세요).
취소는 마법이 아닙니다. 컨텍스트는 Done() 채널을 노출합니다. 채널이 닫히면 요청이 취소되었거나 시간이 다 된 것입니다. 컨텍스트를 존중하는 코드는 보통 select로 Done()을 확인하고 조기에 반환합니다. 또한 ctx.Err()로 종료 이유를 확인할 수 있는데 보통 context.Canceled 또는 context.DeadlineExceeded입니다.
context.WithTimeout은 "X초 후에 멈추기"에 사용하세요. 정확한 컷오프 시간이 이미 정해져 있다면 context.WithDeadline을 사용하세요. 부모 조건에 따라 작업을 일찍 멈춰야 한다면 context.WithCancel을 사용하세요(클라이언트 연결 해제, 사용자가 페이지를 떠남, 이미 답을 얻음 등).
컨텍스트가 취소되면 올바른 동작은 지루하지만 중요합니다: 작업을 멈추고 느린 I/O 대기를 중단하며 명확한 오류를 반환하세요. 핸들러가 데이터베이스 쿼리를 기다리고 있고 컨텍스트가 끝나면 가능한 한 빨리 반환하고, 드라이버가 컨텍스트를 지원한다면 데이터베이스 호출을 중단하게 하세요.
느린 요청을 멈추는 가장 안전한 장소는 트래픽이 서비스에 들어오는 경계입니다. 요청이 타임아웃될 예정이라면 가능한 한 예측 가능하고 초기에 일어나길 원합니다. 그래야 고루틴, DB 연결, 메모리를 잡아먹은 뒤에야 타임아웃이 발생하지 않습니다.
엔지(로드밸런서, API 게이트웨이, 리버스 프록시)에서 시작해 모든 요청이 허용되는 수명을 강제로 정하세요. 그 조치는 핸들러가 타임아웃을 잊더라도 Go 서비스 자체를 보호합니다.
Go 서버 내부에서는 HTTP 타임아웃을 설정해 느린 클라이언트나 정지된 응답을 영원히 기다리지 않도록 하세요. 최소한 헤더 읽기, 전체 요청 본문 읽기, 응답 쓰기, 유휴 연결 유지 시간에 대한 타임아웃을 구성하세요.
제품에 맞는 기본 요청 예산을 선택하세요. 많은 API에서 1~3초는 일반 요청의 시작점으로 적절하고, 내보내기 같은 알려진 느린 작업은 더 높은 한도를 줍니다. 정확한 숫자보다 일관성 있게 적용하고 측정하며 예외 규칙을 두는 것이 더 중요합니다.
스트리밍 응답은 추가 주의가 필요합니다. 서버가 연결을 열어두고 아주 작은 청크를 계속 쓰거나 첫 바이트 전까지 영원히 기다리는 사고를 만들기 쉽습니다. 엔드포인트가 진짜 스트림인지 미리 결정하세요. 아니라면 전체 최대 시간과 첫 바이트까지의 최대 시간을 강제하세요.
경계에서 명확한 데드라인이 있으면 그 데드라인을 요청 전체에 전파하기가 훨씬 쉽습니다.
시작하기 가장 쉬운 곳은 HTTP 핸들러입니다. 한 요청이 시스템에 들어오는 곳이므로 강력한 제한을 두기에 자연스러운 위치입니다.
데드라인이 있는 새 컨텍스트를 만들고 반드시 cancel을 호출하세요. 그런 다음 데이터베이스 작업, HTTP 호출, 느린 계산 등 블로킹할 수 있는 모든 것에 그 컨텍스트를 전달하세요.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
좋은 규칙: 함수가 I/O를 기다릴 수 있다면 context.Context를 받아야 합니다. loadUser 같은 작은 헬퍼로 세부사항을 밀어넣어 핸들러를 읽기 쉽게 유지하세요.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
데드라인에 도달하거나 클라이언트 연결이 끊기면 작업을 멈추고 사용자에게 친절한 응답을 반환하세요. 흔한 매핑은 context.DeadlineExceeded를 504 Gateway Timeout으로, context.Canceled를 "client is gone"으로 처리하는 것입니다(종종 바디 없이 조용히 반환).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
이 패턴은 누적을 방지합니다. 타이머가 만료되면 체인 아래의 모든 컨텍스트-친화적 함수가 동일한 중지 신호를 받아 빠르게 종료할 수 있습니다.
핸들러가 데드라인이 있는 컨텍스트를 가지고 있다면 중요한 규칙은 간단합니다: 그 동일한 ctx를 데이터베이스 호출까지 전달하세요. 그래야 타임아웃이 핸들러만 멈추게 하는 것이 아니라 실제 작업을 멈추게 합니다.
database/sql에서는 컨텍스트-인식 메서드를 선호하세요:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
핸들러 예산이 2초라면 데이터베이스에는 그 중 일부분만 줘야 합니다. JSON 인코딩, 다른 의존성, 오류 처리를 위한 시간을 남겨두세요. 간단한 시작점은 전체 예산의 30%60%를 Postgres에 할당하는 것입니다. 핸들러 데드라인이 2초라면 DB에는 800ms1.2s 정도를 줄 수 있습니다.
컨텍스트가 취소되면 드라이버는 Postgres에 쿼리 중단을 요청합니다. 보통 연결은 풀로 돌아가 재사용될 수 있습니다. 네트워크 문제로 취소 중에 연결이 불안정하면 드라이버는 해당 연결을 버리고 나중에 새 연결을 열 수 있습니다. 어쨌든 고루틴이 영원히 대기하는 것을 피할 수 있습니다.
오류를 확인할 때 타임아웃을 실제 DB 실패와 구분하세요. errors.Is(err, context.DeadlineExceeded)라면 시간이 다 된 것이고 타임아웃을 반환해야 합니다. errors.Is(err, context.Canceled)라면 클라이언트가 떠난 것이므로 조용히 멈추세요. 그 외의 오류는 일반적인 쿼리 문제(잘못된 SQL, 누락된 행, 권한 문제)입니다.
핸들러에 데드라인이 있다면 아웃바운드 HTTP 호출도 이를 존중해야 합니다. 그렇지 않으면 클라이언트는 포기했는데 서버는 느린 업스트림을 기다리며 고루틴, 소켓, 메모리를 묶어둡니다.
상위 컨텍스트로 외부 요청을 빌드하면 취소가 자동으로 전달됩니다:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
그 호출별 타임아웃은 안전망입니다. 상위 요청 데드라인이 여전히 진짜 보스입니다. 요청 전체에 하나의 시계가 있고, 위험한 단계에는 더 작은 캡을 둡니다.
또한 트랜스포트 수준에서 타임아웃을 설정하세요. 컨텍스트는 요청을 취소하지만 트랜스포트 타임아웃은 느린 핸드셰이크나 헤더를 전혀 보내지 않는 서버로부터 보호합니다.
한 팀을 괴롭히는 한 가지 세부사항: 응답 바디는 모든 경로에서 닫혀야 합니다. 조기 반환(상태 코드 검사, JSON 디코드 오류, 컨텍스트 타임아웃) 시에도 바디를 닫지 않으면 연결이 풀에 누수되어 "무작위" 지연 스파이크를 일으킬 수 있습니다.
구체적 시나리오: API가 결제 제공자에 호출합니다. 클라이언트는 2초 후 포기했지만 업스트림이 30초 동안 멈춰 있다면, 요청 취소와 트랜스포트 타임아웃이 없다면 포기된 각 요청에 대해 그 30초를 지불하게 됩니다.
단일 요청은 보통 핸들러 작업, 데이터베이스 쿼리, 하나 이상의 외부 API를 거칩니다. 각 단계에 관대한 타임아웃을 주면 전체 시간이 은밀히 늘어나 사용자가 체감하고 서버가 쌓이기 시작합니다.
예산을 세우는 것이 가장 간단한 해결책입니다. 전체 요청에 대한 부모 데드라인을 정한 다음 각 의존성에 더 작은 할당을 주세요. 자식 데드라인은 부모보다 일찍 오게 해서 빠르게 실패하고 여유 시간 내에 깔끔한 오류를 반환할 수 있게 하세요.
현장에 잘 맞는 경험 법칙:
서로 싸우는 타임아웃을 쌓지 마세요. 핸들러 컨텍스트가 2초 데드라인이고 HTTP 클라이언트가 10초 타임아웃이면 안전하지만 혼란스러울 수 있습니다. 반대로 클라이언트 타임아웃이 더 짧다면 관련 없는 이유로 조기 차단될 수 있습니다.
백그라운드 작업(감사 로그, 메트릭, 이메일 등)은 요청 컨텍스트를 재사용하지 마세요. 클라이언트 취소가 중요한 정리 작업을 죽이지 않도록 자체 짧은 타임아웃이 있는 별도 컨텍스트를 사용하세요.
대부분의 타임아웃 버그는 핸들러가 아닌 그 아래 한두 레이어에서 발생합니다. 경계에 타임아웃을 설정했지만 중간에서 이를 무시하면, 클라이언트가 떠난 뒤에도 고루틴, DB 쿼리, HTTP 호출이 계속 실행될 수 있습니다.
자주 나타나는 패턴은 단순합니다:
context.Background()나 context.TODO()로 호출을 보내 데드라인 전파를 끊음.ctx.Done()를 확인하지 않고 sleep, retry, loop를 수행함.context.WithTimeout을 감싸 수많은 타이머와 혼란스러운 데드라인을 만듦.ctx를 전달하는 것을 잊음.전형적인 실패 사례: 핸들러에 2초 타임아웃을 추가했지만 리포지토리가 DB 쿼리에 context.Background()를 사용하면, 부하가 걸렸을 때 느린 쿼리가 클라이언트 포기 후에도 계속 실행되어 누적을 만듭니다.
기본을 고치세요: 호출 스택을 통해 ctx를 첫 번째 인자로 전달하세요. 긴 작업 내부에서는 select { case <-ctx.Done(): return ctx.Err() default: } 같은 빠른 검사를 추가하세요. context.DeadlineExceeded는 타임아웃 응답(보통 504)으로, context.Canceled는 클라이언트 취소 스타일 응답(환경에 따라 408 또는 499)으로 일관되게 매핑하세요.
타임아웃은 발생하는 것을 볼 수 있고 시스템이 깔끔하게 복구하는 것을 확인해야만 유용합니다. 무언가 느려질 때 요청은 멈추고 자원이 해제되며 API는 응답성을 유지해야 합니다.
각 요청에 대해 동일한 작은 필드 집합을 로그에 남기면 정상 요청과 타임아웃을 비교하기 쉬워집니다. 컨텍스트 데드라인(존재하면)과 무엇이 작업을 끝냈는지 포함하세요.
유용한 필드로는 데드라인(또는 "none"), 전체 경과 시간, 취소 이유(타임아웃 vs 클라이언트 취소), 짧은 작업 라벨(예: "db.query users", "http.call billing"), 그리고 요청 ID가 있습니다.
최소한의 패턴 예시는 다음과 같습니다:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
로그는 한 요청을 디버그하는 데 도움을 주고, 메트릭은 추세를 보여줍니다.
경로 및 의존성별 타임아웃 수, 진행 중인 요청 수(부하하에서 평형을 이뤄야 함), DB 풀 대기 시간, 성공 vs 타임아웃으로 분리한 지연 퍼센타일(p95/p99) 같은 신호를 추적하세요. 이 지표들은 타임아웃이 잘못되었을 때 초기 단계에서 상승합니다.
지연을 예측 가능하게 만드세요. 디버그 전용으로 한 핸들러에 지연을 추가하거나 DB 쿼리를 의도적으로 느리게 하거나 슬립하는 테스트 서버로 외부 호출을 래핑하세요. 그런 다음 두 가지를 확인하세요: 타임아웃 오류가 보이고, 취소 후 작업이 곧 멈추는지.
작은 부하 테스트도 도움이 됩니다. 한 느린 의존성이 강제된 상태에서 2050 동시 요청을 3060초 동안 실행하세요. 고루틴 수와 진행 중인 요청 수는 상승했다가 평형을 이루어야 합니다. 계속 증가하면 어딘가가 컨텍스트 취소를 무시합니다.
타임아웃은 요청이 대기할 수 있는 모든 곳에 적용되어야만 도움이 됩니다. 배포 전에 코드베이스를 한 번 훑어 동일한 규칙이 모든 핸들러에 적용되었는지 확인하세요.
context.DeadlineExceeded와 context.Canceled를 검사한다.http.NewRequestWithContext(또는 req = req.WithContext(ctx))를 사용하고, 클라이언트는 트랜스포트 타임아웃(다이얼, TLS, 응답 헤더)을 갖는다. 프로덕션 경로에서 http.DefaultClient에 의존하지 마라.릴리스 전에 한 번의 "느린 종속성" 드릴은 가치가 있습니다. 하나의 SQL 쿼리에 인공적으로 2초 지연을 추가하고 세 가지를 확인하세요: 핸들러가 제때 반환하는가, DB 호출이 실제로 중단되는가(핸들러만 멈추는 것이 아님), 로그에 DB 타임아웃이라고 명확히 나오는가.
GET /v1/account/summary 같은 엔드포인트를 상상해 보세요. 사용자 액션 하나가 세 가지를 촉발합니다: PostgreSQL 쿼리(계정과 최근 활동)와 두 개의 외부 HTTP 호출(예: 결제 상태 확인과 프로필 보강 조회).
전체 요청에 강력한 2초 예산을 주세요. 예산이 없으면 하나의 느린 의존성이 고루틴, DB 연결, 메모리를 묶어두어 API 전체가 곳곳에서 타임아웃을 일으키게 됩니다.
간단한 분할 예시는 DB 쿼리에 800ms, 외부 호출 A에 600ms, 외부 호출 B에 600ms를 주는 것입니다.
일단 전체 데드라인을 알면 이를 아래로 전달하세요. 각 의존성은 자체 작은 타임아웃을 가지되 부모의 취소 신호를 상속합니다.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
외부 호출 B가 느려져 2.5초가 걸린다면, 핸들러는 600ms에서 기다리는 것을 멈추고 인플라이트 작업을 취소하며 클라이언트에 명확한 타임아웃 응답을 반환해야 합니다. 클라이언트는 늘어지는 스피너 대신 빠른 실패를 보게 됩니다.
로그는 예산을 무엇이 썼는지 분명히 해야 합니다. 예: DB는 빠르게 끝났고 외부 A는 성공했으며 외부 B는 상한에 걸려 context deadline exceeded를 반환했다는 식으로요.
한 엔드포인트에서 타임아웃과 취소가 잘 작동하면 이를 반복 가능한 패턴으로 만드세요. 핸들러 데드라인, DB 호출, 아웃바운드 HTTP까지 엔드투엔드로 적용한 뒤 같은 구조를 다른 엔드포인트로 복사하세요.
지루한 부분을 중앙화하면 더 빠르게 진행할 수 있습니다: 경계 타임아웃 헬퍼, DB와 HTTP 호출에 ctx가 전달되도록 보장하는 래퍼, 일관된 오류 매핑과 로그 포맷을 제공하는 공용 유틸리티 등.
빠르게 프로토타입을 만들고 싶다면 Koder.ai (koder.ai)가 채팅 프롬프트에서 Go 핸들러와 서비스 호출을 생성해 주고 소스 코드를 내보내 타임아웃 헬퍼와 예산을 적용할 수 있게 도와줍니다. 목표는 일관성입니다: 느린 호출은 일찍 멈추고, 오류는 동일하게 보이며, 디버깅이 누가 엔드포인트를 작성했는지에 의존하지 않게 하는 것입니다.
느린 요청은 대기하는 동안 한정된 자원을 점유합니다: 고루틴, 버퍼와 응답 객체를 위한 메모리, 그리고 종종 데이터베이스 커넥션이나 HTTP 클라이언트 연결까지 차지합니다. 이러한 요청이 동시에 쌓이면 대기열이 형성되어 전체 트래픽의 지연이 증가하고, 각 요청이 결국 완료되더라도 서비스가 실패할 수 있습니다.
가장 간단한 해결책은 경계에서 명확한 데드라인을 설정하는 것입니다(프록시/게이트웨이와 Go 서버). 핸들러에서 타임드 컨텍스트를 만들고 그 ctx를 데이터베이스와 외부 HTTP 같은 모든 블로킹 호출에 전달하세요. 데드라인이 지나면 일관된 타임아웃 응답을 빠르게 반환하고 취소 가능한 인-플라이트 작업을 중단합니다.
context.WithTimeout(parent, d)는 “이 기간 후에 중지”할 때 가장 흔히 사용합니다. 이미 고정된 마감 시간이 있다면 context.WithDeadline(parent, t)를 사용하세요. 내부 조건(예: 이미 답을 얻었을 때)으로 작업을 조기에 중단해야 한다면 context.WithCancel(parent)를 사용합니다.
파생 컨텍스트를 만들면 항상 defer cancel()로 cancel을 호출하세요. 타이머를 해제하고, 일찍 반환되는 코드 경로에서 자식 작업에 명확한 중지 신호를 전달하기 위해 필요합니다.
핸들러에서 요청 컨텍스트를 한 번 만들고 블로킹할 수 있는 함수들의 첫 번째 인자로 내려보내세요. 코드베이스에서 context.Background()나 context.TODO()를 검색해 보세요—이것들이 종종 데드라인 전파를 끊습니다.
컨텍스트를 QueryContext, QueryRowContext, ExecContext 같은 컨텍스트-인식 데이터베이스 메서드와 함께 사용하세요. 컨텍스트가 끝나면 드라이버가 Postgres에 쿼리 중단을 요청해 요청이 끝난 뒤에도 시간을 소모하지 않도록 합니다.
상위 요청 컨텍스트를 http.NewRequestWithContext(ctx, ...)로 외부 요청에 연결하고, 연결/ TLS/응답 헤더 대기 같은 단계에서 보호해 줄 클라이언트/트랜스포트 레벨 타임아웃도 구성하세요. 오류나 비-200 응답 경로에서도 반드시 resp.Body.Close()를 호출해 연결이 풀로 반환되도록 하세요.
전체 요청 예산을 먼저 정하고, 각 종속성에 그 안에서 작은 할당을 주세요. 핸들러 오버헤드와 응답 인코딩을 위한 여유 버퍼를 남기고, 여러 외부 호출이 있다면 각 호출에 상한을 두어 한 호출이 예산을 모두 먹지 않게 하세요.
일반적으로 context.DeadlineExceeded는 504 Gateway Timeout으로 매핑하고 간단한 메시지(예: "request timed out")를 반환하는 것이 보편적입니다. context.Canceled는 클라이언트가 연결을 끊은 경우가 많으니 추가 작업을 중단하고 바디를 쓰지 않고 조용히 반환하는 것이 좋습니다.
가장 흔한 실수는 요청 컨텍스트를 버리고 context.Background()를 사용하는 것입니다. 그 밖에 재시도나 sleep을 ctx.Done() 검사 없이 수행하거나 블로킹 호출에 ctx를 전달하는 것을 잊는 경우도 많습니다. 또한 여러 곳에 무작위로 타임아웃을 쌓아두면 실패 양상이 복잡해집니다.