← 블로그 목록

Developer Knowledge

개발자가 알아야 할 지식: 재시도, 백오프, 지터는 장애를 키우지 않기 위한 약속이다

네트워크 호출이 실패했을 때 무작정 다시 시도하면 왜 장애가 커지는지, 타임아웃·재시도·지수 백오프·지터를 함께 설계하는 실무 기준을 정리한다.

Marc Brooker / Amazon Builders' Library
  • 개발자가 알아야 할 지식
  • Software Engineering
  • Reliability
  • Distributed Systems
  • Resilience

왜 개발자가 알아야 하나

분산 시스템에서 가장 흔한 착각은 “실패하면 한 번 더 호출하면 된다”는 생각이다. 실제로 많은 장애는 순간적이다. 네트워크 패킷이 유실되거나, 특정 인스턴스가 잠깐 느려지거나, 로드 밸런서가 막 새 연결을 다른 곳으로 돌리는 중일 수 있다. 그래서 재시도는 유용하다. 하지만 재시도는 공짜가 아니다. 이미 느려진 서버에 같은 요청을 더 많이 보내는 행동이기도 하다. 작은 지연을 회복시키는 약이 될 수도 있지만, 과부하 상태에서는 장애를 증폭시키는 연료가 된다.

예를 들어 결제 API가 평소보다 느려졌다고 해보자. 클라이언트가 1초 안에 응답을 못 받으면 실패로 보고 즉시 세 번 재시도한다. 프런트엔드 서버, 백엔드 서버, 워커, 모바일 앱이 모두 같은 방식으로 동작하면 실제 사용자 요청 1개가 하위 시스템에는 3개, 9개, 27개의 호출로 불어날 수 있다. 원래 문제는 “일부 요청이 느림”이었는데, 몇 초 뒤에는 연결 풀 고갈, 스레드 고갈, 큐 적체, 데이터베이스 락 대기, 캐시 미스 증가가 한꺼번에 나타난다. 운영자는 증상을 보지만 원인은 시스템 전체가 스스로 만들어낸 재시도 폭풍일 수 있다.

그래서 실무에서 중요한 것은 “재시도를 할 것인가”가 아니라 “언제, 몇 번, 어떤 간격으로, 어떤 요청에만, 전체 시간 예산 안에서 재시도할 것인가”다. 타임아웃은 무한 대기를 막고, 재시도는 일시적 실패를 흡수하며, 백오프는 실패한 대상에 숨 쉴 시간을 주고, 지터는 수많은 클라이언트가 같은 순간에 몰리는 현상을 흩뜨린다. 이 네 가지는 따로따로 붙이는 옵션이 아니라 하나의 회복력 설계다.

이 주제는 백엔드 개발자만의 일이 아니다. 모바일 앱의 API 호출, 프런트엔드의 데이터 패칭, 메시지 큐 소비자, 배치 작업, 서드파티 웹훅, 사내 마이크로서비스 호출, 클라우드 SDK 사용 모두에 해당한다. 사용자는 “버튼을 눌렀는데 반응이 없다”로 경험하지만, 개발자는 그 뒤에서 중복 처리, 부분 실패, 요청 폭주, 관측 불가능한 타임아웃을 다뤄야 한다. 재시도 정책을 이해하면 장애가 났을 때 시스템이 조금 더 우아하게 실패하도록 만들 수 있다.

핵심 개념

첫 번째 개념은 타임아웃이다. 타임아웃은 클라이언트가 하위 시스템의 응답을 기다릴 최대 시간이다. 타임아웃이 없으면 느린 호출 하나가 스레드, 커넥션, 메모리, 파일 디스크립터 같은 제한된 자원을 오래 붙잡는다. 요청량이 늘어나는 순간 이런 대기는 큐를 만들고, 큐는 지연을 키우며, 지연은 다시 더 많은 대기와 재시도를 만든다. 타임아웃은 “이 요청은 더 기다려도 전체 시스템에 이롭지 않다”는 경계선이다.

하지만 타임아웃은 너무 짧아도 문제다. 정상적인 지연 분포의 상위 꼬리만 보고 무턱대고 잘라내면 멀쩡히 성공할 요청을 실패로 바꾼다. TLS 핸드셰이크, DNS 조회, 콜드 스타트, 새 연결 생성, 지역 간 네트워크 지연처럼 첫 호출에서만 비용이 큰 단계도 있다. 타임아웃을 정할 때는 평균이 아니라 p95, p99 같은 꼬리 지연과 호출 경로 전체의 시간 예산을 봐야 한다. 사용자가 2초 안에 응답을 받아야 한다면 하위 서비스 하나가 2초를 전부 써서는 안 된다.

