[Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교
이 글의 핵심
고루틴은 저렴하지만 끝나지 않고 쌓이면 결국 리소스가 고갈되고, 배포할 때 이전 프로세스가 연결을 붙잡고 있으면 장애로 이어집니다. 이 글은 핸들러가 반드시 r.Context()를 넘겨야 하는 이유, Shutdown에는 왜 Background()에서 만든 컨텍스트를 쓰는지, 테스트에서 컨텍스트를 다루는 법과 흔한 실수를 정리합니다.
시리즈 안내
📚 Go 2주 완성 시리즈 — 실무 심화 #09 | 전체 목차 보기
이 글은 #06 고루틴과 채널, #08 REST API를 마친 뒤 프로덕션 코드로 올리기 위해 읽기 좋은 후속편입니다.
이전: #08 REST API 프로젝트 ← | → 다음: 필요에 따라 Go 시리즈 목차에서 주제를 고르세요.
들어가며: “언제 멈출지”가 곧 안정성입니다
고루틴은 저렴하지만 무한정 살아 있으면 리소스는 결국 고갈됩니다. HTTP 요청이 끊겼는데도 DB 쿼리가 계속 돌거나, 배포 시에도 옛 프로세스가 연결을 붙잡고 있으면 장애로 이어집니다. Go 생태계는 이 문제를 context.Context 하나로 수렴시키는 편입니다.
이 글에서 배울 내용:
Context가 표현하는 것: 취소 신호, 마감 시각, 선택적 요청 범위 값WithCancel,WithTimeout,WithDeadline의 차이와 전파 규칙http.Server와Shutdown을 이용한 우아한 종료(graceful shutdown)- 흔한 실수: 컨텍스트 저장, 무시된 취소, 고루틴 누수
- C++ 관점에서의 대응: 조건 변수·플래그·
std::jthread/stop_token과 비교해 사고 모델 정리
C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.
실무에서의 관점
REST 서버를 net/http만으로도 띄울 수 있지만, 운영 환경에서는 배포·스케일 인·업스트림 타임아웃이 자주 발생합니다. “각 요청이 언제 끝나야 하는지”를 함수마다 다른 방식으로 흩뿌리기보다, context.Context로 한 줄기에 묶는 패턴이 팀 합의를 단순화하는 경우가 많습니다.
핵심 정리:
- 취소는 책임의 방향이 명확할 때 잘 동작합니다. 생성한 쪽이
cancel()을 호출하며, 하위 작업은ctx.Done()만 듣습니다. - “요청이 끝났다”와 “프로세스가 내려간다”는 다른 이벤트입니다. 둘 다 컨텍스트로 모델링할 수 있지만, 서버 종료용 루트 컨텍스트를 별도로 두는 경우가 많습니다.
Context가 해결하는 문제
동시성 프로그램에서 반복해서 등장하는 요구사항은 크게 세 가지입니다.
- 작업 중단: 클라이언트가 연결을 끊었거나, 상위 단계에서 실패해 하위 단계를 멈추고 싶습니다.
- 시간 제한: “이 DB 읽기는 최대 800ms”처럼 상한을 두고 싶습니다.
- 범위 있는 메타데이터: 요청 ID, 추적 ID처럼 호출 스택을 가로지르는 값을 함수 인자로 일일이 넘기기 싫다.
Go는 이 세 가지를
context패키지로 묶었으며, 표준 라이브러리(net/http,database/sql등)가 이를 일급 입력으로 받습니다. 그 결과 “타임아웃 정책이 코드베이스마다 제각각”인 상황을 줄이는 효과가 큽니다.
C++에서의 감각:
std::condition_variable에 깃발을 세우거나,pthread_cancel같은 이식성 낮은 수단에 의존하는 대신, 취소 토큰을 표준화해 하위 계층에 전달한다고 이해하면 접근이 쉽습니다. C++20의std::stop_token·std::jthread가 비슷한 문화를 지향합니다.
기본 API와 생명 주기
루트 컨텍스트
ctx := context.Background() // 프로세스 전체, main, 테스트 루트
// 또는
ctx := context.TODO() // “상위에서 아직 내려오지 않음”을 표시할 때 임시
애플리케이션 코드에서는 최종적으로 http.Request.Context() 같은 요청 단위 컨텍스트를 루트로 삼는 경우가 대부분입니다.
파생 컨텍스트
// 수동 취소 (부모가 취소되면 자식도 취소됨)
ctx, cancel := context.WithCancel(parent)
defer cancel() // 리소스 누수 방지: 항상 호출
// 절대 시각 기준 마감
ctx, cancel := context.WithDeadline(parent, time.Now().Add(2*time.Second))
defer cancel()
// 상대 시간 기준 타임아웃
ctx, cancel := context.WithTimeout(parent, 800*time.Millisecond)
defer cancel()
규칙:
- 부모가 취소되면 모든 자손이 취소됩니다.
cancel()은 멱등에 가깝게 여러 번 호출해도 안전합니다.defer cancel()을 버릇으로 두면 대부분의 누수를 막을 수 있습니다.
취소 감지
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled 또는 context.DeadlineExceeded
case result := <-workChan:
return result
}
장시간 블로킹하는 코드는 select로 ctx.Done()과 경쟁시키거나, 아예 context.Context를 받는 API(http.NewRequestWithContext, db.QueryContext 등)를 사용합니다.
취소와 마감: 패턴별 레시피
타임아웃이 있는 하위 작업
func fetchWithLimit(ctx context.Context, url string) ([]byte, error) {
// 상위 ctx가 이미 짧게 죽어도, 이 구간만 추가로 상한을 두고 싶다면:
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
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()
return io.ReadAll(resp.Body)
}
(io·net/http·context·time 등은 파일 상단에서 import합니다.)
파이프라인: 상위 취소가 전파될 때
func Pipeline(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
select {
case out <- v * 2:
case <-ctx.Done():
return
}
}
}
}()
return out
}
포인트: in에서 읽은 뒤 out으로 보낼 때도 ctx.Done()과 경쟁시켜야, 큐가 막혔을 때 취소에 걸리지 않고 멈출 수 있습니다.
“외부에서 멈춰야 하는” 장기 고루틴
패턴: 상위 ctx + 내부 errgroup 또는 WaitGroup. 여기서는 표준 라이브러리만으로 최소 형태를 보입니다.
func runWorker(ctx context.Context) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// 주기 작업
}
}
}
HTTP 서버에서의 관례
핸들러는 반드시 r.Context()를 전달한다
func handleQuery(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rows, err := db.QueryContext(ctx, "SELECT id, title FROM posts LIMIT 10")
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
defer rows.Close()
// ... JSON 응답
}
클라이언트가 연결을 끊으면 ctx가 취소되고, QueryContext는 가능한 한 빨리 중단하려 합니다.
우아한 종료: Shutdown + 신호 처리
배포 파이프라인은 보통 SIGTERM으로 이전 프로세스를 먼저 내려 보냅니다. 이때 ListenAndServe만 호출하면 진행 중인 요청이 중간에 끊기기 쉽습니다.
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: newRouter(),
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown: %v", err)
}
}
(newRouter는 예시이며, context·log·net/http·os·os/signal·syscall·time을 import합니다.)
Shutdown에 쓰는 컨텍스트는 왜 Background()인가
요청 컨텍스트가 아니라 서버 프로세스의 수명 주기에 대응하기 때문입니다. 상한(WithTimeout)을 두는 이유는 종료 처리 자체가 무한정 걸리지 않게 Shutdown 호출에 시간 제한을 거는 것입니다.
C++ 개발자를 위한 대응 표
| Go | C++에서 흔한 대응 |
|---|---|
context.Context | 취소 플래그 + condvar, 또는 std::stop_token |
WithTimeout | std::future::wait_for, 타임아웃이 있는 timed_mutex 패턴, 네트워크의 async + timer |
ctx.Done() 채널 | stop_token.stop_requested() 폴링, 또는 condvar wait |
defer cancel() | RAII 래퍼, 스코프 종료 시 타이머/작업 취소 |
http.Server.Shutdown | accept 루프 종료 + 진행 중 연결 drain |
전제가 다릅니다. C++은 수명·스레드·예외 조합이 다양하지만, Go는 context 관례에 맞추면 표준 라이브러리와 서드파티가 같은 릴을 잡습니다.
흔한 실수와 체크리스트
흔한 실수
WithCancel/WithTimeout에서defer cancel()누락 → 부모까지 안 막히지만, 타이머·내부 고루틴이 길게 살아남을 수 있습니다.- 구조체 필드에
Context보관 → 요청 수명이 아닌 객체 수명과 섞여 디버깅이 어려워집니다. 함수의 첫 인자로 전달하는 편이 권장됩니다(공식 블로그·코드 리뷰 코멘트와 동일). - 취소 원인을 문자열만으로 구분하려 함 →
errors.Is/errors.As로 표준 오류와 섞어 처리하세요. context.Value남용 → 도메인 로직이 전역 맵 같은 냄새를 냅니다. 정말 입력은 인자로. 프로덕션 체크리스트
- 모든 외부 I/O 경로(
http,sql,grpc)가 컨텍스트 인자를 받는가 - 서버는 SIGTERM에
Shutdown을 연결했는가 -
Shutdown타임아웃은 배포 시스템의terminationGracePeriod와 현실적으로 맞는가 - 테스트에서
context.Background()대신 timeout이 짧은 ctx로 교착을 드러내는가
실습 과제
- 요청 취소 전파: #08의 핸들러 하나를 고르고, 내부에서
time.Sleep대신http.NewRequestWithContext로 외부 API를 호출해 봅니다.curl로 요청을 중간에 끊었을 때 고루틴이 남지 않는지runtime.NumGoroutine()로 확인해 보세요. - Graceful shutdown:
ListenAndServe만 쓰는 버전과Shutdown버전을 각각 만들고, 진행 중인 긴 요청이 있을 때 프로세스 종료 시나리오를 비교합니다. - 마감 계층: 바깥
ctx는 5초, 특정 DB 호출만 500ms로 제한하는WithTimeout중첩을 구현하며, 어떤 오류가DeadlineExceeded로 돌아오는지 기록합니다.
Shutdown이 기다려 주지 않는 것들
http.Server.Shutdown은 리스너를 닫고, 유휴 연결을 닫고, 처리 중인 요청이 끝나기를 기다립니다. 여기까지만입니다. 서버 바깥에서 띄운 고루틴, 예를 들어 큐 컨슈머나 주기적으로 도는 정리 작업은 Shutdown이 존재 자체를 모릅니다. 그래서 signal.NotifyContext로 만든 루트 컨텍스트를 워커에도 넘기고, sync.WaitGroup으로 워커가 실제로 반환할 때까지 main이 기다리게 해야 합니다. 이걸 빠뜨리면 배포 때마다 처리 중이던 메시지가 ack 없이 끊기는데, 로그에는 서버가 “정상 종료”된 것으로만 남아서 원인을 찾기가 어렵습니다.
두 가지 더 자주 걸리는 지점이 있습니다. 첫째, Shutdown을 호출하면 ListenAndServe는 기다리지 않고 즉시 http.ErrServerClosed를 반환합니다. main이 ListenAndServe의 반환만 보고 끝나 버리면 drain이 시작되자마자 프로세스가 죽습니다. Shutdown이 반환될 때까지 기다리는 쪽이 main의 마지막이어야 합니다. 둘째, WebSocket처럼 hijack된 연결은 Shutdown이 닫지도 기다리지도 않습니다. 이런 연결은 srv.RegisterOnShutdown에 콜백을 등록해 직접 종료를 알려야 합니다.
더 읽을거리: Effective Go, 고루틴 수명 관리의 기초는 #06 고루틴과 채널, 이 글의 패턴을 붙일 서버는 #08 REST API 프로젝트에 있습니다.
Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료
자주 묻는 질문 (FAQ)
Q. ListenAndServe와 ListenAndServeTLS는 어떻게 종료하나요?
A. 둘 다 http.Server 메서드입니다. 동일하게 Shutdown/Close 패턴으로 우아한 종료를 구현하면 됩니다.
Q. gRPC는요?
A. gRPC Go는 상위 컨텍스트를 그대로 전파합니다. 서버 인터셉터·클라이언트 호출 모두 context가 중심이므로, 이번 글의 모델을 그대로 가져가면 됩니다.
Q. 테스트에서 context.Background()만 쓰면 안 되나요?
A. 단위 테스트는 종종 그렇게 시작합니다. 다만 WithTimeout이 걸린 컨텍스트로 통합 테스트를 작성하면, 취소 누락으로 테스트가 영원히 걸리는 문제를 초기에 발견하기 쉽습니다.