실패도 캐싱하라: API 클라이언트 회복력을 위한 TypeScript 패턴 (negative caching + discriminated union)
API hammering을 막으려면 실패도 캐시해야 합니다. 30초 negative caching, 1시간 stale fallback, 그리고 TypeScript discriminated union으로 "데이터 또는 에러" 불변식을 타입에 박아 넣는 세 단계 패턴을 정리합니다.
1. 문제 상황 — 백엔드가 흔들릴 때 클라이언트가 흔들기 시작합니다
claude-dashboard는 Anthropic의 OAuth usage API를 호출해서 5시간/7일 rate limit을 가져옵니다. 그리고 같은 패턴이 Codex, Gemini, z.ai 클라이언트에도 들어가 있어, 합치면 4개의 외부 API를 정기적으로 두드립니다.
대부분의 시간엔 멀쩡히 잘 동작합니다. 그런데 사용자 한 명이 이슈를 열어 줬습니다.
"쓰다 보면 가끔 상태줄에 ⚠️가 깜빡거리는데, debug 로그를 보면 1초 동안 같은 API를 4-5번 부르고 다 실패하고 있어요."
직접 재현해 봤습니다. 시나리오는 단순했습니다.
- 사용자가 노트북 뚜껑을 닫음 → 네트워크 끊김
- Claude Code가 백그라운드에서 계속 동작
- 60초 캐시가 만료되는 순간, 상태줄이 API를 호출 → 실패
- 다음 렌더 때, 캐시가 비었으므로 또 호출 → 또 실패
- 이 패턴이 사용자가 다시 키 입력을 할 때마다 반복됩니다
실패는 캐시되지 않기 때문에, 클라이언트는 매번 새로 시도합니다. 백엔드 입장에서는 같은 토큰의 같은 요청이 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;
이 값을 적용하려면 캐시 항목이 "성공이냐 실패냐"를 구분할 수 있어야 합니다. 그래서 CacheEntry에 isError 플래그를 추가했습니다.
// 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;
}
}
세 가지 신호가 한 번에 작동합니다.
- negative cache hit → "API 부르지 마"
- stale file cache 조회 → "마지막으로 본 값이라도 가져와"
- 그것마저 없으면 → 최후의 수단으로 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;
순서가 중요합니다.
staleMemory를 먼저 캡처 (덮어쓰기 직전)- negative cache로 set
- 캡처해 둔 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 };
두 가지 케이스를 명시적으로 나열했습니다.
- 성공 케이스:
data는T타입,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_SECONDS를 types.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)보다는 "같은 객체의 두 모양"으로 모델링하는 게 자연스러웠습니다. 사용자 코드의 부담을 줄이는 게 목적이라면 neverthrow나 fp-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. 참고 자료
- TypeScript Handbook: Discriminated Unions
- RFC 5861 — HTTP Cache-Control Extensions for Stale Content
- MDN: 429 Too Many Requests
- MDN: Retry-After header
- claude-dashboard GitHub
9. 다음 단계
세 단계가 모두 들어가야 비로소 "사용자가 알아차리지 못하는 API 클라이언트"가 됩니다. 그 중 어느 한 단계만 빠져도 어딘가에서 사용자가 빈 화면을 보거나, 백엔드가 hammering을 당하거나, 타입 시스템이 거짓말을 하게 됩니다.
- 1단계 (429 retry + stale) — 일시적 실패를 흡수합니다.
- 2단계 (negative caching) — 반복 실패를 hammering으로 만들지 않습니다.
- 3단계 (discriminated union) — 위 두 가지가 코드에서 일관되게 적용되도록 타입 시스템이 검사합니다.
이 글은 claude-dashboard 시리즈 #4에서 잠깐 언급된 negative caching 부분의 딥다이브입니다. 시리즈 본편에서는 stdin 데이터 소스 변경, 트랜스크립트 파서 2·3막, lastPrompt 위젯의 데이터 소스 교체 등을 다뤘으니, 함께 보시면 좋습니다.
시리즈 목차:
- Claude Code 상태줄 플러그인 만들기: claude-dashboard 개발기
- Claude Code, Codex, Gemini 사용량을 한 번에 확인하는 CLI 대시보드
- claude-dashboard v1.10~v1.13: 테마, 성능 최적화, 그리고 셸 통합까지
- claude-dashboard v1.14~v1.24: stdin 우선, OSC8 링크, 그리고 파서를 한 번 더 100배 빠르게
- 터미널 상태줄을 클릭 가능하게: OSC8 하이퍼링크와 두 가지 보안 함정 (딥다이브)
- 실패도 캐싱하라: API 클라이언트 회복력을 위한 TypeScript 패턴 ← 현재 글 (딥다이브)