두 번째는 재시도다. 재시도는 부분 실패와 순간 실패에 효과적이다. 네트워크 호출은 실패했지만 서버는 살아 있을 수 있고, 특정 인스턴스만 느릴 수 있으며, 잠깐 뒤 다른 인스턴스에 보내면 성공할 수 있다. 문제는 재시도가 부작용을 가진 요청에도 적용된다는 점이다. 타임아웃이 발생했다고 해서 서버가 작업을 수행하지 않았다는 뜻은 아니다. 클라이언트는 실패를 봤지만 서버는 이미 주문을 생성했거나 이메일을 보냈거나 포인트를 차감했을 수 있다. 그래서 재시도 가능한 API는 가능한 한 멱등성(idempotency)을 갖도록 설계해야 한다. 같은 요청 키로 여러 번 호출해도 결과가 하나로 수렴해야 안전하다.

세 번째는 백오프다. 백오프는 실패 후 다음 재시도까지 기다리는 시간을 점점 늘리는 방식이다. 흔히 지수 백오프를 사용한다. 예를 들어 100ms, 200ms, 400ms, 800ms처럼 간격을 늘린다. 목적은 하위 시스템이 회복할 시간을 주는 것이다. 즉시 재시도는 일시적 네트워크 흔들림에는 빠른 회복을 줄 수 있지만, 과부하에는 더 많은 부하를 얹는다. 백오프는 “실패했으니 더 세게 때린다”가 아니라 “실패했으니 잠깐 물러난다”는 태도다.

네 번째는 지터다. 지터는 재시도 대기 시간에 무작위성을 섞는 것이다. 지수 백오프만 적용해도 모든 클라이언트가 같은 시각에 실패했다면 같은 간격으로 다시 몰릴 수 있다. 1초 뒤, 2초 뒤, 4초 뒤에 수천 개의 요청이 한꺼번에 들어오는 식이다. 지터를 넣으면 재시도 시점이 흩어진다. 이는 크론 작업, 배치 재처리, 배포 직후 캐시 미스, 리전 장애 복구처럼 많은 클라이언트가 동시에 움직이는 상황에서 특히 중요하다.

마지막으로 재시도는 반드시 전체 데드라인 안에 있어야 한다. “각 호출 타임아웃 1초, 재시도 3회”라고 정했는데 상위 요청의 SLA가 2초라면 이미 설계가 모순이다. 호출 하나가 자기 사정만 보고 기다리면 상위 계층은 사용자의 시간을 잃는다. 좋은 클라이언트는 남은 시간 예산을 다음 호출에 전달하고, 시간이 부족하면 재시도하지 않는다. 재시도의 목표는 성공률을 조금 높이는 것이지, 이미 늦은 요청을 붙잡고 전체 시스템을 막는 것이 아니다.

작은 예시 또는 체크리스트

다음은 외부 API를 호출하는 주문 생성 흐름을 단순화한 예다.

deadline = now + 2 seconds
attempt = 0
maxAttempts = 3
baseDelay = 100ms

while attempt < maxAttempts and now < deadline:
  attempt += 1
  remaining = deadline - now
  timeout = min(700ms, remaining)

  result = callPaymentAPI(
    timeout = timeout,
    idempotencyKey = orderRequestId
  )

  if result.success:
    return result

  if !result.isRetryable:
    fail fast

  delay = random(0, baseDelay * 2^attempt) // jitter
  if now + delay >= deadline:
    break

  sleep(delay)

return failure_with_clear_status

실제 코드는 언어와 라이브러리에 따라 달라지지만 확인할 기준은 비슷하다.

  • 요청마다 전체 데드라인이 있는가, 아니면 각 하위 호출이 제멋대로 기다리는가?
  • 재시도 횟수와 최대 대기 시간이 명시되어 있는가?
  • 재시도 대상 오류가 구분되어 있는가? 예: 네트워크 오류, 429, 503은 후보지만 400, 인증 실패, 검증 오류는 대개 재시도 대상이 아니다.
  • 쓰기 요청에는 멱등성 키나 중복 방지 장치가 있는가?
  • 백오프에 지터가 포함되어 있는가?
  • 라이브러리, SDK, 프록시, 애플리케이션 레이어가 중복으로 재시도하고 있지 않은가?
  • 재시도 횟수, 실패 원인, 최종 성공 여부가 로그와 메트릭으로 관측되는가?

중요한 것은 “재시도 로직을 넣었다”가 아니라 “실패 상황에서 트래픽이 얼마나 늘어나는지 알고 있다”는 점이다. 장애 훈련이나 부하 테스트에서 하위 서비스가 느려졌을 때 호출량이 몇 배로 증가하는지 확인해보면, 평소에는 보이지 않던 위험이 드러난다.

