Menu

Golang WaitGroup: Add, Done, Wait와 워커 풀

sync.WaitGroup이 여러 고루틴이 끝나기를 기다리는 방식: Add, Done, Wait 규칙, 포인터로 넘겨야 하는 이유, 결과와 오류 모으기, 그리고 이를 기반으로 만든 워커 풀을 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

기본 패턴

sync.WaitGroup은 실행 중인 고루틴의 수를 셉니다. Add는 수를 늘리고, Done은 줄이며, Wait는 0이 될 때까지 블록됩니다.

다운로드 세 개가 동시에 실행되므로 프로그램은 30ms가 아니라 약 10ms가 걸립니다. 각 고루틴은 sizes의 자기 인덱스에만 쓰고 mainWait 이후에만 읽으므로, 결과는 입력 순서대로 출력됩니다.

WaitGroup의 제로 값은 바로 쓸 수 있습니다. 생성자가 필요 없습니다.

세 가지 규칙

Add는 고루틴 안이 아니라 go 전에 호출하세요. 고루틴이 직접 Add를 호출하면, 어떤 고루틴도 시작하기 전에 mainWait에 도달해 카운터를 0으로 보고 작업이 시작도 안 된 상태에서 반환될 수 있습니다. 개수를 미리 알고 있다면 반복문 앞에서 wg.Add(len(files))를 한 번 호출해도 같습니다.

Done은 고루틴의 첫 줄에서 defer로 호출하세요. 오류로 일찍 반환하거나 패닉이 난 고루틴도 카운터를 줄여야 합니다. Done이 빠지면 Wait는 영원히 블록됩니다. 남은 고루틴이 그것뿐이라면 런타임은 트레이스에 sync.WaitGroup.Wait가 담긴 fatal error: all goroutines are asleep을 보고합니다.

처음 사용한 뒤에는 WaitGroup을 절대 복사하지 마세요. 함수에는 *sync.WaitGroup을 넘기거나, 위처럼 클로저로 변수를 캡처하세요.

함수에 WaitGroup 넘기기

고루틴 본문이 이름 있는 함수라면 포인터를 넘깁니다:

wg sync.WaitGroup을 값 매개변수로 받으면 각 워커는 자기 복사본에 Done을 호출하고, mainWait에서 영원히 블록됩니다. go vet이 실행 전에 이를 잡아 줍니다:

./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy

더 깔끔한 설계는 worker에서 동시성을 완전히 빼는 것입니다. worker는 평범한 함수로 두고, Add/Done 관리는 호출하는 쪽의 클로저에서 하세요. 그러면 worker를 테스트하기도 쉽고 동기적으로 호출하기도 쉽습니다.

음수 카운터

DoneAdd(-1)입니다. 카운터가 0 아래로 내려가면 프로그램이 패닉을 일으킵니다:

출력은 recovered: sync: negative WaitGroup counter입니다. 보통의 원인은 defer wg.Done()이 있는 고루틴이 어떤 경로에서 wg.Done()을 명시적으로 한 번 더 호출하는 것입니다.

오류 모으기

WaitGroup은 세기만 합니다. 오류가 필요하면 고루틴마다 자리를 하나씩 주고 Wait 후에 확인하세요:

errors.Join(Go 1.20)은 nil 값을 건너뛰고 모두 nil이면 nil을 반환하므로, 추가 관리 없이 "고루틴마다 오류 하나"를 합쳐 줍니다.

고루틴 하나가 실패하는 즉시 나머지 작업을 멈추고 싶다면 대신 golang.org/x/sync/errgroup을 쓰세요. WaitGroup에 첫 번째 오류와 실패 시 취소되는 컨텍스트를 더한 것이며, g.SetLimit(n)으로 동시성에 상한을 둘 수 있습니다. 표준 라이브러리 밖에 있어서 이 페이지의 에디터에서는 실행할 수 없습니다:

g, ctx := errgroup.WithContext(ctx)
for _, h := range hosts {
	g.Go(func() error { return checkCtx(ctx, h) })
}
if err := g.Wait(); err != nil {
	return err // the first error; ctx was cancelled for the others
}

워커 풀

