지수 백오프와 재시도 전략: 분산 시스템의 우아한 회복 탄력성 구축하기

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘은 분산 시스템을 구축하고 운영하는 데 있어 필수적인 개념 중 하나인 "지수 백오프와 재시도 전략(Exponential Backoff and Retry Strategies)"에 대해 이야기하려 합니다. 이 전략은 시스템이 일시적인 문제에도 불구하고 스스로 회복하고 안정적으로 작동하도록 돕는 강력한 도구입니다. 초중급 개발자분들이 실제 서비스에서 흔히 마주치는 문제를 해결하고, 더 견고한 시스템을 설계하는 데 큰 도움이 될 것이라고 확신합니다.
1. 개념 소개: 왜 필요한가요?

정의
**재시도(Retry)**는 어떤 작업이 일시적인 오류로 인해 실패했을 때, 일정 시간 후에 다시 시도하는 것을 말합니다. 그리고 **지수 백오프(Exponential Backoff)**는 이 재시도 간격을 점진적으로, 보통 기하급수적으로 늘려나가는 전략입니다. 예를 들어, 첫 번째 실패 후 1초 뒤에 재시도하고, 두 번째 실패 후 2초 뒤, 세 번째 실패 후 4초 뒤에 재시도하는 식입니다.
탄생 배경
이 전략은 분산 시스템의 확산과 함께 중요성이 커졌습니다. 현대의 애플리케이션은 단일 서버가 아닌 여러 서비스와 마이크로서비스, 클라우드 리소스들이 네트워크를 통해 서로 통신하는 형태로 구성되는 경우가 많습니다. 이러한 분산 환경에서는 다음과 같은 일시적인(transient) 장애가 언제든 발생할 수 있습니다:
- 네트워크 지연 또는 일시적인 연결 끊김
- 서비스 과부하로 인한 응답 지연 또는 타임아웃
- 데이터베이스 서버의 일시적인 잠금 또는 연결 오류
- 외부 API의 일시적인 오류 또는 속도 제한(rate limiting)
- 컨테이너 재시작, 로드밸런서의 서비스 인스턴스 교체 등 인프라 변경
이러한 일시적인 장애는 시스템의 근본적인 문제가 아니기 때문에, 조금만 기다렸다가 다시 시도하면 성공할 가능성이 높습니다.
왜 중요한가요?
지수 백오프와 재시도 전략은 시스템의 **회복 탄력성(Resilience)**과 **안정성(Stability)**을 크게 향상시킵니다.
- 회복 탄력성 강화: 시스템이 일시적인 장애 상황에서도 스스로 복구하고 정상 작동을 유지할 수 있도록 돕습니다. 사용자가 즉시 오류를 경험하는 대신, 시스템이 백그라운드에서 문제를 해결할 기회를 제공합니다.
- 시스템 안정성 유지: 일시적인 오류로 인한 전체 시스템의 연쇄적인 장애(cascading failure)를 방지합니다. 예를 들어, 한 서비스가 과부하되어 응답하지 않을 때, 다른 서비스들이 무작정 계속 요청을 보내면 과부하가 더 심해져 전체 시스템이 마비될 수 있습니다.
- 서버 부하 완화: 실패가 반복될 때 무작정 짧은 간격으로 재시도하는 대신, 백오프를 통해 재시도 간격을 늘려 서버에 가해지는 요청 폭증(thundering herd problem)을 완화합니다. 이는 장애를 겪고 있는 서비스가 회복할 시간을 벌어주는 효과를 가져옵니다.
- 자원 효율성 증대: 불필요한 재시도를 줄여 클라이언트와 서버 양쪽의 자원을 효율적으로 사용하게 합니다.
이러한 이유로, 클라우드 서비스(AWS, Google Cloud 등)의 SDK나 다양한 분산 시스템 라이브러리에서는 지수 백오프와 재시도 전략을 기본으로 제공하거나 강력하게 권장하고 있습니다.
2. 핵심 원리 설명: 인내심 있는 현명한 재시도

