[2026] [Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교
이 글의 핵심
Go 실무에서 빠질 수 없는 context 패키지를 정리합니다. 데드라인·취소 전파·HTTP 서버 Shutdown·고루틴 누수 방지를 코드로 익히고, C++의 조건변수·std::stop_token과 개념을 맞춰 봅니다.
시리즈 안내
📚 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()만 듣습니다. - “요청이 끝났다”와 “프로세스가 내려간다”는 다른 이벤트입니다. 둘 다 컨텍스트로 모델링할 수 있지만, 서버 종료용 루트 컨텍스트를 별도로 두는 경우가 많습니다.
목차
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 파생 컨텍스트
아래 코드는 go를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 수동 취소 (부모가 취소되면 자식도 취소됨)
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 취소 감지
아래 코드는 go를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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 타임아웃이 있는 하위 작업
다음은 go를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 파이프라인: 상위 취소가 전파될 때
다음은 go를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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. 여기서는 표준 라이브러리만으로 최소 형태를 보입니다.
아래 코드는 go를 사용한 구현 예제입니다. 함수를 통해 로직을 구현합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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()를 전달한다
아래 코드는 go를 사용한 구현 예제입니다. 함수를 통해 로직을 구현합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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만 호출하면 진행 중인 요청이 중간에 끊기기 쉽습니다.
다음은 go를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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가 중심이므로, 이번 글의 모델을 그대로 가져가면 됩니다.