2025년 3월 15일·5분 읽기

API용 Go 컨텍스트 타임아웃: HTTP 핸들러에서 SQL까지

API에서 Go 컨텍스트 타임아웃은 HTTP 핸들러에서 SQL 호출로 기한을 전달해 멈춰버리는 요청을 막고 부하 시에도 서비스를 안정적으로 유지하도록 돕습니다.

API용 Go 컨텍스트 타임아웃: HTTP 핸들러에서 SQL까지

요청이 멈추는 이유(그리고 부하 시 왜 해로운가)\n\n요청이 "멈췄다(stuck)"는 것은 반환하지 않는 무언가를 기다리고 있을 때 발생합니다: 느린 데이터베이스 쿼리, 풀에서 막힌 연결, DNS 문제, 또는 호출은 받았지만 응답을 하지 않는 상위 서비스 등입니다.\n\n증상은 단순해 보입니다: 일부 요청이 영원히 걸리고, 그 뒤로 점점 더 많은 요청이 쌓입니다. 메모리가 증가하고 고루틴 수가 늘며, 쉽게 비워지지 않는 열린 연결 대기열을 보게 됩니다.\n\n부하가 걸리면 멈춘 요청은 이중으로 해를 끼칩니다. 작업자를 묶어두고 데이터베이스 연결이나 락 같은 귀한 자원을 점유합니다. 그 때문에 보통 빠른 요청도 느려지고, 겹침이 생기며 기다림이 더 늘어납니다.\n\n재시도와 트래픽 스파이크는 이 악순환을 악화시킵니다. 클라이언트가 타임아웃되어 재시도하는 동안 원래 요청은 여전히 실행 중일 수 있으므로 같은 작업에 대해 두 배 비용을 지불하게 됩니다. 짧은 지연 동안 여러 클라이언트가 재시도하면 평균 트래픽이 괜찮더라도 데이터베이스를 과부하시키거나 연결 한도에 도달할 수 있습니다.\n\n타임아웃은 단순한 약속입니다: "우리는 X 이상 기다리지 않겠다." 이는 빠르게 실패(fail fast)하고 자원을 해제하는 데 도움이 되지만 작업을 더 빨리 끝나게 하지는 않습니다.\n\n또한 작업이 즉시 중단된다는 보장도 아닙니다. 예를 들어 데이터베이스는 계속 실행할 수 있고, 상위 서비스는 취소를 무시할 수 있으며, 코드 자체가 취소가 발생했을 때 안전하지 않을 수 있습니다.\n\n타임아웃이 보장하는 것은 핸들러가 기다림을 멈추고 명확한 오류를 반환하며 보유한 자원을 해제할 수 있다는 점입니다. 이 제한된 대기는 몇 번의 느린 호출이 전체 장애로 번지는 것을 막아줍니다.\n\nGo 컨텍스트 타임아웃의 목표는 에지에서 가장 깊은 호출까지 하나의 공유된 데드라인을 갖는 것입니다. HTTP 경계에서 한 번 설정하고 동일한 컨텍스트를 서비스 코드 전반에 전달하며 database/sql 호출에도 사용하면 데이터베이스도 언제 기다림을 멈춰야 하는지 알게 됩니다.\n\n## Go의 컨텍스트를 쉬운 용어로\n\ncontext.Context는 현재 무슨 일이 진행 중인지 설명하기 위해 코드 전체에 전달하는 작은 객체입니다. "이 요청이 아직 유효한가?", "언제 포기해야 하나?", "요청 범위의 어떤 값이 이 작업과 함께 가야 하나?" 같은 질문에 답합니다.\n\n핵심 장점은 시스템의 가장자리(HTTP 핸들러)에서 내린 한 번의 결정이 동일한 컨텍스트를 전달하면 모든 하위 단계들을 보호할 수 있다는 점입니다.\n\n### 컨텍스트가 담는 것\n\n컨텍스트는 비즈니스 데이터를 위한 장소가 아닙니다. 제어 신호와 소량의 요청 범위 정보: 취소, 데드라인/타임아웃, 로그용 요청 ID 같은 작은 메타데이터를 위한 것입니다.\n\n타임아웃과 취소의 차이는 간단합니다: 타임아웃은 취소의 한 이유입니다. 예를 들어 2초 타임아웃을 설정하면 2초가 지나면 컨텍스트는 취소됩니다. 하지만 사용자가 탭을 닫거나 로드밸런서가 연결을 끊거나 코드가 요청을 중단하기로 결정하면 컨텍스트는 더 일찍 취소될 수도 있습니다.\n\n컨텍스트는 보통 첫 번째 매개변수로 명시적으로 전달되어 함수 호출을 따라 흐릅니다: func DoThing(ctx context.Context, ...). 호출 지점에 계속 보이기 때문에 "잊어버리기" 어렵습니다.\n\n데드라인이 만료되면 그 컨텍스트를 관찰하는 것은 빠르게 멈춰야 합니다. 예를 들어 QueryContext를 사용하는 데이터베이스 쿼리는 context deadline exceeded 같은 오류와 함께 조기에 반환해야 하고, 핸들러는 서버가 작업자를 모두 소진할 때까지 매달리지 않고 타임아웃으로 응답할 수 있어야 합니다.\n\n좋은 사고 모델은 이렇습니다: 하나의 요청, 하나의 컨텍스트, 어디든 전달하세요. 요청이 죽으면 작업도 함께 멈춰야 합니다.\n\n## HTTP 경계에서 명확한 데드라인 설정하기\n\n엔드투엔드 타임아웃을 작동시키려면 시계가 어디서 시작되는지 결정해야 합니다. 가장 안전한 위치는 HTTP 에지 바로 지점입니다. 그러면 모든 하위 호출(비즈니스 로직, SQL, 다른 서비스)이 동일한 데드라인을 상속받습니다.\n\n그 데드라인은 여러 곳에서 설정할 수 있습니다. 서버 수준 타임아웃은 좋은 기본 보호막이고 느린 클라이언트로부터 보호해 줍니다. 미들웨어는 라우트 그룹 전반에 일관성을 주기에 훌륭합니다. 핸들러 내부에서 설정하는 것도 명확하고 국소적인 설정이 필요할 때 괜찮습니다.\n\n대부분의 API에서는 미들웨어나 핸들러에서의 요청별 타임아웃이 가장 이해하기 쉽습니다. 현실적으로 설정하세요: 사용자는 느리게 매달리는 것보다 빠르고 명확한 실패를 선호합니다. 많은 팀은 읽기 작업에 대해 짧은 예산(예: 1–2초)을, 쓰기에는 좀 더 긴 예산(예: 3–10초)을 사용합니다.\n\n간단한 핸들러 패턴은 다음과 같습니다:\n\ngo\nfunc (s *Server) getReport(w http.ResponseWriter, r *http.Request) {\n ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)\n defer cancel()\n\n report, err := s.reports.Generate(ctx, r.URL.Query().Get(\"id\"))\n if err != nil {\n http.Error(w, err.Error(), http.StatusGatewayTimeout)\n return\n }\n\n json.NewEncoder(w).Encode(report)\n}\n\n\n이 패턴을 효과적으로 유지하는 두 가지 규칙:\n\n- 항상 cancel()을 호출해 타이머와 자원이 빠르게 해제되도록 하세요.\n- 핸들러 안에서 요청 컨텍스트를 context.Background()context.TODO()로 대체하지 마세요. 그러면 체인이 끊겨서 데이터베이스 호출과 외부 요청이 클라이언트가 떠난 뒤에도 영원히 실행될 수 있습니다.\n\n## 코드베이스 전반에 컨텍스트 전파하기\n\nHTTP 경계에서 데드라인을 설정한 후 실제 작업은 그 동일한 데드라인이 블로킹할 수 있는 모든 계층에 도달하도록 하는 것입니다. 아이디어는 하나의 시계가 핸들러, 서비스 코드, 네트워크나 디스크에 접근하는 모든 곳에서 공유되는 것입니다.\n\n일관성을 유지하는 간단한 규칙: 기다릴 수 있는 모든 함수는 context.Context를 받아야 하고, 이것이 첫 번째 인자가 되어야 합니다. 호출 지점에서 명확해지고 습관이 됩니다.\n\n### 실용적인 시그니처 패턴\n\n서비스와 레포지토리에는 DoThing(ctx context.Context, ...) 같은 시그니처를 선호하세요. 컨텍스트를 구조체 안에 숨기거나 하위 레이어에서 context.Background()로 다시 만들지 마세요. 그러면 호출자의 데드라인이 조용히 사라집니다.\n\ngo\nfunc (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {\n ctx := r.Context()\n\n if err := h.svc.CreateOrder(ctx, r.Body); err != nil {\n // map context errors to a clear client response elsewhere\n http.Error(w, err.Error(), http.StatusRequestTimeout)\n return\n }\n}\n\nfunc (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {\n // parsing or validation can still respect cancellation\n select {\n case <-ctx.Done():\n return ctx.Err()\n default:\n }\n\n return s.repo.InsertOrder(ctx, /* data */)\n}\n\n\n### 조기 종료를 깔끔하게 처리하기\n\nctx.Done()를 정상적인 제어 경로로 취급하세요. 도움이 되는 두 가지 습관:\n\n- 비용이 큰 작업을 시작하기 전과 긴 루프 후에 ctx.Err()를 확인하세요.\n- ctx.Err()를 위쪽으로 변경 없이 반환해 핸들러가 빠르게 응답하고 자원 낭비를 멈추게 하세요.\n\n모든 레이어가 동일한 ctx를 전달하면 하나의 타임아웃으로 파싱, 비즈니스 로직, 데이터베이스 대기가 한 번에 차단될 수 있습니다.\n\n## database/sql 쿼리에 데드라인 적용하기\n\nHTTP 핸들러에 데드라인이 설정되었다면 데이터베이스 작업이 실제로 그 신호를 듣는지 확인하세요. database/sql에서는 매번 컨텍스트 인식 메서드를 사용하는 것을 의미합니다. 컨텍스트 없이 Query()Exec()를 호출하면 클라이언트가 포기한 뒤에도 API가 느린 쿼리를 계속 기다릴 수 있습니다.\n\n일관되게 사용하세요: db.QueryContext, db.QueryRowContext, db.ExecContext, db.PrepareContext(그리고 반환된 statement에서 QueryContext/ExecContext).\n\ngo\nfunc (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {\n row := s.db.QueryRowContext(ctx,\n `SELECT id, email FROM users WHERE id = $1`, id,\n )\n var u User\n if err := row.Scan(&u.ID, &u.Email); err != nil {\n return nil, err\n }\n return &u, nil\n}\n\nfunc (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {\n _, err := s.db.ExecContext(ctx,\n `UPDATE users SET email = $1 WHERE id = $2`, email, id,\n )\n return err\n}\n\n\n놓치기 쉬운 두 가지가 있습니다.\n\n첫째, SQL 드라이버가 컨텍스트 취소를 존중해야 합니다. 많은 드라이버가 그렇지만, 스택에서 느린 쿼리를 의도적으로 실행해 데드라인 초과 시 빠르게 취소되는지 확인하세요.\n\n둘째, 데이터베이스 측 타임아웃을 백스톱으로 고려하세요. 예를 들어 Postgres는 종종 statement timeout으로 불리는 문장별 제한을 적용할 수 있습니다. 이는 앱 버그로 컨텍스트 전달이 누락된 경우에도 데이터베이스를 보호합니다.\n\n작업이 타임아웃 때문에 중단되면 일반 SQL 오류와 다르게 처리하세요. errors.Is(err, context.DeadlineExceeded)errors.Is(err, context.Canceled)를 확인하고(가능하면) 504 같은 명확한 응답으로 매핑하세요. 이렇게 하면 문제를 "데이터베이스가 망가졌다"고 잘못 처리하지 않게 됩니다. AppMaster처럼 Go 백엔드를 생성하는 경우에도 이 오류 경로를 구분하면 로그와 재시도 정책을 이해하기 쉬워집니다.\n\n## 하위 호출: HTTP 클라이언트, 캐시, 기타 서비스\n\n핸들러와 SQL 쿼리가 컨텍스트를 존중하더라도 하위 호출이 영원히 기다리면 요청이 여전히 걸릴 수 있습니다. 부하가 걸리면 몇 가지 걸린 고루틴이 쌓여 연결 풀이 고갈되고 작은 지연이 전체 정지로 번질 수 있습니다. 해결책은 일관된 전파와 명확한 백스톱입니다.\n\n### 아웃바운드 HTTP\n\n다른 API를 호출할 때 동일한 컨텍스트로 요청을 생성하면 데드라인과 취소가 자동으로 전달됩니다.\n\ngo\nreq, err := http.NewRequestWithContext(ctx, "GET", url, nil)\nif err != nil { /* handle */ }\nresp, err := httpClient.Do(req)\n\n\n컨텍스트만 믿지 마세요. 코드가 실수로 background 컨텍스트를 사용하거나 DNS/TLS/유휴 연결이 멈출 경우에 대비해 HTTP 클라이언트와 트랜스포트를 구성하세요. http.Client.Timeout을 전체 호출의 상한으로 설정하고, 다이얼/핸드셰이크/응답 헤더 타임아웃을 설정하며 요청마다 새 클라이언트를 만들지 말고 재사용하세요.\n\n### 캐시와 큐\n\n캐시, 메시지 브로커, RPC 클라이언트는 연결 획득, 응답 대기, 가득 찬 큐에서 블로킹, 락 대기 같은 자체 대기 지점을 가집니다. 이러한 작업이 ctx를 받는지 확인하고 라이브러리 수준 타임아웃이 있다면 함께 사용하세요.\n\n실용적인 규칙: 사용자의 요청에 800ms만 남았다면 2초 걸릴 수 있는 하위 호출을 시작하지 마세요. 건너뛰거나 열화(degrade)하거나 선택적 필드에 대해 부분 응답을 반환하세요.\n\n타임아웃이 API에서 무엇을 의미하는지 미리 결정하세요. 때로는 빠른 오류가 옳고, 때로는 선택적 필드에 대해 부분 데이터를 제공하거나 캐시의 오래된 데이터를 명확히 표시하는 것이 옳습니다.\n\nAppMaster 같은 도구로 Go 백엔드를 생성하는 경우(생성된 코드 포함) 이 차이는 "타임아웃이 있다"와 "타임아웃이 트래픽 급증 시에도 시스템을 일관되게 보호한다"의 차이입니다.\n\n## 단계별: 엔드투엔드 타임아웃으로 API 리팩터링하기\n\n타임아웃을 위한 리팩터링은 한 가지 습관으로 귀결됩니다: HTTP 에지에서 동일한 context.Context를 받아 블로킹할 수 있는 모든 호출까지 전달하세요.\n\n실용적인 작업 순서는 위에서 아래로입니다:\n\n- 핸들러와 핵심 서비스 메서드가 ctx context.Context를 받도록 변경하세요.\n- 모든 DB 호출을 QueryContextExecContext로 업데이트하세요.\n- 외부 호출(HTTP 클라이언트, 캐시, 큐)도 마찬가지로 처리하세요. 라이브러리가 ctx를 받지 않으면 래핑하거나 교체하세요.\n- 타임아웃의 소유자를 결정하세요. 일반 규칙: 핸들러가 전체 데드라인을 설정하고, 하위 레이어는 필요할 때만 더 짧은 데드라인을 설정합니다.\n- 에지에서는 context.DeadlineExceededcontext.Canceled를 명확한 HTTP 응답으로 매핑해 오류가 예측 가능하게 하세요.\n\n레이어 전반에 걸친 형태는 다음과 같습니다:\n\ngo\nfunc (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {\n ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)\n defer cancel()\n\n order, err := h.svc.GetOrder(ctx, r.PathValue(\"id\"))\n if errors.Is(err, context.DeadlineExceeded) {\n http.Error(w, \"request timed out\", http.StatusGatewayTimeout)\n return\n }\n if err != nil {\n http.Error(w, \"internal error\", http.StatusInternalServerError)\n return\n }\n _ = json.NewEncoder(w).Encode(order)\n}\n\nfunc (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {\n row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)\n // scan...\n}\n\n\n타임아웃 값은 지루하게(일관되게) 설정하세요. 핸들러에 총 2초가 있다면 DB 쿼리는 JSON 인코딩과 기타 작업을 위한 여유를 남겨 1초 이하로 유지하세요.\n\n작동을 증명하려면 타임아웃을 강제하는 테스트를 추가하세요. 간단한 방법은 ctx.Done()까지 블록한 뒤 ctx.Err()를 반환하는 페이크 레포를 만드는 것입니다. 테스트는 핸들러가 페이크 지연 이후가 아니라 빠르게 504을 반환하는지 확인해야 합니다.\n\nAppMaster로 Go 백엔드를 생성한다면 규칙은 동일합니다: 하나의 요청 컨텍스트를 모든 곳에 쓰고 데드라인 소유권을 명확히 하세요.\n\n## 관찰성: 타임아웃이 작동하는지 증명하기\n\n타임아웃은 실제로 일어나는 것을 볼 수 있어야만 도움이 됩니다. 목표는 간단합니다: 모든 요청에 데드라인이 있고 실패했을 때 시간이 어디에 소모되었는지 알 수 있어야 합니다.\n\n무해하면서도 유용한 로그부터 시작하세요. 전체 요청 본문을 덤프하는 대신 요청 ID(또는 트레이스 ID), 데드라인이 설정되어 있는지와 주요 지점에서 남은 시간, 작업 이름(핸들러, SQL 쿼리 이름, 아웃바운드 호출 이름), 결과 분류(ok, timeout, canceled, other error) 정도만 기록하면 느린 경로를 추적하기 충분합니다.\n\n행동을 명확히 보여주는 몇 가지 집중된 메트릭을 추가하세요:\n\n- 엔드포인트 및 종속성별 타임아웃 카운트\n- 요청 지연(p50/p95/p99)\n- 인플라이트 요청 수\n- 데이터베이스 쿼리 지연(p95/p99)\n- 오류율(유형별 분리)\n\n오류를 처리할 때 올바르게 태깅하세요. context.DeadlineExceeded는 보통 예산을 초과했다는 의미이고, context.Canceled는 클라이언트가 떠났거나 상위 타임아웃이 먼저 발생했다는 뜻일 수 있습니다. 둘은 원인과 해결책이 다르므로 분리해서 기록하세요.\n\n### 트레이싱: 시간이 어디로 가는지 찾기\n\n트레이스 스팬은 HTTP 핸들러에서 database/sqlQueryContext 호출까지 동일한 컨텍스트를 따라야 합니다. 예를 들어 요청이 2초에서 타임아웃 되고 트레이스 상에 DB 연결 대기에서 1.8초가 소요된 것이 보이면 이는 쿼리 텍스트 문제가 아니라 풀 크기나 느린 트랜잭션 문제를 가리킵니다.\n\n타임아웃별 대시보드(엔드포인트별 타임아웃, 상위 느린 쿼리 등)를 내부적으로 구축하면 회귀를 빨리 발견하는 데 도움이 됩니다. AppMaster 같은 노코드 도구는 관찰성 기능을 별도 엔지니어링 프로젝트로 진행하지 않고도 빠르게 배포할 때 유용할 수 있습니다.\n\n## 타임아웃을 무력화하는 흔한 실수들\n\n대부분의 "가끔 여전히 멈춘다" 버그는 몇 가지 작은 실수에서 옵니다.\n\n- 중간에 시계를 재설정하는 것. 핸들러가 2초 데드라인을 설정했는데 레포지토리가 자체 타임아웃을 새로 만들거나(또는 아예 타임아웃 없이) 하면 데이터베이스가 클라이언트가 떠난 뒤에도 실행될 수 있습니다. 들어온 ctx를 그대로 전달하고, 분명한 이유가 있을 때만 더 짧게 조이는 자식 컨텍스트를 만드세요.\n- 멈추지 않는 고루틴 시작. context.Background()로 작업을 시작하거나 ctx를 버리면 요청이 취소된 뒤에도 작업이 계속 실행됩니다. 고루틴에 요청 ctx를 전달하고 selectctx.Done()을 감시하세요.\n- 실제 트래픽에 비해 너무 짧은 데드라인. 50ms 타임아웃은 로컬에서는 괜찮아 보여도 프로덕션의 작은 스파이크에서는 실패를 초래해 재시도와 추가 부하를 만들 수 있습니다. 정상 지연과 여유를 고려해 타임아웃을 정하세요.\n- 실제 오류를 숨기는 것. context.DeadlineExceeded를 일반 500으로 처리하면 디버깅과 클라이언트 동작이 악화됩니다. 타임아웃 응답을 명확히 하고 "클라이언트가 취소함"과 "예산 초과로 타임아웃됨"의 차이를 로그에 남기세요.\n- 조기 종료 시 자원 닫기 누락. 조기 반환 시에도 defer rows.Close()context.WithTimeout의 cancel 호출을 확실히 하세요. 유출된 rows나 남은 작업은 부하 시 연결을 고갈시킬 수 있습니다.\n\n간단한 예: 엔드포인트가 리포트 쿼리를 트리거합니다. 사용자가 탭을 닫으면 핸들러 ctx는 취소됩니다. 만약 SQL 호출이 background 컨텍스트를 사용했다면 쿼리는 계속 실행되어 연결을 잡아두고 모두를 느리게 합니다. 동일한 ctx를 QueryContext에 전달하면 데이터베이스 호출이 중단되어 시스템이 더 빨리 회복합니다.\n\n## 안정적인 타임아웃 동작을 위한 빠른 체크리스트\n\n타임아웃은 일관되어야만 도움이 됩니다. 하나의 누락된 호출이 고루틴을 바쁘게 하고 DB 연결을 잡아두며 다음 요청들을 느리게 만듭니다.\n\n- 에지(대개 HTTP 핸들러)에서 하나의 명확한 데드라인을 설정하세요. 요청 내부의 모든 것은 이를 상속해야 합니다.\n- 서비스와 레포지토리 레이어에 동일한 ctx를 전달하세요. 요청 코드에서 context.Background()를 피하세요.\n- DB에서는 항상 컨텍스트 인식 메서드(QueryContext, QueryRowContext, ExecContext)를 사용하세요.\n- 아웃바운드 호출(HTTP, 캐시, 큐)에도 동일한 ctx를 붙이세요. 자식 컨텍스트를 만든다면 더 길게 하지 말고 더 짧게 만드세요.\n- 취소와 타임아웃을 일관되게 처리하세요: 명확한 오류를 반환하고 작업을 멈추며 취소된 요청 내에서 재시도 루프를 만들지 마세요.\n\n그다음 부하 테스트로 동작을 검증하세요. 타임아웃이 트리거되지만 자원을 충분히 빨리 해제하지 못하면 여전히 신뢰성이 떨어집니다.\n\n대시보드는 타임아웃을 평균값 안에 숨기지 말고 명확히 보여줘야 합니다. 몇 가지 신호를 추적하면 "데드라인이 실제로 강제되고 있는가?"라는 질문에 답할 수 있습니다: 요청 타임아웃과 DB 타임아웃(별개), 지연 백분위수(p95/p99), DB 풀 통계(사용 중 연결 수, 대기 횟수, 대기 시간), 그리고 오류 원인 분해(context deadline exceeded 대 기타 실패).\n\nAppMaster 같은 플랫폼으로 내부 도구를 만든다면 동일한 체크리스트는 모든 Go 서비스에 적용됩니다: 경계에서 데드라인을 정의하고, 전달하고, 메트릭으로 확인하세요.\n\n## 예시 시나리오와 다음 단계\n\n이게 효과를 보는 일반적인 곳은 검색 엔드포인트입니다. 예를 들어 GET /search?q=printer가 큰 리포트 쿼리로 DB가 바쁠 때 느려진다고 가정해보세요. 데드라인이 없다면 들어오는 각 요청이 긴 SQL 쿼리를 기다리며 대기합니다. 부하가 걸리면 이런 멈춘 요청들이 쌓여 고루틴과 연결을 묶어두어 API 전체가 정지한 것처럼 느껴질 수 있습니다.\n\nHTTP 핸들러에서 명확한 데드라인을 설정하고 동일한 ctx를 레포지토리까지 전달하면 예산이 소진될 때 시스템은 기다림을 멈춥니다. 데드라인이 도달하면 DB 드라이버가(지원되는 경우) 쿼리를 취소하고 핸들러는 반환하며, 서버는 오래된 요청들 때문에 새로운 요청을 서빙하지 못하는 대신 계속 새 요청을 처리할 수 있습니다.\n\n문제가 발생해도 사용자에게 보이는 동작이 더 나아집니다. 30~120초 동안 멈춰있다가 엉성하게 실패하는 대신 클라이언트는 빠르고 예측 가능한 오류(보통 504 또는 간단한 메시지 "request timed out")를 받습니다. 더 중요하게는 시스템이 빨리 회복합니다.\n\n이 규칙을 엔드포인트와 팀 전반에 정착시키기 위한 다음 단계 제안:\n\n- 엔드포인트 유형별 표준 타임아웃(검색 vs 쓰기 vs 내보내기)을 정하세요.\n- 코드 리뷰에서 QueryContextExecContext 사용을 요구하세요.\n- 가장자리에서 타임아웃 오류를 명확히(상태 코드, 간단한 메시지) 처리하세요.\n- 타임아웃과 취소에 대한 메트릭을 추가해 회귀를 조기에 발견하세요.\n- 모든 핸들러가 동일하게 동작하도록 컨텍스트 생성과 로깅을 감싸는 헬퍼를 하나 작성하세요.\n\nAppMaster로 서비스와 내부 도구를 구축한다면 이러한 타임아웃 규칙을 생성된 Go 백엔드, API 통합, 대시보드 전반에 일관되게 적용할 수 있습니다. AppMaster는 appmaster.io에 제공되며(노코드, 실제 Go 소스 코드 생성), 수동으로 모든 관리자 도구를 직접 만들지 않고도 일관된 요청 처리와 관찰성을 확보할 때 실용적인 선택이 될 수 있습니다.

