Go에서 HTTP 요청을 재시도하면서 상황을 악화시키지 않기

발행: (2026년 5월 27일 AM 06:15 GMT+9)
14 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역을 원하는 본문을 제공해 주시면, 요청하신 대로 한국어로 번역해 드리겠습니다.

외부 API를 호출할 때, 문제가 생기기 전까지는 모든 것이 순조롭게 진행됩니다

네트워크 일시 중단, 서버 재시작, 레이트 제한 등. 그래서 재시도를 추가하고, 대부분의 경우 도움이 됩니다. 하지만 우리가 가장 먼저 쓰는 명백한 재시도는 조용히 상황을 악화시킬 수 있습니다: 결제를 두 번 전송하거나, 원래 실패보다 서버를 더 오래 다운시킬 수 있습니다.

이 글에서는 Go로 재시도 클라이언트를 가장 순진한 버전부터 시작해 점진적으로 개선하고, 마지막에는 조금 불편한 결론—라이브러리를 사용하라는 이야기를 합니다. 끝까지 읽으면 그 라이브러리가 정확히 무엇을 하는지 이해하게 되며, 이것이 이 글을 읽어야 하는 진짜 이유입니다.

모두가 처음 쓰는 재시도

세 번 시도하고, 1초 대기한 뒤 포기합니다. 겉보기엔 합리적이지만, 여기에는 세 가지 문제가 숨어 있습니다.

  1. 본문(body). HTTP 클라이언트는 요청 본문을 전송할 때 끝까지 읽고, 다시 되감지 않습니다. 따라서 두 번째 시도에서는 보낼 것이 없어서 서버는 빈 POST를 받고 400을 반환합니다. 이제 당신은 스스로 만든 400을 재시도하고 있는 겁니다.
  2. 대기 시간이 모두에게 동일함. 서버가 503을 반환하기 시작하면, 모든 클라이언트가 같은 1초 간격에 동시에 재시도합니다. 회복할 여지를 주지 않은 것이죠. 모두가 같은 순간에 다시 서버에 부딪히게 됩니다. 이것이 thundering herd 현상입니다.
  3. 모든 오류를 재시도함. 400은 요청이 잘못됐다는 의미이고, 404는 대상이 없다는 의미입니다. 어느 쪽이든 재시도해도 상황이 바뀌지 않는데, 이 루프는 모든 실패를 동일하게 취급합니다.

세 가지 문제이며, 각각은 같은 곳에서 해결되지 않습니다. 하나씩 살펴보겠습니다.

백오프: 동기화된 재시도를 피하라

시도 사이의 대기 시간은 점점 늘어나야 하고, 모든 클라이언트에게 동일하지 않아야 합니다. 늘어나는 것은 exponential backoff이며, 동일하지 않은 것은 jitter입니다. 두 가지를 모두 적용해야 합니다.

여기서 사람들이 흔히 틀리는 두 가지 세부 사항을 천천히 살펴보겠습니다.

  • 저는 math.Pow(2, attempt) 대신 왼쪽 시프트 연산을 사용했습니다. 부동소수점은 이 경우 부정확하고, Base * 2^attemptint64 나노초 범위를 예상보다 빨리 초과해 음수 값이 될 수 있습니다. 정수를 시프트하고 미리 상한을 두면 이런 문제를 모두 피할 수 있습니다.

  • jitter는 별도의 래퍼이며, exponential 수식을 복제하는 것이 아닙니다. 흔히 하는 실수는 ExponentialBackoffWithJitter 타입을 별도로 만들고 내부에서 두 배 증가 로직을 복제하는 것입니다. 그러면 선형 및 고정 전략에는 jitter가 적용되지 않고, 같은 로직이 두 곳에 존재하게 됩니다. 어떤 Backoff든 래핑하면 한 곳에만 유지됩니다.

    한 가지 더: 이것은 full jitter, random(0, d)이며 “대기 시간 ±25%”가 아닙니다. 차이가 핵심 포인트입니다. 모든 클라이언트가 같은 목표값 주변의 좁은 구간에서 jitter하면 여전히 뭉쳐 있게 됩니다—조금만 더 넓은 뭉치일 뿐이죠. 전체 [0, d) 구간에서 균등하게 선택하는 것이 실제로 분산시키는 방법입니다.

    그 배경 시뮬레이션이 궁금하면 AWS의 Exponential Backoff and Jitter 포스트를 참고하세요.

sleep을 모킹 가능하게 만들고, 컨텍스트를 존중하도록 하라

테스트를 작성하는 순간 마주치는 실용적인 문제입니다. 테스트가 실제로 exponential backoff 동안 sleep을 하면 몇 초씩 걸리고, 그런 테스트가 많아지면 전체 스위트가 느려집니다.

따라서 time.Sleep을 직접 호출하지 마세요. 인터페이스 뒤에 숨기고, 그 과정에서 취소 가능하도록 만드세요.

select를 이용한 ctx.Done() 체크는 대부분의 손수 만든 재시도 루프가 놓치는 부분입니다. 루프 시작 시 한 번만 컨텍스트를 확인하고, 이후 10초 동안 일반 time.Sleep에 블록됩니다. 그 대기 중에 요청이 취소되면, 슬립이 끝날 때까지 아무 일도 일어나지 않죠. 타이머와 컨텍스트를 결합한 버전에서는 취소된 요청이 즉시 중단됩니다. 실제 배포 환경에서는 이것이 깔끔한 종료와 30초 정체 사이의 차이를 만들 수 있습니다.

모든 것이 모이는 클라이언트

결정 과정들을 하나씩 살펴보겠습니다. 이것이 바로 핵심 포인트입니다.

  • 본문 처리. 우리는 본문을 한 번만 버퍼링합니다,

