사전 재시도
개념

재시도

gabury1고친 사람 github-actions[bot]

실패한 작업을 한 번 더 해 보는 일입니다. 잠깐 끊긴 연결이나 잠시 바빴던 서버 때문에 난 실패는 조금 뒤에 다시 하면 대개 성공합니다. 재시도는 이런 실패를 사람 손을 거치지 않고 넘깁니다. 대신 같은 일이 두 번 일어나도 괜찮은지를 먼저 따져야 합니다.

쉽고 빠른 이해

재시도는 실패한 요청이나 작업을 조금 기다렸다가 다시 해 보는 일입니다. 결제 서버를 불렀는데 연결이 끊겼다면 잠시 뒤 같은 요청을 한 번 더 보냅니다.

네트워크 너머의 실패는 상당수가 잠깐 지나가는 실패입니다. 재시도가 없으면 그 잠깐의 흔들림이 곧바로 사용자 화면의 오류가 됩니다.

어떻게 도는가:

  1. 실패하면 다시 해서 나아질 실패인지부터 가립니다. 요청 자체가 틀린 실패는 다시 하지 않습니다
  2. 나아질 실패면 조금 기다립니다. 실패할수록 더 오래 기다립니다
  3. 정해 둔 횟수까지 다시 합니다. 그래도 안 되면 실패를 위로 올립니다

대가가 있습니다. 같은 요청이 두 번 처리될 수 있습니다. 여러 곳이 한꺼번에 다시 시도하면 이미 힘든 서버를 더 누릅니다. 사용자는 실패를 늦게 알게 됩니다.

상세

붐비는 식당에서 종업원을 불렀습니다. 돌아보지 않으니 주문이 주방에 들어갔는지 부르는 소리가 묻혔는지 알 수 없습니다. 잠시 기다렸다 다시 부르면 같은 음식이 두 그릇 나올 수 있습니다.

재시도는 실패한 작업을 같은 조건으로 다시 실행하는 동작입니다. 호출하는 쪽이 정합니다. 불린 쪽은 자기가 두 번째로 불린 줄 모르는 경우가 많습니다. 서비스가 다른 서비스를 부를 때, 애플리케이션이 데이터베이스에 쿼리를 보낼 때, 데이터 파이프라인이 한 단계를 돌릴 때 모두 나옵니다.

재시도를 설계할 때 정할 것은 넷입니다. 무엇을 다시 할지, 얼마나 기다릴지, 몇 번까지 할지, 두 번 실행돼도 괜찮은지입니다. 아래 소절이 하나씩 다룹니다.

flowchart TD
    A["작업 실행"] --> B{"성공했나"}
    B -->|예| Z["끝"]
    B -->|아니오| C{"다시 하면 나아질 실패인가"}
    C -->|아니오| F["실패를 위로 올린다"]
    C -->|예| D{"횟수가 남았나"}
    D -->|아니오| F
    D -->|예| E["기다린다"]
    E --> A

그림은 한 번의 호출이 거치는 판단을 보입니다. 성공하면 바로 끝납니다. 실패하면 두 관문을 지나야 다시 실행으로 돌아갑니다. 둘 중 하나라도 막히면 실패를 호출한 쪽에 넘깁니다.

다시 해서 나아지는 실패

모든 실패가 재시도 대상은 아닙니다. 실패는 크게 둘로 나뉩니다. 시간이 지나면 저절로 풀리는 실패와 몇 번을 해도 똑같이 나는 실패입니다.

앞쪽을 일시적 장애라고 부릅니다. 연결이 순간 끊겼거나, 서버가 재시작 중이거나, 요청이 너무 몰려 잠시 거절당한 경우입니다. 조금 기다렸다 다시 하면 성공할 가능성이 큽니다.

뒤쪽은 요청 자체가 틀린 경우입니다. 필수 값이 빠졌거나, 권한이 없거나, 찾는 자원이 없습니다. 이런 요청은 백 번 보내도 백 번 거절됩니다. 다시 보내 봐야 서버만 바빠지고 사용자는 오류를 늦게 봅니다.

그래서 재시도 로직은 실패의 종류부터 읽습니다. 응답이 알려 주는 오류 종류를 보고 일시적인 것만 골라 다시 합니다. 종류를 모르는 실패를 무조건 다시 하면 고쳐지지 않는 오류가 재시도 횟수만큼 불어납니다.