자주 묻는 질문

Go API에서 요청이 “멈췄다”는 건 무슨 뜻인가요?

요청이 “멈춰버렸다(stuck)”는 것은 느린 SQL 쿼리, 풀에서 막힌 연결, DNS 문제, 또는 응답하지 않는 상위 서비스처럼 반환하지 않는 무언가를 기다리고 있을 때를 말합니다. 부하가 걸리면 이런 멈춘 요청들이 쌓여 작업자와 연결을 묶어두고 작은 지연이 전체 장애로 번질 수 있습니다.

타임아웃은 미들웨어, 핸들러, 아니면 코드 깊은 곳 어디에 설정해야 하나요?

전체 데드라인은 HTTP 경계에서 설정하고 그 같은 ctx를 블로킹할 수 있는 모든 레이어로 전달하세요. 이 공유된 데드라인이 몇 번의 느린 작업이 자원을 오래 점유해 전반적인 타임아웃으로 이어지는 것을 막습니다.

타임아웃이 어차피 발동하는데 왜 `cancel()`을 호출해야 하나요?

ctx, cancel := context.WithTimeout(r.Context(), d)를 사용하고 핸들러(또는 미들웨어)에서 항상 defer cancel()을 호출하세요. cancel 호출은 타이머를 해제하고 요청이 일찍 끝났을 때 기다림을 빨리 멈추도록 돕습니다.

