Go에서 병렬성 제한하기: errgroup, 워커 풀 및 백프레셔
취소만 할 게 아니라, 진행 중인 작업을 제한하세요.
무한한 팬아웃(fan-out)은 Go 동시성에서 가장 흔한 실패 모드입니다. 모든 go 문장은 상한선 없이, 소유자 없이 비행 중인 태스크 하나를 더 추가합니다. 이 글은 그 둘—상한선과 소유자—을 부여하는 법에 관한 것입니다.
취소(cancellation)와 경계 설정(bounding)은 별개의 제어 수단입니다. 취소는 작업이 언제 멈추는지를 결정하고, 경계 설정은 동시에 얼마나 많은 작업이 존재하는지를 결정합니다. 서비스가 완벽하게 취소를 처리하더라도, 한 만 개의 고루틴이 각각 독립적으로 “지금 데이터베이스를 호출하기에 좋은 타이밍"이라고 판단해서 서비스가 무너질 수 있습니다.

이 글 주변 페이지들은 문제의 나머지 절반을 다룹니다. Go context.Context 제대로 사용하기에서는 컨텍스트를 작업 정지를 위한 제어 플레인(control plane)으로 다루지만, 여기서의 주제는 용량 플레인(capacity plane)—errgroup.SetLimit, 세마포 채널, 워커 풀, 유한 큐—입니다. 그리고 모든 취소 경로가 제대로 연결되어 보임에도 errgroup.Wait 이후에도 남아있는 한 가지 형태의 리크도 다룹니다.
다섯 가지 프리미티브, 하나의 결정 테이블
| 프리미티브 | 부분 결과 | 즉시 실패 (Fail-fast) | 백프레셔 (Backpressure) | 동적 워크 | 최적 용도 |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
네 | 아니오 | 아니오 | 아니오 | 모든 결과가 중요한 대기형 배치 |
errgroup.WithContext |
아니오 | 네 | 아니오 | 아니오 | 첫 번째 에러가 요청을 종료하는 팬아웃 |
errgroup.SetLimit(n) |
아니오 | 네 | 네 (Go 차단) |
아니오 | 경계 설정이 된 즉시 실패 팬아웃 |
| 세마포 채널 | 네 | 수동 | 네 (획득 시 차단) | 네 | 커스텀 승인 제어 |
| 워커 풀 | 네 | 수동 | 네 (큐 포화 시) | 네 | 안정적 스트림, 장수명 워커 |
두 가지 질문으로 행을 선택합니다. 첫째: 하나의 태스크가 실패했을 때, 다른 태스크들의 결과를 여전히 원합니까? 네라면, 즉시 실패 그룹의 의미론이 필요한 작업을 폐기하게 됩니다. 둘째: 작업이 지속적으로 도착하는가, 아니면 시작 전에 수를 셀 수 있는 고정된 배치인가? 그룹은 배치를, 풀과 큐는 스트림을 처리합니다.
errors.Join은 단순한 WaitGroup 행이 실행 가능한 이유가 됩니다 — 그것은 첫 번째 에러만 유지하는 대신 모든 워커의 에러를 수집하므로, Go 에러 처리 아키텍처에서 다룬 경계 번역 규칙과 잘 짝을 이룹니다.
errgroup.Wait를 살아남는 리크
가장 잘 알려진 errgroup 실패 모드는 경계 설정과 무관합니다. 이것은 취소 간극(cancellation gap)이며, 코드가 올바르게 보이기 때문에 숨겨져 있습니다:
g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // unbuffered
go func() { // producer — 이 고루틴을 소유하는 사람이 없음
for i := 0; i < 100; i++ {
results <- i
}
close(results)
}()
g.Go(func() error { // consumer — 그룹 멤버
for r := range results {
if r == 5 {
return fmt.Errorf("save failed")
}
}
return nil
})
err := g.Wait() // consumer의 에러에서 반환
consumer가 반환하면 gctx가 취소되고 Wait가 반환됩니다. producer는 results <- i에서 대기(parked) 상태입니다 — 그리고 컨텍스트 취소는 채널 전송을 언블록하지 않습니다. 해당 고루틴을 소유하는 사람이 없으므로, 프로세스가 살아있는 동안 대기 상태를 유지합니다. 이 형태를 루프로 실행하면 리크는 정확히 선형적입니다:
| 변형 | 500회 반복 후 활성 고루틴 수 |
|---|---|
ctx.Done 브랜치 없이 전송 |
501 (+500 리크, 반복당 1개씩) |
<-gctx.Done()와 함께 select로 감싼 전송 |
502 (+1 베이스라인) |
// 수정: 취소 가능한 태스크 내의 모든 채널 연산에
// Done 브랜치를 추가
select {
case results <- i:
case <-gctx.Done():
return
}
같은 간극은 수신 측에도 존재합니다. 규칙은 기계적입니다: 취소될 수 있는 코드 내의 모든 채널 연산에는 <-ctx.Done() 브랜치가 필요하며, 모든 고루틴은 대기하거나 취소하는 소유자가 있어야 합니다. CI에서 goleak은 리뷰가 놓치는 사례를 포착합니다 — Go Linters: 코드 품질을 위한 필수 도구에 있는 도구들과 동일한 자동화 품질 게이트에 배치됩니다.
두 가지 관련 형태를 명명할 가치가 있습니다. producer가 그룹 멤버인 경우, 같은 코드는 리크가 아닌 디드락을 발생시킵니다 — Wait는 대기 중인 전송을 위해 영원히 블로킹하므로, 적어도 시끄럽게 실패합니다. 그리고 select로 전송하지만 consumer가 종료된 채널에서 수신하는 producer는 같은 방식으로 리크됩니다; Done 브랜치는 양쪽에 필요합니다.
errgroup.SetLimit: 경계 설정이 된 즉시 실패 팬아웃
SetLimit은 errgroup을 승인 제어된 그룹으로 바꿉니다. 한도를 초과하는 각 Go 호출은 슬롯이 해제될 때까지 블로킹됩니다:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
return process(ctx, item)
})
}
err := g.Wait()
프로덕션에서 세 가지 의미론이 중요합니다. Go가 블로킹되므로, 위의 루프는 producer에 백프레셔를 적용합니다 — 보통 이것이 원하는 동작이지만, 호출자 고루틴이 정지할 수 있음을 의미합니다. 따라서 대량 배치를 전달하는 요청 핸들러는 루프 전체를 둘러싸는 자체적인 타임아웃 예산이 필요합니다. SetLimit(0)은 모든 Go 호출을 영원히 블로킹합니다. 그리고 고루틴이 활성인 동안 한도를 변경하면 패닉이 발생합니다 — 한도는 그룹의 수명 동안 고정됩니다.
SetLimit은 그룹 의미론을 상속합니다: 첫 번째 에러가 컨텍스트를 취소하고 Wait는 첫 번째 에러를 반환하며, 비행 중인 작업의 결과를 폐기합니다. 전체 작업을 첫 번째 실패 시 실패시키는 것이 올바른 경우의 올바른 프리미티브입니다 — 데이터 페이지 하나 가져오기, 독립적인 헬스 체크 세트 호출, 요청을 샤드로 팬아웃하기 등.
세마포 채널: 기능으로서의 백프레셔
용량 n인 버퍼드 채널은 종속성이 없는 세마포입니다:
sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // 8개의 태스크가 비행 중일 때 블로킹
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = process(ctx, item)
}()
}
wg.Wait()
획득(acquire)은 go 문장 이전에 발생하므로, 슬롯이 존재할 때만 고루틴이 생성됩니다. 버퍼 크기는 계약입니다: 그것은 동시에 동시성 상한선이자 대기 중인 승인들의 큐입니다.
그룹이 표현하지 않는 승인 제어가 필요할 때 이 프리미티브가 자리에 설 자격을 갖습니다 — 컨텍스트 인식 획득, 다운스트림별 풀, 또는 실패 시 부분 결과:
select {
case sem <- struct{}{}: // 슬롯 획득
case <-ctx.Done(): // 진입하기 전에 호출자가 포기
return ctx.Err()
}
같은 워크로드에서 측정한 결과, 세마포 채널과 SetLimit(8)은 1.5% 이내로 마감 시간을 맞춥니다 — 둘 사이의 선택은 성능이 아니라 의미론입니다.
워커 풀: 스트림을 위해, 배치를 위해가 아님
장수명 워커의 고정 풀은 승인(큐)과 실행(워커)을 분리합니다:
func pool(ctx context.Context, workers int) chan<- func() {
jobs := make(chan func())
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case f, ok := <-jobs:
if !ok {
return
}
f()
case <-ctx.Done():
return
}
}
}()
}
return jobs
}
풀은 지속적으로 도착하는 워크 — 큐 컨슈머, 폴링 루프, 요청 워커 — 에 적합합니다. 여기서 배치별 그룹은 끊임없이 생성되고 철거될 것입니다. Go 1.25 이후, sync.WaitGroup.Go는 일반적인 형태에서 Add/Done 페어링의 불필요한 코드를 제거합니다. 종료 계약(shutdown contract)을 올바르게 얻는 부분이 중요합니다: jobs를 닫으면 큐가 비워진 후 워커가 종료됩니다; ctx를 취소하면 큐에 남은 작업이 폐기됩니다. 둘 다 합법적이며, 하지만 문서화된 동작은 하나만일 수 있습니다. Go 1.27은 같은 방향으로 계속됩니다 — 제네릭 메서드, 새로운 go fix 모던라이저, 인메모리 httptest.NewTestServer — 모두 Go 1.27 업그레이드 가이드에서 다룹니다.
유한 큐: 블로킹 또는 드롭
버퍼드 채널은 상한선이 있는 큐이기도 합니다. 버퍼가 가득 차면, producer가 블로킹됩니다 — 그리고 그 순간이 디자인 결정입니다. 블로킹은 큐에 피드를 공급하는 사람에게 압력을 상류로 전파하고, 드롭은 작업 손실이라는 대가로 부하를 벗겨냅니다. 모니터링 파이프라인은 드롭할 수 있지만, 주문 파이프라인은 블로킹하거나 지속적 저장소로 스피밍(spill)해야 합니다.
select {
case queue <- event: // 승인
default: // 큐 포화: 드롭 정책이 여기 존재
dropped.Add(1)
}
정책이 무엇이든, 명시적이고 측정 가능하게 하세요. 침묵하는 default 브랜치는 이벤트가 사라지는 곳입니다.
경계 설정의 비용 — 측정된 값
같은 워크로드, 각 태스크당 5ms의 시뮬레이션 I/O를 가진 10,000개 태스크, Go 1.27.1:
| 전략 | 피크 고루틴 수 | 벽 시간 (Wall time) |
|---|---|---|
태스크당 무한한 go |
~10,000 발사 | 18 ms |
errgroup.SetLimit(8) |
8 | 6.9 s |
| 세마포 채널 (cap 8) | 8 | 6.6 s |
무한한 실행은 벽 시간에서 이깁니다. 실험 내에서 반격(push back)하는 것이 아무것도 없기 때문입니다 — 만 개의 타이머 고루틴이 단순히 병렬로 수면합니다. 이것이 정확히 그 함정입니다. 무한한 팬아웃의 비용은 자신의 프로세스 내 CPU가 결코 아닙니다; 그것은 만 개의 동시 연결, 버스트 하에서 타임아웃을 시작하는 다운스트림, 그리고 당신이 호출하는 대상의 큐 깊이를 따르는 메모리 그래프입니다. 실제 의존성에 대해서는, 무한한 실행은 18ms에 완료되지 않습니다 — 그것은 타임아웃됩니다. 경계 설정된 실행은 6.6초를 지불합니다. 왜냐하면 10,000개 태스크 ÷ 8 슬롯 × 5ms는 피할 수 없는 직렬화 6.25초이며, 캡(cap)의 목적은 제어되지 않은 버스트를 예측 가능한 6.25초의 배수로 바꾸는 것이기 때문입니다.
캡이 몇 개의 슬롯보다 높을 때 중요한 하나의 정교화가 있습니다: 캡을 다운스트림이 동시에 감당할 수 있는 수준 이하로 유지하고, 그것을 “합리적” 병렬성에 대한 느낌에서가 아니라 한계(연결 풀 크기, 레이트 리밋, 워커 용량)에서 파생해내세요.
경계 설정 코드의 측정 및 테스트
세 가지 신호가 대부분을 커버합니다. 고루틴 수 추세(Prometheus Go collector를 통해 내보낸 /sched/goroutines:goroutines)는 기울기로 리크를 포착합니다. 스케줄러 레이턴시(/sched/latencies:seconds)는 포화(saturation)를 포착합니다 — CPU 시간을 기다리는 런너블(runnable) 고루틴은 카운트에 관계없이 실제 압력입니다. 두 번의 고루틴 덤프에 대한 pprof 델타는 대기 중인 고루틴이 어디에 있는지 정확히 지정합니다.
테스트에 대해, 경계 설정된 워커는 Testing Concurrent Go Code with synctest가 만들어지기 위해 존재하는 경우입니다: 가짜 시간은 10,000개 태스크의 완전한 배수를 밀리초에 실행하게 하고, synctest.Wait는 침묵(quiescence)을 위한 sleep-and-hope를 대체하며, 워커가 지속적으로 블로킹된 채 남을 때 버블이 즉시 실패합니다 — 위의 리크 재현은 프로덕션에서가 아니라 테스트 버블에서 죽습니다.
서비스 규모에서, 같은 경계 설정 질문은 서비스 사이에서 다시 나타납니다. 여기서 세마포는 레이트 리밋이 되고 서킷 브레이커는 다운스트림을 가릅니다 — Circuit Breaker Pattern in Go가 그 계층을 다루고, Go Microservices for AI/ML Orchestration은 오케스트레이션 계층이 취하는 큐 기반 형태를 보여줍니다.
선택
} D -- 네 --> E[WaitGroup + errors.Join
또는 세마포 채널] D -- 아니오 --> F[errgroup.WithContext] F --> G{동시성 상한선 필요?} G -- 네 --> H[errgroup.SetLimit] G -- 아니오 --> I[한도 없는 그룹
배치가 입증되게 작은 경우에만] C --> J{큐 포화: 블로킹 또는 드롭?} J -- 블로킹 --> K[상류로 백프레셔] J -- 드롭 --> L[부하 감소, 드롭 수 카운트]
테이블과 플로우차트는 하나의 습관으로 압축됩니다: go를 작성하기 전에, 상한선과 소유자를 명명하세요. 상한선은 SetLimit, 세마포, 또는 유한 큐이고; 소유자는 Wait, 모든 채널 연산의 ctx.Done 브랜치, 또는 그 둘입니다. 한눈에 그 두 이름을 대답할 수 있는 모든 고루틴 생성 사이트는 새벽 3시에 누구에게도 페이지(page)를 보내지 않을 것입니다.