ErrGroup (에러그룹): Go (고) 동시성 프로그래밍의 숨겨진 보석

ErrGroup (에러그룹): Go (고) 동시성 프로그래밍의 숨겨진 보석

Go (고) 언어 errgroup (에러그룹) 라이브러리: 강력한 동시성 제어 도구

errgroup (에러그룹)은 공식 Go (고) 라이브러리 x에 있는 유틸리티로, 여러 고루틴(goroutine)을 동시에 실행하고 오류를 처리하는 데 사용됩니다.

이는 sync.WaitGroup (씽크 점 웨이트그룹)을 기반으로 errgroup.Group (에러그룹 점 그룹)을 구현하여 동시성 프로그래밍을 위한 더욱 강력한 기능을 제공합니다.

errgroup (에러그룹)의 장점

sync.WaitGroup (씽크 점 웨이트그룹)과 비교하여 errgroup.Group (에러그룹 점 그룹)은 다음과 같은 장점이 있습니다.

오류 처리: sync.WaitGroup (씽크 점 웨이트그룹)은 고루틴(goroutine)이 완료될 때까지 기다리는 역할만 하며 반환 값이나 오류를 처리하지 않습니다.

반면 errgroup.Group (에러그룹 점 그룹)은 반환 값을 직접 처리할 수는 없지만, 고루틴(goroutine)에서 오류가 발생하면 실행 중인 다른 고루틴(goroutine)을 즉시 취소하고 Wait (웨이트) 메서드에서 첫 번째 nil (닐)이 아닌 오류를 반환할 수 있습니다.


컨텍스트 취소: errgroup (에러그룹)은 context.Context (컨텍스트 점 컨텍스트)와 함께 사용할 수 있습니다.

고루틴(goroutine)에서 오류가 발생하면 다른 고루틴(goroutine)을 자동으로 취소하여 리소스를 효과적으로 제어하고 불필요한 작업을 방지할 수 있습니다.


동시성 프로그래밍 단순화: errgroup (에러그룹)을 사용하면 오류 처리를 위한 상용구 코드를 줄일 수 있습니다.

개발자는 오류 상태와 동기화 로직을 수동으로 관리할 필요가 없어 동시성 프로그래밍이 더 간단하고 유지 관리가 용이해집니다.


동시성 수 제한: errgroup (에러그룹)은 과부하를 피하기 위해 동시 고루틴(goroutine) 수를 제한하는 인터페이스를 제공하는데, 이는 sync.WaitGroup (씽크 점 웨이트그룹)에는 없는 기능입니다.

sync.WaitGroup (씽크 점 웨이트그룹) 사용 예시

errgroup.Group (에러그룹 점 그룹)을 소개하기 전에 먼저 sync.WaitGroup (씽크 점 웨이트그룹)의 사용법을 복습해 보겠습니다.

package main

import (
    "fmt"
    "net/http"
    "sync"
)

func main() {
    var urls = []string{ // HTTP GET 요청을 보낼 URL 목록입니다.
        "http://www.golang.org/",
        "http://www.google.com/",
        "http://www.somestupidname.com/", // 존재하지 않는 호스트입니다.
    }
    var err error // 오류를 저장할 변수입니다.

    var wg sync.WaitGroup // WaitGroup을 선언합니다.

    for _, url := range urls { // 각 URL에 대해 반복합니다.
        wg.Add(1) // WaitGroup 카운터를 1 증가시킵니다.

        // 고루틴을 시작합니다.
        go func() {
            defer wg.Done() // 고루틴 종료 시 WaitGroup 카운터를 1 감소시킵니다.

            resp, e := http.Get(url) // HTTP GET 요청을 보냅니다. (주의: 클로저 내 url 변수 사용)
            if e != nil { // 오류가 발생하면
                err = e // 오류를 저장하고 (주의: 동시성 문제 발생 가능)
                return  // 고루틴을 종료합니다.
            }
            defer resp.Body.Close() // 응답 본문을 닫습니다.
            fmt.Printf("fetch url %s status %s\n", url, resp.Status) // URL과 상태 코드를 출력합니다.
        }()
    }

    wg.Wait()    // 모든 고루틴이 완료될 때까지 기다립니다.
    if err != nil { // 저장된 오류가 있으면
        fmt.Printf("Error: %s\n", err) // 오류를 출력합니다.
    }
}



실행 결과:

$ go run waitgroup/main.go
fetch url http://www.google.com/ status 200 OK
fetch url http://www.golang.org/ status 200 OK
Error: Get "http://www.somestupidname.com/": dial tcp: lookup www.somestupidname.com: no such host



