C
TypeScript/비동기/Lesson 05

Fetch API — 응답 타입을 박는 제네릭 헬퍼 패턴

30분·theory
이 챕터
5/7
TypeScript

Fetch API — 응답 타입을 박는 제네릭 헬퍼 패턴

💡 왜 배워야 할까요? — fetch 는 항상 any 가 새어나가는 출입구

🎯 `fetch().then(r => r.json())` 의 결과는 기본 `Promise`. 타입 시스템이 끝나는 지점입니다.
💼 TS 는 제네릭 헬퍼 `fetchJson()` 한 번 만들면, 모든 API 호출의 결과를 안전한 타입으로 흘려보낼 수 있습니다.
런타임 검증(zod·valibot) 과 결합하면 외부 데이터가 정말 그 타입인지까지 보장합니다.
🔗 에러 응답(`!res.ok`), 네트워크 에러, 파싱 에러 — 셋을 분리해서 다루는 게 실무 패턴.
🏢 실무에서는
Next.js Server Component 에서도 `await fetch()` 가 핵심입니다. 같은 데이터 모양을 여기저기 가져다 쓰니까, 한 번 정의한 `User`·`Post` 인터페이스가 fetch 결과부터 컴포넌트 props 까지 일관되게 흘러야 합니다. fetch 출입구에서 타입을 박지 않으면 그 일관성이 깨집니다.

Response · res.json() · 제네릭 헬퍼

1. Response 의 타입은 알지만, body 는 any

ts
const res: Response = await fetch('/api/users/1');
// res 의 메서드(json·text·blob·formData) 는 TS 가 알지만
// res.json() 의 반환은 Promise<any> — body 의 모양은 모름
const data = await res.json(); // data: any

2. 가장 흔한 패턴 — as Promise<User>

ts
const user = await fetch('/api/users/1').then(
  (r) => r.json() as Promise<User>,
);

간단하지만 거짓말 일 수 있음 — 서버가 갑자기 다른 모양을 반환해도 컴파일 통과. 작은 프로젝트면 OK.

3. 제네릭 헬퍼 — 재사용 가능, 캐스트 한 곳에 모음

ts
async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
  return res.json() as Promise<T>;
}

// 호출부
const user = await fetchJson<User>('/api/users/1');
const posts = await fetchJson<Post[]>('/api/posts');

4. 진짜 안전 — 런타임 검증과 결합

ts
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});
type User = z.infer<typeof UserSchema>;

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const raw = await res.json();
  return UserSchema.parse(raw); // 모양이 다르면 여기서 throw
}

런타임에서 진짜 모양을 검증하므로 거짓말 못함. 외부 API 와 통신할 때 권장.

💻 🅰️ JS 방식 — res.json() 결과는 any
// ❌ JS — fetch 결과의 모양이 불투명

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('실패');
  return res.json();
}

async function main() {
  const user = await getUser(42);
  // user 의 필드를 IDE 가 모름
  console.log(user.name);   // 운 좋으면 OK
  console.log(user.naem);   // 오타 — undefined
  console.log(user.profile.avatar); // 중첩 접근 — 안전한지 모름
}
main();
💻 🅱️ TS 방식 — 제네릭 헬퍼 + 응답 타입 박기
// ✅ TS — 제네릭 헬퍼 + 응답 타입 명시

interface User {
  id: number;
  name: string;
  email: string;
  profile: { avatar: string };
}
interface Post {
  id: number;
  title: string;
  authorId: number;
}

class HttpError extends Error {
  constructor(message: string, public status: number) {
    super(message);
    this.name = 'HttpError';
  }
}

// 한 번 정의 — 프로젝트 전체에서 재사용
async function fetchJson<T>(
  url: string,
  init?: RequestInit,
): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new HttpError(`${res.status} ${res.statusText}`, res.status);
  }
  return res.json() as Promise<T>;
}

// 호출부 — 타입 인자만 적으면 끝
async function main(): Promise<void> {
  try {
    const user = await fetchJson<User>('/api/users/1');
    console.log(user.name);             // ✅
    console.log(user.profile.avatar);   // ✅ 중첩까지 안전
    // console.log(user.naem);          // ❌ 컴파일 거부

    const posts = await fetchJson<Post[]>('/api/posts');
    posts.forEach((p) => console.log(p.title));

    // POST 도 같은 헬퍼로
    const created = await fetchJson<Post>('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'hi', authorId: 1 }),
    });
    console.log('created:', created.id);
  } catch (err) {
    if (err instanceof HttpError) {
      console.log(`HTTP ${err.status}: ${err.message}`);
    } else if (err instanceof Error) {
      console.log('네트워크 에러:', err.message);
    }
  }
}
main();

💡 💡 fetch + TS 실무 패턴 5

1. 프로젝트마다 fetchJson 헬퍼 1개
fetch 의 출입구를 한 곳으로 모으면 캐싱·로깅·에러 정책을 한 군데에서 관리.

2. as Promise 는 '서버를 믿는' 캐스팅
외부 API 면 zod 같은 런타임 검증으로 모양 확인이 안전.

3. HttpError 같은 커스텀 에러로 status 보존

ts
class HttpError extends Error {
  constructor(msg: string, public status: number) { super(msg); }
}

status 별로 다른 UI 처리 가능 (401 → 로그인, 403 → 권한 안내).

4. RequestInit 은 표준 타입 — TS 가 알고 있다

ts
fetch(url, {
  method: 'POST',                 // ✅ TS 가 'POST'|'GET'|... 추론
  headers: { 'Content-Type': 'application/json' }, // ✅
  body: JSON.stringify(payload),
});

5. Next.js 의 fetch 는 캐시 옵션을 받는다

ts
fetch(url, { cache: 'no-store' });                  // 매번 새로
fetch(url, { next: { revalidate: 60 } });           // 60초 ISR

표준 fetch 의 RequestInit 을 Next 가 확장. 타입도 함께 확장됨.

⚡ 직접 실행해보기 — fetchJson 헬퍼 (Mock)

실제 fetch 는 외부 호출이라 모킹합니다. 헬퍼의 흐름만 체감합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

`async function fetchJson<T>(url: string): Promise<T> { ... return res.json() as Promise<T>; }` 의 약점은?
💡 타입 단언(`as Promise<T>`) 은 컴파일러에게 '믿어' 하는 거지, 런타임 검증이 아닙니다. 서버가 갑자기 다른 모양을 반환해도 컴파일은 통과합니다. 외부 API 와 통신할 때는 zod·valibot 같은 라이브러리로 `res.json()` 결과를 한 번 더 검증하는 게 안전합니다.
Fetch API — 제네릭 헬퍼 fetchJson<T> - TypeScript