Go 동시성 프로그래밍의 지휘자 sync.WaitGroup 완벽 가이드

Go 동시성 프로그래밍의 지휘자 sync.WaitGroup 완벽 가이드

Go로 동시성 코드를 짜다 보면, 정말 흔하게 마주치는 황당한 상황이 하나 있는데요.

바로 메인 함수가 고루틴(goroutine)들이 일을 끝내기도 전에 '저 먼저 퇴근합니다!'하고 끝나버리는 경우죠.

분명히 여러 개의 작업을 동시에 시켰는데, 콘솔에는 아무것도 찍히지 않는 허무한 결과를 보게 되더라고요.

바로 이럴 때 우아하게 등장하는 해결사가 바로 sync.WaitGroup입니다.

마치 오케스트라의 지휘자처럼, 흩어져서 연주하는 여러 고루틴들이 모두 연주를 마칠 때까지 기다렸다가 깔끔하게 공연을 마무리 짓게 해주는 아주 중요한 도구죠.

오늘은 이 sync.WaitGroup의 기초부터 실전에서 마주칠 수 있는 함정까지, 완벽하게 파헤쳐 보겠습니다.

sync.WaitGroup의 핵심 삼총사 Add, Done, Wait

sync.WaitGroup을 이해하는 건 정말 간단한데요.

핵심 메서드가 딱 세 개뿐이거든요.

바로 Add, Done, Wait 이 삼총사입니다.

저는 이걸 '팀장님의 업무 관리법'에 비유하곤 하는데요.

Add(delta int): 이건 팀장님이 팀원들에게 '오늘 처리해야 할 업무가 N개야!'라고 할당해주는 것과 같습니다.

고루틴 하나를 시작하기 전에 wg.Add(1)을 호출해서, WaitGroup의 내부 카운터를 1만큼 늘려주는 거죠.

앞으로 기다려야 할 작업이 하나 추가되었다고 알려주는 신호입니다.

Done(): 이건 팀원이 '팀장님, 제 업무 끝났습니다!'라고 보고하는 것과 같아요.

고루틴이 자신의 모든 작업을 마쳤을 때 wg.Done()을 호출하면, WaitGroup의 카운터가 1만큼 줄어듭니다.

사실 내부적으로는 Add(-1)을 호출하는 것과 똑같은 역할을 하죠.

Wait(): 이건 팀장님이 '모든 팀원들이 보고를 마칠 때까지 퇴근 안 하고 기다릴게'라고 선언하는 것과 같습니다.

wg.Wait()을 호출한 고루틴(보통은 main 고루틴이겠죠)은 WaitGroup의 카운터가 0이 될 때까지 그 자리에서 꼼짝 않고 대기합니다.

모든 작업이 완료되었다는 Done 보고가 들어와서 카운터가 0이 되는 순간, 비로소 대기가 풀리고 다음 코드를 실행하게 되는 거예요.

백문이 불여일견 직접 코드로 살펴보기

자, 그럼 이 삼총사가 실제로 어떻게 협력하는지 코드로 직접 확인해볼까요?

5명의 작업자(worker) 고루틴을 만들고, 이들이 모두 일을 마칠 때까지 main 함수가 기다리게 하는 예제입니다.

package main

import (
    "fmt"
    "sync"
    "time"
)

// 각 고루틴이 수행할 작업 함수
func worker(id int, wg *sync.WaitGroup) {
    // 함수가 끝나기 직전에 Done()을 호출해서 작업 완료를 알립니다.
    defer wg.Done()

    fmt.Printf("워커 %d 시작\n", id)
    // 실제 작업을 흉내 내기 위해 잠시 대기
    time.Sleep(time.Second)
    fmt.Printf("워커 %d 완료\n", id)
}

