Go에서 병렬 HTTP 요청을 안정적으로 만들기: Markdown Linter 구축에서 얻은 교훈
Source: Dev.to
gomarklint(Go 기반 Markdown 린터)를 만들면서 나는 100,000줄이 넘는 문서에서 깨진 링크를 검사해야 하는 문제에 직면했습니다. 이 작업을 goroutine으로 병렬 처리하는 것이 “당연한 선택”처럼 보였지만, CI 환경에서는 바로 flaky 테스트가 발생했습니다. Go에서는 속도는 쉽게 낼 수 있지만, 안정성을 확보하는 것이 진짜 도전 과제였습니다. 아래는 두 마리 토끼를 잡기 위해 구현한 세 가지 패턴입니다.
URL Caching
대규모 문서 집합에서는 동일한 URL이 수십 번씩 등장합니다. 순진한 동시 접근 방식은 각 발생마다 요청을 보내게 되며, 이는 호스트에 대한 DoS 공격처럼 보일 수 있습니다. sync.Map을 사용해 간단한 URL 캐시를 구현하여 각 고유 URL을 한 번만 검사하도록 했습니다.
var urlCache sync.Map
// Check if we've seen this URL before
if val, ok := urlCache.Load(url); ok {
return val.(*checkResult), nil
}
Limiting Concurrency
캐시가 있더라도 동시에 많은 고유 URL을 검사하면 로컬 파일 디스크립터가 고갈되거나 레이트 제한에 걸릴 수 있습니다. 세마포어 채널을 사용해 동시 검사 수를 제한합니다.
maxConcurrency := 10
sem := make(chan struct{}, maxConcurrency)
for _, url := range urls {
sem = 500 {
time.Sleep(retryDelay * time.Duration(attempt))
// retry...
}
Caching the Full Result
가장 까다로운 버그는 상태 코드만 캐시했을 때 발생했습니다. 요청이 타임아웃되면 status: 0을 저장했는데, 이후 검사에서는 0만 반환되고 오류가 있었는지 알 수 없어 논리가 일관되지 않았습니다. 해결책은 오류를 포함한 전체 결과를 캐시하는 것입니다.
type checkResult struct {
status int
err error
}
// Store the pointer to this struct in your cache
Dealing with Cache Stampedes
위 조치들을 적용해도 “캐시 스탬프”(여러 goroutine이 정확히 같은 순간에 아직 캐시되지 않은 URL을 동시에 요청) 문제가 남아 있습니다. 이를 해결하기 위해 현재 golang.org/x/sync/singleflight 사용을 검토 중입니다.
대규모 병렬 검사를 위해 http.Client 튜닝 경험이 있으시다면, 댓글이나 GitHub에서 여러분의 의견을 듣고 싶습니다!