Go의 sync 패키지 공개 - WaitGroup: 동시 고루틴 완료 조정
James Reed
Infrastructure Engineer · Leapcell

Go의 동시성 모델은 고루틴과 채널을 중심으로 구축되어 있으며, 믿을 수 없을 정도로 강력하고 우아합니다. 하지만 강력함에는 책임이 따릅니다. 이러한 동시 프로세스를 효과적으로 관리하는 것은 견고하고 신뢰할 수 있는 애플리케이션을 구축하는 데 중요합니다. sync
패키지에서 제공하는 동시성 도구 키트의 기본 도구 중 하나가 sync.WaitGroup
입니다.
sync.WaitGroup
타입은 고루틴 컬렉션이 완료될 때까지 기다리도록 설계되었습니다. 이는 증가 및 감소될 수 있는 카운터 역할을 합니다. 카운터가 0에 도달하면 Wait
메서드가 차단되지 않습니다. 이 간단한 메커니즘은 여러 고루틴을 시작하고 메인 고루틴(또는 다른 고루틴)이 진행하기 전에 모두 작업을 완료했는지 확인해야 하는 시나리오에 매우 유용합니다.
WaitGroup
은 왜 필요한가? 해결하는 문제
애플리케이션이 많은 수의 작업을 동시에 처리해야 하는 시나리오를 상상해 보세요. 각 작업에 대해 고루틴을 시작하기로 결정했습니다. 이러한 고루틴을 기다리는 메커니즘이 없으면 메인 프로그램이 조기에 종료되거나 아직 계산되지 않은 결과에 액세스하려고 할 수 있습니다.
이 순진하고 문제가 있는 예제를 고려해 보세요.
package main import ( "fmt" "time" ) func processTask(id int) { fmt.Printf("Task %d started\n", id) time.Sleep(time.Duration(id) * 100 * time.Millisecond) // 작업 시뮬레이션 fmt.Printf("Task %d finished\n", id) } func main() { for i := 1; i <= 5; i++ { go processTask(i) } fmt.Println("All tasks launched. Exiting main.") // 여기서 무슨 일이 일어나는가? 메인이 종료되기 전에 많은 작업이 완료되지 않을 수 있습니다. }
위의 코드를 실행하면 "Task X finished" 메시지가 모두 표시되지 않거나 "Exiting main." 이후에 조정되지 않은 방식으로 표시되는 것을 관찰할 수 있습니다. main
고루틴은 processTask
고루틴이 완료될 때까지 기다리지 않습니다. 이것이 바로 sync.WaitGroup
이 해결하는 문제입니다.
sync.WaitGroup
작동 방식
sync.WaitGroup
은 세 가지 주요 메서드를 노출합니다.
Add(delta int)
:WaitGroup
카운터를delta
만큼 증가시킵니다. 일반적으로 새 고루틴을 시작하기 전에 이 메서드를 호출하여 또 다른 고루틴이 그룹에 참여함을 나타냅니다.delta
가 음수이면 카운터가 감소합니다.Done()
:WaitGroup
카운터를 하나씩 감소시킵니다. 일반적으로 고루틴 실행이 끝날 때 (종종defer
를 사용하여) 호출되어 완료되었음을 신호합니다.Add(-1)
과 동일합니다.Wait()
:WaitGroup
카운터가 0이 될 때까지 호출하는 고루틴을 차단합니다. 이는Add
된 모든 고루틴이Done
을 호출했음을 의미합니다.
WaitGroup
올바르게 구현하기
이전 예제를 sync.WaitGroup
을 사용하여 리팩토링해 보겠습니다.
package main import ( "fmt" "sync" "time" ) func processTaskWithWG(id int, wg *sync.WaitGroup) { // 중요: 고루틴을 종료하기 전에 Done()을 호출합니다. // defer는 오류가 발생해도 호출되도록 합니다. defer wg.Done() fmt.Printf("Task %d started\n", id) time.Sleep(time.Duration(id) * 100 * time.Millisecond) // 작업 시뮬레이션 fmt.Printf("Task %d finished\n", id) } func main() { var wg sync.WaitGroup // WaitGroup 선언 for i := 1; i <= 5; i++ { wg.Add(1) // 각 새 고루틴에 대해 WaitGroup 카운터 증가 go processTaskWithWG(i, &wg) // WaitGroup을 포인터로 전달 } // 모든 고루틴이 완료될 때까지 기다립니다. wg.Wait() fmt.Println("All tasks complete. Exiting main.") }
이 수정된 코드를 실행하면 모든 "Task X finished" 메시지가 "All tasks complete. Exiting main." 전에 일관되게 표시됩니다. main
고루틴은 이제 모든 processTaskWithWG
고루틴이 실행을 완료할 때까지 올바르게 기다립니다.
중요 고려 사항:
- 포인터 대 값:
WaitGroup
은 항상 포인터(*sync.WaitGroup
)로 고루틴에 전달해야 합니다. 값으로 전달하면 각 고루틴은WaitGroup
의 복사본을 받게 되며,Done()
호출은 원래main
고루틴의WaitGroup
이 아닌 로컬 복사본만 감소시킵니다. 이는 일반적인 함정입니다. go
전에Add
: 새 고루틴을 시작하기 전에wg.Add(1)
을 호출합니다. 고루틴 내에서Add
를 호출하면main
고루틴이 새 고루틴이 카운터를 증가시키기 전에wg.Wait()
를 실행하여Wait()
가 조기에 차단되는 경쟁 상태가 발생할 수 있습니다.defer wg.Done()
:defer
를 사용하여wg.Done()
을 호출하면 고루틴이 패닉하거나 오류로 인해 조기에 반환되더라도 카운터가 감소됩니다. 이렇게 하면Wait
메서드가 무기한 차단되는 것을 방지할 수 있습니다(교착 상태).
더 복잡한 예제: Fan-Out 및 Fan-In 패턴
WaitGroup
은 여러 작업자로 작업을 분배(fan-out)한 다음 결과를 수집(fan-in)하는 fan-out/fan-in 패턴을 구현하는 데 탁월합니다. WaitGroup
자체는 결과를 수집하지 않지만(일반적으로 채널이 사용됨), 수집된 결과를 처리하기 전에 모든 작업자가 완료되었는지 확인합니다.
여러 URL에서 동시에 데이터를 가져온 다음 모든 응답을 처리한다고 가정해 보겠습니다.
package main import ( "fmt" "io/ioutil" "net/http" "sync" "time" ) // fetchData는 URL에서 데이터를 가져와 채널로 보냅니다. func fetchData(url string, results chan<- string, wg *sync.WaitGroup) { defer wg.Done() // WaitGroup 카운터가 감소되도록 합니다. fmt.Printf("Fetching %s...\n", url) resp, err := http.Get(url) if err != nil { results <- fmt.Sprintf("Error fetching %s: %v", url, err) return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { results <- fmt.Sprintf("Error reading body from %s: %v", url, err) return } results <- fmt.Sprintf("Content from %s (first 50 chars): %s", url, string(body)[:min(50, len(body))]) } func min(a, b int) int { if a < b { return a } return b } func main() { urls := []string{ "http://example.com", "http://google.com", "http://bing.com", "http://invalid-url-example.com", // 오류를 유발합니다. } var wg sync.WaitGroup // 결과를 보유할 버퍼링된 채널을 생성합니다. URL 수만큼 크기를 지정합니다. // 이렇게 하면 수신기가 느린 경우 송신자가 차단되는 것을 방지할 수 있습니다. results := make(chan string, len(urls)) fmt.Println("Starting data fetching...") for _, url := range urls { wg.Add(1) // 항상 고루틴을 시작하기 전에 추가합니다. go fetchData(url, results, &wg) } // 모든 가져오기 고루틴이 완료된 후 결과 채널을 닫는 고루틴을 시작합니다. // 이것은 "results" 채널에 대한 범위 루프가 언제 종료되어야 하는지 알리는 데 중요합니다. go func() { wg.Wait() // 모든 fetchData 고루틴이 완료될 때까지 기다립니다. close(results) // 채널 닫기 }() // 들어오는 대로 또는 모두 수집된 후에 결과를 처리합니다. // 채널의 범위 루프를 사용하면 모든 결과를 처리할 수 있습니다. fmt.Println("\nProcessing fetched data:") for res := range results { fmt.Println(res) } fmt.Println("\nAll data processing complete. Exiting main.") time.Sleep(time.Second) // 출력 순서를 보장하기 위해 잠시 기다립니다. }
이 fan-out/fan-in
예제에서:
- 각 URL에 대해
fetchData
고루틴을 시작하고wg.Add(1)
를 사용하여 각 고루틴을 추적합니다. - 각
fetchData
고루틴은 완료(또는 오류) 시wg.Done()
을 호출합니다. - 별도의 익명 고루틴이
wg.Wait()
를 호출한 다음close(results)
를 호출하는 역할을 합니다. 채널을 닫으면for res := range results
루프에 더 이상 값이 전송되지 않음을 신호하여 정상적으로 종료할 수 있습니다. 이것이 없으면 메인 고루틴의range results
루프는 모든 항목이 처리된 후 무기한 차단됩니다. main
고루틴은results
채널을 반복하여 각 가져온 데이터를 인쇄합니다.
이 패턴은 동시 데이터 처리에 매우 일반적이고 강력합니다.
모범 사례 및 일반적인 함정
- 고루틴 내에서
Add
하지 마세요: 앞에서 논의한 바와 같이, 생성된 고루틴 내에서wg.Add(1)
을 호출하면 경쟁 상태가 발생할 수 있습니다. 항상 고루틴을 생성하기 전에 카운터를 증가시키세요. - 항상
defer wg.Done()
: 카운터가 감소되도록 보장하는 가장 견고한 방법입니다. - 포인터로 전달:
sync.WaitGroup
은 고루틴에 포인터(*sync.WaitGroup
)로 전달해야 합니다. Wait
가 호출된 후Add
피하기:Wait()
가 반환되면WaitGroup
은 이론적으로 재사용될 수 있습니다. 그러나Wait()
가 호출된 후에 카운터를Add
하는 것은 다른 고루틴이 동일한WaitGroup
인스턴스를 기다리고 있거나 새로 기다리는 경우 정의되지 않은 동작이나 패닉을 초래할 수 있습니다. 특히Wait()
가 루프에서 호출되는 경우 각 동시 작업 배치에 대해 새WaitGroup
을 만드는 것이 일반적으로 더 안전합니다.- 제로 값
WaitGroup
:WaitGroup
은 선언 후 직접 사용할 수 있습니다(제로 값은 사용할 준비가 되어 있음).sync.WaitGroup{}
으로 초기화할 필요는 없습니다.
결론
sync.WaitGroup
은 Go의 동시성 도구 상자에서 필수적인 도구입니다. 여러 고루틴의 완료를 조정하고 조기 종료 및 경쟁 상태와 같은 일반적인 동시성 버그를 방지하는 간단하면서도 효과적인 메커니즘을 제공합니다. Add
, Done
, Wait
메서드를 마스터하고 모범 사례를 준수함으로써 더 견고하고 예측 가능하며 성능이 뛰어난 동시 애플리케이션을 구축할 수 있습니다. 이는 더 복잡한 동시 패턴의 기본 골격을 형성하며 모든 진지한 Go 개발자의 기본 구성 요소입니다.