23. 요청 데드라인 전파 부재와 재시도 증폭 (C#)
난이도 상해설 — 요청 데드라인 전파 부재와 재시도 증폭 (C#)
난이도: 상
요약
각 계층이 자기만의 타임아웃과 재시도를 갖고 있고, 상위 계층이 "언제까지 기다릴 것인가"를 하위로 전달하지 않는다. 그래서 (1) 게이트웨이가 포기한 뒤에도 하위 서비스는 계속 일하고 (고아 작업), (2) 계층별 재시도 횟수가 곱해져 하위 호출량이 폭증하며, (3) 계층별 타임아웃 합이 상위 타임아웃보다 커서 상위 타임아웃이 사실상 유일한 제한이 된다. 장애 시 부하가 줄기는커녕 늘어나 회복을 막는 전형적인 구조다.
문제점
1) 데드라인이 전파되지 않음 — (A)+(B) [데드라인 전파 부재]
- 증상: 게이트웨이가 5초에 취소해 클라에 실패를 반환해도, 랭킹 서비스는 이미 시작한 하위 호출과 DB 쿼리를 끝까지 수행한다. 아무도 결과를 받지 않는 고아 작업(orphan work) 이 CPU·DB 커넥션·스레드를 계속 소비한다.
- 재현 조건: 통계 서비스 응답을 8초로 지연시키고 부하를 준다. 게이트웨이 성공률은 0 인데 통계 서비스 QPS 와 DB 부하는 정상 시보다 높게 유지된다.
- 근본 원인: 남은 시간(deadline)은 요청의 속성인데 각 계층의 로컬 상수로 하드코딩되어
있다. HTTP 요청에 데드라인 헤더가 실리지 않고,
RankingService.HandleAsync는 아예CancellationToken을 받지 않는다. 게이트웨이의cts.Token은 자기 HTTP 소켓만 끊을 뿐, 원격 서버의 처리를 멈추게 하지 못한다.
2) 재시도의 곱셈 증폭 — (A)+(B) [재시도 증폭/메타스테이블 장애]
- 증상: 게이트웨이 3회 × 랭킹 3회 = 하위 서비스 호출이 최대 9배. 통계 서비스가 느려질수록 타임아웃 → 재시도가 더 자주 발생해 부하가 더 커지는 양의 되먹임이 생긴다. 원인이 사라져도 부하가 스스로 유지되는 메타스테이블 장애로 이어진다.
- 재현 조건: 통계 서비스 지연을 3초 이상으로 올리면 즉시 재현된다.
- 근본 원인: 재시도를 여러 계층에 중복 배치했다. 재시도는 원칙적으로 한 계층에서만 수행해야 하며, 배치할 곳은 보통 가장 바깥(또는 실패 원인을 가장 잘 아는 한 곳)이다.
3) 백오프·지터 부재 — (A)+(B) [thundering herd]
- 증상: 타임아웃 즉시 재시도하므로, 장애 순간 모든 요청이 같은 리듬으로 동시에 재시도해 하위 서비스에 파상 공격을 가한다.
- 근본 원인: 지수 백오프와 무작위 지터가 없다.
4) 타임아웃 예산이 계층 간에 맞지 않음 — (A)+(B) [예산 배분]
- 증상: 게이트웨이 5초인데 랭킹 서비스는 하위 호출당 3초 × 3회 = 최대 9초를 쓸 수 있다. 통계 서비스는 DB 쿼리 10초를 허용한다. 하위 계층의 상한이 상위보다 크면 하위 타임아웃은 작동하지 않는 장식이 된다.
- 근본 원인: 타임아웃을 계층마다 독립적으로 정했고, "남은 예산"이라는 개념이 없다.
5) 취소 불가능한 재시도 루프 — (B) [취소 전파]
CallWithRetryAsync는 외부CancellationToken을 받지 않고 매번 새CancellationTokenSource만 만든다. 상위가 취소해도 이 루프를 멈출 방법이 없다.
6) 재시도 대상 구분 부재 — (B) [멱등성]
HttpRequestException을 무조건 재시도한다. 이 예제는 조회라 멱등하지만, 같은 헬퍼가 쓰기 요청에 쓰이면 중복 부수효과가 생긴다. 4xx 같은 재시도 무의미한 오류까지 재시도한다.
수정안
핵심은 데드라인을 요청과 함께 전파하고, 재시도는 한 계층에만, 예산을 계층 간에 배분 하는 것이다.
// ---- 게이트웨이: 데드라인의 유일한 소유자 ----
public sealed class GatewayHandler
{
private static readonly TimeSpan RequestBudget = TimeSpan.FromSeconds(5);
public async Task<string> GetRankingAsync(long accountId, CancellationToken clientCt)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(clientCt);
cts.CancelAfter(RequestBudget);
var deadline = DateTimeOffset.UtcNow + RequestBudget; // 절대 시각
// (2) 재시도는 여기 한 곳에서만. 남은 예산이 있을 때만 재시도한다.
int attempt = 0;
while (true)
{
try
{
return await _ranking.FetchAsync(accountId, deadline, cts.Token);
}
catch (Exception ex) when (IsTransient(ex) && !cts.IsCancellationRequested)
{
attempt++;
var backoff = Backoff(attempt); // (3) 지수 + 지터
if (DateTimeOffset.UtcNow + backoff + MinCallTime >= deadline) throw; // (4)
await Task.Delay(backoff, cts.Token);
}
}
}
private static TimeSpan Backoff(int attempt) =>
TimeSpan.FromMilliseconds(
Math.Min(1000, 50 * Math.Pow(2, attempt)) * (0.5 + Random.Shared.NextDouble() * 0.5));
}
public sealed class RankingClient
{
public async Task<string> FetchAsync(long accountId, DateTimeOffset deadline, CancellationToken ct)
{
var req = new HttpRequestMessage(HttpMethod.Get, $"http://ranking/v1/{accountId}");
// (1) 남은 시간을 헤더로 전파 (gRPC 의 grpc-timeout 과 같은 역할)
var remaining = deadline - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) throw new TimeoutException();
req.Headers.Add("X-Request-Deadline-Ms", ((int)remaining.TotalMilliseconds).ToString());
var res = await Http.SendAsync(req, ct);
return await res.Content.ReadAsStringAsync(ct);
}
}
// ---- 랭킹 서비스: 전달받은 데드라인을 존중하고, 재시도하지 않는다 ----
public sealed class RankingService
{
public async Task<string> HandleAsync(long accountId, DateTimeOffset deadline, CancellationToken ct)
{
var remaining = deadline - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) throw new TimeoutException(); // (1) 시작 전 조기 포기
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(remaining);
// 병렬 호출: 각자 남은 예산을 그대로 상속한다. 계층 내 재시도 없음.
var statsTask = _stats.GetAsync(accountId, deadline, cts.Token);
var profileTask = _profile.GetAsync(accountId, deadline, cts.Token);
await Task.WhenAll(statsTask, profileTask);
return Combine(statsTask.Result, profileTask.Result);
}
}
서버 진입점(미들웨어)에서 헤더를 읽어 요청 컨텍스트에 데드라인을 심고, 이후 모든 하위 호출이 그 값을 상속하게 한다. 데드라인이 이미 지났으면 작업을 시작하지도 않고 즉시 거절한다. 이것만으로도 장애 시 하위 부하가 급감한다.
// 미들웨어
if (ctx.Request.Headers.TryGetValue("X-Request-Deadline-Ms", out var v) && int.TryParse(v, out int ms))
{
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(ms);
if (deadline <= DateTimeOffset.UtcNow) { ctx.Response.StatusCode = 504; return; }
ctx.Items["deadline"] = deadline;
}
DB 호출도 남은 예산으로 CommandTimeout 을 설정해야 체인 전체가 일관된다.
더 나은 설계
- gRPC 사용:
grpc-timeout헤더로 데드라인이 표준적으로 전파되고, 만료 시 서버 측context.CancellationToken이 자동으로 취소된다. 트레이드오프 — 프로토콜 전환 비용. - 재시도 예산(retry budget) / 어댑티브 재시도: 전체 요청 대비 재시도 비율을 10% 등으로 제한해 장애 시 증폭을 원천 차단한다(Envoy·Finagle 방식). 트레이드오프 — 구현·튜닝 복잡도.
- 서킷 브레이커 + 격벽: 하위 서비스가 계속 실패하면 회로를 열어 호출 자체를 막고, 서비스별 동시 호출 수를 격리한다. 트레이드오프 — 반열림 구간의 정책 설계 필요.
- 부분 응답 허용(graceful degradation): 프로필이 늦으면 랭킹만이라도 반환. 게임 UI 에서는 "일부 정보 없음"이 "전체 실패"보다 낫다. 트레이드오프 — 클라 처리 분기 증가.
- 데드라인은 절대 시각으로: 상대 시간을 계층마다 다시 계산하면 네트워크 지연이 누적 반영되지 않는다. 단, 서버 간 시계 오차가 있으므로 "남은 밀리초"를 전달하고 각 서버가 자기 단조 시계로 절대 시각을 만드는 방식이 안전하다.
면접 포인트
- 타임아웃과 데드라인의 차이. 왜 "각 호출 3초"가 아니라 "이 요청은 앞으로 N ms"가 옳은가.
- 재시도 증폭(retry amplification). 계층 수 L, 계층별 재시도 R 일 때 최악 호출량이 R^L 이 되는 구조와, 재시도를 한 계층으로 모아야 하는 이유.
- 메타스테이블 장애 — 트리거가 사라져도 재시도 부하가 스스로를 유지하는 상태. 벗어나는 방법 (부하 차단, 재시도 예산, 백오프+지터, 조기 거절).
- 고아 작업이 왜 위험한가. 아무도 기다리지 않는 계산이 스레드풀·DB 커넥션을 점유해 정상 요청까지 굶기는 경로.
해설 — 요청 데드라인 전파 부재와 재시도 증폭, 그리고 취소 불가능한 블로킹 (C++)
난이도: 최상
요약
C# 판과 같은 구조적 결함(데드라인 미전파, 계층별 재시도 곱셈, 예산 불일치)에 더해, C++ 판은
std::async + future::wait_for 로 타임아웃을 흉내 내면서 작업을 실제로 취소하지 못하고,
타임아웃 후 future 소멸자가 블로킹하며, 재시도마다 스레드를 새로 만들어 자원을
고갈시킨다. 즉 타임아웃이 "포기"가 아니라 "무시"로 구현되어 있다.
문제점
1) wait_for 타임아웃은 작업을 취소하지 않는다 — callWithTimeout [취소 불가]
- 증상: 타임아웃으로
TimeoutError를 던져도std::async가 띄운 스레드는 계속 실행된다. 통계 서비스 호출과 DB 쿼리가 끝까지 수행되며, 결과는 버려진다. - 근본 원인:
future::wait_for는 대기만 중단한다. 실행 중인 작업에 취소 신호를 보내는 수단이 없다. 협조적 취소를 위해서는 작업 자체가std::stop_token(C++20) 이나 취소 플래그를 주기적으로 확인해야 하고, 블로킹 소켓 I/O 는 소켓을 닫거나SO_RCVTIMEO/논블로킹으로 바꿔야 실제로 끊긴다.
2) std::async future 소멸자의 블로킹 — callWithTimeout [가용성/치명적]
- 증상:
throw TimeoutError()로 스코프를 벗어나는 순간fut이 소멸한다.std::launch::async로 만든 future 의 소멸자는 공유 상태가 준비될 때까지 블로킹한다. 즉 5초 타임아웃을 걸었는데도 함수는 하위 작업이 끝날 때까지(예: 10초) 반환하지 못한다. 타임아웃이 사실상 동작하지 않는다. - 재현 조건:
stats_.get을 10초 걸리게 만들고 3초 타임아웃을 걸면,callWithTimeout은 3초가 아니라 10초 뒤에 예외를 던진다. - 근본 원인:
std::async의 잘 알려진 함정.std::packaged_task+ 직접 관리하는 스레드, 또는 취소 가능한 비동기 I/O 로 바꿔야 한다.
3) 데드라인이 전파되지 않음 — (A)+(B) [데드라인 전파 부재]
- 증상: 게이트웨이가 포기한 뒤에도 랭킹 서비스와 그 하위가 계속 일한다(고아 작업). 아무도 받지 않을 결과를 위해 CPU·DB 커넥션·스레드가 소비된다.
- 근본 원인: 남은 시간은 요청의 속성인데 각 계층 로컬 상수로 하드코딩되어 있고, 원격 호출에
실려 나가지 않는다.
RankingService::handle은 데드라인 인자조차 받지 않는다.
4) 재시도의 곱셈 증폭 — (A)+(B) [재시도 증폭]
- 증상: 게이트웨이 3회 × 랭킹 3회 = 하위 호출 최대 9배. 하위가 느려질수록 재시도가 늘어 부하가 더 커지는 양의 되먹임(메타스테이블 장애).
- 근본 원인: 재시도가 여러 계층에 중복 배치됐다.
5) 백오프·지터 부재 — (A)+(B) [thundering herd]
- 타임아웃 즉시 재시도하므로 장애 순간 모든 요청이 동기화되어 파상 재시도를 만든다.
6) 예산 불일치 — (A)+(B) [예산 배분]
- 게이트웨이 5초 < 랭킹의 최대 소요 3초×3회 = 9초 < 통계의 DB 10초. 하위 상한이 상위보다 크면 하위 타임아웃은 작동하지 않는 장식이다.
7) 재시도마다 스레드 생성 — [자원 고갈]
callWithTimeout이 호출될 때마다std::async가 스레드를 새로 만든다(구현에 따라 풀 재사용이 없을 수 있다). (2)와 겹쳐 타임아웃된 스레드가 계속 살아 있으므로, 부하 상황에서 스레드 수가 요청 수 × 재시도 수만큼 쌓여 메모리와 스케줄러를 압박한다.
수정안
데드라인을 값으로 만들어 체인 전체에 전달
using Clock = std::chrono::steady_clock;
struct RequestContext {
Clock::time_point deadline;
std::stop_source stop; // C++20 협조적 취소
std::chrono::milliseconds remaining() const {
auto d = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now());
return d > std::chrono::milliseconds::zero() ? d : std::chrono::milliseconds::zero();
}
bool expired() const { return Clock::now() >= deadline; }
};
게이트웨이: 데드라인의 유일한 소유자, 재시도도 여기서만
std::string GatewayHandler::getRanking(std::int64_t accountId) {
RequestContext ctx{Clock::now() + std::chrono::seconds(5), {}};
for (int attempt = 0; ; ++attempt) {
if (ctx.expired()) throw TimeoutError();
try {
return ranking_.fetch(accountId, ctx); // (3) 컨텍스트 전파
} catch (const TransientError&) {
auto backoff = jitteredBackoff(attempt); // (5) 지수 + 지터
// (6) 남은 예산으로 재시도 가능성을 판단
if (Clock::now() + backoff + kMinCallTime >= ctx.deadline) throw TimeoutError();
std::this_thread::sleep_for(backoff);
}
}
}
랭킹 서비스: 전달받은 예산을 존중, 계층 내 재시도 없음
std::string RankingService::handle(std::int64_t accountId, const RequestContext& ctx) {
if (ctx.expired()) throw TimeoutError(); // (3) 시작 전 조기 거절
// 각 하위 호출은 남은 예산을 상속. 재시도하지 않는다.
auto stats = stats_.get(accountId, ctx);
auto profile = profile_.get(accountId, ctx);
return stats + profile;
}
타임아웃을 "진짜 포기"로 만들기
std::async 대신 취소 가능한 I/O 를 쓴다. 블로킹 소켓이라면 남은 예산으로 소켓 타임아웃을
설정하고, 취소 시 소켓을 닫아 대기를 깨운다.
std::string StatsClient::get(std::int64_t id, const RequestContext& ctx) {
auto budget = ctx.remaining();
if (budget.count() == 0) throw TimeoutError();
Socket s = connect(endpoint_, budget);
s.setRecvTimeout(budget); // (1)(2) 대기가 아니라 I/O 자체에 상한
// 취소 시 소켓을 닫아 블로킹 recv 를 즉시 깨운다
std::stop_callback cb(ctx.stop.get_token(), [&] { s.shutdown(); });
sendRequest(s, id, budget);
return recvResponse(s, budget); // 만료 시 TimeoutError
}
위 스니펫은 std::stop_source/std::stop_callback(C++20)을 쓴다. C++17 로 빌드해야 한다면
std::shared_ptr<std::atomic<bool>> 취소 플래그를 RequestContext 에 두고, 취소 측이 플래그를
세운 뒤 shutdown(fd, SHUT_RDWR) 로 블로킹 recv 를 깨우는 방식으로 같은 효과를 낸다.
(플래그만으로는 블로킹 I/O 가 깨지지 않는다는 점이 핵심이다.)
원격 호출에는 남은 밀리초를 헤더로 실어 보낸다(gRPC 의 grpc-timeout 과 동일한 역할).
수신 측은 진입점에서 이를 읽어 자신의 RequestContext 를 만들고, 이미 만료됐으면 처리를
시작하지 않고 즉시 거절한다. 서버 간 시계 오차 때문에 절대 시각이 아니라 "남은 시간"을
전달하고, 각 서버가 자기 단조 시계로 데드라인을 재구성하는 것이 안전하다.
DB 쿼리에도 남은 예산을 statement timeout 으로 반영해야 체인이 일관된다.
더 나은 설계
std::async를 코드베이스에서 금지하고 명시적 스레드풀 +std::packaged_task사용: future 소멸자 블로킹과 스레드 폭증을 동시에 해결한다. 트레이드오프 — 직접 관리 부담.- 비동기 I/O 프레임워크(asio/코루틴): 타임아웃과 취소가 I/O 레벨에서 일급으로 지원되어 "포기했는데 스레드가 남아 있는" 상황이 사라진다. 트레이드오프 — 학습 곡선, 구조 변경.
- gRPC 채택: 데드라인 전파와 서버 측 자동 취소가 표준으로 제공된다. 트레이드오프 — 프로토콜 전환 비용.
- 재시도 예산 + 서킷 브레이커 + 격벽: 재시도 비율을 전체 요청의 일정 %로 제한하고, 실패가 지속되면 회로를 열어 호출 자체를 차단한다. 트레이드오프 — 정책 튜닝 복잡도.
- 부분 응답 허용: 프로필이 늦으면 랭킹만 반환. 게임 UI 에서는 전체 실패보다 낫다.
면접 포인트
std::async로 얻은 future 의 소멸자가 블로킹한다는 사실과, 그 때문에wait_for기반 타임아웃이 무력화되는 메커니즘. 왜std::packaged_task+ 스레드 관리가 대안인가.- C++ 에서 "취소"가 협조적일 수밖에 없는 이유. 블로킹 소켓 I/O 를 실제로 끊는 방법
(
shutdown, 타임아웃 옵션, 논블로킹 + 이벤트 루프,eventfd로 깨우기). - 데드라인 전파의 설계 — 절대 시각 vs 남은 시간, 시계 오차와 단조 시계의 역할.
- 재시도 증폭이 R^L 로 커지는 구조와 메타스테이블 장애에서 벗어나는 방법.