실패도 캐싱하라: API 클라이언트 회복력을 위한 TypeScript 패턴 (negative caching + discriminated union)

API hammering을 막으려면 실패도 캐시해야 합니다. 30초 negative caching, 1시간 stale fallback, 그리고 TypeScript discriminated union으로 "데이터 또는 에러" 불변식을 타입에 박아 넣는 세 단계 패턴을 정리합니다.

실패도 캐싱하라: API 클라이언트 회복력을 위한 TypeScript 패턴 (negative caching + discriminated union)

1. 문제 상황 — 백엔드가 흔들릴 때 클라이언트가 흔들기 시작합니다

claude-dashboard는 Anthropic의 OAuth usage API를 호출해서 5시간/7일 rate limit을 가져옵니다. 그리고 같은 패턴이 Codex, Gemini, z.ai 클라이언트에도 들어가 있어, 합치면 4개의 외부 API를 정기적으로 두드립니다.

대부분의 시간엔 멀쩡히 잘 동작합니다. 그런데 사용자 한 명이 이슈를 열어 줬습니다.

"쓰다 보면 가끔 상태줄에 ⚠️가 깜빡거리는데, debug 로그를 보면 1초 동안 같은 API를 4-5번 부르고 다 실패하고 있어요."

직접 재현해 봤습니다. 시나리오는 단순했습니다.

  1. 사용자가 노트북 뚜껑을 닫음 → 네트워크 끊김
  2. Claude Code가 백그라운드에서 계속 동작
  3. 60초 캐시가 만료되는 순간, 상태줄이 API를 호출 → 실패
  4. 다음 렌더 때, 캐시가 비었으므로 또 호출 → 또 실패
  5. 이 패턴이 사용자가 다시 키 입력을 할 때마다 반복됩니다

실패는 캐시되지 않기 때문에, 클라이언트는 매번 새로 시도합니다. 백엔드 입장에서는 같은 토큰의 같은 요청이 1초에 여러 번 들어옵니다. 만약 이게 여러 사용자에게서 동시에 일어난다면 백엔드 자체에 부하가 됩니다.

게다가 이 과정에서 사용자에게 보이는 것은 빈 상태줄입니다. 30분 전까지 잘 보이던 데이터가, 네트워크 한 번 끊겼다고 즉시 사라집니다. 마지막으로 알고 있던 값이라도 보여 주는 게 백배 낫습니다.

이 글은 이 문제를 세 단계로 풀어 가는 이야기입니다.

  • 1단계: 429 retry + stale fallback — 일시적 실패에 대한 1차 방어
  • 2단계: negative caching — "실패도 캐시하라"
  • 3단계: TypeScript discriminated union — 위 두 가지를 타입 시스템으로 강제

2. 1단계: 429 retry + stale fallback

2.1 가장 흔한 실패 — 429 Too Many Requests

API 실패는 여러 모양을 가집니다. 네트워크 오류, 5xx 서버 오류, 타임아웃, 인증 만료... 그 중에서도 가장 자주 보이는 것 하나는 429 Too Many Requests입니다. Anthropic API는 RFC 7231 표준대로 retry-after 헤더로 "이만큼 기다렸다 다시 와라"고 알려 줍니다.

기존 코드는 429를 받으면 그냥 null을 돌려주고 끝이었습니다. retry-after를 무시하고 있었던 것입니다.

// Before
async function fetchFromApi(token: string): Promise<UsageLimits | null> {
  const response = await makeRequest(token);
  if (!response.ok) {
    return null;   // ← 429든 503이든 그냥 포기
  }
  // ...
}

2.2 retry-after를 존중하되, 너무 길면 포기

처음에는 단순히 if (status === 429) sleep(retryAfter) 정도로 짤까 했는데, 한 가지가 걸렸습니다. 만약 retry-after가 60초로 오면 어쩌지? 상태줄은 "지금 즉시" 보여 줘야 하는 컴포넌트이기 때문에, 60초 동안 렌더링을 기다리는 건 말이 안 됩니다.