기다리는 간격

실패 직후 바로 다시 보내면 대개 또 실패합니다. 서버가 버거워서 거절했다면 곧바로 온 두 번째 요청도 같은 처지이기 때문입니다. 그래서 다시 하기 전에 기다립니다. 이 기다림이 백오프입니다.

실패할 때마다 기다리는 시간을 두 배로 늘리는 방식이 흔히 쓰이는 지수 백오프입니다. 처음에는 짧게 기다려 금방 풀리는 실패를 빨리 넘깁니다. 실패가 이어지면 간격을 벌려 상대에게 회복할 시간을 줍니다.

아래 코드는 지수 백오프를 넣은 재시도입니다. 일시적인 오류만 잡고 최대 네 번 시도합니다.

Python
delay = 1
for attempt in range(1, 5):
    try:
        return call()
    except TemporaryError:
        if attempt == 4:
            raise            # 네 번째 실패
        sleep(delay)         # 1 → 2 → 4초
        delay *= 2

TemporaryError 는 일시적인 실패를 뜻하는 예외로 가정했습니다. 네 번 모두 실패하면 마지막 raise 가 실패를 호출한 쪽에 올립니다. 기다리는 시간은 1초, 2초, 4초로 늘어납니다.

간격만 늘려서는 모자랄 때가 있습니다. 서버 하나가 잠깐 멈추면 수많은 클라이언트가 같은 순간에 실패합니다. 모두 같은 규칙으로 기다리면 1초 뒤에 한꺼번에, 2초 뒤에 또 한꺼번에 몰려옵니다.

한꺼번에 몰려오는 요청을 흩뜨리려면 기다리는 시간에 무작위 값을 섞습니다. 이 무작위 값이 지터입니다. 각 클라이언트가 조금씩 다른 때에 다시 보내므로 요청이 시간축 위에 고르게 퍼집니다.

앞 코드의 sleep(delay) 줄을 아래처럼 바꿉니다. 0초에서 delay초 사이 아무 때나 기다립니다.

Python
sleep(uniform(0, delay))  # 0~delay 중 하나

횟수와 전체 시간의 상한

재시도에는 끝이 있어야 합니다. 끝없이 다시 하면 영구적인 실패 앞에서 작업이 영영 안 끝납니다. 그동안 스레드와 연결을 붙잡고 있어 다른 요청까지 막힙니다.

상한은 두 가지로 겁니다. 하나는 시도 횟수입니다. 다른 하나는 첫 시도부터 따진 전체 시간입니다.

사용자가 응답을 기다리는 요청이라면 전체 시간이 더 중요합니다. 재시도가 성공해도 사용자가 이미 떠났다면 소용이 없습니다.

한 번의 시도에 거는 타임아웃도 함께 정합니다. 타임아웃은 응답을 얼마나 기다릴지 정한 시간입니다. 이게 없으면 응답 없는 호출 하나가 재시도 차례를 영영 붙잡습니다.

두 번 실행되는 문제

재시도의 가장 까다로운 대가는 중복 실행입니다. 요청을 보냈는데 응답 전에 연결이 끊겼다고 합시다. 보낸 쪽은 요청이 서버에 닿아 처리됐는지, 닿지도 않았는지 알 수 없습니다.

이때 다시 보내면 서버가 같은 요청을 두 번 처리할 수 있습니다. 조회라면 문제가 없습니다. 결제나 주문 생성이라면 돈이 두 번 빠지고 주문이 두 건 생깁니다.

그래서 재시도는 멱등성과 짝을 이룹니다. 멱등성은 같은 일을 여러 번 해도 결과가 한 번 한 것과 같은 성질입니다. 「이 값을 5로 바꿔라」는 여러 번 해도 5입니다. 「이 값에 5를 더해라」는 할 때마다 값이 커집니다.

멱등하지 않은 작업을 안전하게 다시 하려면 요청에 고유한 번호를 붙입니다. 같은 요청을 다시 보낼 때는 처음 붙인 번호를 그대로 씁니다. 이 번호를 멱등 키라고 부릅니다. 서버는 이미 처리한 번호가 다시 오면 새로 처리하지 않고 지난번 결과를 돌려줍니다.

계층마다 곱해지는 재시도