실무에서 자주 생기는 오해

  • “재시도 횟수를 늘리면 안정성이 올라간다”는 오해: 낮은 오류율에서는 성공률이 올라갈 수 있다. 하지만 하위 시스템이 과부하라면 재시도는 추가 트래픽이다. 성공률보다 시스템 전체 처리량과 회복 시간을 함께 봐야 한다.

  • “타임아웃은 짧을수록 좋다”는 오해: 너무 짧은 타임아웃은 정상 요청을 실패로 만들고 재시도를 유발한다. 특히 새 연결, DNS, TLS, 콜드 캐시처럼 간헐적으로 느린 경로를 고려하지 않으면 배포 직후나 트래픽 패턴 변화 때 거짓 실패가 늘어난다.

  • “읽기 요청만 재시도하면 안전하다”는 오해: 읽기 요청도 비용이 있다. 비싼 검색, 대규모 집계, 캐시를 우회하는 조회는 재시도만으로 데이터베이스나 검색 클러스터를 밀어붙일 수 있다. 읽기라고 해서 무제한 재시도해도 되는 것은 아니다.

  • “쓰기 요청은 절대 재시도하면 안 된다”는 오해: 쓰기 요청도 멱등성 키, 조건부 업데이트, 작업 상태 조회, 중복 처리 방지 테이블을 갖추면 안전하게 재시도할 수 있다. 오히려 결제·주문·예약처럼 중요한 흐름은 안전한 재시도를 설계해야 사용자 경험이 좋아진다.

  • “SDK가 알아서 해준다”는 오해: 많은 클라우드 SDK와 HTTP 클라이언트는 기본 재시도 정책을 갖고 있다. 편리하지만 애플리케이션 레이어, 서비스 메시, 프록시, 큐 소비자가 각각 재시도하면 곱셈 효과가 생긴다. 기본값을 끄라는 뜻이 아니라, 전체 호출 경로에서 재시도가 몇 번 일어나는지 알아야 한다.

  • “백오프만 있으면 된다”는 오해: 백오프가 있어도 모든 클라이언트가 같은 계산을 하면 같은 시점에 다시 몰린다. 지터는 사소한 랜덤이 아니라 동시성 폭발을 완화하는 핵심 장치다.

  • “재시도 실패는 그냥 최종 실패로 보면 된다”는 오해: 운영 관점에서는 첫 시도 실패 후 성공한 요청, 모든 시도가 실패한 요청, 재시도하지 않은 요청이 다르다. 이 차이가 보이지 않으면 장애 초기에 하위 시스템이 이미 흔들리고 있다는 신호를 놓친다.

오늘 바로 적용해보기

먼저 코드베이스에서 외부 HTTP 호출, 데이터베이스 연결, 메시지 발행, 클라우드 SDK 호출을 하나 골라 재시도 정책을 확인해보자. 기본 타임아웃이 무한대인지, 재시도 횟수가 라이브러리 기본값인지, 어떤 오류를 재시도하는지, 지터가 있는지 적어보는 것만으로도 팀의 운영 리스크가 보인다.

두 번째로 “시간 예산”을 문서화하자. 사용자 요청 하나가 2초 안에 끝나야 한다면 인증, 주요 비즈니스 로직, 외부 API, 데이터베이스 조회가 각각 얼마를 쓸 수 있는지 대략의 예산을 둔다. 이 예산이 있어야 타임아웃과 재시도 횟수를 감으로 정하지 않는다. 상위 요청의 데드라인을 하위 호출에 전달할 수 있다면 더 좋다.

세 번째로 쓰기 API에는 멱등성 설계를 검토하자. 주문 요청 ID, 결제 요청 키, 작업 실행 ID처럼 클라이언트가 재시도해도 서버가 같은 작업으로 인식할 수 있는 키가 필요하다. 서버는 이미 처리된 키에 대해 같은 결과를 반환하거나, 최소한 중복 부작용을 막아야 한다. 이 장치가 없으면 타임아웃 뒤 재시도는 늘 불안한 선택이 된다.

네 번째로 관측 지표를 추가하자. 총 요청 수만 보지 말고 시도 횟수별 성공률, 재시도 후 성공률, 최종 실패율, 오류 코드별 재시도 횟수, 하위 시스템별 타임아웃 수를 보자. 재시도는 사용자에게 실패를 숨길 수 있기 때문에, 메트릭이 없으면 시스템이 조용히 나빠지고 있다는 사실을 늦게 알게 된다.

마지막으로 장애 상황을 가정한 테스트를 해보자. 하위 서비스 응답을 2초 늦추거나 503을 일정 비율로 반환하게 만든 뒤, 상위 서비스의 동시 요청 수와 하위 호출량이 어떻게 변하는지 본다. 이때 호출량이 사용자 트래픽보다 훨씬 빠르게 증가한다면 재시도 정책이 회복력보다 증폭기에 가깝다는 신호다.

더 알아보기

오늘의 takeaway

재시도는 실패를 숨기는 버튼이 아니라, 타임아웃·백오프·지터·멱등성과 함께 설계해야 장애를 키우지 않는 안전장치다.