그래서 상한선을 두는 retry로 갔습니다. retry-after가 짧으면(10초 이하) 한 번 재시도, 길면 그냥 포기.

// scripts/utils/api-client.ts
const MAX_RETRY_AFTER_MS = 10000;

async function fetchFromApi(token: string, tokenHash: string): Promise<UsageLimits | null> {
  try {
    let response = await makeRequest(token);

    // Retry once on 429 if retry-after is short enough
    if (response.status === 429) {
      const retryAfterHeader = response.headers.get('retry-after');
      if (retryAfterHeader === null) {
        debugLog('api', '429 received, no retry-after header, skipping');
      } else {
        const retryAfter = parseInt(retryAfterHeader, 10);
        if (!isNaN(retryAfter) && retryAfter * 1000 <= MAX_RETRY_AFTER_MS) {
          debugLog('api', `429 received, retrying after ${retryAfter}s`);
          await new Promise((r) => setTimeout(r, retryAfter * 1000));
          response = await makeRequest(token);
        } else {
          debugLog('api', `429 received, retry-after ${retryAfter}s exceeds limit, skipping`);
        }
      }
    }

    if (!response.ok) {
      return null;
    }
    const data = await response.json();
    return parseAndCacheLimits(data, tokenHash);
  } catch (error) {
    debugLog('api', 'Request failed', error);
    return null;
  }
}

makeRequest를 별도 함수로 분리한 것은 retry 시 같은 헤더/타임아웃을 두 번 작성하지 않기 위함입니다. 이런 식으로 단일 retry 지점이 명확해야 나중에 retry 정책을 바꿀 때 한 군데만 수정하면 됩니다.

2.3 stale cache fallback

429 retry는 일시적 hiccup엔 도움이 되지만, "1시간 동안 백엔드가 죽어 있는" 상황에는 무력합니다. 그런 경우에는 마지막으로 알고 있던 값이라도 보여 줘야 합니다.

// scripts/utils/api-client.ts (개념상)
const STALE_FALLBACK_SECONDS = 3600;  // 1시간까지는 stale 허용

// API 실패 시 ...
const staleFile = await loadFileCache(tokenHash, STALE_FALLBACK_SECONDS);
if (staleFile) return staleFile;
return null;

핵심은 일반 캐시 TTL(예: 300초)과 stale 한도(예: 3600초)를 분리한 것입니다.

  • 정상 흐름: 캐시가 300초 이내라면 그대로 사용, 아니면 API 호출
  • API 실패 시: 캐시가 3600초 이내라면 stale이라도 그대로 사용

이걸 흔히 stale-while-revalidate 패턴이라고 부릅니다. HTTP 캐싱 표준에서도 같은 이름의 헤더 디렉티브가 있습니다. 사용자한테는 "조금 오래된 값"이 "빈 화면"보다 무조건 낫습니다.


3. 2단계: 실패도 캐시하라 (negative caching)

3.1 문제 — 429 retry가 사실은 더 위험할 수 있음

여기까지 했는데도 디버그 로그에는 같은 토큰에 대한 API 호출이 1초에 여러 번 찍히는 걸 발견했습니다.

이유는 단순했습니다.

[t=0]      렌더 #1 → 캐시 만료 → API 호출 → 실패 → null 반환
[t=0.05]   렌더 #2 → 캐시 비어 있음 → API 호출 → 실패 → null 반환
[t=0.1]    렌더 #3 → 캐시 비어 있음 → API 호출 → 실패 → null 반환

상태줄은 사용자 키 입력에 따라 빠르게 다시 렌더링됩니다. 그동안 캐시는 비어 있으므로, 매번 실패할 줄 알면서도 또 호출합니다.

