← 문제로

7. 동기 다운스트림 호출의 연쇄 장애 (타임아웃·차단기·격벽 부재)

난이도 상
내 리뷰 · 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을 다운스트림 성격별로 어떻게 정하는가, 재시도가 장애를 키우는 안티패턴.