API 응답 타입을 제네릭으로 설계하는 실전 패턴
fetch 래퍼를 제네릭으로 감싸기
지금까지 배운 걸 모아서 실제로 자주 쓰는 코드를 만들어보겠습니다. 대부분의 프론트엔드 코드베이스에는 fetch를 감싼 헬퍼 함수가 하나씩 있습니다.
type ApiEnvelope<T> =
| { data: T; error: null }
| { data: null; error: { code: string; message: string } };
async function apiGet<T>(url: string): Promise<ApiEnvelope<T>> {
const res = await fetch(url);
const json = await res.json();
if (!res.ok) {
return { data: null, error: json };
}
return { data: json, error: null };
}
const result = await apiGet<{ id: number; name: string }>("/api/users/1");
if (result.error) {
console.error(result.error.message);
} else {
console.log(result.data.name); // 이 시점에서 data는 null이 아니라고 좁혀졌다
}여기서 눈여겨볼 부분은 ApiEnvelope<T>를 유니온으로 정의했다는 겁니다. data와 error를 각각 T | null, Error | null로 따로 선언하는 대신, "성공했으면 data만 있고 error는 null, 실패했으면 반대"라는 관계를 유니온으로 표현했습니다. 그래서 if (result.error)로 분기하는 순간 컴파일러는 나머지 브랜치에서 result.data가 null이 아니라는 걸 자동으로 압니다. 이런 패턴을 판별 유니온(discriminated union)이라 부르는데, 제네릭과 함께 쓰면 실무에서 가장 유용한 조합 중 하나가 됩니다.
페이지네이션 응답을 표준화하기
목록 API는 대개 페이지네이션 정보를 함께 반환합니다. 매번 새로 정의하는 대신 제네릭 하나로 통일할 수 있습니다.
interface Paginated<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
}
async function fetchPaginated<T>(url: string, page: number): Promise<Paginated<T>> {
const res = await fetch(`${url}?page=${page}`);
return res.json();
}
interface Post {
id: number;
title: string;
}
const posts = await fetchPaginated<Post>("/api/posts", 1);
posts.items[0].title; // Post[]로 정확히 추론된다Paginated<T>는 어떤 도메인 객체를 페이지네이션하든 재사용됩니다. Paginated<Post>, Paginated<Comment>, Paginated<User> 모두 같은 구조를 공유하면서 items의 타입만 다릅니다. 이 지점에서 앞서 다룬 "제네릭이 필요한 순간"의 기준이 다시 등장합니다 — 입력(url, 어떤 도메인인지)과 출력(items의 타입)이 함께 변하는 전형적인 경우입니다.
판별 유니온과 제네릭을 함께 쓰는 상태 관리 패턴
비동기 요청의 상태(로딩 중, 성공, 실패)를 표현할 때도 같은 조합이 쓰입니다.
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function render<T>(state: AsyncState<T>, onSuccess: (data: T) => string): string {
switch (state.status) {
case "idle":
return "대기 중";
case "loading":
return "불러오는 중...";
case "success":
return onSuccess(state.data); // 여기서만 data에 접근 가능
case "error":
return `에러: ${state.error}`;
}
}switch (state.status)의 각 케이스 안에서, TypeScript는 state의 타입을 해당 케이스에 맞는 멤버 하나로 좁혀줍니다. "success" 케이스에서만 state.data에 접근할 수 있고, "idle"이나 "loading" 케이스에서 state.data를 쓰려고 하면 즉시 컴파일 에러가 납니다. 이 패턴은 리덕스나 Zustand 같은 상태 관리 라이브러리, React Query 같은 데이터 페칭 라이브러리가 내부적으로 쓰는 구조와 본질적으로 같습니다.
stateDiagram-v2 [*] --> idle idle --> loading: 요청 시작 loading --> success: 응답 성공 loading --> error: 응답 실패 success --> loading: 재요청 error --> loading: 재시도
여기까지 오면 제네릭은 더 이상 "문법 연습"이 아니라 "API 계약을 코드로 표현하는 도구"가 됩니다. 서버가 보내는 응답의 모양, 성공과 실패의 관계, 로딩 상태의 전이 — 이런 것들을 타입으로 표현해두면, 백엔드 스펙이 바뀌었을 때 프론트엔드 코드 어디를 고쳐야 하는지 컴파일러가 알려줍니다. 런타임에 터지기 전에요.