이게 바로 API hammering 패턴입니다. 클라이언트가 자기도 모르는 사이에 백엔드를 두드리는 것입니다. 사용자 한 명만 이래도 이미 부담이고, 같은 환경의 사용자가 100명이면 백엔드 입장에서는 DoS 공격과 비슷합니다.

3.2 해결 — 실패 자체를 30초간 캐시

해결은 단순합니다. 실패를 캐시에 저장하라. 단, TTL을 짧게 잡아서 (30초 정도) 일시적 hiccup이 지나가면 자연스럽게 retry되게 합니다.

// scripts/types.ts
export const NEGATIVE_CACHE_SECONDS = 30;

이 값을 적용하려면 캐시 항목이 "성공이냐 실패냐"를 구분할 수 있어야 합니다. 그래서 CacheEntryisError 플래그를 추가했습니다.

// scripts/utils/api-client.ts
function isCacheValid(tokenHash: string, ttlSeconds: number): boolean {
  const cache = usageCacheMap.get(tokenHash);
  if (!cache) return false;
  const ageSeconds = (Date.now() - cache.timestamp) / 1000;
  // ★ 핵심: 실패 항목은 더 짧은 TTL을 적용
  const effectiveTtl = cache.isError ? NEGATIVE_CACHE_SECONDS : ttlSeconds;
  return ageSeconds < effectiveTtl;
}

성공한 응답은 평소 TTL(300초)을 따르고, 실패한 항목은 30초 동안만 유효합니다. 30초가 지나면 자동으로 만료되어 다시 시도합니다.

3.3 negative cache hit ≠ stale fallback 차단

여기서 중요한 디자인 결정 하나. 실패가 캐시되어 있다고 해서 사용자에게 빈 화면을 보여 주면 안 됩니다. negative cache는 "API를 다시 부르지 마라"는 신호일 뿐이고, 사용자에게는 여전히 마지막 성공 값을 보여 줘야 합니다.

// scripts/utils/api-client.ts
if (isCacheValid(tokenHash, ttlSeconds)) {
  const cached = usageCacheMap.get(tokenHash);
  if (cached) {
    if (cached.isError) {
      debugLog('api', 'Negative cache hit, returning stale or null');
      // ★ 핵심: 실패 캐시 hit이지만, stale 파일 캐시로 폴백
      return loadFileCache(tokenHash, STALE_FALLBACK_SECONDS);
    }
    return cached.data;
  }
}

세 가지 신호가 한 번에 작동합니다.

  1. negative cache hit → "API 부르지 마"
  2. stale file cache 조회 → "마지막으로 본 값이라도 가져와"
  3. 그것마저 없으면 → 최후의 수단으로 null

3.4 음수 캐시 set 시 stale 보존

API 호출 결과가 실패였을 때 negative cache를 세팅하는 부분도 한 가지 함정이 있었습니다. negative cache 항목으로 덮어쓰기 전에, 직전의 성공 항목을 따로 보관해야 합니다. 안 그러면 stale fallback에 쓸 수 있는 마지막 메모리 값까지 같이 날려 버립니다.

// scripts/utils/api-client.ts
const result = await requestPromise;
if (result) return result;

// ★ 핵심: negative cache로 덮어쓰기 전에 stale 메모리 참조 저장
const staleMemory = usageCacheMap.get(tokenHash);

// API 실패 → negative cache 설정
debugLog('api', `Setting negative cache for ${NEGATIVE_CACHE_SECONDS}s`);
usageCacheMap.set(tokenHash, {
  data: null,
  timestamp: Date.now(),
  isError: true,
});

// 메모리 stale → 파일 stale → null 순으로 폴백
if (staleMemory && !staleMemory.isError) return staleMemory.data;
const staleFile = await loadFileCache(tokenHash, STALE_FALLBACK_SECONDS);
if (staleFile) return staleFile;
return null;

