은행 송금 시스템에서 제일 어려운 지점은 “실패했다”보다 “성공했는지 실패했는지 모른다”에 가깝다. 사용자가 토스뱅크에서 다른 은행으로 송금했는데, 요청을 보낸 뒤 타임아웃이 났다고 해보자. 이때 단순히 다시 요청하면 중복 송금이 될 수 있고, 실패로 안내하면 실제로는 돈이 빠져나간 상태일 수도 있다.

User → 토스뱅크 서버 → 타행 서버
                  ↳ Request / Response timeout 가능

그래서 금융 시스템의 장애 대응은 재시도 로직 하나로 끝나지 않는다. 요청이 어디까지 도달했는지, 외부 은행이 처리를 시작했는지, 나중에 결과를 어떻게 확인할지까지 함께 설계해야 한다.

타임아웃을 먼저 구분한다

Connection Timeout은 TCP 연결을 맺기 전에 실패한 경우다. 네트워크가 막혔거나 상대 서버가 죽어 있거나 방화벽 문제일 수 있다. 이 경우에는 요청이 상대 시스템에 도달하지 않았다고 볼 수 있어서 비교적 안전하게 재시도할 수 있다.

반대로 Read Timeout은 연결은 됐지만 응답을 받지 못한 경우다. 이게 더 위험하다. 상대 은행이 이미 송금을 처리했는데 응답만 늦었을 수도 있고, 아직 처리 중일 수도 있고, 내부에서 실패했을 수도 있다. 즉 Read Timeout 이후의 상태는 “모름”이다.

HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(3))
    .build();
 
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.hanabank.com/transfer"))
    .timeout(Duration.ofSeconds(10))
    .build();

송금 같은 요청에서는 이 차이를 분명히 다뤄야 한다. 연결 자체가 안 된 실패와, 요청은 갔지만 응답을 못 받은 실패를 같은 재시도 정책으로 묶으면 사고가 난다.

핵심은 상태 조회와 멱등성이다

송금 요청에는 클라이언트나 내부 시스템이 만든 고유한 요청 ID가 필요하다. 이 ID는 한 번의 송금 시도를 대표한다. 같은 요청 ID로 다시 들어온 요청은 새 송금이 아니라 기존 송금의 재확인으로 처리해야 한다.

request_id = f"{user_id}_{timestamp}_{uuid4()}"
 
def process_transfer(request_id, data):
    existing = redis.get(f"transfer:{request_id}")
    if existing:
        return json.loads(existing)
 
    result = execute_transfer(data)
    redis.setex(f"transfer:{request_id}", 86400, json.dumps(result))
    return result

Read Timeout이 발생하면 바로 같은 송금을 다시 실행하기보다 상태 조회 API를 호출한다. 상태가 성공이면 성공으로 안내하고, 실패면 실패 사유를 안내한다. 아직 진행 중이면 짧은 간격으로 몇 번 더 확인하고, 그래도 모르면 사용자에게 “확인 중” 상태로 보여준 뒤 별도 보정 프로세스에서 마무리한다.

def transfer_with_status_check(request_id, transfer_data):
    try:
        return call_transfer_api(transfer_data, request_id)
    except ReadTimeoutError:
        for _ in range(3):
            time.sleep(2)
            status = check_transfer_status(request_id)
            if status != "PENDING":
                return status
        return "UNKNOWN"

상태는 대략 다섯 가지로 나눠볼 수 있다. 요청이 아예 도달하지 않았으면 재시도할 수 있고, 진행 중이면 폴링한다. 성공과 실패는 그대로 사용자에게 안내한다. 문제는 불확실 상태인데, 이때는 임의로 성공이나 실패를 단정하지 않고 상태 조회와 보정 흐름으로 넘기는 게 안전하다.

재시도는 조건부로만 한다

재시도는 장애 대응의 기본 도구지만, 송금에서는 항상 조심스럽게 써야 한다. 일시적인 네트워크 오류나 503, Rate Limit, 연결 실패처럼 명확히 재시도 가능한 에러만 대상으로 삼고, 잔액 부족이나 계좌 오류, 인증 실패, 중복 요청 같은 비즈니스 오류는 재시도하지 않는다.