up front, and only when it’s a raw stream. Requests built with http.NewRequest from a bytes.Reader or a string already carry GetBody, so we reuse it instead of reading anything. We also don’t mutate the request you passed in. (This does read the whole body into memory, so if you’re streaming a very large upload, know that retries and streaming pull in opposite directions, and pick one on purpose.)

  • Retry policy. The default policy won’t retry your POST, and this is the one I’d most want you to take away. A POST that timed out may have already succeeded on the server before the response got lost on the way back. Retry it blindly and you’ve charged the customer twice. So GET, HEAD, PUT and the other safe‑to‑repeat methods retry by default, and POST doesn’t unless you opt in with an idempotency key. The policy gets the request so it can actually make that distinction.
  • Retry-After handling. It is respected and capped. If a server says “wait two seconds”, we wait two seconds. If a misconfigured server says “wait 86400”, we don’t park the goroutine for a day. We also handle the HTTP‑date form of the header, not just delta‑seconds, since the spec allows both and the date form is the one that usually gets forgotten.
  • Return value. When we finally give up, we hand back the last response with its body still open, plus an error. An earlier version of mine closed that body before returning it, which left the caller holding a response it couldn’t read. Close it yourself and check the status if you want to know why we stopped.

Now the tests run in microseconds

Because the sleeper is mocked, we can assert the exact backoff schedule, prove Retry-After overrides the backoff, prove a cancelled context stops immediately, and prove a POST is left alone. None of it waits on a real clock. That last test—the one that pins the POST behaviour—is the one I’d be most nervous shipping without.

You probably shouldn’t use any of this

Here’s the uncomfortable turn. For real work, reach for a library. go-retryablehttp does all of this and more, battle‑tested and maintained. The point of this post is to show what such a library does under the hood so you can use it confidently, or roll your own with eyes open.

Source:

기존 라이브러리를 사용하지 않는 이유?

hashicorp/go-retryablehttp는 본문을 버퍼링하고, Retry‑After를 존중하며, 지터가 포함된 지수 백오프를 수행하고, Terraform과 Vault에 연결되어 있어 이번 주에 여러분이나 제가 작성할 어떤 코드보다 훨씬 많은 남용을 견뎌냈습니다.
전체 HTTP 클라이언트에 체이너블 API와 JSON 처리를 원한다면, resty에도 재시도 기능이 내장되어 있습니다.

그렇다면 왜 직접 만들었을까요?

그 이유는 이제 그 라이브러리의 소스를 열어 마법이 아니라 일련의 결정으로 읽을 수 있기 때문입니다:

  • 왜 버퍼링을 하는가
  • 왜 요청을 복제하는가
  • 왜 기본 정책이 메서드에 대해 신중한가

예상치 못한 동작을 할 때, 여러분이 이해하고 있는 부분을 디버깅하게 됩니다. 이것이 처음부터 목표였던 것이죠 – 코드 자체가 아니라 이해였습니다.

실제로 사람을 물어뜯는 것

1. Idempotency (멱등성)

재시도는 부작용이 없는 경우에만 안전하고, 부작용이 생기면 안전하지 않습니다.

  • 작업을 멱등하게 만들기,
  • 멱등성 키를 보내기, 또는
  • 재시도하지 않기.

잘 끝나는 네 번째 선택지는 없습니다.

2. Logging (로깅)

본능적으로 매 재시도마다 전체 요청과 응답을 로그에 남기게 되는데, 그러면 로그가 중복된 페이로드로 가득 차게 되고—그 중 일부는 토큰을 포함하고 있습니다.

다음만 로그에 남기세요:

  • 시도 번호
  • 상태 코드
  • 엔드포인트

본문은 로그에 남기지 마세요.

3. Timeouts (타임아웃)

재시도가 타임아웃을 대체하지 않습니다.

  • HTTP 클라이언트 타임아웃은 시도당 적용됩니다.
  • 전체 마감 시간은 컨텍스트에 포함시켜야 합니다.

이것은 서로 다른 두 시계이며, 두 가지 모두 필요합니다. 그렇지 않으면 하나의 느린 호출이 전체 재시도 루프를 잡아먹게 됩니다.

4. Chained Retries (the one worth saying out loud) (체인형 재시도)

서비스 A가 서비스 B를 세 번 재시도하고, 서비스 B가 서비스 C를 세 번 재시도하면, C에서 발생한 작은 문제조차도 아홉 개의 요청으로 전파됩니다. 몇 층을 겹치면 재시도 폭풍이 발생해 원인 문제가 사라진 뒤에도 시스템을 오랫동안 다운시킵니다.

해결 방안

  • Retry budget – 요청 비율의 작은 비율로 재시도 횟수를 제한해, 일시적인 트래픽 급증이 장애로 확대되는 것을 방지합니다.
  • Single‑server jitter – 동시에 몰리는 요청을 분산시킵니다.
  • Budget – 체인형 재시도를 제어합니다.

실제 시스템에서는 두 가지 모두 필요합니다.

다음 단계

전체 구현은 위의 gist에 있습니다.

  1. 복제하세요.
  2. 테스트를 실행하세요.
  3. 뭔가를 깨뜨리고 테스트가 무엇을 알려주는지 보세요.

그게 나에게는 작동했으며, 여러분에게도 작동할 것입니다.

행복한 코딩!

0 조회
Back to Blog

관련 글

더 보기 »

첫 포스트: 짧은 전기

Introduction Hello, my name is Jay. Growing up, I wanted to follow in my dad's footsteps and become an engineer—and I did, just not in the way I originally exp...