# errgroup으로 병렬 조회 묶기, 그리고 캐시 TTL을 정하는 기준
여러 외부 시스템에서 자원 정보를 모아 하나의 응답으로 만드는 API는 흔합니다. 순차로 호출하면 지연이 그대로 더해지고, go로 흩뿌리면 에러 처리와 취소가 곧바로 지저분해집니다. errgroup은 이 문제를 정리해 주지만, 동작을 정확히 모르면 첫 에러가 나머지 작업을 조용히 취소한다는 성질에 걸립니다.
이 글에서는 errgroup.WithContext가 언제 무엇을 취소하는지, 동시 실행 수를 왜 제한해야 하는지, 그리고 그 앞단에 두는 캐시의 TTL을 어떤 근거로 정하는지 정리합니다.
# 1. WithContext가 취소하는 시점
핵심 문장이 이것입니다.
The derived Context is canceled the first time a function passed to Go returns a non-nil error or the first time Wait returns, whichever occurs first.
두 가지를 읽어야 합니다.
첫째, 첫 에러가 전체를 취소합니다. 다섯 개를 병렬로 호출하다가 하나가 실패하면 나머지 넷의 컨텍스트가 즉시 취소됩니다. 부분 성공을 원했다면 잘못된 도구를 고른 것입니다. Wait가 돌려주는 것도 하나뿐입니다.
Wait blocks until all function calls from the Go method have returned, then returns the first non-nil error (if any) from them.
둘째, Wait가 반환되는 순간에도 취소됩니다. 모두 성공해도 마찬가지입니다. 그래서 파생 컨텍스트를 Wait 이후까지 들고 쓰면 안 됩니다. 스트리밍 응답이나 지연 평가되는 값을 이 컨텍스트에 묶어 두면 Wait 직후에 취소된 컨텍스트를 쓰게 됩니다.
func (s *service) Overview(ctx context.Context) (*Overview, error) {
g, ctx := errgroup.WithContext(ctx) // ctx 를 가려 쓰는 관례
var (
nodes []Node
quota *Quota
)
g.Go(func() error {
var err error
nodes, err = s.k8s.ListNodes(ctx)
return err
})
g.Go(func() error {
var err error
quota, err = s.billing.Quota(ctx)
return err
})
if err := g.Wait(); err != nil {
return nil, err // 첫 에러 하나만 돌아온다
}
return &Overview{Nodes: nodes, Quota: quota}, nil
}
각 고루틴이 서로 다른 변수에만 쓰기 때문에 잠금이 없어도 안전합니다. 같은 슬라이스에 append하는 순간 경쟁 상태가 됩니다. go test -race로 확인하는 습관이 필요합니다.
# 2. 부분 성공이 필요할 때
대시보드처럼 일부 데이터가 없어도 화면을 그려야 하는 경우가 있습니다. 이때는 에러를 그룹에 올리지 말고 결과에 담아야 합니다.
type partial[T any] struct {
val T
err error
}
g := new(errgroup.Group) // WithContext 를 쓰지 않는다
var nodes, quota partial[any]
g.Go(func() error {
nodes.val, nodes.err = s.k8s.ListNodes(ctx)
return nil // 에러를 그룹에 전달하지 않는다
})
문서가 밝힌 대로 제로 값 그룹은 유효하고, 에러로 취소하지 않습니다.
A zero Group is valid, has no limit on the number of active goroutines, and does not cancel on error.
즉 "하나라도 실패하면 전부 중단"과 "각자 실패해도 계속"은 서로 다른 생성 방식으로 구분합니다. WithContext를 쓸지 말지가 그 선택입니다. 이 결정을 코드에 남기지 않으면 나중에 읽는 사람이 의도를 알 수 없습니다.
# 3. 동시 실행 수를 제한해야 하는 이유
노드 100개의 상태를 각각 조회하는 코드에서 g.Go를 100번 부르면 고루틴 100개가 동시에 API 서버를 때립니다. 고루틴 자체는 싸지만 그 끝에 붙은 자원은 싸지 않습니다. API 서버의 요청 처리 한도, DB 커넥션 풀, 파일 디스크립터가 먼저 바닥납니다.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // 동시 8개
for _, n := range nodes {
n := n
g.Go(func() error { return s.probe(ctx, n) })
}
동작은 명확합니다.
Any subsequent call to the Go method will block until it can add an active goroutine without exceeding the configured limit.
g.Go가 블로킹된다는 점이 중요합니다. 루프가 즉시 끝나지 않고 자연스럽게 흐름 제어가 됩니다. 다만 제약이 하나 붙습니다.
The limit must not be modified while any goroutines in the group are active.
실행 중 변경은 안 됩니다. 부하에 따라 동적으로 조절하려는 설계는 여기서 막힙니다.
한도 값은 감으로 정하지 말고 가장 먼저 고갈되는 자원에서 역산합니다. DB 커넥션 풀이 20이고 한 작업이 커넥션 하나를 잡는다면, 한도는 20보다 작아야 합니다. 다른 요청도 같은 풀을 쓴다는 사실까지 계산에 넣어야 합니다.
# 4. 캐시 TTL은 무엇으로 정하는가
병렬화로 지연을 줄였다면 다음은 호출 자체를 줄이는 단계입니다. 여기서 TTL을 "적당히 30초" 식으로 정하면 나중에 근거를 설명할 수 없습니다. 판단 기준은 세 가지입니다.
첫째, 데이터가 실제로 변하는 주기. 노드 목록은 분 단위로도 거의 안 바뀌고, GPU 할당 상태는 초 단위로 바뀝니다. 변화 주기보다 짧은 TTL은 낭비이고, 긴 TTL은 거짓말입니다.
둘째, 틀린 값을 보여 줬을 때의 대가. 대시보드 숫자가 30초 늦는 것과, 그 값을 근거로 배포 결정을 내리는 것은 다릅니다. 후자라면 캐시를 쓰지 말거나 사용자에게 기준 시각을 함께 보여 줘야 합니다.
셋째, 원본을 때리는 비용. 조회가 무거울수록 TTL을 늘릴 유인이 커집니다.
| 데이터 | 변화 주기 | 오차 허용 | 적정 TTL |
|---|---|---|---|
| 노드 목록·스펙 | 시간 단위 | 큼 | 수 분 |
| 자원 할당량 | 초~분 | 중간 | 수십 초 |
| 파드 상태 | 초 | 작음 | 캐시 부적합 |
# 5. TTL 캐시의 진짜 문제 - 만료가 겹칠 때
TTL을 정하고 나면 다음 문제가 나옵니다. 캐시가 만료되는 순간 대기 중이던 요청이 한꺼번에 원본으로 몰립니다. 병렬화까지 해 뒀다면 그 순간 부하가 곱해집니다.
두 가지로 대응합니다.
요청 병합. 같은 키에 대한 동시 조회를 하나로 묶고 결과를 공유합니다. golang.org/x/sync/singleflight가 이 역할을 합니다.
var sf singleflight.Group
func (s *service) nodes(ctx context.Context) ([]Node, error) {
v, err, _ := sf.Do("nodes", func() (interface{}, error) {
return s.k8s.ListNodes(ctx) // 동시에 100개가 들어와도 실제 호출은 1회
})
if err != nil {
return nil, err
}
return v.([]Node), nil
}
여기에는 함정이 있습니다. Do는 컨텍스트를 받지 않고, 실제로 실행되는 클로저는 첫 호출자의 것이라 그 안의 ctx도 첫 호출자의 것입니다. 첫 호출자가 취소하면 뒤에 붙은 요청까지 함께 실패합니다. 취소가 잦은 환경이라면 DoChan으로 받아 각자의 컨텍스트로 대기하는 편이 안전합니다.
백그라운드 갱신. 만료 시점에 요청 스레드가 기다리게 하지 말고, 별도 루프가 미리 갱신합니다. 응답 지연이 일정해지는 대신 갱신 실패 시 오래된 값을 계속 내보낼 위험이 생기므로, 마지막 성공 시각을 함께 노출하는 편이 좋습니다.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for range t.C {
if v, err := s.fetchNodes(bgCtx); err == nil {
s.cache.Store("nodes", entry{v, time.Now()})
} else {
s.metrics.CacheRefreshFailed.Inc() // 실패를 반드시 관측한다
}
}
}()
# 6. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 하나 실패하면 나머지도 실패 | WithContext의 첫 에러 취소 | 부분 성공이 필요하면 제로 값 그룹 |
| 에러가 하나만 보임 | Wait는 첫 에러만 반환 | 결과 구조체에 개별 에러 수집 |
Wait 이후 컨텍스트가 취소됨 | 파생 컨텍스트 수명이 Wait까지 | 이후 작업은 상위 컨텍스트 사용 |
| 병렬화했더니 오히려 느려짐 | 하류 자원 포화 | SetLimit으로 제한, 풀 크기에서 역산 |
| 만료 직후 스파이크 | 캐시 만료 동시 발생 | singleflight 또는 백그라운드 갱신 |
| 오래된 값이 계속 나옴 | 백그라운드 갱신 실패 무시 | 실패 카운터와 마지막 성공 시각 노출 |
| 간헐적 데이터 손상 | 공유 변수 동시 쓰기 | go test -race |
# 7. 마무리
errgroup.WithContext는 첫 에러에 전부 취소하고Wait는 에러 하나만 돌려줍니다. 부분 성공이 필요하면 제로 값 그룹을 쓰고 에러를 결과에 담아야 합니다.- 파생 컨텍스트는
Wait반환과 함께 취소됩니다. 그 이후 작업에 재사용하면 안 됩니다. SetLimit값은 감이 아니라 가장 먼저 고갈되는 하류 자원에서 역산합니다.- TTL은 데이터 변화 주기·오차 비용·조회 비용 세 가지로 정합니다. 그리고 TTL을 정한 다음에는 만료가 겹치는 순간을 반드시 따로 처리해야 합니다.