- Published on
Go 고루틴 컨텍스트 취소 누락으로 멈춤 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 “가끔 멈춘다”는 증상은 대개 CPU가 100%를 치는 형태가 아니라, 고루틴이 조용히 누적되거나 특정 요청이 끝나지 않는 형태로 나타납니다. Go에서는 이 문제가 context 취소(또는 데드라인)를 전파는 했지만 소비하지 않은 코드에서 자주 발생합니다. 즉, 상위 요청은 이미 타임아웃/취소됐는데 하위 고루틴은 여전히 chan receive, WaitGroup.Wait, io.Read, time.Sleep, 외부 API 호출 같은 블로킹 지점에서 빠져나오지 못하는 상황입니다.
이 글에서는 “컨텍스트 취소 누락으로 인해 고루틴이 멈추는” 대표 패턴을 재현하고, 실전에서 안전하게 고치는 방법을 코드로 설명합니다. 데드라인/리트라이와 결합될 때 폭주로 이어지는 케이스는 gRPC MSA에서 데드라인·리트라이 폭주 막는 법도 함께 참고하면 좋습니다.
1) 문제의 본질: 취소 신호가 전달되지 않는 게 아니라 “관찰”되지 않는다
Go의 context는 취소 신호를 전달하기 위한 표준이지만, 실제로 블로킹을 풀어주는 것은 각 함수가 ctx.Done()을 select로 관찰하거나, http.NewRequestWithContext처럼 ctx를 I/O에 결합해주는 형태로 구현되어야 합니다.
다음 중 하나라도 해당하면 멈춤(또는 누수)이 발생하기 쉽습니다.
- 고루틴 내부에서
ctx.Done()을 전혀 보지 않음 - 채널 수신/송신이 영원히 블록되는데 ctx로 탈출 경로가 없음
WaitGroup.Wait()가 영원히 기다리는데 취소 시 중단할 수단이 없음- 외부 호출이 ctx를 받지 않거나(혹은 무시하거나) 타임아웃이 없음
time.Sleep로 장시간 대기하면서 취소를 확인하지 않음
2) 전형적인 재현: 채널 수신에서 영원히 블록
아래 코드는 요청마다 워커 고루틴을 만들고 결과를 기다립니다. 그런데 워커가 결과를 보내지 못하는 경로가 있고, 메인 루틴은 resCh를 영원히 기다립니다. 상위 ctx가 취소되어도 탈출하지 못해 요청이 “멈춘 것처럼” 보입니다.
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
)
func doWork(ctx context.Context) (string, error) {
resCh := make(chan string) // 버퍼 없음
go func() {
// 어떤 조건에서 결과를 보내지 않고 그냥 리턴한다고 가정
if rand.Intn(2) == 0 {
return
}
// 오래 걸리는 작업
time.Sleep(2 * time.Second)
resCh <- "ok"
}()
// 문제: ctx가 취소되어도 resCh 수신에서 빠져나오지 못할 수 있음
res := <-resCh
return res, nil
}
func main() {
rand.Seed(time.Now().UnixNano())
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
res, err := doWork(ctx)
if err != nil {
fmt.Println("err:", err)
return
}
fmt.Println("res:", res)
_ = errors.New("dummy")
}
해결: select로 ctx.Done()을 함께 기다리기
- 수신/송신 등 블로킹 지점은
select로 감싸서 취소 시 즉시 탈출하도록 만듭니다. - 채널을 닫는 방식(클로징)과 결과 전송 방식 중 하나로 규칙을 정해 레이스를 피합니다.
func doWork(ctx context.Context) (string, error) {
resCh := make(chan string, 1) // 버퍼 1로 워커가 블록되지 않게
go func() {
defer close(resCh)
if rand.Intn(2) == 0 {
return
}
// Sleep 대신 ctx-aware 대기
t := time.NewTimer(2 * time.Second)
defer t.Stop()
select {
case <-ctx.Done():
return
case <-t.C:
resCh <- "ok"
}
}()
select {
case <-ctx.Done():
return "", ctx.Err()
case res, ok := <-resCh:
if !ok {
return "", context.Canceled // 혹은 명시적 에러
}
return res, nil
}
}
핵심은 “취소 가능성 있는 모든 대기 지점”에 ctx.Done()을 병렬 조건으로 넣는 것입니다.
3) WaitGroup.Wait()는 취소를 모른다: 취소 가능한 Wait 패턴
sync.WaitGroup은 취소를 지원하지 않습니다. 아래처럼 요청 스코프에서 wg.Wait()를 호출하면, 하위 고루틴이 어떤 이유로든 종료되지 않는 순간 요청은 영원히 대기합니다.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// ctx를 안 보고 무한 루프
for {
// ...
}
}()
wg.Wait() // 영원히 기다릴 수 있음
해결 1: errgroup.WithContext 사용
errgroup은 “첫 에러” 또는 “ctx 취소”를 트리거로 하위 작업을 정리하는 패턴을 제공합니다.
package main
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// 작업 수행
time.Sleep(50 * time.Millisecond)
}
}
})
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
return nil
}
})
if err := g.Wait(); err != nil {
fmt.Println("done with err:", err)
}
}
해결 2: Wait()를 채널로 감싸고 select로 취소 처리
외부 의존성을 줄이고 싶다면 다음 패턴을 씁니다.
func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error {
done := make(chan struct{})
go func() {
defer close(done)
wg.Wait()
}()
select {
case <-ctx.Done():
return ctx.Err()
case <-done:
return nil
}
}
주의할 점은, 이 방식은 wg.Wait() 자체를 중단시키는 게 아니라 “기다리는 호출자만 탈출”시키는 것입니다. 하위 고루틴이 ctx를 보고 실제로 종료되도록 만드는 것이 함께 필요합니다.
4) HTTP/외부 I/O에서 ctx를 안 붙이면 타임아웃이 무력화된다
서비스 멈춤의 큰 비중은 외부 I/O입니다. 특히 다음 실수를 많이 합니다.
http.NewRequest를 쓰고req = req.WithContext(ctx)를 안 함http.Client{Timeout: ...}이 없고 ctx도 안 붙음- DB/Redis/SDK 호출에 ctx를 전달하지 않음
올바른 HTTP 호출 예시
func fetch(ctx context.Context, url string) ([]byte, error) {
client := &http.Client{
Timeout: 3 * time.Second, // 최후의 안전장치
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
- ctx는 “요청 스코프 취소”를 전달합니다.
client.Timeout은 “라이브러리/프록시/커넥션 레벨에서 ctx 취소가 제대로 작동하지 않더라도” 빠져나오게 하는 마지막 방어선입니다.
5) time.Sleep 대신 취소 가능한 타이머를 써라
백그라운드 루프에서 흔히 보이는 코드:
for {
// ... 작업
time.Sleep(10 * time.Second)
}
이 루프는 종료 신호가 와도 최대 10초 동안 반응하지 않습니다. 더 나쁘게는 “종료해야 하는데 Sleep 때문에 종료가 지연”되며 프로세스 종료/롤링 업데이트를 방해합니다.
개선: time.Ticker 또는 time.Timer에 ctx.Done() 결합
func run(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// ... 주기 작업
}
}
}
6) 채널 송신도 멈출 수 있다: 소비자가 죽으면 생산자가 블록
컨텍스트 누락은 수신에서만 문제가 되는 게 아닙니다. 버퍼 없는 채널에 송신하는 고루틴은 소비자가 사라지면 영원히 블록될 수 있습니다.
나쁜 예
func producer(ctx context.Context, out chan<- int) {
for i := 0; ; i++ {
out <- i // 소비자가 없으면 블록
}
}
개선: 송신에도 select로 취소 경로 추가
func producer(ctx context.Context, out chan<- int) {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}
또는 “드롭 가능”한 이벤트라면 버퍼를 두고, 꽉 찼을 때 버리는 정책도 고려합니다.
select {
case out <- i:
default:
// drop
}
7) 진단: 멈춤을 고루틴 덤프로 확인하기
운영에서 “멈춤”이 의심되면 가장 먼저 고루틴 상태를 확인해야 합니다.
net/http/pprof를 붙여서/debug/pprof/goroutine?debug=2확인SIGQUIT를 보내 스택 덤프 확인
pprof에서 자주 보이는 패턴:
chan receive에서 다수 고루틴 대기sync.(*WaitGroup).Wait에서 대기net.(*pollDesc).waitRead등 I/O 대기
이때 스택 상단에 select 없이 단일 블로킹 호출만 보이면 ctx 취소 누락 가능성이 큽니다.
8) 실전 체크리스트
- 요청 핸들러 시작점에서
ctx := r.Context()를 사용하고, 내부로 계속 전달하는가 - 모든 고루틴은 다음 중 하나를 만족하는가
- 종료 조건이 명확하고,
ctx.Done()을 관찰한다 - 또는 프로세스 라이프사이클과 동일하며, 종료 시그널 컨텍스트를 관찰한다
- 종료 조건이 명확하고,
- 채널 송수신은
select로 취소 경로가 있는가 WaitGroup대기는 취소 가능한 구조로 감쌌는가(가능하면errgroup)- 외부 I/O는 ctx를 실제로 결합했는가(HTTP는
NewRequestWithContext) - 타임아웃이 “ctx”와 “클라이언트/드라이버 레벨”로 이중 적용되어 있는가
9) 마무리: 컨텍스트는 규약이고, 탈출은 구현이다
Go에서 컨텍스트 취소는 자동으로 고루틴을 죽이지 않습니다. 상위에서 cancel()을 호출해도 하위가 ctx.Done()을 보지 않으면 아무 일도 일어나지 않습니다. 따라서 멈춤을 해결하려면 다음 원칙을 코드 리뷰 규칙처럼 강제하는 것이 가장 효과적입니다.
- 블로킹 지점에는 반드시
select로 취소 경로를 둔다 - 외부 호출에는 반드시 ctx를 전달하고, 클라이언트 타임아웃도 둔다
- 고루틴은 “누가, 언제, 어떻게 종료시키는지”가 코드에 드러나야 한다
데드라인/리트라이 설계까지 포함해 전체 요청 흐름에서 멈춤과 폭주를 함께 줄이는 관점은 gRPC MSA에서 데드라인·리트라이 폭주 막는 법에서 더 확장해 볼 수 있습니다.