콘텐츠로 이동

재시도 전략과 크래시-온리 설계

에이전틱 루프에서 발생하는 오류는 성격이 다르다. 잠시 기다리면 해결되는 오류가 있고, 아무리 기다려도 절대 해결되지 않는 오류가 있다. 이 두 가지를 구분하지 않으면 재시도 전략은 역효과를 낸다.

┌──────────────────────────────────────────────────────────────┐
│ 오류 분류 기준 │
├─────────────────────┬────────────────────────────────────────┤
│ 일시적 오류 │ 영구적 오류 │
│ (transient) │ (permanent) │
├─────────────────────┼────────────────────────────────────────┤
│ 네트워크 타임아웃 │ 잘못된 API 키 │
│ 서버 일시 과부하 │ 존재하지 않는 파일 경로 │
│ 요금 한도 일시 초과 │ 스키마 불일치 (코드 버그) │
│ 503/429 HTTP 오류 │ 권한 없는 작업 (403) │
│ │ 잘못된 인수 (400) │
└─────────────────────┴────────────────────────────────────────┘

일시적 오류는 재시도가 도움된다. 영구적 오류는 재시도해 봐야 결과는 동일하다. 더 나쁘게는, 영구적 오류를 재시도하면 둠 루프(7-1챕터)로 빠진다.

일시적 오류에 대한 재시도는 즉시 재시도가 아니라 점점 늘어나는 대기 시간과 함께 해야 한다. 여러 에이전트가 동시에 같은 API에 접근하는 경우, 즉시 재시도하면 모든 에이전트가 동시에 부하를 만들어 서버 복구를 방해한다. 이를 방지하기 위해 지수 백오프(exponential backoff)지터(jitter) 를 더한다.

# 개념 이해용 의사 코드이며 실제 API와 다를 수 있습니다
import time
import random
def with_retry(fn, max_retries=3, base_delay=1.0, max_delay=60.0):
"""
지수 백오프 + 완전 지터 재시도 래퍼.
일시적 오류만 재시도하고 영구적 오류는 즉시 전파.
"""
last_error = None
for attempt in range(max_retries + 1):
try:
return fn()
except PermanentError:
raise # 영구적 오류는 재시도 없이 즉시 전파
except TransientError as e:
last_error = e
if attempt == max_retries:
break # 마지막 시도 후 재시도 없음
# 완전 지터: 0 ~ min(cap, base * 2^attempt) 사이 균등 분포
delay = random.uniform(0, min(max_delay, base_delay * (2 ** attempt)))
time.sleep(delay)
raise MaxRetriesExceeded(f"{max_retries}회 재시도 후 실패") from last_error

지터(무작위성)의 역할은 여러 클라이언트가 동시에 재시도하는 thundering herd 현상을 방지하는 것이다. 지수 백오프만 쓰면 모든 클라이언트가 같은 지점에서 동시에 재시도한다. 지터를 추가하면 재시도가 시간적으로 분산된다.

재시도 컨텍스트에 이전 오류 포함

섹션 제목: “재시도 컨텍스트에 이전 오류 포함”

에이전틱 루프의 재시도에는 분산 시스템과 다른 중요한 요소가 있다. 모델이 재시도할 때 이전에 무엇이 실패했는지를 알아야 다른 접근을 취할 수 있다. 이전 오류를 컨텍스트에 포함하지 않으면 모델은 동일한 행동을 반복한다.

# 개념 이해용 의사 코드이며 실제 API와 다를 수 있습니다
def build_retry_context(original_messages, error_history):
"""
재시도 시 이전 실패 이력을 컨텍스트에 주입.
모델이 이전과 다른 접근을 취하도록 유도.
"""
if not error_history:
return original_messages
error_summary = "\n".join([
f"- 시도 {i+1}: {err['action']}{err['error']}"
for i, err in enumerate(error_history)
])
retry_note = {
"role": "user",
"content": (
f"다음 접근들이 이미 실패했습니다:\n{error_summary}\n\n"
"완전히 다른 방법으로 시도하세요. "
"동일한 접근을 반복하지 마세요."
)
}
return original_messages + [retry_note]

이 패턴은 7-1챕터의 둠 루프 대응과 연결된다. 재시도 시 이전 실패를 명시적으로 알리는 것이 단순 재실행보다 훨씬 효과적이다.

분산 시스템 연구에서 발전한 크래시-온리 설계(crash-only design) 는 역설적인 아이디어를 담고 있다. “정상 종료”와 “비정상 종료”를 구분하지 않는다. 모든 종료는 크래시이고, 유일한 복구 방법은 재시작이다.

크래시-온리 설계의 핵심 원칙:

  1. 안전한 종료(graceful shutdown) 코드를 작성하지 않는다. 모든 상태는 항상 영속화되어 있어야 하므로, 중간에 종료되어도 데이터 손실이 없다.
  2. 복구 경로는 하나뿐이다 — 재시작. 이 단일 경로를 완벽하게 만드는 데 집중한다.
  3. 모든 연산은 멱등적이어야 한다. 재시작 후 작업이 중복 실행되어도 결과가 동일해야 한다.

에이전틱 루프에 이 철학을 적용하면:

크래시-온리 에이전틱 루프 설계
전통적 설계:
정상 경로 ──▶ 정상 종료
오류 경로 ──▶ 오류 처리 ──▶ 복구 시도 ──▶ 정상 종료?
크래시-온리 설계:
모든 경로 ──▶ 크래시 허용 ──▶ 재시작 ──▶ 마지막 체크포인트에서 재개
멱등성 + 체크포인팅이 이를 가능하게 함

복잡한 오류 복구 코드를 작성하는 대신, 항상 재시작 가능한 상태를 유지하는 데 에너지를 집중한다. 7-3챕터의 체크포인팅과 멱등성이 이 철학의 기반이다.

┌──────────────────────────────────────────────────────────────┐
│ 재시도 전략 결정 흐름 │
├──────────────────────────────────────────────────────────────┤
│ 오류 발생 │
│ │ │
│ ▼ │
│ 영구적 오류? ──▶ Yes ──▶ 즉시 실패 전파 (재시도 없음) │
│ │ No │
│ ▼ │
│ 재시도 횟수 초과? ──▶ Yes ──▶ 체크포인트 저장 후 종료 │
│ │ No │
│ ▼ │
│ 동일 오류 반복? ──▶ Yes ──▶ 전략 변경 후 재시도 │
│ │ No │
│ ▼ │
│ 지터 + 지수 백오프 후 재시도 │
└──────────────────────────────────────────────────────────────┘

LangGraph는 노드 수준의 재시도 정책을 지원한다. 각 노드에 retry_policy를 설정하면 그래프 수준에서 일관된 재시도 로직을 적용할 수 있다. 이를 통해 루프 전체에 균일한 재시도 전략을 적용하면서도, 노드별로 다른 임계값을 설정할 수 있다.

재시도는 도구이지 만병통치약이 아니다. 재시도 횟수를 늘려도 해결되지 않는 문제는 근본 원인 수정이 필요하다. 에이전틱 루프에서 과도한 재시도는 비용만 증가시키고 실제 문제를 가린다.

재시도 전략의 목표는 일시적 장애를 투명하게 처리하면서 영구적 문제는 빠르게 표면화하는 것이다. 다음 챕터에서는 실패의 폭발 반경을 제한하는 또 다른 방어 계층인 샌드박싱을 다룬다.

참고 자료