Go의 동시성 제한: errgroup, 워커 풀, 백프레셔

취소만 하려는 것이 아니라, 진행 중인 작업을 캡(상한)으로 제한하세요.

Page content

무제한 팬아웃은 Go 병행 처리의 기본 실패 모드입니다. 모든 go 문장은 상한이 없고 소유자도 없는 상태로 비행 중인 태스크 하나를 더 추가할 뿐입니다. 이 글은 그 두 가지를 부여하는 방법에 관한 것입니다.

취소(cancellation)와 제한(bounding)은 별개의 제어 수단입니다. 취소는 작업이 언제 중단될지 결정하고, 제한은 동시에 얼마나 많은 작업이 존재할지 결정합니다. 서비스가 완벽하게 취소 기능을 수행하더라도, 만 개의 고루틴이 각각 독립적으로 “이제 데이터베이스를 호출할 좋은 시간"이라고 판단하여 서비스를 무너뜨릴 수 있습니다.

조절된 게이트를 통해 빛나는 입자 흐름을 조절하는 유리 퍼널

이 페이지를 둘러싼 페이지들은 문제의 나머지 절반을 다룹니다. Go context.Context 제대로 활용하기는 작업을 정지하기 위한 제어 평면(control plane)으로서의 컨텍스트를 다루며, 여기서의 주제는 용량 평면(capacity plane)입니다. 이는 errgroup.SetLimit, 시그널 채널(semaphore channels), 워커 풀, 유한 큐(bounded queues)를 포함하며, 모든 취소 경로가 제대로 연결되어 보일 때조차 errgroup.Wait에서 살아남는 단 하나의 누수 형태에 관한 것입니다.

다섯 가지 원시 프리미티브, 하나의 의사결정 표

프리미티브 부분 결과 첫 오류 시 빠른 실패(Fail-fast) 백프레셔(Backpressure) 동적 작업 최적 용도
sync.WaitGroup + errors.Join 예 아니오 아니오 아니오 모든 결과가 중요한 대기-전체(wait-all) 배치
errgroup.WithContext 아니오 예 아니오 아니오 첫 오류가 요청을 종료하는 팬아웃
errgroup.SetLimit(n) 아니오 예 예 (Go 블로킹) 아니오 제한된 빠른 실패 팬아웃
시그널 채널 예 수동 예 (acquire 블로킹) 예 맞춤형 진입 제어(admission control)
워커 풀 예 수동 예 (큐 가득 참) 예 안정적 스트림, 긴 수명 워커

두 가지 질문이 행을 선택합니다. 첫째: 하나의 태스크가 실패했을 때, 다른 태스크들의 결과를 여전히 원하는가? 네라면, 빠른 실패 그룹 시맨틱은 필요했던 작업을 폐기하게 됩니다. 둘째: 작업이 지속적으로 도착하는가, 아니면 시작 전 세울 수 있는 고정 배치인가? 그룹은 배치를, 풀과 큐는 스트림을 처리합니다.

errors.Join은 단순한 WaitGroup 행을 실행 가능하게 만드는 요소입니다 — 첫 번째 오류만 유지하는 대신 모든 워커의 오류를 수집하며, 이는 Go 에러 처리 아키텍처의 경계 번역 규칙과 잘 조합됩니다.

errgroup.Wait을 견디는 누수

가장 잘 알려진 errgroup 실패 모드는 제한과 아무 관련이 없습니다. 이는 취소 갭(cancellation gap)이며, 올바르게 보이던 코드 뒤에 숨어 있습니다:

g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // unbuffered

go func() { // 프로듀서 — 이 고루틴의 소유자 없음
    for i := 0; i < 100; i++ {
        results <- i
    }
    close(results)
}()

g.Go(func() error { // 컨슈머 — 그룹 멤버
    for r := range results {
        if r == 5 {
            return fmt.Errorf("save failed")
        }
    }
    return nil
})

err := g.Wait() // 컨슈머의 오류에서 반환

컨슈머가 반환하면, gctx가 취소되고 Wait가 반환됩니다. 프로듀서는 results <- i에 머물러(parked) 있고 — 컨텍스트 취소는 채널 전송을 언블록(unblock)하지 않습니다. 그 고루틴의 소유자가 없으므로, 프로세스의 수명 동안 그대로 머물러 있게 됩니다. 이 형태를 루프에서 실행하면 누수는 정확히 선형적입니다:

