Go 동시성 프로그래밍의 치트키, `errgroup` 완벽 가이드

Go 동시성 프로그래밍의 치트키, errgroup 완벽 가이드

Go 언어의 가장 큰 매력 포인트 중 하나가 바로 '동시성(Concurrency)'을 정말 쉽고 강력하게 다룰 수 있다는 점인데요.

고루틴(goroutine)과 채널(channel)만 있으면, 마치 마법처럼 수천, 수만 개의 작업을 동시에 처리할 수 있죠.

하지만 여기서 딱 한 걸음만 더 나아가면, 우리는 금세 골치 아픈 문제들과 마주하게 됩니다.

'여러 고루틴 중 하나라도 에러가 나면 다른 작업들은 어떻게 멈추지?', '모든 작업이 끝날 때까지 기다렸다가 에러를 한 번에 처리할 수는 없을까?' 같은 문제들 말이죠.

이런 고민을 한방에 해결해주는, Go 동시성 프로그래밍의 '치트키'가 바로 errgroup 패키지입니다.

오늘은 sync.WaitGroup의 아쉬운 점을 완벽하게 보완하고, 에러 처리와 컨텍스트 취소 기능까지 탑재한 errgroup의 모든 것을 알아보겠습니다.

sync.WaitGroup으로는 부족했던 2%, '에러 처리'

errgroup을 이야기하기 전에, 먼저 sync.WaitGroup을 잠깐 짚고 넘어가야 하는데요.

WaitGroup은 여러 고루틴이 모두 끝날 때까지 기다려주는 아주 기본적인 동기화 도구죠.

Add()로 작업 수를 등록하고, 각 고루틴이 끝날 때마다 Done()을 호출하고, 메인 스레드에서는 Wait()로 모든 작업이 끝나길 기다리는 방식입니다.

하지만 WaitGroup에는 치명적인 단점이 하나 있었으니, 바로 '에러 처리' 기능이 없다는 거였어요.

여러 고루틴 중 하나가 실패하더라도 WaitGroup은 그걸 전혀 알지 못하거든요.

그래서 우리는 보통 채널을 하나 만들어서 에러를 전달받고, 복잡한 로직을 추가로 구현해야만 했습니다.

errgroup은 바로 이 지점에서 탄생했습니다.

WaitGroup의 기본 기능에 아주 우아한 에러 전파 메커니즘을 더한 거죠.

errgroup 기본 사용법, 에러가 이렇게 쉬워진다고?

errgroup의 사용법은 WaitGroup만큼이나 직관적이고 간단한데요.

가장 대표적인 예제인 '여러 URL을 동시에 가져오기'를 통해 바로 코드를 살펴보죠.

package main

import (
    "fmt"
    "net/http"
    "golang.org/x/sync/errgroup"
)

func main() {
    var g errgroup.Group
    var urls = []string{
        "http://www.golang.org/",
        "http://www.google.com/",
        "http://some-invalid-url.com/", // 일부러 에러가 나는 URL을 넣었습니다.
    }

    for _, url := range urls {
        // 중요! 루프 변수를 고루틴 클로저에서 안전하게 사용하기 위해 새로운 변수에 복사합니다.
        url := url 
        g.Go(func() error {
            resp, err := http.Get(url)
            if err == nil {
                resp.Body.Close()
            }
            // 에러가 발생하면 그대로 반환합니다.
            return err
        })
    }

    // Wait()는 모든 고루틴이 끝날 때까지 기다립니다.
    // 만약 고루틴 중 하나라도 nil이 아닌 에러를 반환하면, 그 '첫 번째' 에러를 반환합니다.
    if err := g.Wait(); err == nil {
        fmt.Println("모든 URL을 성공적으로 가져왔습니다.")
    } else {
        fmt.Printf("URL을 가져오는 데 실패했습니다: %v\n", err)
    }
}


코드가 정말 깔끔하죠?

`sync.WaitGroup`처럼 `Add()`나 `Done()`을 직접 호출할 필요가 전혀 없습니다.

`g.Go()` 함수에 실행할 작업을 넘겨주기만 하면, `errgroup`이 알아서 고루틴을 시작하고 내부적으로 카운터를 관리하거든요.

그리고 `g.Wait()`를 호출하면 모든 작업이 끝날 때까지 기다렸다가, 그중 '가장 먼저 발생한 에러 하나'를 반환해 줍니다.

더 이상 에러 처리를 위해 별도의 채널을 만들고 복잡한 select 문을 쓸 필요가 없어진 거예요.

하나가 실패하면 모두가 멈춘다, WithContext

기본 errgroup만으로도 충분히 강력하지만, 진짜 마법은 WithContext와 함께 사용할 때 시작되는데요.

