본문으로 건너뛰기
Previous
Next
Go context로 타임아웃·취소 처리하기 | 실전 패턴 가이드

Go context로 타임아웃·취소 처리하기 | 실전 패턴 가이드

Go context로 타임아웃·취소 처리하기 | 실전 패턴 가이드

이 글의 핵심

Go에서 context.WithTimeout·WithCancel·WithDeadline으로 작업 한계를 걸고 취소를 전파하는 법. HTTP 서버·클라이언트 예제와 흔한 실수까지 정리합니다.

들어가며

분산 시스템과 HTTP 서비스에서는 “언제까지 기다릴지”“상위 단계가 실패했을 때 하위 작업을 멈출지”가 곧 안정성입니다. Go는 이를 context.Context로 표준화했고, net/http·database/sql 등이 요청 단위로 컨텍스트를 받도록 설계되어 있습니다. 이 글은 타임아웃·취소·데드라인을 코드로 연결하는 패턴에 초점을 맞춥니다. Go context 취소·타임아웃 패턴을 한 곳에 모아 두면, 팀 코드 리뷰에서도 기준이 맞춰집니다. 시리즈의 context·우아한 종료 심화와 맞닿지만, 여기서는 API 선택과 HTTP 서버·클라이언트에 바로 붙이는 레시피를 압축해 정리합니다. 다루는 내용: WithCancel / WithTimeout / WithDeadline 차이, 취소 전파 규칙, http.Server·http.Client와의 조합, 고루틴 누수를 피하는 습관입니다.

실전 경험에서 배운 교훈

이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.

가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.

이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.

개념 설명

context.Context취소 신호(Done), 마감 시각(Deadline), 요청 범위 값(Value)을 묶은 불변 인터페이스입니다. 하위 함수로 같은 줄기를 넘기면, 한곳에서 취소·타임아웃이 걸렸을 때 트리 전체에 일관되게 전달됩니다.

  • 취소(WithCancel): 사용자가 요청을 취소했거나, 상위 로직이 실패해 더 이상 의미 없는 작업을 멈출 때.
  • 상대 타임아웃(WithTimeout): “지금부터 N초 안에” 같은 기간 제한.
  • 절대 데드라인(WithDeadline): “오늘 23:59까지”처럼 시각이 정해진 경우. 취소 가능한 컨텍스트를 만들면 반드시 cancel 함수를 호출해야 합니다(리소스 누수 방지). defer cancel() 패턴이 사실상 표준입니다.

실전 구현 (단계별 코드)

1) WithTimeout: DB·외부 API 호출 상한

package main
import (
	"context"
	"database/sql"
	"errors"
	"time"
)
func UserByID(ctx context.Context, db *sql.DB, id int64) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()
	var name string
	err := db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, id).Scan(&name)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			return "", err
		}
		return "", err
	}
	return name, nil
}

호출부의 ctx는 보통 http.Request.Context()에서 온 요청 루트입니다.

2) WithCancel: 한 단계 실패 시 나머지 워커 중단

func work(ctx context.Context, id int) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	errs := make(chan error, 2)
	go func() { errs <- fetchA(ctx, id) }()
	go func() { errs <- fetchB(ctx, id) }()
	for i := 0; i < 2; i++ {
		if err := <-errs; err != nil {
			cancel() // 실패 시 다른 고루틴에 취소 전파
			return err
		}
	}
	return nil
}
func fetchA(ctx context.Context, id int) error {
	select {
	case <-time.After(100 * time.Millisecond):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}
func fetchB(ctx context.Context, id int) error {
	select {
	case <-time.After(200 * time.Millisecond):
		return errors.New("upstream")
	case <-ctx.Done():
		return ctx.Err()
	}
}

3) HTTP 서버: 요청 컨텍스트와 클라이언트 타임아웃

package main
import (
	"context"
	"io"
	"net/http"
	"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1/x", nil)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		http.Error(w, err.Error(), 502)
		return
	}
	defer resp.Body.Close()
	w.Header().Set("Content-Type", "application/json")
	_, _ = io.Copy(w, resp.Body)
}

http.Client.Timeout전체 요청(연결+TLS+바디) 상한이고, NewRequestWithContextctx취소·데드라인을 전달합니다. 둘을 함께 두면 “느린 업스트림”과 “클라이언트가 떠남”을 동시에 다루기 쉽습니다.

4) 서버 종료: 별도의 Shutdown 컨텍스트

운영에서는 요청 단위 ctx프로세스 종료 신호를 분리하는 경우가 많습니다. 우아한 종료 예시는 Go 심화 #09Shutdown 패턴을 참고하면 됩니다.

고급 활용: 전파·값·계층

  • 전파: 부모가 취소되면 자식도 취소됩니다. 자식만 취소하려면 그 가지에서 파생된 WithCancelcancel()만 호출하면 됩니다.
  • 값(context.WithValue): 추적 ID·인증 주체처럼 요청 스코프 메타데이터만 넣고, 비즈니스 입력은 함수 인자로 두는 편이 테스트에 유리합니다.
  • 중첩 타임아웃: 바깥이 5초, 안쪽이 800ms처럼 더 짧은 쪽이 먼저 발화합니다. 각 계층이 자신의 SLA만 알면 됩니다.

성능·비교: 세 API를 언제 쓰나