func main() {
    // WaitGroup 변수 선언
    var wg sync.WaitGroup

    // 5개의 워커 고루틴을 생성합니다.
    for i := 1; i <= 5; i++ {
        // 기다려야 할 고루틴의 수를 1 증가시킵니다.
        wg.Add(1)
        // 고루틴을 시작합니다. WaitGroup의 주소를 넘겨줍니다.
        go worker(i, &wg)
    }

    // 모든 고루틴이 끝날 때까지 대기합니다.
    wg.Wait()

    fmt.Println("모든 워커가 작업을 완료했습니다.")
}

이 코드의 실행 흐름을 차근차근 따라가 보면 WaitGroup의 역할이 명확하게 보이는데요.

  1. var wg sync.WaitGroup: 먼저 WaitGroup 인스턴스를 하나 만듭니다.
  2. for 루프: 5번의 루프를 돌면서 워커를 생성하죠.
  3. wg.Add(1): 고루틴을 만들기 '직전에' Add(1)을 호출해서 카운터를 1 올립니다.

    '이제부터 한 놈 기다려야 해!'라고 등록하는 과정이죠.
  4. go worker(i, &wg): 그리고 워커 고루틴을 시작시킵니다.

    여기서 중요한 점은 wg를 값으로 복사해서 넘기는 게 아니라, &wg처럼 '주소'를 넘겨준다는 거예요.

    그래야 모든 고루틴이 '동일한' WaitGroup 인스턴스의 카운터를 함께 조작할 수 있거든요.
  5. defer wg.Done(): worker 함수 맨 위에는 defer 키워드와 함께 wg.Done()이 있는데요.

    defer는 함수가 종료되기 직전에 실행을 보장해주기 때문에, worker 함수가 어떤 경로로 끝나든 상관없이 '나 일 끝났소!'라는 보고를 확실하게 할 수 있게 해줍니다.

    아주 안정적인 패턴이죠.
  6. wg.Wait(): main 함수는 for 루프가 끝난 후 wg.Wait()에서 멈춰 섭니다.

    이때 카운터는 5인 상태죠.

    이제 main 함수는 카운터가 0이 될 때까지 묵묵히 기다립니다.
  7. 워커 고루틴들이 하나둘씩 wg.Done()을 호출하면서 카운터는 4, 3, 2, 1로 줄어들고, 마침내 마지막 워커가 Done()을 호출하면 카운터는 0이 됩니다.
  8. 카운터가 0이 되는 순간, wg.Wait()의 봉인이 풀리고 main 함수는 "모든 워커가 작업을 완료했습니다."를 출력하며 프로그램을 정상적으로 종료하게 되는 거예요.

이것만은 피하자 WaitGroup 사용 시 흔히 하는 실수 3가지

sync.WaitGroup은 강력하지만, 몇 가지 규칙을 어기면 프로그램에 예측 불가능한 문제를 일으킬 수 있는데요.

실무에서 정말 자주 보이는 실수 세 가지를 짚어드릴게요.

실수 1 엇갈린 타이밍, Add를 고루틴 안에서 호출하기

가장 흔하면서도 치명적인 실수는 바로 wg.Add(1)을 고루틴 '안에서' 호출하는 건데요.

코드를 보면서 이야기해볼까요?

// 잘못된 예제: Add를 고루틴 안에서 호출
for i := 1; i <= 5; i++ {
    // wg.Add(1)을 여기서 호출해야 합니다!
    go func(id int) {
        wg.Add(1) // 아주 위험한 코드!
        defer wg.Done()
        fmt.Printf("워커 %d 시작\n", id)
    }(i)
}
wg.Wait()


이 코드는 운이 좋으면 잘 동작할 수도 있지만, 대부분의 경우 `wg.Wait()`이 즉시 통과되어 버리는 '레이스 컨디션(Race Condition)'을 유발합니다.
왜냐하면 `main` 고루틴이 `for` 루프를 돌면서 고루틴들을 생성하고 `wg.Wait()`에 도달하는 속도가, 새로 생성된 고루틴들이 스케줄링되어 `wg.Add(1)`을 실행하는 속도보다 훨씬 빠를 수 있거든요.
`main` 함수가 `Wait()`에 도달했을 때 `Add(1)`이 단 한 번도 호출되지 않았다면, 카운터는 여전히 0이라서 그냥 프로그램을 끝내버리는 거죠.
`Add`는 반드시 작업을 할당하는 쪽에서, 고루틴을 시작하기 '전에' 호출해야 한다는 점, 꼭 기억하세요.