고정된 수의 고루틴이 채널에서 작업을 읽게 하면 작업이 몇 개든 동시성이 제한됩니다. WaitGroup은 모든 워커가 끝나는 시점, 즉 결과 채널을 닫아도 되는 시점을 알려 줍니다.

세 부분의 순서가 중요합니다:

  • 워커가 실행되는 동안 main이 결과를 받고 있어야 합니다. main이 읽기 전에 바로 wg.Wait()를 호출하면, 워커는 results로 보내다가 블록되어 Done에 도달하지 못하고 모든 것이 교착 상태에 빠집니다. 그래서 Wait를 별도의 고루틴에서 실행합니다.
  • close(results)Wait 이후에만 일어나므로, 어떤 워커도 닫힌 채널에 보내지 않습니다.
  • 작업을 공급하는 쪽도 고루틴에서 실행되므로 공급과 수집이 겹쳐서 진행됩니다.

어느 워커가 어느 작업을 처리했는지는 실행할 때마다 바뀌므로, 프로그램은 출력하기 전에 작업 기준으로 정렬합니다. 출력되는 내용은 모두 결정적입니다.

WaitGroup, 채널, errgroup

필요한 것사용할 것
고루틴 N개를 기다리고, 결과는 인덱스 자리에sync.WaitGroup
고루틴 하나를 기다림done 채널이나 결과 채널 자체
끝나는 대로 결과를 흘려보냄wg.Wait() 후에 닫는 채널
첫 오류에서 모두 멈춤errgroup.WithContext
타임아웃이나 호출자 취소 시 모두 멈춤context.Context와 WaitGroup 또는 errgroup

Go 1.25에는 Add(1)과 지연된 Done을 대신 해 주는 wg.Go(func() { ... })가 추가됩니다. 이 페이지의 에디터를 포함해 Go 1.24 이하용 코드는 위에 나온 명시적 형태를 씁니다.

흔한 실수

  • 고루틴 안에서 wg.Add(1). 그것이 실행되기 전에 Wait가 반환될 수 있습니다.
  • 이른 반환에서 Done을 잊음. 항상 defer wg.Done()을 쓰세요.
  • WaitGroup을 값으로 넘김. 포인터를 쓰세요. go vet이 복사를 지적합니다.
  • 채널을 비워야 하는 고루틴에서 기다림. wg.Wait()close를 별도의 고루틴으로 옮기세요.
  • 이전 Wait가 반환되기 전에 WaitGroup을 재사용함. Wait가 끝난 뒤에만 새로운 Add 주기를 시작하세요.

자주 묻는 질문

Go에서 sync.WaitGroup은 어떻게 동작하나요?

WaitGroup은 카운터입니다. wg.Add(n)이 카운터를 늘리고, wg.Done()이 하나 줄이며, wg.Wait()는 카운터가 0이 될 때까지 블록됩니다. 각 고루틴을 시작하기 전에 Add를 호출하고, 고루틴 안에서 defer wg.Done()을, 모든 작업이 끝나야 하는 곳에서 Wait를 호출하세요.

WaitGroup은 값으로 넘겨야 하나요, 포인터로 넘겨야 하나요?

포인터(*sync.WaitGroup)로 넘기거나 고루틴이 클로저로 캡처하게 하세요. 복사본은 자기만의 카운터를 가지므로, 복사본의 Done은 원본에 닿지 않고 Wait는 영원히 블록됩니다. go vet은 이 실수를 "passes lock by value"로 보고합니다.

"sync: negative WaitGroup counter"는 왜 발생하나요?

Done 호출이 Add 호출보다 많을 때입니다. 보통 고루틴이 Done을 두 번(defer로 한 번, 명시적으로 한 번) 호출하거나, 어떤 경로에서 Add(1)을 빠뜨린 경우입니다. 카운터가 더는 참인 정보를 줄 수 없으므로 프로그램은 패닉을 일으킵니다.

WaitGroup으로 시작한 고루틴에서 오류는 어떻게 받나요?

WaitGroup은 결과나 오류를 전달하지 않습니다. 오류 슬라이스에 고루틴마다 자리를 하나씩 주고 Wait 후에 합치거나(예: errors.Join), Wait가 첫 번째 오류를 반환하고 컨텍스트로 나머지를 취소할 수 있는 golang.org/x/sync/errgroup을 쓰세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기