순서가 중요합니다.

  1. staleMemory를 먼저 캡처 (덮어쓰기 직전)
  2. negative cache로 set
  3. 캡처해 둔 stale 값으로 폴백

4. 3단계: 타입 시스템으로 불변식 강제하기

4.1 첫 시도 — 옵셔널 필드 (안티패턴)

isError 플래그를 처음 추가했을 때는 이렇게 짰습니다.

// Before — 옵셔널 필드 안티패턴
interface CacheEntry<T> {
  data: T;             // ← 실패 시에는 무엇이 들어가야 하지?
  timestamp: number;
  isError?: boolean;
}

그런데 곧장 의문이 생깁니다. 실패 케이스에서 data 필드에는 무엇이 들어가야 할까요? null? undefined? 그렇게 하려면 data: T | null이 되어야 하고, 그러면 모든 cache 사용처에서 매번 null 체크를 해야 합니다.

그래서 코드 곳곳에 이런 부적절한 캐스트가 생겨났습니다.

// 4개 클라이언트(api/codex/gemini/zai) 모두에 있던 패턴
usageCacheMap.set(tokenHash, {
  data: null as unknown as T,   // ← TypeScript 거짓말
  timestamp: Date.now(),
  isError: true,
});

null as unknown as T는 "타입 시스템에 거짓말한다"는 신호입니다. 컴파일은 통과하지만 런타임에 누가 cache.data.foo에 접근하는 순간 NullPointerException입니다.

4.2 더 나은 해결 — discriminated union

TypeScript에는 **discriminated union(태그된 유니언)**이라는 도구가 있습니다. 객체의 한 필드를 "discriminator(판별자)"로 정해 두면, TypeScript가 그 필드 값에 따라 다른 필드 타입을 자동으로 좁혀(narrowing) 줍니다.

// scripts/types.ts (After)
export const NEGATIVE_CACHE_SECONDS = 30;

/**
 * Cache entry for API responses (discriminated union).
 * Success entries hold data of type T; error entries hold null.
 */
export type CacheEntry<T> =
  | { data: T;    timestamp: number; isError?: false }
  | { data: null; timestamp: number; isError: true };

두 가지 케이스를 명시적으로 나열했습니다.

  • 성공 케이스: dataT 타입, isError는 없거나 false
  • 실패 케이스: data는 항상 null, isError는 반드시 true

이게 의미하는 바는 강력합니다. "isError가 true면 data는 반드시 null이고, 그 반대도 마찬가지"라는 불변식이 타입 시스템 자체에 박힙니다. 컴파일러가 이걸 검사해 줍니다.

4.3 사용처에서의 narrowing

isError로 분기하면, TypeScript가 자동으로 다른 분기에서 data의 타입을 좁혀 줍니다.

const cached = usageCacheMap.get(tokenHash);
if (cached) {
  if (cached.isError) {
    // ★ 이 블록 안에서 cached.data는 null 타입으로 narrow됨
    return loadFileCache(tokenHash, STALE_FALLBACK_SECONDS);
  }
  // ★ 이 줄에서 cached.data는 T 타입으로 narrow됨 (null 아님)
  return cached.data;
}

null as unknown as T 같은 거짓말이 더 이상 필요 없습니다. 4개 클라이언트(api-client, codex-client, gemini-client, zai-api-client)에서 같은 캐스트가 모두 사라졌습니다.

// After — 캐스트 없이 자연스럽게
usageCacheMap.set(tokenHash, {
  data: null,         // ← 그냥 null
  timestamp: Date.now(),
  isError: true,
});

4.4 NEGATIVE_CACHE_SECONDS 상수의 단일 출처

discriminated union 리팩터링과 함께 한 가지 더 정리했습니다. 처음에 NEGATIVE_CACHE_SECONDS = 30을 4개 클라이언트 파일에 각각 정의해 놨었는데(복붙), types.ts로 통합해서 단일 출처로 만들었습니다.

