실전 패턴 — 워커 풀, 파이프라인, 팬아웃/팬인
워커 풀 — 동시 실행 개수를 제한하고 싶을 때
작업이 1만 개 있는데 전부 go로 한꺼번에 띄우면 어떻게 될까. 각 작업이 외부 API를
부르는 거라면, 그 API 서버가 1만 개의 동시 연결을 받고 버텨줄 리가 없다. 워커 풀은
"동시에 실행되는 작업 수를 N개로 제한한다"는 문제에 대한 표준 답이다. 아이디어는
단순하다 — 작업을 채널에 넣고, 정해진 개수의 워커 고루틴이 그 채널에서 하나씩 꺼내
처리한다.
func workerPool(jobs <-chan int, results chan<- int, workerCount int) {
var wg sync.WaitGroup
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs { // jobs가 닫히면 자동으로 루프 종료
results <- job * job
}
}()
}
wg.Wait()
close(results) // 모든 워커가 끝난 뒤에만 닫는다
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
go func() {
for i := 1; i <= 20; i++ {
jobs <- i
}
close(jobs)
}()
go workerPool(jobs, results, 4) // 워커 4개만 동시 실행
for r := range results {
fmt.Println(r)
}
}워커 개수 4는 임의로 고른 게 아니어야 한다 — 외부 API의 rate limit, DB 커넥션 풀
크기, 또는 CPU 바운드 작업이라면 runtime.NumCPU() 같은 실제 제약에서 역산해야
한다. "일단 10개로 하자"는 식으로 정하면 나중에 왜 10인지 아무도 설명하지 못하는
매직 넘버가 된다.
파이프라인 — 단계를 채널로 잇기
여러 단계를 거치는 처리(읽기 → 변환 → 검증 → 저장)를 각 단계별 함수가 채널을 입력받아 채널을 리턴하는 형태로 이으면, 단계마다 독립적으로 동시 실행되면서도 코드는 순서대로 읽힌다.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func main() {
for v := range square(generate(1, 2, 3, 4)) {
fmt.Println(v) // 1, 4, 9, 16
}
}generate가 값을 다 밀어 넣는 동안 square는 이미 앞에서 들어온 값을 처리하고
있을 수 있다 — 파이프라인 전체가 하나의 흐름처럼 보이지만 실제로는 단계마다 다른
고루틴이 동시에 돈다. 각 단계가 defer close(out)으로 자기 출력 채널을 닫는다는
규칙을 지키기 때문에, 마지막 range가 자연스럽게 끝난다. 3장에서 배운 "송신자만
채널을 닫는다"는 규칙이 여기서도 그대로 적용된다 — 각 단계 함수가 out의 유일한
송신자이므로 자기가 닫는 게 맞다.
이 패턴들, 정말 필요한가
여기까지 읽고 나면 "일단 워커 풀이나 파이프라인으로 짜고 보자"는 유혹이 생길 수
있는데, 솔직히 말하면 실무 코드의 상당수는 이런 패턴 없이 errgroup 하나로 끝난다.
golang.org/x/sync/errgroup은 표준 라이브러리는 아니지만 Go 팀이 관리하는
사실상 표준에 가까운 패키지다 — 여러 고루틴을 띄우고, 그중 하나라도 에러를 내면
나머지에게 취소를 전파하고, 첫 에러를 모아서 리턴해 준다.
g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
url := url // Go 1.22 미만이면 필요 (7장 참고)
g.Go(func() error {
return fetch(ctx, url)
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}작업 개수가 적고(수십 개 이하), 서로 결과를 스트리밍할 필요 없이 "다 끝나면 한꺼번에
결과를 본다"는 요구라면 errgroup이 워커 풀보다 코드도 짧고 에러 처리도 깔끔하다.
워커 풀과 파이프라인은 작업이 대량이거나(수천~수만 개), 단계별로 처리량이 다르거나,
스트리밍하듯 결과를 흘려보내야 하는 상황을 위한 도구다. 동시성 패턴을 쓰는 것
자체가 목표가 되면 안 된다. 이 챕터의 패턴들은 문제가 요구할 때 꺼내 쓰는 것이지,
Go 코드는 응당 이래야 한다는 표준 답안이 아니다.
이 책에서 다룬 걸 한 줄로 정리하면: 고루틴은 값싸지만 관리 책임은 여전히 개발자
몫이고, 채널은 값을 옮기는 동시에 동기화 지점을 만들며, context는 그 위에 취소를
얹는 도구다. 이 세 가지가 왜 이렇게 설계됐는지 이해하고 나면, 새로운 동시성 문제를
만났을 때 라이브러리를 찾아 헤매기 전에 이 셋의 조합으로 먼저 풀어보게 될 것이다.