요청은 여러 계층을 지납니다. 게이트웨이가 서비스를 부릅니다. 그 서비스가 다른 서비스를 부르고, 마지막 서비스가 데이터베이스를 부릅니다. 계층마다 각자 재시도를 넣으면 시도 횟수가 곱해집니다.

flowchart TD
    subgraph L1["게이트웨이 · 3번 시도"]
        A["보내는 요청 3번"]
    end
    subgraph L2["서비스 · 받은 요청마다 3번 시도"]
        B["보내는 요청 9번"]
    end
    subgraph L3["다른 서비스 · 받은 요청마다 3번 시도"]
        C["보내는 요청 27번"]
    end
    D["데이터베이스 · 받는 요청 27번"]
    A --> B
    B --> C
    C --> D

세 계층이 저마다 세 번씩 시도하면 데이터베이스에는 요청이 27번 닿습니다. 데이터베이스가 이미 느려서 실패하던 중이라면 이 요청이 회복을 더 늦춥니다. 이렇게 재시도가 장애를 키우는 현상을 재시도 폭풍이라고 부릅니다.

막는 방법은 재시도를 한 계층에만 두는 것입니다. 나머지 계층은 실패를 받으면 다시 하지 않고 곧바로 위로 올립니다. 그러면 시도 횟수가 곱해지지 않고 한 계층의 횟수에서 멈춥니다.

실패가 계속되면 아예 호출을 멈추는 서킷 브레이커를 함께 두기도 합니다. 서킷 브레이커는 일정 시간 동안 호출을 보내지 않고 바로 실패를 돌려주어 아래 계층이 회복할 틈을 줍니다.

데이터 파이프라인의 재시도

데이터 파이프라인에서는 요청 하나가 아니라 단계 하나를 다시 돌립니다. 파이프라인을 이루는 이 단계 하나하나가 태스크입니다. 데이터를 읽어 오는 태스크, 고치는 태스크, 테이블에 쓰는 태스크가 차례로 이어집니다.

태스크를 순서대로 띄우고 실패를 지켜보는 프로그램을 오케스트레이션 도구라고 합니다. 이 도구가 실패한 태스크를 정해 둔 횟수만큼 다시 실행합니다.

여기서도 중복 실행이 문제입니다. 태스크가 테이블에 행을 절반쯤 쓰다 실패했다고 합시다. 처음부터 다시 돌면 앞에서 쓴 절반이 한 번 더 들어갑니다.

이를 막으려고 파이프라인 태스크는 다시 돌아도 결과가 같게 짭니다. 흔한 방법은 추가하지 않고 덮어쓰는 것입니다.

날짜 하나의 데이터를 쓰는 태스크라면 그 날짜 칸을 비우고 새로 채웁니다. 몇 번을 돌아도 그 날짜에는 한 벌만 남습니다. 지난 기간을 다시 돌리는 백필도 같은 성질에 기댑니다.

재전송과 가르는 선

재전송은 이미 보낸 데이터를 잃어버렸다고 보고 같은 바이트를 다시 내보내는 일입니다. 대표적으로 TCP(Transmission Control Protocol, 전송 제어 프로토콜)가 잃어버린 데이터를 스스로 다시 보냅니다. 애플리케이션 코드는 보통 이 일을 알아채지 못합니다.

재시도는 그보다 위에서 작업 단위로 다시 요청합니다. 새 연결을 맺고 새 요청을 만들기도 합니다. 연결이 끊겨 재전송으로도 못 살린 실패를 재시도가 한 계층 위에서 받는 셈입니다.

관련 항목

재시도가 기다리는 방식

백오프 · 지수 백오프 · 지터 · 타임아웃

재시도를 안전하게 만드는 성질과 장치

멱등성 · 멱등 키 · 중복 처리 · 최소 한 번 전달 · 정확히 한 번 실행

재시도가 키우는 장애

재시도 폭풍 · 썬더링 허드 · 연쇄 장애 · 과부하

재시도와 함께 두는 보호 장치

서킷 브레이커 · 레이트 리밋 · 데드 레터 큐 · 벌크헤드

재시도가 가려내는 실패의 종류

일시적 장애 · 영구적 장애 · 부분 실패 · 네트워크 분할

재시도가 쓰이는 실행 환경

분산 시스템 · 파이프라인 · 오케스트레이션 · 태스크 · 워크플로 · 백필

재시도와 헷갈리는 이웃

재전송 · 자동 재시도 · 재처리 · 롤백

다른 이름: retry · 리트라이