타임아웃을 무력화하는 가장 큰 실수는 무엇인가요?

요청 코드에서 context.Background()context.TODO()로 대체하면 취소와 데드라인 체인이 끊깁니다. 그러면 SQL이나 외부 HTTP 같은 하위 작업이 클라이언트가 떠난 뒤에도 계속 실행되어 타임아웃을 무용지물로 만듭니다.

`context deadline exceeded`와 `context canceled`를 어떻게 다뤄야 하나요?

context.DeadlineExceededcontext.Canceled는 정상적인 제어 결과로 취급해 상위로 그대로 전달하세요. 가장자리에서는 보통 타임아웃에 대해 504 같은 명확한 응답으로 매핑해 클라이언트가 무작정 재시도하지 않도록 합니다.

database/sql에서 어떤 호출에 컨텍스트를 사용해야 하나요?

항상 컨텍스트를 지원하는 메서드를 사용하세요: QueryContext, QueryRowContext, ExecContext, PrepareContext. Query()Exec()처럼 컨텍스트 없는 호출을 사용하면 핸들러는 타임아웃될 수 있어도 데이터베이스 호출은 계속 블로킹되어 연결을 잡아두게 됩니다.

컨텍스트 취소가 실제로 PostgreSQL에서 실행 중인 쿼리를 멈추나요?