sync.WaitGroup (씽크 점 웨이트그룹)의 일반적인 사용 패턴:

var wg sync.WaitGroup // WaitGroup 선언

for ... { // 반복문
    wg.Add(1) // 카운터 증가

    go func() { // 고루틴 시작
        defer wg.Done() // 고루틴 종료 시 카운터 감소
        // 작업 수행
    }()
}

wg.Wait() // 모든 고루틴 완료 대기



errgroup.Group (에러그룹 점 그룹) 사용 예시

기본 사용법

errgroup.Group (에러그룹 점 그룹)의 사용 패턴은 sync.WaitGroup (씽크 점 웨이트그룹)과 유사합니다.

package main

import (
    "fmt"
    "net/http"
    "golang.org/x/sync/errgroup" // errgroup 패키지를 가져옵니다.
)

func main() {
    var urls = []string{ // HTTP GET 요청을 보낼 URL 목록입니다.
        "http://www.golang.org/",
        "http://www.google.com/",
        "http://www.somestupidname.com/", // 존재하지 않는 호스트입니다.
    }

    var g errgroup.Group // errgroup.Group을 선언합니다.

    for _, url := range urls { // 각 URL에 대해 반복합니다.
        // g.Go를 사용하여 고루틴을 시작합니다.
        // 클로저가 올바른 url 값을 사용하도록 url을 매개변수로 전달하는 것이 좋습니다.
        // 여기서는 예제 간결성을 위해 외부 스코프의 url을 직접 사용합니다.
        // 실제 사용 시에는 currentURL := url과 같이 복사하여 사용하거나,
        // g.Go(func(u string) func() error { ... }(url)) 형태로 전달하는 것이 안전합니다.
        // 아래 코드에서는 외부 url 변수를 직접 참조하고 있습니다.
        // 이는 동시 실행 시 예기치 않은 동작을 유발할 수 있습니다.
        // 하지만 이 예제에서는 각 고루틴이 시작될 때의 url 값을 사용하게 됩니다.
        // 더 안전한 방법은 아래와 같습니다:
        // currentURL := url
        // g.Go(func() error {
        //   resp, err := http.Get(currentURL)
        //   ...
        // })
        // 또는
        // g.Go(func(u string) func() error {
        //   return func() error {
        //     resp, err := http.Get(u)
        //     ...
        //   }
        // }(url))

        // 현재 예제 코드 (주의 필요)
        // url 변수를 각 고루틴에 복사하여 전달하는 것이 좋습니다.
        // 아래와 같이 수정하면 각 고루틴이 올바른 url 값을 사용하게 됩니다.
        currentURL := url
        g.Go(func() error {
            resp, err := http.Get(currentURL) // HTTP GET 요청을 보냅니다.
            if err != nil {
                return err // 오류가 발생하면 오류를 반환합니다.
            }
            defer resp.Body.Close() // 응답 본문을 닫습니다.
            fmt.Printf("fetch url %s status %s\n", currentURL, resp.Status) // URL과 상태 코드를 출력합니다.
            return nil // 오류가 없으면 nil을 반환합니다.
        })
    }

    // g.Wait()는 모든 고루틴이 완료될 때까지 기다리고, 첫 번째 발생한 오류를 반환합니다.
    if err := g.Wait(); err != nil {
        fmt.Printf("Error: %s\n", err) // 오류를 출력합니다.
    }
}



실행 결과:

$ go run examples/main.go
fetch url http://www.google.com/ status 200 OK
fetch url http://www.golang.org/ status 200 OK
Error: Get "http://www.somestupidname.com/": dial tcp: lookup www.somestupidname.com: no such host



컨텍스트 취소

errgroup (에러그룹)은 취소 기능을 추가하기 위해 errgroup.WithContext (에러그룹 점 위드컨텍스트)를 제공합니다.

package main

import (
    "context" // context 패키지를 가져옵니다.
    "fmt"
    "net/http"
    "sync"
    "golang.org/x/sync/errgroup"
)

