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로 정기적인 프로파일링이 중요하다
Go 채널 타임아웃 처리 시 겪은 고루틴 릭 문제