많은 드라이버가 컨텍스트 취소를 honor하지만, 직접 느린 쿼리를 실행해 데드라인이 초과되었을 때 빠르게 취소되는지 확인하세요. 또한 일부 코드 경로가 컨텍스트 전달을 잊는 경우를 대비해 DB 쪽 문장(statement) 타임아웃을 백스톱으로 두는 것이 좋습니다.

아웃바운드 HTTP 호출에 같은 데드라인을 어떻게 적용하나요?

아웃바운드 요청을 만들 때 http.NewRequestWithContext(ctx, ...)로 생성해 동일한 데드라인과 취소 신호가 전달되게 하세요. 또한 누군가 실수로 background 컨텍스트를 쓰거나 DNS/TLS/유휴 연결이 멈출 경우를 대비해 http.Client.Timeout과 트랜스포트의 다이얼/핸드셰이크/응답 헤더 타임아웃을 설정해 두는 것이 안전합니다.

하위 레이어(레포/서비스)가 자체 타임아웃을 만들어도 되나요?

하위 레이어에서 시간을 늘리는 새로운 컨텍스트를 생성하지 마세요. 자식 컨텍스트를 만들어야 한다면 부모보다 더 짧게 설정하세요. 요청에 시간이 거의 남지 않았다면 선택적 하위 호출을 건너뛰고, 부분 응답을 반환하거나 빠르게 실패하는 쪽이 낫습니다.

엔드투엔드 타임아웃이 작동하는지 무엇을 모니터링해야 하나요?

엔드투엔드 타임아웃이 작동하는지 확인하려면 엔드포인트와 종속성별로 타임아웃과 취소를 분리해 추적하고, 지연 백분위수(p95/p99)와 인플라이트 요청 수를 모니터링하세요. 트레이스에서는 핸들러에서 QueryContext 같은 DB 호출까지 동일한 컨텍스트가 이어지는지 확인하면 시간이 어디에 소모됐는지 파악하기 쉽습니다.

쉬운 시작
멋진만들기

무료 요금제로 AppMaster를 사용해 보세요.
준비가 되면 적절한 구독을 선택할 수 있습니다.

시작하다