Go에서 병렬 HTTP 요청을 안정적으로 만들기: Markdown Linter 구축에서 얻은 교훈

발행: (2026년 1월 15일 오후 10:28 GMT+9)
4 분 소요
원문: Dev.to

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에서 여러분의 의견을 듣고 싶습니다!

Back to Blog

관련 글

더 보기 »

Go용 Rate Limiter 라이브러리 소개

개요 현대 백엔드 시스템에서 레이트 리밋팅은 필수적입니다. 레이트 리밋팅이 없으면 API가 남용, 리소스 고갈 및 불공정 사용에 노출됩니다. 이 라이브러리는 …

Go의 비밀스러운 삶: Concurrency

경쟁 조건(race condition)의 혼란에 질서를 부여한다. Chapter 15: Sharing by Communicating 아카이브는 그 화요일에 유난히 시끄러웠다. 목소리 때문이 아니라 t...

Java 멀티스레딩/동시성

Java에서 멀티스레딩이란 무엇인가요? 멀티스레딩은 두 개 이상의 프로그램 부분을 동시에 실행할 수 있게 하는 기능으로, 이러한 부분을 스레드라고 합니다.