func main() {
    var urls = []string{ // HTTP GET 요청을 보낼 URL 목록입니다.
        "http://www.golang.org/",
        "http://www.google.com/",
        "http://www.somestupidname.com/", // 존재하지 않는 호스트입니다.
    }

    // errgroup.WithContext를 사용하여 Group과 함께 취소 가능한 Context를 생성합니다.
    g, ctx := errgroup.WithContext(context.Background())

    var result sync.Map // 결과를 저장할 동기화된 맵입니다.

    for _, url := range urls { // 각 URL에 대해 반복합니다.
        // url 변수를 각 고루틴에 복사하여 전달합니다.
        currentURL := url
        g.Go(func() error {
            // Context를 사용하여 HTTP 요청을 생성합니다.
            req, err := http.NewRequestWithContext(ctx, "GET", currentURL, nil)
            if err != nil {
                return err // 오류가 발생하면 오류를 반환합니다.
            }

            resp, err := http.DefaultClient.Do(req) // HTTP 요청을 실행합니다.
            if err != nil {
                return err // 오류가 발생하면 오류를 반환합니다.
            }
            defer resp.Body.Close() // 응답 본문을 닫습니다.

            result.Store(currentURL, resp.Status) // 결과를 맵에 저장합니다.
            return nil // 오류가 없으면 nil을 반환합니다.
        })
    }

    // 모든 고루틴이 완료될 때까지 기다리고, 오류가 발생하면 해당 오류를 출력합니다.
    if err := g.Wait(); err != nil {
        fmt.Println("Error: ", err)
    }

    // 저장된 결과를 출력합니다.
    result.Range(func(key, value any) bool {
        fmt.Printf("fetch url %s status %s\n", key, value)
        return true
    })
}



실행 결과:

$ go run examples/withcontext/main.go
Error:  Get "http://www.somestupidname.com/": dial tcp: lookup www.somestupidname.com: no such host
fetch url http://www.google.com/ status 200 OK



http://www.somestupidname.com/ (에이치티티피 콜론 슬래시슬래시 더블유더블유더블유 쩜 썸스투피드네임 쩜 컴 슬래시) 요청에서 오류가 발생했기 때문에 프로그램은 http://www.golang.org/ (에이치티티피 콜론 슬래시슬래시 더블유더블유더블유 쩜 고랭 쩜 오알지 슬래시) 요청을 취소했습니다.

동시성 수 제한

errgroup (에러그룹)은 동시에 실행되는 고루틴(goroutine) 수를 제한하기 위해 errgroup.SetLimit (에러그룹 점 셋리미트)를 제공합니다.

package main

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

func main() {
    var g errgroup.Group
    g.SetLimit(3) // 동시에 실행될 고루틴 수를 3으로 제한합니다.

    for i := 1; i <= 10; i++ {
        // 클로저가 올바른 i 값을 사용하도록 i를 매개변수로 전달합니다.
        // currentI := i 와 같이 복사하여 사용하는 것이 더 명확할 수 있습니다.
        // 여기서는 예제의 간결성을 위해 외부 스코프의 i를 직접 사용하고 있습니다.
        // 주의: 아래 코드에서 고루틴 내 i는 반복문 변수를 직접 참조하므로,
        // 실제 실행 시점의 i 값을 사용하게 되어 의도와 다를 수 있습니다.
        // 안전한 방법은 아래와 같습니다:
        // currentI := i
        // g.Go(func() error {
        //   fmt.Printf("Goroutine %d is starting\n", currentI)
        //   ...
        //   fmt.Printf("Goroutine %d is done\n", currentI)
        //   return nil
        // })
        // 또는
        // g.Go(func(val int) func() error {
        //   return func() error {
        //     fmt.Printf("Goroutine %d is starting\n", val)
        //     ...
        //     fmt.Printf("Goroutine %d is done\n", val)
        //     return nil
        //   }
        // }(i))

        // 현재 예제 코드 (주의 필요)
        // 각 고루틴에 i 값을 복사하여 전달해야 합니다.
        // 아래와 같이 수정하면 각 고루틴이 시작 시점의 올바른 i 값을 사용하게 됩니다.
        currentI := i
        g.Go(func() error {
            fmt.Printf("Goroutine %d is starting\n", currentI) // 고루틴 시작을 알립니다.
            time.Sleep(2 * time.Second) // 2초 동안 대기합니다.
            fmt.Printf("Goroutine %d is done\n", currentI)    // 고루틴 완료를 알립니다.
            return nil
        })
    }

    // 모든 고루틴이 완료될 때까지 기다립니다.
    if err := g.Wait(); err != nil {
        fmt.Printf("Encountered an error: %v\n", err) // 오류가 발생하면 출력합니다.
    }

    fmt.Println("All goroutines complete.") // 모든 고루틴 완료를 알립니다.
}



실행 결과:

$  go run examples/setlimit/main.go
Goroutine 3 is starting
Goroutine 1 is starting
Goroutine 2 is starting
Goroutine 2 is done
Goroutine 1 is done
Goroutine 5 is starting
Goroutine 3 is done
Goroutine 6 is starting
Goroutine 4 is starting
Goroutine 6 is done
Goroutine 5 is done
Goroutine 8 is starting
Goroutine 4 is done
Goroutine 7 is starting
Goroutine 9 is starting
Goroutine 9 is done
Goroutine 8 is done
Goroutine 10 is starting
Goroutine 7 is done
Goroutine 10 is done
All goroutines complete.