지수 백오프와 재시도 전략의 핵심은 '스마트하게 기다리는 것'입니다. 무작정 기다리거나, 무작정 다시 시도하는 것이 아니라, 상황에 맞춰 인내심을 조절하는 것이 중요합니다.
1. 재시도 (Retry)
가장 기본적인 아이디어는 간단합니다. "한 번 실패했다고 바로 포기하지 말고, 다시 시도해봐!" 하지만 모든 실패에 대해 즉시, 그리고 무한정 재시도하는 것은 오히려 시스템에 더 큰 부하를 주거나 불필요한 자원 낭비를 초래할 수 있습니다.
2. 지수 백오프 (Exponential Backoff)
여기서 '지수 백오프'가 등장합니다. 실패가 반복될수록 다음 재시도까지 기다리는 시간을 기하급수적으로 늘리는 전략입니다.
- 원리: 첫 실패 후 짧은 시간(예: 1초)을 기다렸다가 재시도합니다. 만약 두 번째도 실패하면 이전보다 두 배 긴 시간(예: 2초)을 기다립니다. 세 번째 실패 시에는 다시 두 배 긴 시간(예: 4초)을 기다리는 식으로, 재시도 간격이 1초, 2초, 4초, 8초... 와 같이 2의 거듭제곱으로 증가합니다.
- 비유: 친구에게 전화를 걸었는데 통화 중입니다. 1분 뒤에 다시 걸어보고, 그래도 통화 중이면 5분 뒤에, 또 통화 중이면 30분 뒤에 걸어보는 것과 같습니다. 처음에는 짧게 기다리지만, 계속 실패하면 친구가 통화를 마칠 수 있도록 더 길게 기다려주는 것입니다. 이처럼 시스템에게도 회복할 시간을 충분히 주는 것이 중요합니다.
- 다이어그램 설명: (텍스트로 설명) 시간을 X축, 재시도 간격을 Y축으로 하는 그래프를 상상해 보세요. 재시도 횟수가 증가함에 따라 재시도 간격이 가파르게 상승하는 곡선을 그릴 것입니다. 이는 선형적으로 증가하는 간격보다 훨씬 빠르게 대기 시간을 늘려, 시스템에 가해지는 부담을 효과적으로 줄여줍니다.
3. 최대 재시도 횟수 (Max Retries)
무한정 재시도할 수는 없습니다. 결국 해결되지 않는 문제(예: 잘못된 요청, 영구적인 서비스 종료)에 대해서는 포기하고 적절한 오류 처리를 해야 합니다. 따라서 최대 재시도 횟수를 정해두어, 이 횟수를 초과하면 최종적으로 실패를 선언하고 예외를 발생시키거나 다른 폴백(fallback) 로직을 실행해야 합니다.
4. 최대 백오프 시간 (Max Backoff Time)
재시도 간격이 무한히 길어지는 것을 방지하기 위해, 최대 백오프 시간을 설정합니다. 예를 들어, 재시도 간격이 60초를 넘으면 더 이상 늘리지 않고 60초로 유지하는 방식입니다. 이는 너무 긴 대기 시간으로 인해 사용자 경험이 저해되는 것을 막습니다.
5. 지터 (Jitter)
지수 백오프만으로는 완벽하지 않습니다. 만약 여러 클라이언트가 동시에 어떤 서비스에 요청을 보냈다가 동시에 실패하고, 똑같은 지수 백오프 전략으로 동시에 재시도한다면 어떻게 될까요? 모든 클라이언트가 1초 뒤에 동시에 재시도하고, 2초 뒤에 동시에 재시도하는 식으로 **'thundering herd problem'**이 다시 발생할 수 있습니다. 이는 장애를 겪고 있는 서비스에 다시 한번 갑작스러운 요청 폭증을 유발하여 회복을 방해할 수 있습니다.
**지터(Jitter)**는 이 문제를 해결하기 위해 계산된 백오프 시간(예: 4초)에 무작위 값(예: 0~2초)을 더하거나 빼서 재시도 시간을 약간씩 분산시키는 기술입니다.
- 비유: 여러 사람이 동시에 친구에게 전화를 걸었는데 통화 중입니다. 각자 '좀 이따 다시 걸어야지'라고 생각하지만, 정확히 똑같은 '좀 이따'가 아니라 각자 조금씩 다른 시간에 다시 거는 것과 같습니다.
- 지터 적용 방식:
- Full Jitter: 계산된 백오프 시간
T를 기준으로,0부터T사이의 임의의 시간R을 선택하여R초 후에 재시도합니다. (가장 효과적으로 분산) - Equal Jitter: 계산된 백오프 시간
T를 기준으로,T/2부터T사이의 임의의 시간R을 선택하여R초 후에 재시도합니다. (최소한의 대기 시간을 보장하면서 분산)
- Full Jitter: 계산된 백오프 시간
지터는 분산 시스템의 안정성을 극대화하는 데 매우 중요한 요소이므로, 재시도 전략을 구현할 때는 반드시 고려해야 합니다.
3. 코드 예제: Python으로 구현하기
Python을 사용하여 지수 백오프와 재시도 전략을 구현하는 예제를 살펴보겠습니다. 실제 프로젝트에서는 tenacity와 같은 견고한 라이브러리를 사용하는 것을 권장하지만, 원리를 이해하기 위해 직접 구현해봅니다.
예제 1: 기본적인 지수 백오프 재시도 구현
이 예제는 간단한 simulate_api_call 함수를 정의하고, 이 함수가 특정 횟수만큼 실패한 후 성공하거나 최종 실패하도록 만듭니다. retry_with_exponential_backoff 함수는 지수 백오프 로직을 적용하여 simulate_api_call을 재시도합니다.
import time
import random
def simulate_api_call(attempt_number):
"""
API 호출을 시뮬레이션합니다.
처음 3번은 실패하고, 4번째 시도부터 성공한다고 가정합니다.
"""
print(f" API 호출 시도 #{attempt_number}...")
if attempt_number <= 3: # 처음 3번은 실패
print(" ❌ API 호출 실패!")
raise ConnectionError("일시적인 네트워크 오류 발생")
else:
print(" ✅ API 호출 성공!")
return "데이터 수신 성공!"
def retry_with_exponential_backoff(max_retries=5, initial_delay_seconds=1, max_delay_seconds=32):
"""
지수 백오프 전략을 사용하여 함수를 재시도합니다.
:param max_retries: 최대 재시도 횟수
:param initial_delay_seconds: 첫 재시도까지의 초기 지연 시간 (초)
:param max_delay_seconds: 재시도 간격의 최대 상한선 (초)
"""
current_attempt = 1
current_delay = initial_delay_seconds
while current_attempt <= max_retries:
try:
print(f"--- 재시도 루프 시작 (시도 #{current_attempt}) ---")
result = simulate_api_call(current_attempt)
print(f"최종 결과: {result}")
return result
except ConnectionError as e:
print(f"오류 발생: {e}")
if current_attempt == max_retries:
print(f"최대 재시도 횟수({max_retries}) 초과. 최종 실패.")
raise # 최종 실패 시 예외 다시 발생
# 다음 재시도까지 대기할 시간 계산 (지수 백오프)
# min() 함수를 사용하여 max_delay_seconds를 초과하지 않도록 합니다.
wait_time = min(current_delay, max_delay_seconds)
print(f"다음 재시도까지 {wait_time:.2f}초 대기...")
time.sleep(wait_time)
current_delay *= 2 # 지수적으로 대기 시간 증가
current_attempt += 1
print("-" * 30) # 구분선
# 모든 재시도 후에도 실패한 경우 (여기까지 코드가 도달하면 안 되지만, 방어적인 코딩)
print("알 수 없는 이유로 재시도 실패")
raise Exception("알 수 없는 이유로 재시도 실패")
if __name__ == "__main__":
try:
retry_with_exponential_backoff()
except Exception as e:
print(f"작업 최종 실패: {e}")
코드 실행 결과 예시:
--- 재시도 루프 시작 (시도 #1) ---
API 호출 시도 #1...
❌ API 호출 실패!
오류 발생: 일시적인 네트워크 오류 발생
다음 재시도까지 1.00초 대기...
------------------------------
--- 재시도 루프 시작 (시도 #2) ---
API 호출 시도 #2...
❌ API 호출 실패!
오류 발생: 일시적인 네트워크 오류 발생
다음 재시도까지 2.00초 대기...
------------------------------
--- 재시도 루프 시작 (시도 #3) ---
API 호출 시도 #3...
❌ API 호출 실패!
오류 발생: 일시적인 네트워크 오류 발생
다음 재시도까지 4.00초 대기...
------------------------------
--- 재시도 루프 시작 (시도 #4) ---
API 호출 시도 #4...
✅ API 호출 성공!
최종 결과: 데이터 수신 성공!
예제 2: 지터(Jitter)를 추가한 고급 재시도 구현
이제 지수 백오프에 지터(Jitter)를 추가하여, 여러 클라이언트가 동시에 재시도하는 상황에서 발생할 수 있는 'thundering herd problem'을 완화하는 코드를 작성해봅니다. 여기서는 Full Jitter 방식을 적용합니다.
import time
import random
def simulate_api_call_with_random_failure(attempt_number):
"""
API 호출을 시뮬레이션합니다.
약 70% 확률로 실패하고, 30% 확률로 성공합니다.
"""
print(f" API 호출 시도 #{attempt_number}...")
if random.random() < 0.7: # 70% 확률로 실패
print(" ❌ API 호출 실패!")
raise ConnectionError("일시적인 서비스 오류")
else:
print(" ✅ API 호출 성공!")
return "데이터 수신 성공!"
def retry_with_exponential_backoff_and_jitter(max_retries=5, initial_delay_seconds=0.5, max_delay_seconds=30):
"""
지수 백오프와 지터 전략을 사용하여 함수를 재시도합니다 (Full Jitter).
:param max_retries: 최대 재시도 횟수
:param initial_delay_seconds: 첫 재시도까지의 초기 지연 시간 (초)
:param max_delay_seconds: 재시도 간격의 최대 상한선 (초)
"""
current_attempt = 1
current_base_delay = initial_delay_seconds # 지수적으로 증가할 기준 지연 시간
while current_attempt <= max_retries:
try:
print(f"--- 재시도 루프 시작 (시도 #{current_attempt}) ---")
result = simulate_api_call_with_random_failure(current_attempt)
print(f"최종 결과: {result}")
return result
except ConnectionError as e:
print(f"오류 발생: {e}")
if current_attempt == max_retries:
print(f"최대 재시도 횟수({max_retries}) 초과. 최종 실패.")
raise
# 지수 백오프 기본 값 계산
# current_base_delay는 initial_delay_seconds * (2 ** (current_attempt - 1))
# 하지만 max_delay_seconds를 넘지 않도록 합니다.
exponential_delay = min(current_base_delay, max_delay_seconds)
# Full Jitter 적용: 0부터 exponential_delay 사이의 랜덤 값 선택
wait_time = random.uniform(0, exponential_delay)
print(f"다음 재시도까지 {wait_time:.2f}초 대기 (기준: {exponential_delay:.2f}s, 지터 적용)...")
time.sleep(wait_time)
current_base_delay *= 2 # 다음 시도를 위해 기준 지연 시간 증가
current_attempt += 1
print("-" * 30)
print("알 수 없는 이유로 재시도 실패")
raise Exception("알 수 없는 이유로 재시도 실패")
if __name__ == "__main__":
print("--- 지터가 적용된 재시도 시작 ---")
try:
retry_with_exponential_backoff_and_jitter()
except Exception as e:
print(f"작업 최종 실패: {e}")
코드 실행 결과 예시 (실패 확률에 따라 다양하게 나타남):
--- 지터가 적용된 재시도 시작 ---
--- 재시도 루프 시작 (시도 #1) ---
API 호출 시도 #1...
❌ API 호출 실패!
오류 발생: 일시적인 서비스 오류
다음 재시도까지 0.38초 대기 (기준: 0.50s, 지터 적용)...
------------------------------
--- 재시도 루프 시작 (시도 #2) ---
API 호출 시도 #2...
❌ API 호출 실패!
오류 발생: 일시적인 서비스 오류
다음 재시도까지 0.92초 대기 (기준: 1.00s, 지터 적용)...
------------------------------
--- 재시도 루프 시작 (시도 #3) ---
API 호출 시도 #3...
✅ API 호출 성공!
최종 결과: 데이터 수신 성공!
이 예제에서 볼 수 있듯이, 지터는 계산된 exponential_delay 범위 내에서 무작위 대기 시간을 선택하여 여러 클라이언트의 재시도 시간을 분산시킵니다.
4. 실무 적용 사례
지수 백오프와 재시도 전략은 거의 모든 분산 시스템 환경에서 광범위하게 사용됩니다.
- 마이크로서비스 간 통신: 한 마이크로서비스(예: 주문 서비스)가 다른 마이크로서비스(예: 재고 서비스)를 호출할 때, 재고 서비스가 일시적으로 과부하되거나 네트워크 문제로 응답하지 않을 수 있습니다. 이때 주문 서비스는 지수 백오프를 사용하여 재고 서비스에 대한 요청을 재시도하여 견고성을 확보합니다.
- 외부 API 연동: AWS, Google Cloud, Stripe, Twilio 등과 같은 클라우드 서비스 또는 외부 SaaS(Software as a Service) API를 호출할 때,
429 Too Many Requests(속도 제한),500 Internal Server Error,503 Service Unavailable등의 응답을 받을 수 있습니다. 이때 재시도 로직은 필수적입니다. - 데이터베이스 연결 및 쿼리: 데이터베이스 서버가 일시적으로 과부하되어 연결이 끊기거나 특정 쿼리가 타임아웃될 수 있습니다. 특히 클라우드 환경의 관리형 데이터베이스(RDS, Cosmos DB 등)는 유지보수나 스케일링 과정에서 일시적인 연결 손실이 발생할 수 있습니다. 애플리케이션은 이러한 상황에 대비해 재시도 로직을 적용해야 합니다.
- 메시지 큐 컨슈머: Kafka, RabbitMQ, SQS 등 메시지 큐에서 메시지를 가져와 처리하는 컨슈머(Consumer)가 메시지 처리 중 외부 리소스(DB, 다른 서비스) 접근에 실패할 수 있습니다. 이때 컨슈머는 메시지를 다시 큐에 넣거나, 재시도 로직을 적용하여 나중에 다시 처리하도록 할 수 있습니다. Dead-letter queue(DLQ)와 함께 사용하여 최종 실패 메시지를 처리하기도 합니다.
- 분산 락(Distributed Lock) 획득: 분산 시스템에서 여러 인스턴스가 공유 리소스에 접근하기 위해 락을 획득하려 할 때, 락 획득에 실패할 수 있습니다. 이때 일정 시간 후 재시도하는 전략을 사용하여 락을 다시 시도할 수 있습니다.
5. 자주 하는 실수와 해결법
지수 백오프와 재시도 전략은 강력하지만, 잘못 구현하면 오히려 시스템에 문제를 일으킬 수 있습니다.
1. 무한 루프 또는 너무 짧은 재시도 간격
- 실수:
max_retries나max_backoff_time을 설정하지 않거나 너무 높게 설정하여 무한정 재시도하거나, 재시도 간격이 너무 짧아 오히려 서버에 부하를 가중시키는 경우. - 해결법: 항상 합리적인
최대 재시도 횟수와최대 백오프 시간을 설정해야 합니다. 대부분의 클
