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를 포착하는 가장 저렴한 방법입니다.

golangconcurrencygoroutineerrgroupsync