7. 동기 다운스트림 호출의 연쇄 장애 (타임아웃·차단기·격벽 부재)
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 동기 다운스트림 호출의 연쇄 장애 (타임아웃·차단기·격벽 부재)
난이도: 상
요약
게이트웨이는 모든 요청에서 (A) 인증 서비스를 타임아웃 없이 동기 호출하고, (B) 어떤 보호장치(차단기·격벽·폴백)도 없이 항상 같은 경로를 탄다. 인증이 느려지면 호출 한 건이 워커 스레드를 수 초씩 붙잡고, 요청이 쌓이면서 스레드풀이 고갈된다. 그 결과 인증과 무관한 요청(핑/헬스체크 포함)까지 처리할 스레드가 없어, 다운스트림 한 곳의 지연이 게이트웨이 전체 장애로 번진다 — 전형적 cascading failure.
문제점
- [자원 고갈을 통한 장애 전파 / 타임아웃 부재]
- 증상: 인증 지연 시 게이트웨이의 모든 요청 지연·타임아웃, 결국 무응답. 헬스체크 실패 → 로드밸런서가 인스턴스를 빼고 트래픽이 옆 인스턴스로 몰려 그곳도 같은 식으로 무너짐(횡전파).
- 재현 조건: 워커 N개, 인증 평소 2ms·정상 처리량 충분. 인증 지연이 2s로 뛰면 각 요청이 스레드를 1000배 오래 점유 → 도착률이 같아도 동시 점유 스레드가 1000배 필요 → N을 금세 초과해 큐 적체·고갈.
- 근본 원인: ①
(A)호출에 시간 상한이 없어 느린 호출이 자원을 무한정 묶는다. ②(B)격리(bulkhead)가 없어 인증 호출이 공용 스레드풀 전부를 잠식하고, 무관한 작업까지 굶긴다. ③ 차단기(circuit breaker)가 없어 이미 죽은 다운스트림에 계속 호출을 쌓아 회복을 방해한다.
- [폴백 정책 부재]
- 인증 불가 시 "거부/열화 모드/대기" 중 무엇을 할지 정의가 없다. 그냥 멈춰 선다.
수정안
타임아웃 + 격벽(bulkhead) + 차단기(circuit breaker) + 폴백을 결합하고, 가능하면 비동기 I/O로 스레드 점유 자체를 없앤다.
public sealed class ResilientGateway
{
private static readonly TimeSpan CallTimeout = TimeSpan.FromMilliseconds(200);
private readonly IAuthClient _auth;
private readonly SemaphoreSlim _bulkhead = new(64); // 인증 동시 호출 상한(전체 스레드와 분리)
private readonly CircuitBreaker _breaker = new(failThreshold: 20, open: TimeSpan.FromSeconds(5));
public ResilientGateway(IAuthClient auth) => _auth = auth;
public async Task<PlayerResponse> HandleRequestAsync(PlayerRequest req, CancellationToken ct)
{
if (_breaker.IsOpen) return Fallback(req); // 죽었으면 즉시 폴백(빠른 실패)
if (!await _bulkhead.WaitAsync(0, ct)) return Fallback(req); // 격벽 만석이면 안 기다리고 폴백
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(CallTimeout); // 시간 상한
var r = await _auth.ValidateAsync(req.Token, cts.Token); // 비동기: 대기 중 스레드 점유 안 함
_breaker.RecordSuccess();
return r.Ok ? Process(req) : PlayerResponse.Denied;
}
catch (OperationCanceledException)
{
_breaker.RecordFailure(); // 타임아웃 = 실패로 집계
return Fallback(req);
}
finally { _bulkhead.Release(); }
}
// 인증은 보안 경계이므로 기본 fail-closed(거부). 재시도/캐시된 토큰 허용 등은 정책에 따라.
private PlayerResponse Fallback(PlayerRequest req) => PlayerResponse.Unavailable;
private PlayerResponse Process(PlayerRequest req) => PlayerResponse.Ok;
}
핵심:
- 타임아웃: 느린 호출이 자원을 무한정 잡지 못하게 상한을 둔다(클라 타임아웃 < 호출자 큐 누적 한계).
- 격벽(bulkhead): 인증 호출 동시 수를 별도 세마포어로 제한 → 인증이 느려도 전체 워커를 잠식 못 함. 무관한 경로는 계속 동작.
- 차단기(circuit breaker): 실패율이 임계 넘으면 OPEN → 일정 시간 즉시 폴백(빠른 실패)으로 다운스트림에 숨 쉴 틈을 주고, HALF-OPEN로 탐침 후 회복.
- 비동기 I/O:
await는 대기 동안 스레드를 반납하므로, 지연이 길어도 스레드 고갈로 직결되지 않는다(가장 근본적인 완화).
더 나은 설계
- 폴백 정책 명시: 인증처럼 보안 경계는 보통 fail-closed(거부)가 안전. 반면 랭킹/추천 같은 비핵심 다운스트림은 fail-open(캐시값·기본값으로 열화 모드). 무엇을 닫고 무엇을 여는지 사전에 결정.
- 재시도는 신중히: 죽은 다운스트림에 재시도하면 부하를 키워 회복을 막는다. 지수 백오프+지터, 그리고 차단기와 결합. 재시도는 멱등 작업에만.
- 부하 차단/우선순위: 큐가 길어지면 저우선 요청을 흘려보내고(load shedding) 핵심만 처리. 데드라인 전파(요청에 남은 예산을 실어 보내 늦으면 즉시 포기).
- 트레이드오프: 타임아웃이 너무 짧으면 정상 호출도 실패 처리(거짓 양성), 너무 길면 보호 효과↓. 차단기 임계/복구 시간도 민감도와 가용성의 절충.
면접 포인트
- "느린 것은 실패보다 위험하다(slow is the new down)" — 타임아웃 없는 동기 호출이 스레드풀을 고갈시켜 무관한 트래픽까지 죽이는 경로를 설명할 수 있는가.
- 격벽·차단기·타임아웃·폴백의 역할 구분과 조합. 비동기 I/O가 왜 근본 완화인지.
- fail-closed vs fail-open을 다운스트림 성격별로 어떻게 정하는가, 재시도가 장애를 키우는 안티패턴.
해설 · C++
해설 — 동기 다운스트림 호출의 연쇄 장애 (타임아웃·차단기·격벽 부재)
난이도: 상
요약
게이트웨이는 모든 요청에서 (A) 인증 서비스를 타임아웃 없이 동기 호출하고, (B) 차단기·격벽·폴백 없이 항상 같은 경로를 탄다. 인증이 느려지면 호출 한 건이 워커 스레드를 수 초씩 붙잡아 스레드풀이 고갈되고, 인증과 무관한 요청까지 처리 불가가 되어 다운스트림 한 곳의 지연이 전체 장애로 번진다(cascading failure).
문제점
- [자원 고갈을 통한 장애 전파 / 타임아웃 부재]
- 증상: 인증 지연 시 게이트웨이 전 요청 지연·무응답. 헬스체크 실패 → LB가 인스턴스 제외 → 트래픽이 옆 노드로 몰려 같은 식으로 횡전파.
- 재현 조건: 워커 N개, 인증 평소 2ms. 인증 지연이 2s가 되면 요청당 스레드 점유 시간이 1000배 → 동시 점유 스레드 수요 1000배 → N 초과로 큐 적체·고갈.
- 근본 원인: ①
(A)호출에 시간 상한이 없어 느린 호출이 자원을 무한 점유. ② 격리(bulkhead) 부재로 인증 호출이 공용 풀을 잠식, 무관한 작업까지 굶음. ③ 차단기 부재로 죽은 다운스트림에 호출을 계속 쌓아 회복 방해.
- [블로킹 소켓 기본 동작]
- 타임아웃을 안 걸면
recv는 응답이 올 때까지(또는 TCP keepalive가 끊을 때까지, 보통 수십 분) 반환하지 않는다. C++ 동기 소켓의 기본은 무한 대기에 가깝다.
- 타임아웃을 안 걸면
- [폴백 정책 부재]: 인증 불가 시 거부/열화/대기 중 무엇을 할지 미정.
수정안
타임아웃 + 격벽 + 차단기 + 폴백을 결합한다. 동기 호출이라면 최소한 소켓 타임아웃(SO_RCVTIMEO)과 future의 wait_for로 상한을 강제하고, 가능하면 비동기 I/O로 전환한다.
#include <atomic>
#include <chrono>
#include <future>
#include <semaphore> // C++20 std::counting_semaphore
class CircuitBreaker {
public:
bool is_open() {
if (!open_.load()) return false;
auto now = std::chrono::steady_clock::now();
if (now - opened_at_ > std::chrono::seconds(5)) { open_.store(false); fails_ = 0; }
return open_.load();
}
void on_success() { fails_ = 0; }
void on_failure() {
if (++fails_ >= 20) { open_.store(true); opened_at_ = std::chrono::steady_clock::now(); }
}
private:
std::atomic<bool> open_{false};
std::atomic<int> fails_{0};
std::chrono::steady_clock::time_point opened_at_{};
};
class ResilientGateway {
public:
explicit ResilientGateway(IAuthClient& auth) : auth_(auth) {}
PlayerResponse handle_request(const PlayerRequest& req) {
if (breaker_.is_open()) return fallback(req); // 빠른 실패
if (!bulkhead_.try_acquire()) return fallback(req); // 격벽 만석이면 폴백
struct Release { std::counting_semaphore<64>& s; ~Release(){ s.release(); } } guard{bulkhead_};
// 동기 호출에 시간 상한을 씌운다(실무에선 소켓 타임아웃이 더 바람직).
auto fut = std::async(std::launch::async, [&]{ return auth_.validate(req.token); });
if (fut.wait_for(std::chrono::milliseconds(200)) != std::future_status::ready) {
breaker_.on_failure(); // 타임아웃 = 실패 집계
return fallback(req); // 주의: async 작업은 백그라운드에 남음 → 취소 가능한 소켓 타임아웃이 정석
}
AuthResult r = fut.get();
breaker_.on_success();
return r.ok ? process(req) : PlayerResponse::Denied;
}
private:
PlayerResponse process(const PlayerRequest&) { return PlayerResponse::Ok; }
PlayerResponse fallback(const PlayerRequest&) { return PlayerResponse::Unavailable; } // 인증=fail-closed
IAuthClient& auth_;
std::counting_semaphore<64> bulkhead_{64};
CircuitBreaker breaker_;
};
핵심:
- 타임아웃: 정석은
setsockopt(SO_RCVTIMEO)또는 논블로킹+epoll데드라인으로, 대기 중인 호출을 깨워 자원을 회수한다. (std::async+wait_for는 대기는 풀리지만 백그라운드 작업이 남으므로 데모용; 실무는 취소 가능한 I/O를 쓴다.) - 격벽(bulkhead):
counting_semaphore로 인증 동시 호출 수를 제한 → 전체 워커 잠식 방지. - 차단기: 실패 누적 시 OPEN → 일정 시간 즉시 폴백 → HALF-OPEN 탐침.
- 비동기 I/O: 가장 근본적. epoll/io_uring 기반 비동기로 바꾸면 대기 동안 스레드를 점유하지 않는다.
더 나은 설계
- 폴백 정책 명시: 인증=fail-closed(거부)가 안전. 랭킹/추천 등 비핵심은 fail-open(캐시·기본값 열화 모드).
- 재시도 주의: 죽은 곳에 재시도는 부하를 키워 회복을 막는다 → 지수 백오프+지터, 차단기와 결합, 멱등 작업에만.
- 부하 차단/데드라인 전파: 큐가 길어지면 저우선 요청 폐기, 요청에 남은 시간 예산을 실어 보내 늦으면 즉시 포기.
- 트레이드오프: 타임아웃이 짧으면 거짓 실패, 길면 보호 약화. 차단기 임계/복구 시간도 민감도 vs 가용성 절충.
면접 포인트
- 타임아웃 없는 동기 호출이 스레드풀을 고갈시켜 무관한 트래픽까지 죽이는 메커니즘.
- bulkhead/circuit breaker/timeout/fallback의 역할 구분과 조합, 비동기 I/O가 근본 완화인 이유.
- C++ 블로킹 소켓의 기본 무한 대기와
SO_RCVTIMEO/논블로킹+epoll 데드라인.