작업 시작 시도

errgroup (에러그룹)은 작업을 시작하려고 시도하는 errgroup.TryGo (에러그룹 점 트라이고)를 제공하며, 이는 errgroup.SetLimit (에러그룹 점 셋리미트)와 함께 사용해야 합니다.

package main

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

func main() {
    var g errgroup.Group
    g.SetLimit(3) // 동시에 실행될 고루틴 수를 3으로 제한합니다.

    for i := 1; i <= 10; i++ {
        // 클로저가 올바른 i 값을 사용하도록 i를 매개변수로 전달합니다.
        // 이전 예제와 동일한 주의사항이 적용됩니다.
        // currentI := i 와 같이 복사하여 사용하는 것이 좋습니다.

        // 현재 예제 코드 (주의 필요)
        // 각 고루틴에 i 값을 복사하여 전달해야 합니다.
        // 아래와 같이 수정하면 각 고루틴이 시작 시점의 올바른 i 값을 사용하게 됩니다.
        currentI := i
        // TryGo는 현재 실행 중인 고루틴 수가 제한에 도달하지 않았으면 true를 반환하고 작업을 시작합니다.
        if g.TryGo(func() error {
            fmt.Printf("Goroutine %d is starting\n", currentI) // 고루틴 시작을 알립니다.
            time.Sleep(2 * time.Second) // 2초 동안 대기합니다.
            fmt.Printf("Goroutine %d is done\n", currentI)    // 고루틴 완료를 알립니다.
            return nil
        }) {
            fmt.Printf("Goroutine %d started successfully\n", currentI) // 고루틴 시작 성공을 알립니다.
        } else {
            fmt.Printf("Goroutine %d could not start (limit reached)\n", currentI) // 고루틴 시작 실패(제한 도달)를 알립니다.
        }
    }

    // 모든 고루틴이 완료될 때까지 기다립니다.
    if err := g.Wait(); err != nil {
        fmt.Printf("Encountered an error: %v\n", err) // 오류가 발생하면 출력합니다.
    }

    fmt.Println("All goroutines complete.") // 모든 고루틴 완료를 알립니다.
}



실행 결과:

$ go run examples/trygo/main.go
Goroutine 1 started successfully
Goroutine 1 is starting
Goroutine 2 is starting
Goroutine 2 started successfully
Goroutine 3 started successfully
Goroutine 4 could not start (limit reached)
Goroutine 5 could not start (limit reached)
Goroutine 6 could not start (limit reached)
Goroutine 7 could not start (limit reached)
Goroutine 8 could not start (limit reached)
Goroutine 9 could not start (limit reached)
Goroutine 10 could not start (limit reached)
Goroutine 3 is starting
Goroutine 2 is done
Goroutine 3 is done
Goroutine 1 is done
All goroutines complete.



소스 코드 해석

errgroup (에러그룹)의 소스 코드는 주로 3개의 파일로 구성됩니다.

핵심 구조체

// token은 빈 구조체로, 동시성 수를 제어하기 위한 신호를 전달하는 데 사용됩니다.
type token struct{}

// Group은 여러 고루틴을 관리하고 오류를 처리하는 구조체입니다.
type Group struct {
    cancel func(error) // 컨텍스트가 취소될 때 호출되는 함수입니다.
    wg     sync.WaitGroup // 내부적으로 사용되는 sync.WaitGroup입니다.

    sem chan token // 동시 실행 고루틴 수를 제어하는 신호 채널입니다.

    errOnce sync.Once // 오류가 한 번만 처리되도록 보장합니다.
    err     error     // 첫 번째 발생한 오류를 기록합니다.
}



token (토큰): 동시성 수를 제어하기 위해 신호를 전달하는 데 사용되는 빈 구조체입니다.


Group (그룹):


cancel (캔슬): 컨텍스트가 취소될 때 호출되는 함수입니다.


wg (더블유지): 내부적으로 사용되는 sync.WaitGroup (씽크 점 웨이트그룹)입니다.


sem (셈): 동시 실행 코루틴 수를 제어하는 신호 채널입니다.


errOnce (에러원스): 오류가 한 번만 처리되도록 보장합니다.


err (에러): 첫 번째 오류를 기록합니다.

주요 메서드

SetLimit (셋리미트): 동시성 수를 제한합니다.

