--- id: web-fetch-wrapper-design title: Fetch Wrapper 설계 — 인증 / 재시도 / 에러 정규화 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [web, fetch, http-client, vibe-coding] tech_stack: { language: "TypeScript / fetch", applicable_to: ["Web", "Node"] } applied_in: [] aliases: [api client, axios alternative, ky, ofetch] --- # Fetch Wrapper 설계 > Raw `fetch` 직접 사용 = 매번 인증 / 에러 / 재시도 / 타임아웃 반복. **얇은 wrapper** (또는 ofetch / ky) 가 표준. axios 도 옵션이지만 native fetch 가 가벼움. ## 📖 핵심 개념 - 단일 baseUrl + 공통 헤더. - 자동 인증 헤더 + 401 → refresh. - 타임아웃 (AbortSignal.timeout). - 재시도 (backoff + retryable). - 에러 정규화 (status + body). ## 💻 코드 패턴 ### 자체 wrapper ```ts class APIError extends Error { constructor(public status: number, public body: any, message: string) { super(message); } } class APIClient { constructor(private baseUrl: string, private getToken: () => string | null) {} async request(path: string, init: RequestInit & { timeout?: number; retries?: number } = {}): Promise { const url = this.baseUrl + path; const { timeout = 10_000, retries = 2, ...rest } = init; const headers = new Headers(rest.headers); headers.set('Content-Type', 'application/json'); const token = this.getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); let lastErr: unknown; for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await fetch(url, { ...rest, headers, signal: rest.signal ?? AbortSignal.timeout(timeout), }); if (res.ok) return await res.json(); const body = await res.text(); const err = new APIError(res.status, tryParse(body), `${res.status} ${path}`); if (this.isRetryable(res.status) && attempt < retries) { lastErr = err; await wait(200 * Math.pow(2, attempt) + Math.random() * 100); continue; } throw err; } catch (e) { if ((e as Error).name === 'AbortError') throw e; if (attempt === retries) throw e; lastErr = e; await wait(200 * Math.pow(2, attempt)); } } throw lastErr; } private isRetryable(status: number) { return status === 408 || status === 429 || status >= 500; } get(path: string, opts?: RequestInit): Promise { return this.request(path, { ...opts, method: 'GET' }); } post(path: string, body: unknown): Promise { return this.request(path, { method: 'POST', body: JSON.stringify(body) }); } } const api = new APIClient('https://api.example.com', () => getAccessToken()); ``` ### ofetch / ky (라이브러리) ```ts import { ofetch } from 'ofetch'; const $api = ofetch.create({ baseURL: 'https://api.example.com', retry: 2, retryDelay: 200, retryStatusCodes: [408, 429, 500, 502, 503, 504], onRequest({ options }) { options.headers = { ...options.headers, Authorization: `Bearer ${getToken()}` }; }, async onResponseError({ response }) { if (response.status === 401) await refreshTokenAndRetry(); }, }); const user = await $api('/users/1'); ``` ### Schema 검증 결합 ```ts import { z } from 'zod'; const UserS = z.object({ id: z.string(), email: z.string().email() }); type User = z.infer; async function getUser(id: string): Promise { const raw = await api.get(`/users/${id}`); return UserS.parse(raw); // 응답 schema 보장 } ``` ## 🤔 의사결정 기준 | 상황 | 도구 | |---|---| | 작은 프로젝트 | 직접 wrapper (50줄) | | Vue / Nuxt | ofetch (Nuxt 표준) | | Node + 다양 옵션 | ky 또는 undici | | React + cache | tanstack-query + 자체 fetch | | GraphQL | graphql-request 또는 Apollo | | Streaming (LLM) | fetch + ReadableStream 직접 | ## ❌ 안티packs턴 - **fetch 매번 직접**: 인증 / 에러 / 재시도 매번 작성. - **에러를 throw 안 함**: `if (!res.ok)` 무시 → 빈 body 처리. - **JSON 자동 parse 가정**: 4xx body 가 HTML 일 수도. content-type 확인. - **AbortSignal 누락**: 떠난 페이지의 요청 계속. - **401 무한 retry**: refresh 한 번만 시도. - **재시도 시 같은 idempotency key 또는 키 없음**: 중복 처리 위험. - **응답 schema 검증 X**: API 형식 변경 시 silent. ## 🤖 LLM 활용 힌트 - 직접 wrapper 50줄 또는 ofetch. - 응답 = zod schema 검증 + brand. ## 🔗 관련 문서 - [[Backend_Retry_Strategy]] - [[Security_Input_Validation]]