Go에서 context 취소 시 goroutine 정리 패턴

문제 상황

사용자 요청에 따라 여러 외부 API를 병렬로 호출하는 aggregation 엔드포인트를 만들었다. 클라이언트가 연결을 끊어도 goroutine들이 계속 실행되면서 메모리 사용량이 증가하는 문제가 발견됐다.

func fetchData(userID string) (*Result, error) {
    resultCh := make(chan *APIResponse, 3)
    
    go fetchUserInfo(userID, resultCh)
    go fetchUserPosts(userID, resultCh)
    go fetchUserStats(userID, resultCh)
    
    // 클라이언트가 연결을 끊어도 goroutine은 계속 실행됨
    return collectResults(resultCh)
}

해결 방법

context를 활용해 요청 취소 시그널을 전파하도록 수정했다.

func fetchData(ctx context.Context, userID string) (*Result, error) {
    resultCh := make(chan *APIResponse, 3)
    errCh := make(chan error, 3)
    
    go fetchUserInfo(ctx, userID, resultCh, errCh)
    go fetchUserPosts(ctx, userID, resultCh, errCh)
    go fetchUserStats(ctx, userID, resultCh, errCh)
    
    return collectResultsWithContext(ctx, resultCh, errCh)
}

func fetchUserInfo(ctx context.Context, userID string, resultCh chan *APIResponse, errCh chan error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
    resp, err := client.Do(req)
    if err != nil {
        select {
        case errCh <- err:
        case <-ctx.Done():
            return
        }
        return
    }
    // response 처리...
}

주의사항

  1. 채널 전송 시에도 context 확인: 채널이 블록되면 goroutine이 leak 될 수 있다
  2. http.Client timeout 설정: context만으로는 네트워크 타임아웃을 완전히 제어할 수 없다
  3. 버퍼 크기: 버퍼 없는 채널은 수신자가 없으면 영원히 블록된다

pprof로 확인해보니 동시 요청 1000개 기준 goroutine 수가 3000+에서 100 이하로 감소했다. 프로덕션 배포 후 메모리 사용량도 안정화됐다.

Go에서 context 취소 시 goroutine 정리 패턴