// SetLimit은 동시에 실행될 수 있는 고루틴의 수를 제한합니다.
// n < 0 이면 제한이 없습니다.
func (g *Group) SetLimit(n int) {
    if n < 0 {
        g.sem = nil // 제한 없음을 나타냅니다.
        return
    }
    if len(g.sem) != 0 { // 이미 활성 고루틴이 있는 상태에서 제한을 변경하려고 하면 패닉을 발생시킵니다.
        panic(fmt.Errorf("errgroup: modify limit while %v goroutines in the group are still active", len(g.sem)))
    }
    g.sem = make(chan token, n) // 제한된 크기의 채널을 생성합니다.
}



Go (고): 작업을 실행하기 위해 새 코루틴을 시작합니다.

// Go는 새로운 고루틴에서 주어진 함수를 호출합니다.
// 첫 번째 nil이 아닌 오류가 반환되면 Wait는 해당 오류를 반환하고
// 그룹의 컨텍스트가 있는 경우 해당 컨텍스트를 취소합니다.
func (g *Group) Go(f func() error) {
    if g.sem != nil { // 동시성 제한이 설정된 경우
        g.sem <- token{} // 채널에 토큰을 보내 실행 권한을 얻습니다. (꽉 차면 대기)
    }

    g.wg.Add(1) // WaitGroup 카운터를 증가시킵니다.
    go func() { // 새로운 고루틴을 시작합니다.
        defer g.done() // 고루틴 종료 시 done 메서드를 호출합니다. (sem에서 토큰을 반환하고 wg.Done() 호출)

        if err := f(); err != nil { // 함수를 실행하고 오류가 발생하면
            g.errOnce.Do(func() { // 한 번만 오류를 기록하고 처리합니다.
                g.err = err
                if g.cancel != nil { // 취소 함수가 설정되어 있으면
                    g.cancel(g.err) // 컨텍스트를 취소합니다.
                }
            })
        }
    }()
}



Wait (웨이트): 모든 작업이 완료될 때까지 기다리고 첫 번째 오류를 반환합니다.

// Wait는 g.Go로 시작된 모든 함수 호출이 반환될 때까지 차단한 다음,
// 해당 호출에서 반환된 첫 번째 nil이 아닌 오류(있는 경우)를 반환합니다.
func (g *Group) Wait() error {
    g.wg.Wait() // 모든 고루틴이 완료될 때까지 기다립니다.
    if g.cancel != nil { // 취소 함수가 설정되어 있으면
        g.cancel(g.err) // (이미 오류로 인해 취소되었을 수 있지만) 컨텍스트를 취소합니다.
    }
    return g.err // 기록된 첫 번째 오류를 반환합니다.
}



TryGo (트라이고): 작업 시작을 시도합니다.

// TryGo는 현재 실행 중인 고루틴 수가 제한에 도달하지 않은 경우에만 함수를 실행합니다.
// 함수가 실행되면 true를 반환하고, 그렇지 않으면 false를 반환합니다.
func (g *Group) TryGo(f func() error) bool {
    if g.sem != nil { // 동시성 제한이 설정된 경우
        select {
        case g.sem <- token{}: // 채널에 토큰을 즉시 보낼 수 있으면 (실행 가능)
            // 토큰을 획득했습니다.
        default: // 채널이 꽉 차서 즉시 보낼 수 없으면 (실행 불가)
            return false // false를 반환합니다.
        }
    }

    g.wg.Add(1) // WaitGroup 카운터를 증가시킵니다.
    go func() { // 새로운 고루틴을 시작합니다.
        defer g.done() // 고루틴 종료 시 done 메서드를 호출합니다.

        if err := f(); err != nil { // 함수를 실행하고 오류가 발생하면
            g.errOnce.Do(func() { // 한 번만 오류를 기록하고 처리합니다.
                g.err = err
                if g.cancel != nil { // 취소 함수가 설정되어 있으면
                    g.cancel(g.err) // 컨텍스트를 취소합니다.
                }
            })
        }
    }()
    return true // 함수 실행이 시작되었으므로 true를 반환합니다.
}



결론

errgroup (에러그룹)은 sync.WaitGroup (씽크 점 웨이트그룹)을 기반으로 오류 처리 기능을 추가한 공식 확장 라이브러리로, 동기화, 오류 전파, 컨텍스트 취소와 같은 기능을 제공합니다.

WithContext (위드컨텍스트) 메서드는 취소 기능을 추가할 수 있고, SetLimit (셋리미트)는 동시성 수를 제한할 수 있으며, TryGo (트라이고)는 작업 시작을 시도할 수 있습니다.

소스 코드는 독창적으로 설계되었으며 참고할 가치가 있습니다.