[Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교
이 글의 핵심
2주 시리즈 이후 실무로 넘어갈 때 가장 먼저 마주치는 주제가 context입니다. WithTimeout·WithCancel·취소 전파·http.Server Shutdown까지 한 흐름으로 정리하고, C++ 동시성 모델과 대응 관계를 짚습니다.
시리즈 안내
📚 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**과 비교해 사고 모델 정리
실무에서의 관점
REST 서버를 net/http만으로도 띄울 수 있지만, 운영 환경에서는 배포·스케일 인·업스트림 타임아웃이 자주 발생합니다. “각 요청이 언제 끝나야 하는지”를 함수마다 다른 방식으로 흩뿌리기보다, context.Context로 한 줄기에 묶는 패턴이 팀 합의를 단순화하는 경우가 많습니다.
핵심 정리:
- 취소는 책임의 방향이 명확할 때 잘 동작합니다. 생성한 쪽이
cancel()을 호출하고, 하위 작업은ctx.Done()만 듣습니다. - “요청이 끝났다”와 “프로세스가 내려간다”는 다른 이벤트입니다. 둘 다 컨텍스트로 모델링할 수 있지만, 서버 종료용 루트 컨텍스트를 별도로 두는 경우가 많습니다.
목차
1. Context가 해결하는 문제
동시성 프로그램에서 반복해서 등장하는 요구사항은 크게 세 가지입니다.
- 작업 중단: 클라이언트가 연결을 끊었거나, 상위 단계에서 실패해 하위 단계를 멈추고 싶다.
- 시간 제한: “이 DB 읽기는 최대 800ms”처럼 상한을 두고 싶다.
- 범위 있는 메타데이터: 요청 ID, 추적 ID처럼 호출 스택을 가로지르는 값을 함수 인자로 일일이 넘기기 싫다.
Go는 이 세 가지를 context 패키지로 묶었고, 표준 라이브러리(net/http, database/sql 등)가 이를 일급 입력으로 받습니다. 그 결과 “타임아웃 정책이 코드베이스마다 제각각”인 상황을 줄이는 효과가 큽니다.
C++에서의 감각:
std::condition_variable에 깃발을 세우거나,pthread_cancel같은 이식성 낮은 수단에 의존하는 대신, 취소 토큰을 표준화해 하위 계층에 전달한다고 이해하면 접근이 쉽습니다. C++20의std::stop_token·std::jthread가 비슷한 문화를 지향합니다.
2. 기본 API와 생명 주기
2.1 루트 컨텍스트
ctx := context.Background() // 프로세스 전체, main, 테스트 루트
// 또는
ctx := context.TODO() // “상위에서 아직 내려오지 않음”을 표시할 때 임시
애플리케이션 코드에서는 최종적으로 http.Request.Context() 같은 요청 단위 컨텍스트를 루트로 삼는 경우가 대부분입니다.
2.2 파생 컨텍스트
// 수동 취소 (부모가 취소되면 자식도 취소됨)
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()을 버릇으로 두면 대부분의 누수를 막을 수 있습니다.
2.3 취소 감지
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 등)를 사용합니다.
3. 취소와 마감: 패턴별 레시피
3.1 타임아웃이 있는 하위 작업
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합니다.)
3.2 파이프라인: 상위 취소가 전파될 때
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()과 경쟁시켜야, 큐가 막혔을 때 취소에 걸리지 않고 멈출 수 있습니다.
3.3 “외부에서 멈춰야 하는” 장기 고루틴
패턴: 상위 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:
// 주기 작업
}
}
}
4. HTTP 서버에서의 관례
4.1 핸들러는 반드시 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는 가능한 한 빨리 중단하려 합니다.
4.2 우아한 종료: 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합니다.)
4.3 Shutdown에 쓰는 컨텍스트는 왜 Background()인가
요청 컨텍스트가 아니라 서버 프로세스의 수명 주기에 대응하기 때문입니다. 상한(WithTimeout)을 두는 이유는 종료 처리 자체가 무한정 걸리지 않게 Shutdown 호출에 시간 제한을 거는 것입니다.
5. 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 관례에 맞추면 표준 라이브러리와 서드파티가 같은 릴을 잡습니다.
6. 흔한 실수와 체크리스트
흔한 실수
WithCancel/WithTimeout에서defer cancel()누락 → 부모까지 안 막히지만, 타이머·내부 고루틴이 길게 살아남을 수 있습니다.- 구조체 필드에
Context보관 → 요청 수명이 아닌 객체 수명과 섞여 디버깅이 어려워집니다. 함수의 첫 인자로 전달하는 편이 권장됩니다(공식 블로그·코드 리뷰 코멘트와 동일). - 취소 원인을 문자열만으로 구분하려 함 →
errors.Is/errors.As로 표준 오류와 섞어 처리하세요. context.Value남용 → 도메인 로직이 전역 맵 같은 냄새를 냅니다. 진짜 입력은 인자로.
프로덕션 체크리스트
- 모든 외부 I/O 경로(
http,sql,grpc)가 컨텍스트 인자를 받는가 - 서버는 **SIGTERM에
Shutdown**을 연결했는가 -
Shutdown타임아웃은 배포 시스템의terminationGracePeriod와 현실적으로 맞는가 - 테스트에서
context.Background()대신 **timeout이 짧은 ctx**로 교착을 드러내는가
7. 실습 과제
- 요청 취소 전파: #08의 핸들러 하나를 고르고, 내부에서
time.Sleep대신http.NewRequestWithContext로 외부 API를 호출해 봅니다.curl로 요청을 중간에 끊었을 때 고루틴이 남지 않는지runtime.NumGoroutine()로 확인해 보세요. - Graceful shutdown:
ListenAndServe만 쓰는 버전과Shutdown버전을 각각 만들고, 진행 중인 긴 요청이 있을 때 프로세스 종료 시나리오를 비교합니다. - 마감 계층: 바깥
ctx는 5초, 특정 DB 호출만 500ms로 제한하는WithTimeout중첩을 구현하고, 어떤 오류가DeadlineExceeded로 돌아오는지 기록합니다.
정리
context.Context는 취소·마감·일부 메타데이터를 한 타입으로 운반하는 Go의 실질적 표준입니다.defer cancel(),r.Context()전파,Shutdown처리 세 가지만 습관화해도 운영 품질이 크게 달라집니다.- C++에서 익숙한 “스레드 kill” 사고방식 대신, 협력적 취소(cooperative cancellation) 모델로 전환하는 것이 핵심입니다.
권장 다음 읽기:
- Effective Go
- #06 고루틴과 채널
- #08 REST API 프로젝트
Go 2주 완성 시리즈:
커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료
같이 보면 좋은 글 (내부 링크)
- C++ 개발자를 위한 2주 완성 Go 커리큘럼
- Go 2주 완성 시리즈 전체 목차
- C++ 네트워크 가이드: Post·Dispatch·Defer — 이벤트 루프에서 “언제 일을 미룰지”와 대비해 보면 재미있습니다.
- C++ vs Go 성능·동시성 비교
이 글에서 다루는 키워드 (관련 검색어)
Go context, WithTimeout, graceful shutdown, http.Server Shutdown, 고루틴 취소, Golang 타임아웃, request context, 프로덕션 Go 등으로 검색하면 이 글과 맥이 통합니다.
실전 팁
- 프로덕션에서는 **로깅·메트릭에
request_id**를 남기고,context.Value는 그 정도로 제한하는 팀이 많습니다. Shutdown이 끝난 뒤에도 백그라운드 워커가 있다면, 같은 신호 경로에서 별도ctx로 함께 닫아 “버티는 고루틴”이 없게 만드세요.
자주 묻는 질문 (FAQ)
Q. ListenAndServe와 ListenAndServeTLS는 어떻게 종료하나요?
A. 둘 다 http.Server 메서드입니다. 동일하게 Shutdown/Close 패턴으로 우아한 종료를 구현하면 됩니다.
Q. gRPC는요?
A. gRPC Go는 상위 컨텍스트를 그대로 전파합니다. 서버 인터셉터·클라이언트 호출 모두 context가 중심이므로, 이번 글의 모델을 그대로 가져가면 됩니다.
Q. 테스트에서 context.Background()만 쓰면 안 되나요?
A. 단위 테스트는 종종 그렇게 시작합니다. 다만 WithTimeout이 걸린 컨텍스트로 통합 테스트를 작성하면, 취소 누락으로 테스트가 영원히 걸리는 문제를 초기에 발견하기 쉽습니다.
관련 글
- [Go 2주 완성 #08] Day 14: 실전 미니 프로젝트 - REST API 서버 구축
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
- [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법 - C++ 개발자의 첫인상