RETRYABLE_ERRORS = {
    "TIMEOUT",
    "SERVICE_UNAVAILABLE",
    "RATE_LIMITED",
    "NETWORK_ERROR",
}
 
NON_RETRYABLE_ERRORS = {
    "INSUFFICIENT_BALANCE",
    "INVALID_ACCOUNT",
    "AUTHENTICATION_FAILED",
    "DUPLICATE_REQUEST",
}

재시도 간격은 지수 백오프와 지터를 섞는 편이 좋다. 모든 요청이 같은 주기로 재시도되면 장애가 난 외부 은행에 더 큰 부하를 줄 수 있기 때문이다.

def retry_with_backoff(func, max_retries=5, base_delay=1):
    for attempt in range(max_retries):
        try:
            return func()
        except RetryableError:
            if attempt == max_retries - 1:
                raise
            delay = base_delay * (2 ** attempt)
            jitter = random.uniform(0, delay * 0.1)
            time.sleep(delay + jitter)

요청 시간도 함께 본다. 너무 오래된 요청은 거부하고, 서버 시간보다 과하게 미래인 요청도 막아야 한다. 멱등성 키만으로는 오래된 요청의 재실행까지 모두 설명하기 어렵기 때문에, 요청 ID와 요청 시각을 같이 들고 가는 편이 안전하다.

서킷브레이커는 외부 장애를 내부 장애로 번지지 않게 한다

타행 API가 계속 느려지거나 실패한다면 우리 서버가 계속 기다리며 리소스를 잡아먹게 된다. 이때 서킷브레이커가 필요하다. 정상 상태에서는 요청을 보내지만, 실패율이나 타임아웃이 임계치를 넘으면 잠시 차단한다. 일정 시간이 지난 뒤에는 일부 요청만 보내 회복 여부를 확인한다.

CLOSED(정상) ── 실패 임계치 초과 ──> OPEN(차단)
   ▲                                  │
   └──── 성공 확인 <── HALF-OPEN <────┘

오픈 기준은 하나의 숫자보다 여러 지표를 함께 보는 편이 낫다. 최근 에러율, 타임아웃 비율, 연속 실패 횟수, P99 지연 시간을 함께 보고 결정한다. 금융 시스템이라면 일반 서비스보다 더 보수적으로 잡을 수 있다.

open_conditions = {
    "error_rate": 0.3,
    "timeout_rate": 0.2,
    "consecutive_failures": 3,
    "p99_latency_ms": 3000,
}
 
def should_open(metrics):
    return (
        metrics.error_rate > open_conditions["error_rate"]
        or metrics.timeout_rate > open_conditions["timeout_rate"]
        or metrics.consecutive_failures >= open_conditions["consecutive_failures"]
        or metrics.p99_latency > open_conditions["p99_latency_ms"]
    )

서킷이 열렸을 때도 사용자 경험을 따로 설계해야 한다. 무작정 실패로 보여주기보다 “현재 해당 은행 송금이 지연되고 있다”처럼 상태를 명확히 안내하고, 가능한 경우 예약 처리나 나중에 다시 시도하도록 유도한다.

운영 정책까지 정해야 완성된다

송금 장애 대응은 코드보다 정책이 더 중요할 때가 많다. 최대 재시도 횟수, 재시도 간격, 불확실 상태를 얼마나 오래 확인할지, 특정 금액 이상은 수동 확인으로 넘길지 같은 기준이 필요하다.

transfer_policy:
  max_retries: 3
  retry_intervals: [1s, 2s, 4s]
  auto_cancel_after: 30m
  manual_review_threshold: 10000000
  uncertain_state_action: "hold_and_notify"

모니터링도 같은 기준으로 잡는다. 요청 성공률과 실패율, P50/P99 응답 시간, 타임아웃 빈도, 재시도 횟수, 서킷 상태 변화는 최소한 봐야 한다. 특히 불확실 상태가 얼마나 자주 생기고 얼마나 오래 남는지는 금융 서비스에서 중요한 운영 지표가 된다.

정리하면 이 문제의 핵심은 “실패하면 다시 보낸다”가 아니다. 송금 요청을 하나의 상태 머신으로 보고, 불확실한 상태를 안전하게 확인하고, 중복 실행을 막고, 외부 장애가 내부 시스템 전체로 번지지 않게 막는 것이다.