errgroup.WithContext 함수는 errgroup.Group과 함께, 그 그룹의 생명주기와 연결된 context.Context를 새로 만들어줍니다.

이게 무슨 뜻이냐면, 그룹 내의 고루틴 중 단 하나라도 에러를 반환하는 순간, errgroup이 이 컨텍스트를 '취소(cancel)'시켜 버린다는 거예요.

그리고 다른 고루틴들은 이 컨텍스트의 취소 신호를 받아서, 하던 작업을 즉시 중단하고 깔끔하게 종료할 수 있죠.

package main

import (
    "context"
    "fmt"
    "golang.org/x/sync/errgroup"
    "time"
)

func main() {
    // WithContext를 사용하여 그룹과 컨텍스트를 생성합니다.
    g, ctx := errgroup.WithContext(context.Background())

    for i := 0; i < 3; i++ {
        i := i
        g.Go(func() error {
            fmt.Printf("고루틴 %d 시작\n", i)

            // 1번 고루틴에서 일부러 에러를 발생시킵니다.
            if i == 1 {
                time.Sleep(1 * time.Second)
                return fmt.Errorf("%d번 고루틴에서 에러 발생!", i)
            }

            // 다른 고루틴들은 컨텍스트가 취소될 때까지 대기합니다.
            select {
            case <-time.After(3 * time.Second):
                fmt.Printf("고루틴 %d 완료\n", i)
            case <-ctx.Done():
                // ctx.Done() 채널이 닫히면, 다른 고루틴이 에러를 냈다는 뜻입니다.
                fmt.Printf("고루틴 %d 취소됨: %v\n", i, ctx.Err())
                return ctx.Err()
            }
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        fmt.Printf("그룹이 에러와 함께 종료되었습니다: %v\n", err)
    } else {
        fmt.Println("그룹이 성공적으로 종료되었습니다.")
    }
}


이 코드를 실행해 보면 1번 고루틴에서 에러가 발생하자마자, 아직 3초가 지나지 않은 다른 고루틴들도 '취소됨' 메시지를 출력하며 바로 종료되는 것을 확인할 수 있습니다.

여러 작업이 서로 의존적이거나, 굳이 끝까지 실행될 필요가 없는 경우에 정말 유용한 기능이죠.

자원 낭비를 막고 프로그램의 반응성을 높이는 데 큰 도움이 됩니다.

넘쳐나는 작업을 제어하는 법, SetLimit

수백, 수천 개의 작업을 동시에 처리해야 할 때, 아무런 제약 없이 고루틴을 생성하면 시스템 자원을 순식간에 고갈시킬 수 있는데요.

이럴 때 SetLimit 메소드를 사용하면 동시에 실행되는 고루틴의 수를 제한할 수 있습니다.

마치 세마포어(semaphore)처럼 작동하는 거죠.

package main

import (
    "context"
    "fmt"
    "golang.org/x/sync/errgroup"
    "time"
)

func main() {
    g, _ := errgroup.WithContext(context.Background())

    // 동시에 실행될 고루틴의 수를 2개로 제한합니다.
    g.SetLimit(2)

    for i := 0; i < 5; i++ {
        i := i
        g.Go(func() error {
            fmt.Printf("%d번 작업 시작\n", i)
            time.Sleep(1 * time.Second) // 작업을 시뮬레이션합니다.
            fmt.Printf("%d번 작업 완료\n", i)
            return nil
        })
    }

    g.Wait()
    fmt.Println("모든 작업 완료!")
}


코드를 실행해보면, 처음 0번과 1번 작업이 시작되고 1초 뒤에 두 작업이 끝나자마자 2번과 3번 작업이 시작되는 것을 볼 수 있습니다.

한 번에 최대 2개의 고루틴만 활성화되는 거죠.

이 기능을 사용하면 외부 API 호출이나 데이터베이스 작업을 처리할 때 부하를 조절하는 'Worker Pool' 패턴을 정말 간단하게 구현할 수 있습니다.

마무리하며

errgroup은 Go의 동시성 모델이 얼마나 실용적이고 강력한지를 보여주는 최고의 예시 중 하나입니다.

복잡한 동기화 로직과 에러 처리를 직접 구현하는 데 드는 시간을 아껴주고, 개발자가 비즈니스 로직 자체에 더 집중할 수 있도록 도와주죠.

단순한 WaitGroup을 넘어, 컨텍스트를 이해하고 동시성의 수를 제어해야 하는 단계로 나아가고 있다면, errgroup은 여러분의 코드 품질을 한 단계 끌어올려 줄 최고의 무기가 될 거예요.