변형 500회 반복 후 활성 고루틴 수
ctx.Done 아ーム 없이 전송 501 (+500 누수, 반복당 1개)
<-gctx.Done()을 가진 select로 전송 래핑 502 (+1 베이스라인)
// 해결책: 취소 가능한 작업 내부의 모든 채널 연산에
// Done 아ーム을 추가
select {
case results <- i:
case <-gctx.Done():
    return
}

같은 갭은 수신(receive) 쪽에도 존재합니다. 규칙은 기계적입니다: 취소될 수 있는 코드 내부의 모든 채널 연산에는 <-ctx.Done() 아ーム이 있어야 하며, 모든 고루틴은 이를 기다리거나 취소하는 소유자가 있어야 합니다. CI에서 goleak은 리뷰가 놓치는 사례를 포착합니다 — Go 린터: 코드 품질을 위한 필수 도구의 도구들과 동일한 자동화 품질 게이트에 슬롯됩니다.

관련된 두 가지 형태를 명시하는 가치가 있습니다. 프로듀서가 그룹 멤버인 경우, 같은 코드는 누수가 아닌 디드록(deadlock)을 발생시킵니다 — Wait가 주차된 전송을 위해 영원히 블로킹되며, 적어도 이는 크게 실패(fails loudly)합니다. 그리고 프로듀서가 select로 전송하지만 컨슈머가 종료된 채널에서 수신하는 경우에도 같은 방식으로 누수가 발생합니다; Done 아ーム은 양쪽 모두에 필요합니다.

errgroup.SetLimit: 제한된 빠른 실패 팬아웃

SetLimit은 errgroup을 진입 제어(admission-controlled) 그룹으로 전환합니다. 제한을 초과하는 각 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가 블로킹되므로, 위의 루프는 프로듀서에게 백프레셔를 적용합니다 — 이는 일반적으로 원하는 바이지만, 호출자의 고루틴이 멈출 수 있음을 의미하므로, 대규모 배치를 공급하는 요청 핸들러는 전체 루프 주위의 자체 타임아웃 예산이 필요합니다. SetLimit(0)은 모든 Go 호출을 영원히 블로킹합니다. 그리고 고루틴이 활성인 동안 제한을 변경하면 패닉(panic)이 발생합니다 — 제한은 그룹의 수명에 걸쳐 고정됩니다.

SetLimit은 그룹 시맨틱을 상속합니다: 첫 번째 오류가 컨텍스트를 취소하고 Wait가 첫 번째 오류를 반환하며, 아직 비행 중인 작업의 결과를 폐기합니다. 이는 첫 번째 실패 시 전체 작업을 실패시키는 것이 올바른 경우에 적합한 프리미티브입니다 — 데이터 페이지 하나를 가져오기, 독립적인 헬스 체크 세트를 호출하기, 요청을 샤드로 팬아웃하기.

시그널 채널: 백프레셔를 기능으로

용량이 n인 버퍼드 채널은 의존성 없이 시그널(semaphore) 역할을 합니다:

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 페어링 불필요 boilerplate를 제거합니다. 셧다운 계약은 올바르게 해야 할 부분입니다: jobs를 닫으면 큐가 비워진 후 워커를 종료하고; ctx를 취소하면 큐에 쌓인 작업을 포기합니다. 둘 다 합법적이지만, 문서화된 동작은 하나만 가능합니다.

유한 큐: 블로킹 또는 드롭

버퍼드 채널은 상한이 있는 큐이기도 합니다. 버퍼가 가득 차면 프로듀서는 블로킹되며 — 그 순간이 설계 결정입니다. 블로킹은 큐에 공급하는 사람에게서 상류(upstream)로 압력을 전파하고; 드롭은 작업 손실의 대가로 부하를 벗겨냅니다. 모니터링 파이프라인은 드롭할 수 있지만; 주문 파이프라인은 블로킹하거나 내구성 저장소로 스피일해야 합니다.

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