// scripts/types.ts
export const NEGATIVE_CACHE_SECONDS = 30;
// 4개 클라이언트 파일
import { NEGATIVE_CACHE_SECONDS, type CacheEntry } from '../types.js';

이건 그 자체로 큰 문제는 아니지만, "같은 정책 상수가 여러 곳에 흩어져 있으면 언젠가 한 군데가 누락된 채로 바뀝니다." 30이 60으로 변경되었는데 한 클라이언트만 30을 유지한다면, 디버깅이 매우 고통스러워집니다. 정책 상수는 항상 한 군데에서만 정의해야 합니다.


5. 세 단계가 한 줄에 작용하는 모습

지금까지의 변경을 합쳐서, fetchUsageLimits 한 호출의 흐름을 추적해 보면 이렇습니다.

┌─ fetchUsageLimits(ttl=300) ────────────────────────────────┐
│                                                              │
│  1. token 가져오기 (실패 시 lastTokenHash로 stale 폴백)        │
│                                                              │
│  2. 메모리 캐시 체크                                         │
│     ├─ 유효한 성공 캐시 → 그대로 반환                          │
│     ├─ 유효한 negative 캐시 → stale 파일 캐시로 폴백           │
│     └─ 만료/없음 → 다음 단계                                   │
│                                                              │
│  3. 파일 캐시에서 raw로 로드 (timestamp 보존)                 │
│     └─ 있으면 메모리에 hydrate 후 반환                          │
│                                                              │
│  4. pendingRequests 체크 (중복 호출 방지)                     │
│     └─ 같은 토큰에 in-flight 요청 있으면 그 promise 공유        │
│                                                              │
│  5. 새 API 요청                                              │
│     ├─ 429 + retry-after ≤ 10s → 한 번 재시도                │
│     └─ 응답 처리 → 성공 시 메모리 + 파일 캐시 저장               │
│                                                              │
│  6. API 결과 분기                                            │
│     ├─ 성공 → 반환                                            │
│     └─ 실패:                                                  │
│        ├─ stale 메모리 캡처                                    │
│        ├─ negative cache 설정 (30s)                           │
│        ├─ stale 메모리 반환                                    │
│        ├─ stale 파일 캐시 반환                                 │
│        └─ 마지막 수단: null                                    │
└──────────────────────────────────────────────────────────────┘

중요한 건, 이 모든 폴백 단계가 사용자한테는 그냥 "상태줄에 숫자가 안 깜빡인다"는 한 가지 결과로 느껴진다는 것입니다.


6. 핵심 개념 정리

개념 적용 위치 효과
상한 있는 429 retry MAX_RETRY_AFTER_MS = 10s 일시적 hiccup 흡수, 긴 backoff는 그냥 포기
stale-while-revalidate STALE_FALLBACK_SECONDS = 3600 백엔드 다운 시 마지막 값 유지
negative caching NEGATIVE_CACHE_SECONDS = 30 API hammering 차단
negative hit ≠ 빈 화면 negative cache hit 시 stale 파일 폴백 사용자에게는 여전히 데이터 표시
stale 캡처 후 negative set 덮어쓰기 직전에 staleMemory 보관 마지막 메모리 값 보존
discriminated union CacheEntry<T> 두 케이스 분기 "isError ⇔ data is null" 불변식을 타입에 강제
정책 상수 단일 출처 NEGATIVE_CACHE_SECONDStypes.ts 여러 클라이언트의 정책 일관성
request deduplication pendingRequests Map 같은 토큰의 동시 요청은 단일 in-flight 공유

7. FAQ

Q: 30초가 negative cache TTL로 적정한가요?

A: 상태줄처럼 자주 갱신되는 컴포넌트에선 짧은 편이 좋습니다. 너무 길면(예: 5분) 사용자가 "왜 안 고쳐지지" 하면서 직접 재시도할 가능성이 높고, 너무 짧으면(예: 5초) hammering 방지 효과가 약합니다. 30초는 일반적인 네트워크 hiccup이 회복되는 시간과 비슷합니다.

