[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 파생 컨텍스트
// 수동 취소 (부모가 취소되면 자식도 취소됨)
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++ 개발자의 첫인상
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「[Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「[Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.