
errgroup에서 '전부 중단하기'를 그만둔 이야기
요약
Go 언어의 errgroup 사용 시 발생하는 '전부 중단' 문제를 해결하기 위해, 도메인 에러와 제어용 에러를 분리하여 부분적 실패(Partial Failure)를 허용하도록 설계하는 방법을 다룹니다.
핵심 포인트
- errgroup.WithContext는 하나의 에러만 발생해도 전체 컨텍스트를 취소함
- 도메인 상의 실패와 제어상의 에러를 구분하여 설계해야 함
- 부분적 실패가 필요한 경우 에러 반환 방식을 재설계하여 해결 가능
- Go 1.22 이후의 루프 변수 캡처 동작을 고려한 구현 필요
개인 개발 중인 코드 리뷰 서비스에서, errgroup의 표준적인 사용법이 상황에 맞지 않는 경우를 마주했습니다. 필요했던 것은 "전부 성공하거나, 전부 실패하거나"가 아니라, "실패한 리뷰만 버리고, 그 외의 결과는 반환한다"라는 부분적 실패 (Partial Failure)의 허용이었습니다.
결론부터 말씀드리면, errgroup을 그만둔 것은 아닙니다. goroutine으로부터 errgroup에 "어떤 에러를 반환할 것인가"를 다시 설계함으로써 해결했습니다. 이번 문제의 핵심은 도메인 상의 실패와, 처리를 중단해야 하는 제어상의 에러를 반환값으로 분리했다는 점에 있습니다.
참고로, 이 기사의 코드는 Go 1.22 이후를 전제로 합니다. 1.21 이전에는 루프 변수 캡처 (Loop Variable Capture) 동작이 다르기 때문에, 그 점을 코드 측에도 주석으로 남기겠습니다.
이 서비스에서는 하나의 Pull Request에 대해 여러 관점의 리뷰를 병렬로 실행하고 있습니다. 관점은 다음 세 가지입니다.
- Security
- Performance
- Design
어느 날, Security 리뷰만 LLM API 에러로 인해 실패했습니다. Performance와 Design은 정상적으로 끝났습니다.
원했던 응답은 다음과 같은 형태였습니다.
Security : Error
Performance : OK
Design : OK
하지만 실제로는 리뷰 전체가 실패 처리되어 아무것도 반환되지 않았습니다. errgroup.WithContext가 반환하는 공유 Context가 Security의 실패를 계기로 취소(Cancel)되었고, 아직 실행 중이던 Performance와 Design도 그 취소 신호를 받아 중단되었기 때문입니다.
Performance와 Design의 결과까지 버릴 이유는 없습니다. 이때 처음으로 "하나가 실패하면 전부 중단한다"라는 전제가 정말 필요한 것인지 고민하게 되었습니다.
우선은 순수하게 sync.WaitGroup으로 작성했습니다. 병렬 실행하여 결과를 수집하는 것만이라면 WaitGroup, Mutex, 결과를 담을 슬라이스(Slice)만으로 충분합니다.
var wg sync.WaitGroup
var mu sync.Mutex
results := make([]ReviewResult, len(tasks))
...
리뷰의 실패 그 자체는 ReviewResult.Error에 담아두면 부분적 실패로서 표현할 수 있습니다. 여기까지는 문제가 없습니다.
문제는 타임아웃(Timeout)이었습니다. 타임아웃이 발생하면 아직 동작 중인 리뷰도 중단하고 싶습니다. 하지만 sync.WaitGroup이 제공하는 것은 goroutine의 종료 대기뿐이며, 에러의 집약(Aggregation)이나 취소의 전파(Propagation)는 대상이 아닙니다. 따라서 이를 구현하려고 하면 다음과 같은 처리를 모두 직접 구현해야 합니다.
context.WithCancel()로 공유 컨텍스트(Context)를 만든다 - 그 cancel을 모든 goroutine에서 볼 수 있도록 전달한다 - 각 goroutine 측에서 ctx.Done()을 감시한다 - 중단해야 할 에러를 감지하면 cancel()을 호출한다
병렬 실행 그 자체보다 "취소를 어떻게 전파할 것인가"라는 배선(Wiring) 작업이 더 무겁게 느껴졌습니다. 도메인 로직보다 제어를 위한 코드가 더 눈에 띄게 됩니다.
여기서 다시 한번 errgroup의 문서를 다시 읽었습니다.
errgroup이 하고 있는 일은 크게 세 가지입니다. goroutine의 실행을 그룹으로서 관리하는 것. Wait()가 처음으로 반환된 non-nil 에러를 반환하는 것. 그리고 어느 하나의 goroutine이 non-nil 에러를 반환했을 때, WithContext로 만든 공유 Context를 취소하는 것입니다.
관점이 바뀐 것은 이 세 번째—에러와 Context의 취소를 연결하고 있는 부분—이, 방금 전 WaitGroup으로 직접 작성하려 했던 배선 그 자체라는 것을 깨달았을 때였습니다. "에러를 감지하면 공유 컨텍스트를 취소한다"를 라이브러리가 대신해주고 있었을 뿐입니다.
여기서 제목의 "전부 중단하기"에 한 가지 정정을 하겠습니다. errgroup은 다른 goroutine을 강제로 정지시키는 것이 아닙니다. 하고 있는 일은 공유 Context의 취소일 뿐이며, 실제로 처리가 멈추는 것은 각 goroutine이 해당 Context
를 감시하며 스스로 빠져나오기 때문입니다. "하나가 실패하면 나머지도 멈춘다"를 성립시키는 것은 errgroup 단독이 아니라, Context가 말단까지 전달되고 있다는 점에 있습니다.
그렇게 다시 파악하면, 취소의 트리거(Trigger)는 "goroutine이 non-nil 에러를 반환하는 것"임을 알 수 있습니다. 그렇다면 그 트리거를 당길지 말지는, 우리가 반환값(return value)을 선택함으로써 컨트롤할 수 있습니다.
그래서 goroutine의 반환값을 에러의 종류에 따라 구분하여 내보내기로 했습니다.
- 리뷰 API의 실패(도메인 상의 실패)는 부분 실패로서 허용하고 싶습니다. 그래서
errgroup에 에러를 반환하지 않고, 결과 구조체 안에 유지하며return nil합니다. - 타임아웃이나 부모 컨텍스트로부터의 중단 (
context.DeadlineExceeded/context.Canceled)은 전체를 멈춰야 하는 제어상의 에러이므로, 그대로return err하여errgroup의 취소를 발화(fire)시킵니다.
g, gctx := errgroup.WithContext(ctx)
results := make([]ReviewResult, len(tasks))
for i, task := range tasks {
...
WaitGroup 버전과 달리, 이 errgroup 버전에서는 각 goroutine이 서로 다른 인덱스(index)에만 쓰기 때문에 Mutex를 제거했습니다. 공유 슬라이스(slice)에 대한 쓰기를 보호하는 역할은 남아있지 않습니다.
보충하자면, 이 설계는 runReview에 gctx (errgroup.WithContext가 반환한 Context)를 말단까지 전달해야만 성립합니다. 앞 장에서 썼듯이, errgroup은 처리를 강제 중단하지 않습니다. 타임아웃으로 gctx가 취소되었을 때, 그것을 적용시키는 것은 각 goroutine의 내부 로직입니다. runReview 내부에서 HTTP 요청에 gctx를 전달하거나, gctx.Done()을 감시하는 배선(wiring)이 없다면, return err로 errgroup이 취소를 발화하더라도 실행 중인 처리는 멈추지 않습니다.
한마디로 요약하자면, "errgroup을 사용하지 않는다"가 아니라, errgroup에 반환할 에러와 리뷰 결과로서 유지할 도메인 에러를 분리했을 뿐입니다. "에러를 반환할 것인가, nil을 반환할 것인가"라는 Go의 표준적인 반환값 제어만으로, 공통 타임아웃 처리를 활용하면서도 한 관점의 실패로 인한 전멸을 방지할 수 있었습니다.
이번 구현에서 가장 변한 것은 errgroup을 바라보는 관점이었습니다.
이전에는 "전부 멈추는 라이브러리"라고 생각했습니다. Fail-fast 용도로는 그 이해도 틀린 것은 아니지만, 이번과 같이 부분 실패를 허용하는 설계는 그 이해만으로는 설명할 수 없었습니다. 실제 errgroup은 "non-nil 에러를 트리거로 공유 Context를 취소하는 라이브러리"이며, 멈출지 말지를 결정하는 것은 errgroup이 아니라 우리가 어떤 에러를 반환하느냐입니다.
그리고 이 글에서 정말 쓰고 싶었던 것은 errgroup의 사용법 그 자체가 아닙니다. 도메인 상의 실패와 제어 흐름상의 에러를 goroutine의 반환값으로 명확히 분리한 것—이 설계 판단에 관한 것입니다. errgroup은 그 판단을 솔직하게 표현할 수 있는 그릇이었을 뿐입니다.
라이브러리의 사용법보다 그 라이브러리가 무엇을 추상화(abstraction)하고 있는지를 이해하는 것이 설계의 선택지를 넓혀준다고 느꼈습니다.
이 글의 설계를 다듬는 과정에서는 LLM과의 대화도 활용했습니다. 요구사항의 트레이드오프(trade-off)를 정리하고, "WaitGroup으로 직접 작성하면 무엇이 복잡해지는가"를 언어화하는 벽치기(sparring)를 통해, errgroup을 선택하는 이유와 에러를 분리한다는 타협점을 빠르게 확정할 수 있었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기