토스뱅크 상황 가정 질문
금융 시스템에서 발생할 수 있는 장애 상황과 대응 전략을 다룹니다.
기본 시나리오
토스뱅크에서 하나의 은행으로 송금하는 상황을 가정합니다.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 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
- 서킷 상태 변화 히스토리
- 재시도 횟수
- 타임아웃 발생 빈도