Go 채널 타임아웃 처리 시 겪은 고루틴 릭 문제
문제 상황
외부 API를 호출하는 함수에 타임아웃을 적용했는데, 프로덕션에서 메모리 사용량이 지속적으로 증가했다. pprof로 확인해보니 고루틴이 계속 쌓이고 있었다.
func fetchData(url string) (string, error) {
result := make(chan string)
go func() {
data := slowAPICall(url) // 10초 이상 걸림
result <- data
}()
select {
case data := <-result:
return data, nil
case <-time.After(3 * time.Second):
return "", errors.New("timeout")
}
}
타임아웃이 발생하면 함수는 리턴되지만, 고루틴은 slowAPICall이 끝날 때까지 계속 실행된다. 채널에 보낼 곳이 없어 블로킹되면서 고루틴이 누수되는 구조였다.
해결 방법
버퍼 채널을 사용하거나, context로 취소 신호를 전달하는 방식으로 해결할 수 있다.
func fetchData(ctx context.Context, url string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
result := make(chan string, 1) // 버퍼 추가
go func() {
data := slowAPICall(url)
select {
case result <- data:
default: // 받는 쪽이 없으면 그냥 종료
}
}()
select {
case data := <-result:
return data, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
더 나은 방법은 http.Client의 context를 활용하는 것이다.
func slowAPICall(ctx context.Context, url string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// ...
}
교훈
- 고루틴을 생성할 때는 항상 종료 조건을 고려해야 한다
- 채널 블로킹은 고루틴 릭의 주요 원인이다
- context는 단순 타임아웃뿐 아니라 취소 신호 전파에도 유용하다
- pprof로 정기적인 프로파일링이 중요하다