Q: stale-while-revalidate의 stale 한도(1시간)는 어떻게 정했나요?

A: 1시간 정도는 사용자가 "이 값 좀 오래된 것 같다"고 느끼지 않을 만한 한도입니다. 그보다 길어지면 잘못된 정보를 보여 줄 위험이 커지고(예: rate limit이 이미 reset됐는데 어제 값을 보여 주는 등), 짧으면 폴백 효과가 약합니다. 도메인마다 다르게 정해야 합니다.

Q: discriminated union 대신 Result<T, Error> 같은 Rust-style 타입을 쓰면 안 되나요?

A: 가능하고 더 일반적인 패턴입니다. 다만 cache 항목에는 timestamp라는 공통 메타데이터가 있어서, 두 케이스가 완전히 분리된 타입(success/failure)보다는 "같은 객체의 두 모양"으로 모델링하는 게 자연스러웠습니다. 사용자 코드의 부담을 줄이는 게 목적이라면 neverthrowfp-ts 같은 라이브러리도 좋은 선택입니다.

Q: negative cache 항목도 파일에 영속화해야 하나요?

A: 의도적으로 안 했습니다. negative cache는 "지금 이 프로세스가 막 실패했다"는 휘발성 신호이고, 다음 프로세스 실행에는 새로 시도해야 자연스럽습니다. 파일 캐시에 영속화하면 프로세스가 재시작돼도 30초 동안 API 호출을 막아 버려서, 디버깅이나 빠른 retry가 어려워집니다.

Q: discriminated union의 narrowing은 모든 TypeScript 버전에서 작동하나요?

A: TypeScript 2.0+부터 지원합니다. 다만 narrowing이 잘 동작하려면 discriminator 필드(isError)가 literal type(true/false)이어야 하고, 각 케이스가 객체 리터럴이어야 합니다. 이 조건만 지키면 거의 모든 환경에서 잘 작동합니다.


8. 참고 자료


9. 다음 단계

세 단계가 모두 들어가야 비로소 "사용자가 알아차리지 못하는 API 클라이언트"가 됩니다. 그 중 어느 한 단계만 빠져도 어딘가에서 사용자가 빈 화면을 보거나, 백엔드가 hammering을 당하거나, 타입 시스템이 거짓말을 하게 됩니다.

  • 1단계 (429 retry + stale) — 일시적 실패를 흡수합니다.
  • 2단계 (negative caching) — 반복 실패를 hammering으로 만들지 않습니다.
  • 3단계 (discriminated union) — 위 두 가지가 코드에서 일관되게 적용되도록 타입 시스템이 검사합니다.

이 글은 claude-dashboard 시리즈 #4에서 잠깐 언급된 negative caching 부분의 딥다이브입니다. 시리즈 본편에서는 stdin 데이터 소스 변경, 트랜스크립트 파서 2·3막, lastPrompt 위젯의 데이터 소스 교체 등을 다뤘으니, 함께 보시면 좋습니다.

시리즈 목차:

  1. Claude Code 상태줄 플러그인 만들기: claude-dashboard 개발기
  2. Claude Code, Codex, Gemini 사용량을 한 번에 확인하는 CLI 대시보드
  3. claude-dashboard v1.10~v1.13: 테마, 성능 최적화, 그리고 셸 통합까지
  4. claude-dashboard v1.14~v1.24: stdin 우선, OSC8 링크, 그리고 파서를 한 번 더 100배 빠르게
  5. 터미널 상태줄을 클릭 가능하게: OSC8 하이퍼링크와 두 가지 보안 함정 (딥다이브)
  6. 실패도 캐싱하라: API 클라이언트 회복력을 위한 TypeScript 패턴 ← 현재 글 (딥다이브)