Go에서 context를 이용한 goroutine 종료 패턴

문제 상황

회사 결제 서비스의 일부를 Node.js에서 Go로 전환하는 작업을 진행 중이다. 외부 API 호출 시 타임아웃을 걸고 여러 goroutine을 동시에 실행하는 로직에서, 타임아웃 발생 시에도 goroutine이 계속 실행되는 문제가 있었다.

func fetchUserData(userID string) (*User, error) {
    done := make(chan *User)
    go func() {
        user := callExternalAPI(userID) // 오래 걸릴 수 있음
        done <- user
    }()

    select {
    case user := <-done:
        return user, nil
    case <-time.After(3 * time.Second):
        return nil, errors.New("timeout")
    }
}

위 코드는 타임아웃 후에도 goroutine이 계속 실행되어 리소스 낭비가 발생한다.

해결 방법

context 패키지를 사용해서 goroutine에 취소 신호를 전달하도록 수정했다.

func fetchUserData(ctx context.Context, userID string) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    done := make(chan *User)
    errCh := make(chan error)

    go func() {
        user, err := callExternalAPIWithContext(ctx, userID)
        if err != nil {
            errCh <- err
            return
        }
        done <- user
    }()

    select {
    case user := <-done:
        return user, nil
    case err := <-errCh:
        return nil, err
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

HTTP 클라이언트도 context를 받도록 수정했다.

func callExternalAPIWithContext(ctx context.Context, userID string) (*User, error) {
    req, err := http.NewRequest("GET", apiURL, nil)
    if err != nil {
        return nil, err
    }
    req = req.WithContext(ctx)
    
    resp, err := httpClient.Do(req)
    // ...
}

결과

타임아웃 발생 시 goroutine이 즉시 종료되고, 불필요한 외부 API 호출도 취소된다. context는 Go 1.7부터 표준 라이브러리에 포함되었고, 이제는 거의 모든 장기 실행 함수의 첫 번째 인자로 사용하는 것이 관례가 되었다.

Node.js에서는 Promise 취소가 표준이 아니라 타임아웃 처리가 까다로웠는데, Go의 context 패턴이 훨씬 명확하고 안전한 것 같다.