API용도메모
WithCancel수동 취소, 파이프라인 조기 종료cancel() 호출 필수
WithTimeout(d)상대 기한(지금부터 d)내부적으로 WithDeadline
WithDeadline(t)절대 시각클럭 동기화가 중요한 배치·스케줄에 적합
비용 자체는 가볍지만, 컨텍스트 없이 블로킹 호출을 남겨 두면 고루틴·연결이 쌓입니다. 성능 이슈는 대부분 “취소가 안 닿는 I/O”에서 옵니다.

실무 사례

  • 게이트웨이: 업스트림 호출마다 WithTimeout을 걸고, 클라이언트가 끊기면 r.Context()로 빠져나오게 합니다.
  • 배치 작업: 한 건 실패 시 전체를 멈추려면 WithCancel 루트 하나를 두고, 워커에 같은 ctx를 넘깁니다.
  • gRPC/DB: PingContext, QueryContext*Context API를 쓰면 동일한 모델을 유지할 수 있습니다.

트러블슈팅

증상: 타임아웃이 안 걸린다
→ 블로킹 호출이 컨텍스트를 무시하는지 확인하세요. 표준 라이브러리는 *Context 변형을 써야 합니다. 증상: cancel을 안 불러서 리소스 누수 경고
defer cancel()을 습관화하세요. WithTimeout도 동일합니다. 증상: Err()Canceled인지 DeadlineExceeded인지 헷갈린다
errors.Is(err, context.DeadlineExceeded)처럼 값으로 비교하세요. 증상: 테스트가 느리다
context.Background() 대신 짧은 WithTimeout을 쓰거나, 취소 가능한 ctx로 끊으세요.

내부 동작과 핵심 메커니즘

이 글의 주제는 「Go context로 타임아웃·취소 처리하기 | 실전 패턴 가이드」입니다. 여기서는 앞선 설명을 구현·런타임 관점에서 한 번 더 압축합니다. 시스템·런타임 경계(스케줄링, I/O, 메모리, 동시성)를 기준으로 생각하면, “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)이 어디서 터지는가”가 한눈에 드러납니다.

처리 파이프라인(개념도)

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]

알고리즘·프로토콜 관점에서의 체크포인트

  • 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(예: 버퍼 경계, 프로토콜 상태, 트랜잭션 격리)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 동일 입력에 동일 출력이 보장되는 순수한 층과, 시간·네트워크에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합처럼 “한 번의 호출이 아니라 누적되는 비용”을 의심 목록에 넣습니다.

프로덕션 운영 패턴

실서비스에서는 기능 구현과 함께 관측·배포·보안·비용이 동시에 요구됩니다. 아래는 팀에서 자주 쓰는 최소 체크리스트입니다.

영역운영 관점에서의 질문
관측성요청 단위 상관 ID, 에러율/지연 분위수, 주요 의존성 타임아웃이 보이는가
안전성입력 검증·권한·비밀 관리가 코드 경로마다 일관적인가
신뢰성재시도는 멱등한 연산에만 적용되는가, 서킷 브레이커·백오프가 있는가
성능캐시 계층·배치 크기·풀링·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리, 마이그레이션 호환성이 문서화되어 있는가

운영 환경에서는 “개발자 PC에서는 재현되지 않던 문제”가 시간·부하·데이터 크기 때문에 드러납니다. 따라서 스테이징의 데이터 양·네트워크 지연을 가능한 한 현실에 가깝게 맞추는 것이 중요합니다.


문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스 컨디션, 타임아웃, 외부 의존성 불안정최소 재현 스크립트 작성, 분산 트레이스·로그 상관관계 확인
성능 저하N+1 쿼리, 동기 I/O, 잠금 경합, 과도한 직렬화프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 클로저/이벤트 구독 누수, 대용량 객체의 불필요한 복사상한·TTL·스냅샷 비교(힙 덤프/트레이스)
빌드·배포만 실패환경 변수·권한·플랫폼 차이CI 로그와 로컬 diff, 컨테이너/런타임 버전 핀(pin)

권장 디버깅 순서: (1) 최소 재현 만들기 (2) 최근 변경 범위 좁히기 (3) 의존성·환경 변수 차이 확인 (4) 관측 데이터로 가설 검증 (5) 수정 후 회귀·부하 테스트.

마무리

context는 Go에서 동시성과 마감 정책을 팀 전체가 같은 방식으로 표현하게 해 주는 핵심 도구입니다. WithTimeout·WithCancel·WithDeadline을 상황에 맞게 고르고, HTTP에서는 요청 ctx + Client 타임아웃을 함께 설계하면 운영 환경에서 재현하기 어려운 “느린 누수”를 많이 줄일 수 있습니다. 고루틴과 채널 기본기는 고루틴·채널과 REST 실습 API 프로젝트와 이어 읽으면 좋습니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Go에서 context.WithTimeout·WithCancel·WithDeadline으로 작업 한계를 걸고 취소를 전파하는 법. HTTP 서버·클라이언트 예제와 흔한 실수까지 정리합니다. Start now. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • [Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교
  • [Go 2주 완성 #08] Day 14: 실전 미니 프로젝트 - REST API 서버 구축
  • C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴

이 글에서 다루는 키워드 (관련 검색어)

Go, context, 타임아웃, 취소, 동시성, HTTP, goroutine 등으로 검색하시면 이 글이 도움이 됩니다.