sync.WaitGroup, Mutex 그리고 errgroup: Go에서 goroutine 조정하기
1. 개요
Go에서 goroutine을 실행하는 것은 쉽습니다. go 키워드를 붙이면 됩니다. 더 어려운 부분은 goroutine이 언제 끝나는지 알기, 공유 데이터 보호, goroutine이 실패했을 때 오류 처리입니다.
이 글에서는 이 세 가지 문제를 해결하는 세 가지 도구를 다룹니다: sync.WaitGroup, sync.Mutex, errgroup.Group. 읽은 후에 각 상황에서 어떤 도구를 선택할지, -race 플래그로 테스트를 작성하는 방법, 피해야 할 흔한 오류를 알게 됩니다.
예제는 Go 1.22 이상을 사용합니다. 이 버전부터 각 for 루프는 매 반복마다 새로운 변수를 생성하므로 더 이상 id := id를 쓸 필요가 없습니다.
2. 예제 문제
주문 코드(Order) 목록이 있다고 가정하세요. 각 주문은 느린 서비스를 호출해야 하며 약 100ms가 걸립니다. 10개의 주문을 순차적으로 호출하면 약 1초가 소요됩니다. 병렬로 호출하면 약 100ms만 소요됩니다.
서비스 호출을 시뮬레이션하는 함수는 다음과 같습니다:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
루프에서 단순히 go fetchOrder(id)만 작성하면 goroutine이 완료되기 전에 main 함수가 종료됩니다. 프로그램이 종료되고 결과가 손실됩니다. 다음 섹션에서 이 문제를 해결합니다.
3. sync.WaitGroup: goroutine 그룹 대기하기
3.1. 기본 사용법
WaitGroup은 카운터입니다. Add로 카운터를 증가시키고, Done으로 감소시키며, Wait는 카운터가 0이 될 때까지 대기합니다.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
위 코드는 다음과 같이 작동합니다:
wg.Add(1)은 goroutine을 시작하기 전에, 부모 goroutine에서 호출됩니다.defer wg.Done()은 goroutine이 조기에 종료되거나 panic이 발생해도 카운터가 항상 감소하도록 보장합니다.wg.Wait()은 세 goroutine이 모두Done을 호출할 때까지main을 차단합니다.
결과는 다음과 같을 수 있습니다. fetched 라인의 순서는 실행할 때마다 변합니다:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. Go 1.25의 WaitGroup.Go
Go 1.25부터 WaitGroup에 Go 메서드가 추가되었습니다. 이 메서드는 Add, go, Done을 하나의 호출로 결합합니다:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
이렇게 작성하면 더 짧고 두 가지 흔한 오류를 피할 수 있습니다: Done 호출 잊기, 또는 goroutine 내에서 Add 호출하기.
3.3. Lock 없이 결과 수집하기
보통 결과를 유지해야 하고 단지 화면에 인쇄하는 것이 아닙니다. 안전한 방법은 미리 정해진 길이의 slice를 만들고 각 goroutine이 자신의 인덱스에만 쓰게 하는 것입니다:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
어떤 goroutine도 같은 요소에 쓰지 않으므로 여기서는 data race가 없습니다. wg.Wait()는 또한 happens-before 관계를 만듭니다. 따라서 Wait 이후 부모 goroutine은 자식 goroutine이 쓴 전체 데이터를 읽을 수 있습니다.
주의: goroutine 내에서
wg.Add(1)을 호출하지 마세요. 그렇게 하면Wait()가Add전에 실행되어 작업이 완료되지 않았을 때 반환할 수 있습니다.
4. sync.Mutex: 공유 데이터 보호
4.1. Data race는 어떻게 보이는가
이제 고객별(Customer) 수익을 누적해야 하고 여러 goroutine이 같은 map을 업데이트한다고 가정하세요:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
이 프로그램은 다음 오류로 충돌할 수 있습니다:
fatal error: concurrent map writes
단 하나의 int 변수만 사용하는 경우는 더 위험합니다. 프로그램은 충돌하지 않지만 조용히 잘못된 결과를 나타냅니다. 예를 들어 10000 대신 9730입니다. 이러한 종류의 오류를 감지하려면 race detector를 사용하여 실행하세요:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Mutex로 수정하기
sync.Mutex는 주어진 시간에 Lock과 Unlock 사이의 코드를 실행하는 goroutine이 하나뿐임을 보장합니다. 일반적인 방법은 mutex를 보호하는 데이터와 함께 같은 struct에 포함시키는 것입니다:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
주의해야 할 몇 가지 사항:
total에 대한 모든 접근은 읽기 포함 lock을 통해 진행되어야 합니다.defer r.mu.Unlock()은 함수에 여러return분기가 있어도 lock이 항상 해제되도록 합니다.- 메서드는 포인터 리시버(
*RevenueTracker)를 사용합니다. 값 리시버를 사용하면 매 호출마다 mutex가 복사되고 그 lock은 더 이상 아무것도 보호하지 못합니다.
읽기가 쓰기보다 훨씬 많은 경우 sync.RWMutex를 사용할 수 있습니다. 읽기 메서드는 RLock/RUnlock을 호출하고, 쓰기 메서드는 Lock/Unlock을 호출합니다. 여러 goroutine이 동시에 읽을 수 있지만 쓰기는 하나의 goroutine만 lock을 유지합니다.
주의:
Mutex를 포함하는 struct를 사용한 후 복사하지 마세요.go vet명령은copylocks경고로 이를 알려줍니다. 또한 Go는 재진입 가능한 lock이 없습니다: 하나의 goroutine이 같은 mutex에서Lock()을 두 번 호출하면 deadlock이 발생합니다.
4.3. Mutex가 필요 없는 경우
간단한 카운터의 경우 sync/atomic이 더 간단하고 빠릅니다:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
데이터가 스트림 형태로 한 goroutine에서 다른 goroutine으로 이동해야 할 때 channel이 더 적합합니다. Mutex는 map이나 struct와 같은 공유 상태가 있고 여러 goroutine이 읽거나 쓰기를 해야 할 때 가장 적합합니다.
5. errgroup: 오류 처리가 있는 WaitGroup
WaitGroup은 오류를 모릅니다. fetchOrder가 error를 반환할 수 있으면 mutex나 channel로 오류를 직접 수집하고 나머지 goroutine을 직접 취소해야 합니다. errgroup 패키지가 이러한 작업을 미리 수행합니다.
5.1. 의존성 추가
errgroup은 golang.org/x/sync 모듈에 있으며 표준 라이브러리에 속하지 않습니다:
go get golang.org/x/sync
5.2. 오류 수집 및 context로 취소
첫 번째 단계는 함수가 context.Context를 받도록 fetchOrder를 다시 작성하고 오류를 반환할 수 있도록 하는 것입니다:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
위 코드는 다음과 같이 작동합니다:
errgroup.WithContext(ctx)는 group과 자식 context를 만듭니다. goroutine이 첫 번째 오류를 반환하는 즉시 자식 context가 취소됩니다.g.SetLimit(3)은 최대 3개의 goroutine을 동시에 실행할 수 있도록 합니다. 다음g.Go호출은 슬롯이 비울 때까지 차단됩니다. 이렇게 하면 리스트에 수천 개의 주문이 있을 때 백엔드 서비스에 과부하를 걸지 않습니다.g.Go는func() error타입의 함수를 받으며Add/Done을 호출할 필요가 없습니다.g.Wait()는 모든 goroutine이 완료될 때까지 기다린 후 첫 번째 오류를 반환합니다. 그 후에 발생하는 오류는 무시됩니다.fetchOrder는ctx.Done()을 들으므로 나머지 goroutine은 전체 100ms를 실행하는 대신 조기에 중지됩니다.
유효하지 않은 주문 코드로 함수를 호출하십시오:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
제한에 도달했을 때 차단하지 않고 시도하고 싶다면 g.TryGo(f)를 사용하세요. 이 함수는 기다리는 대신 false를 반환합니다.
주의: context 취소는 goroutine 내부의 코드가 적극적으로
ctx를 확인할 때만 작동합니다.fetchOrder가 context를 청취하지 않고time.Sleep을 사용하면 그룹이 실패해도 goroutine은 전체 시간을 실행합니다.
6. -race로 테스트 작성하기
Go는 JUnit이나 AssertJ가 없습니다. 표준 라이브러리의 testing 패키지를 사용합니다. 가장 중요한 것은 -race 플래그로 테스트를 항상 실행하는 것입니다. 많은 concurrency 오류는 race detector가 있을 때만 드러나기 때문입니다.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
테스트 실행:
go test -race ./...
ok example.com/shop 0.412s
Add에서 Lock/Unlock의 두 줄을 삭제한 후 다시 실행해 보세요. 테스트는 WARNING: DATA RACE로 실패하거나 concurrent map writes로 충돌합니다. 이것이 mutex가 실제로 필요한지 확인하는 빠른 방법입니다.
두 번째 테스트는 또한 결과의 순서가 올바르게 유지되는지 확인합니다: orders[i]가 ids[i]와 일치합니다. goroutine이 어떤 순서로 종료되든 상관없습니다.
7. 흔한 오류 및 도구 선택 방법
가장 흔한 오류:
- goroutine 내에서
Add호출.Wait가 너무 빨리 반환될 수 있습니다.go명령 전에Add를 호출하거나wg.Go를 사용하세요. - 값으로
WaitGroup또는Mutex전달. 함수는 복사본을 받으므로 원래의 카운터나 lock이 변경되지 않습니다.*sync.WaitGroup포인터를 전달하거나 closure를 사용하세요. - I/O를 호출하는 동안 lock 유지. 예를 들어
Lock내에서 HTTP를 호출하는 경우. 다른 모든 goroutine은 기다려야 하고 프로그램은 병렬 실행의 이점을 잃습니다. 가능한 가장 짧은 코드 섹션에서만 lock을 유지하세요. - 빈
errgroup.Group(var g errgroup.Group)을 사용하지만 context가 취소되기를 기대.WithContext만 취소 가능한 context를 만듭니다. g.Wait()반환 값을 무시. 그러면 오류가 조용히 사라집니다.- 수십만 개의 요청에 대해 무제한 goroutine 시작.
SetLimit을 사용하거나 worker pool을 사용하세요.
도구 선택:
| 상황 | 도구 |
|---|---|
| 오류를 반환하지 않는 여러 작업 대기 | sync.WaitGroup |
| 여러 goroutine이 공유 map 또는 struct 수정 | sync.Mutex / sync.RWMutex |
| 간단한 카운터 또는 플래그 | sync/atomic |
| 오류 가능, 조기 취소 필요, goroutine 수 제한 | errgroup |
주의:
errgroup은 첫 번째 오류만 유지합니다. 모든 오류를 알아야 하는 경우, 예를 들어 어떤 주문이 손상되었는지 보고하려면 각 goroutine이errs[i]에 오류를 작성하고Wait호출 후errors.Join(errs...)으로 병합하세요.
8. 결론
sync.WaitGroup은 “언제 완료?“라는 질문에 답합니다. sync.Mutex는 “지금 누가 데이터를 수정할 수 있나?“라는 질문에 답합니다. errgroup은 대기를 오류 처리, context를 통한 취소, 동시 실행 goroutine 수 제한과 결합합니다.
실제 코드에서 여러 서비스를 병렬로 호출할 때 SetLimit과 함께 errgroup.WithContext를 사용하는 것이 일반적인 기본 선택입니다. 어떤 도구를 사용하든 항상 go test -race를 실행하세요. 이는 프로덕션에 들어가기 전에 data race를 포착하는 가장 저렴한 방법입니다.