토스뱅크 상황 가정 질문

금융 시스템에서 발생할 수 있는 장애 상황과 대응 전략을 다룹니다.

기본 시나리오

토스뱅크에서 하나의 은행으로 송금하는 상황을 가정합니다.

┌──────────┐     ┌──────────┐     ┌──────────┐
│   User   │────▶│ 토스뱅크  │────▶│  타행    │
│          │     │  Server  │     │ (하나은행)│
└──────────┘     └──────────┘     └──────────┘
                       │
                 Request/Response
                 Timeout 발생 가능

타임아웃 처리

Connection Timeout vs Read Timeout

Connection Timeout:

  • TCP 연결 수립 단계에서 발생
  • 원인: 네트워크 문제, 서버 다운, 방화벽 차단
  • 결론: 요청이 전달되지 않았음이 확실

Read Timeout:

  • 연결은 됐지만 응답을 기다리다 발생
  • 원인: 서버 처리 지연, 네트워크 지연
  • 결론: 요청이 처리됐는지 불확실 (가장 위험)
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(3))  // Connection Timeout
    .build();
 
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.hanabank.com/transfer"))
    .timeout(Duration.ofSeconds(10))  // Read Timeout
    .build();

응답 종류와 대처

가능한 응답 상태

상태의미대처 방안
요청 없었음서버에 요청 도달 전 실패안전하게 재시도 가능
진행 중처리 중이나 아직 완료 안됨폴링으로 결과 확인
성공송금 완료사용자에게 성공 알림
실패잔액 부족 등으로 실패실패 사유 안내
불확실Read Timeout 등상태 조회 API 호출

상태 조회 패턴

def transfer_with_status_check(request_id, transfer_data):
    try:
        response = call_transfer_api(transfer_data, request_id)
        return response
    except ReadTimeoutError:
        # 상태 조회로 결과 확인
        for _ in range(3):
            time.sleep(2)
            status = check_transfer_status(request_id)
            if status != 'PENDING':
                return status
        return 'UNKNOWN'

요청 ID와 시간 활용

멱등성 키 (Idempotency Key)

# 요청 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)
 
    # 결과 캐싱 (24시간)
    redis.setex(f"transfer:{request_id}", 86400, json.dumps(result))
    return result

요청 시간 기반 제어

def validate_request_time(request_timestamp):
    now = time.time()
    # 5분 이상 지난 요청은 거부
    if now - request_timestamp > 300:
        raise RequestExpiredError("Request too old")
    # 미래 시간 요청 거부
    if request_timestamp > now + 60:
        raise InvalidRequestTimeError("Future request not allowed")

정책 기반 해결

송금 정책 예시

transfer_policy:
  # 최대 재시도 횟수
  max_retries: 3
 
  # 재시도 간격 (지수 백오프)
  retry_intervals: [1s, 2s, 4s]
 
  # 타임아웃 시 자동 취소 대기 시간
  auto_cancel_after: 30m
 
  # 수동 확인 필요 금액
  manual_review_threshold: 10000000  # 1천만원
 
  # 불확실 상태 처리
  uncertain_state_action: "hold_and_notify"

재시도 전략

지수 백오프 (Exponential Backoff)

def retry_with_backoff(func, max_retries=5, base_delay=1):
    for attempt in range(max_retries):
        try:
            return func()
        except RetryableError as e:
            if attempt == max_retries - 1:
                raise
 
            # 지수 백오프 + 지터
            delay = base_delay * (2 ** attempt)
            jitter = random.uniform(0, delay * 0.1)
            time.sleep(delay + jitter)

재시도 가능 여부 판단

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

서킷브레이커

서킷브레이커 상태

      ┌─────────────────────────────────┐
      │                                 │
      ▼                                 │
┌──────────┐  실패 임계치  ┌──────────┐ │
│  CLOSED  │────────────▶│   OPEN   │ │
│ (정상)    │              │ (차단)    │ │
└──────────┘              └──────────┘ │
      ▲                        │       │
      │                   타임아웃 후   │
      │                        ▼       │
      │               ┌──────────────┐ │
      │               │  HALF-OPEN   │ │
      └───────────────│  (테스트)     │─┘
         성공 시       └──────────────┘
                          실패 시

구현 예시

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.state = 'CLOSED'
        self.last_failure_time = None
 
    def call(self, func):
        if self.state == 'OPEN':
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = 'HALF_OPEN'
            else:
                raise CircuitOpenError("Circuit is open")
 
        try:
            result = func()
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise
 
    def _on_success(self):
        self.failure_count = 0
        self.state = 'CLOSED'
 
    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = 'OPEN'

Rate Limiting

토큰 버킷 알고리즘

class TokenBucket:
    def __init__(self, capacity, refill_rate):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_rate = refill_rate  # tokens per second
        self.last_refill = time.time()
 
    def consume(self, tokens=1):
        self._refill()
        if self.tokens >= tokens:
            self.tokens -= tokens
            return True
        return False
 
    def _refill(self):
        now = time.time()
        elapsed = now - self.last_refill
        self.tokens = min(
            self.capacity,
            self.tokens + elapsed * self.refill_rate
        )
        self.last_refill = now

서킷브레이커 오픈 기준

이 상황에서 어떤 지표로 Open을 결정할까?

1. 에러율 기반

# 최근 100건 중 50% 이상 실패 시 Open
if error_count / total_count > 0.5:
    circuit.open()

2. 연속 실패 기반

# 연속 5회 실패 시 Open
if consecutive_failures >= 5:
    circuit.open()

3. 지연 시간 기반

# P99 지연이 5초 초과 시 Open
if percentile_99_latency > 5000:
    circuit.open()

4. 복합 조건

# 금융 시스템에 적합한 복합 조건
open_conditions = {
    'error_rate': 0.3,           # 30% 에러율
    'timeout_rate': 0.2,         # 20% 타임아웃율
    'consecutive_failures': 3,   # 연속 3회 실패
    'p99_latency_ms': 3000      # P99 3초 초과
}
 
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']
    )

모니터링 대시보드 지표

  • 요청 성공률 / 실패율
  • 평균 응답 시간 / P50 / P99
  • 서킷 상태 변화 히스토리
  • 재시도 횟수
  • 타임아웃 발생 빈도