무제한 실행은 벽 시간에서 승리합니다. 실험 내에서 반발하는 것이 아무것도 없기 때문입니다 — 만 개의 타이머 고루틴은 단순히 병렬로 수면합니다. 이것이 바로 함정입니다. 무제한 팬아웃의 비용은 자신의 프로세스 내 CPU가 결코 아닙니다; 그것은 만 개의 동시 연결, 버스트 하에서 타임아웃을 시작하는 다운스트림, 그리고 당신이 호출하는 사람의 큐 깊이를 따르는 메모리 그래프입니다. 실제 의존성에서는, 무제한 실행은 18ms에 완료되지 않습니다 — 타임아웃됩니다. 제한된 실행이 6.6초를 지불하는 이유는 10,000개 태스크 ÷ 8 슬롯 × 5 ms는 회피 불가능한 직렬화 6.25 s이기 때문이며, 상한이 핵심입니다: 무제어된 버스트를 예측 가능한 6.25초 드레인으로 전환합니다.

상한이 손가락으로 셀 수 있는 슬롯보다 높은 경우, 하나의 개선이 중요합니다: 상한을 다운스트림이 동시에 감당할 수 있는 것 이하로 유지하고, “합리적” 병렬성에 대한 감각이 아니라 그 제한(커넥션 풀 크기, 레이트 리밋, 워커 용량)에서 파생시키세요.

제한된 코드 측정 및 테스트

세 가지 시그널이 대부분의 것을 커버합니다. 고루틴 수 추세(Prometheus Go collector를 통해 내보내는 /sched/goroutines:goroutines)는 기울기로 누수를 포착합니다. 스케줄러 지연(/sched/latencies:seconds)은 포화 상태를 포착합니다 — CPU 시간을 기다리는 러너블(runnable) 고루틴은 횟수에 관계없이 실제 압력입니다. 두 고루틴 덤프의 pprof 델타는 주차된 고루틴이 어디에 있는지 정확히 지적합니다.

테스트에 있어, 제한된 워커는 synctest로 동시 Go 코드 테스트하기가 구축된 경우가 됩니다: 가짜 시간은 전체 10,000개 태스크 드레인을 밀리초 단위로 실행하게 하고, synctest.Wait는 휴식(quiescence)을 위한 sleep-and-hope를 대체하며, 워커가 영구적으로 블로킹되면 버블이 빠르게 실패합니다 — 위의 누수 재현은 프로덕션이 아니라 테스트 버블에서 죽습니다.

서비스 규모에서, 같은 제한 문제는 서비스 사이에 다시 나타나며,那里 시그널은 레이트 리밋이 되고 서킷 브레이커가 다운스트림을 보호합니다 — Go에서의 서킷 브레이커 패턴가 그 레이어를 다루며, AI/ML 오케스트레이션을 위한 Go 마이크로서비스는 오케스트레이션 티어가 취하는 큐 기반 형태를 보여줍니다.

선택

flowchart TD A[병렬로 실행할 작업] --> B{고정 배치 또는 연속 스트림?} B -- 연속 스트림 --> C[워커 풀 또는 유한 큐] B -- 고정 배치 --> D{첫 실패 시
부분 결과 필요?} D -- 예 --> E[WaitGroup + errors.Join
또는 시그널 채널] D -- 아니오 --> F[errgroup.WithContext] F --> G{병행성 상한 필요?} G -- 예 --> H[errgroup.SetLimit] G -- 아니오 --> I[제한 없는 그룹
배치가 증명적으로 작을 때만] C --> J{큐 가득 참: 블로킹 또는 드롭?} J -- 블로킹 --> K[상류 백프레셔] J -- 드롭 --> L[부하 벗기기, 드롭 카운팅]

표와 플로우차트는 하나의 습관으로 압축됩니다: go를 작성하기 전에 상한(ceiling)과 소유자(owner)를 명명하세요. 상한은 SetLimit, 시그널, 또는 유한 큐입니다; 소유자는 Wait, 모든 채널 연산의 ctx.Done 아ーム, 또는 둘 모두입니다. 두 이름을 한눈에 답할 수 있는 모든 고루틴 생성 사이트는 3시에 누구를 페이지(page)하지 않을 것입니다.

유용한 링크

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.