실수 2 마이너스 카운터의 비극

WaitGroup의 카운터는 절대 음수가 되어서는 안 되는데요.

Done()Add로 추가한 숫자보다 더 많이 호출하면 어떻게 될까요?

프로그램은 panic: sync: negative WaitGroup counter라는 메시지와 함께 즉시 죽어버립니다.

var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    defer wg.Done() // Done을 두 번 호출!
    fmt.Println("작업 중...")
}()
wg.Wait() // 여기서 panic이 발생할 수 있음


이런 실수는 보통 복잡한 로직 속에서 에러 처리 분기 등을 타면서 `Done`이 중복으로 호출될 때 발생하곤 합니다.
`Add` 한 번에 `Done` 한 번, 이 짝을 맞추는 것이 핵심입니다.

실수 3 끝난 WaitGroup 다시 보기

한번 wg.Wait()이 끝나서 카운터가 0이 된 WaitGroup은 재사용하면 안 되는데요.

정확히 말하면, Wait이 반환된 후에 다시 Add를 호출하면 패닉이 발생합니다.

var wg sync.WaitGroup
// 첫 번째 작업 그룹
for i := 0; i < 3; i++ {
    wg.Add(1)
    go worker(i, &wg)
}
wg.Wait() // 여기서 카운터가 0이 되고 대기 종료

// 두 번째 작업 그룹 (잘못된 재사용)
for i := 3; i < 6; i++ {
    wg.Add(1) // PANIC! Wait이 끝난 후 Add 호출
    go worker(i, &wg)
}
wg.Wait()


`WaitGroup`은 일회성 지휘자라고 생각하는 게 편합니다.
하나의 공연(작업 그룹)이 끝나면 퇴장해야 하죠.
만약 여러 작업 그룹을 순차적으로 동기화해야 한다면, 각 그룹마다 새로운 `WaitGroup` 인스턴스를 만들어서 사용해야 합니다.

WaitGroup이 만능은 아닐 때 채널(Channel)과의 차이

WaitGroup은 여러 고루틴이 '끝날 때까지' 기다리는 데에는 최고의 도구인데요.

하지만 만약 고루틴으로부터 어떤 '결과 값'을 받아야 하거나, 고루틴들끼리 데이터를 주고받아야 하는 상황이라면 어떨까요?

이런 경우에는 WaitGroup보다는 Go의 또 다른 동시성 프리미티브인 '채널(Channel)'이 더 적합한 해결책입니다.

WaitGroup은 작업 완료 '신호'만 주고받는 단방향 통신에 가깝다면, 채널은 데이터를 실어 나를 수 있는 양방향 파이프라인과 같거든요.

'불을 지르고 잊어버리는(fire-and-forget)' 식의 작업 동기화에는 WaitGroup을, 작업 결과를 취합하거나 고루틴 간의 소통이 필요할 땐 채널을 사용하는 것이 Go 동시성 코드를 아름답게 만드는 비결이죠.

마무리하며

sync.WaitGroup은 Go의 동시성 모델을 떠받치는 가장 기본적인 기둥 중 하나입니다.

그 구조는 놀랍도록 단순하지만, 우리가 동시성 코드를 얼마나 안정적이고 예측 가능하게 만들 수 있는지를 결정하는 강력한 힘을 가지고 있죠.

오늘 함께 살펴본 핵심 메서드 삼총사와 흔한 실수들을 잘 기억해두신다면, 여러분의 고루틴 오케스트라는 언제나 완벽한 하모니를 연주하게 될 거예요.