타입스크립트 + React 실전 #6 fetch와 API 응답 타이핑

16 분 소요

#5 Context와 제네릭 컴포넌트 까지로 컴포넌트 안쪽의 거의 모든 타이핑 결정을 다뤘습니다. 마지막 편은 가장 위험하고, 그래서 가장 자주 실수하는 자리 — 외부에서 온 데이터의 타이핑입니다.

fetch().then(r => r.json()) 의 반환은 unknown 입니다. 이걸 User 라고 우리가 우긴다고 해서 진짜 User 가 되는 건 아니에요. 이번 글에서는 그 위험을 정확히 짚고, 안전하게 좁히는 방법을 정리합니다.

fetch + json() 은 왜 unknown 인가

Response.json() 의 타입 시그니처를 보면 다음과 같습니다.

lib.dom.d.ts (발췌)
interface Body {
  json(): Promise<any>;   // 사실은 unknown으로 다뤄야 안전
}

any 로 정의되어 있어서 그냥 받으면 어떤 모양이든 통과합니다. 그래서 좋은 습관은 첫 단계에서 unknown 으로 받아내는 것이에요.

외부 데이터는 unknown으로
const res = await fetch('/api/me');
const data: unknown = await res.json();
// data를 그대로 쓰면 거의 모든 작업이 막힘 — 좁혀야 함

unknown 으로 받으면 다음 사용처에서 자연스럽게 "어떻게 좁힐 거야?" 라는 질문이 나옵니다. 타입스크립트가 의도적으로 검증을 강제하는 자리예요.

제네릭 fetcher — 흔한 패턴, 그리고 위험

웹에 검색하면 가장 많이 나오는 패턴은 다음입니다.

흔한 제네릭 fetcher
async function api<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T;
}
 
// 사용처
type User = { id: string; name: string };
const me = await api<User>('/api/me');
// me는 User로 추론됨

코드는 깔끔합니다. 하지만 이게 실제로 안전하다는 의미는 아닙니다. as T 는 단순히 컴파일러를 속이는 것뿐, 서버가 진짜 User 모양을 보냈다는 보장은 없어요. 다음과 같은 일이 흔히 일어납니다.

  • 서버가 name 을 빼고 nickname 만 보냄 → 클라이언트가 me.name 을 읽다가 undefined
  • id 가 숫자로 바뀜 → 문자열 메서드 호출이 런타임 에러
  • 백엔드가 멀쩡한데 네트워크가 다른 응답 (프록시 에러 페이지 HTML)을 끼워 넣음

타입스크립트는 컴파일 타임 도구라 런타임에 실제로 들어오는 데이터를 검증할 수 없습니다. 이 자리는 별도의 도구가 필요해요.

사용자 정의 타입 가드로 좁히기

가장 의존성 없이 좁히는 방법은 타입 가드 함수입니다. 기초 강좌 #4 union, literal, narrowing 에서 다뤘던 패턴이죠.

타입 가드 — 라이브러리 없이
type User = { id: string; name: string };
 
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === 'string' && typeof v.name === 'string';
}
 
async function fetchMe(): Promise<User> {
  const res = await fetch('/api/me');
  const data: unknown = await res.json();
  if (!isUser(data)) throw new Error('잘못된 응답');
  return data;
}

장점: 의존성이 없습니다. 단점: 필드가 많아질수록 가드 함수가 길어지고, 서버 스펙이 바뀌면 여기저기를 수동으로 고쳐야 합니다. 핵심 엔티티가 두세 개뿐이라면 손으로 짤 만합니다.

zod — 한 번 정의해서 타입 + 런타임 검증을 동시에

필드가 많거나 응답 종류가 다양해지면 zod 같은 스키마 라이브러리가 거의 필수입니다. zod는 한 번 적은 스키마에서 타입을 추론해 주고, 런타임 검증도 같은 코드로 해 줘요.

zod 스키마 + 추론
import { z } from 'zod';
 
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});
 
type User = z.infer<typeof UserSchema>;
// User = { id: string; name: string; email: string; createdAt: string }
 
async function fetchMe(): Promise<User> {
  const res = await fetch('/api/me');
  const data: unknown = await res.json();
  return UserSchema.parse(data);   // 스키마와 안 맞으면 throw
}

UserSchema.parse(data) 가 핵심입니다. 들어온 데이터가 스키마와 한 군데라도 안 맞으면 에러를 던지고, 통과하면 그 시점부터 타입이 User 로 좁혀져요. 컴파일 타임 타입과 런타임 검증이 같은 한 곳에서 정의 됩니다.

변환과 기본값

zod는 단순 검증을 넘어 변환도 지원합니다. 서버에서 ISO 문자열로 오는 날짜를 Date 객체로 바꿔서 받고 싶다면:

zod로 날짜 변환
const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  createdAt: z.string().transform((s) => new Date(s)),
  views: z.number().default(0),
});
 
type Post = z.infer<typeof PostSchema>;
// { id: string; title: string; createdAt: Date; views: number }

스키마 한 곳만 손보면 클라이언트 코드에서는 항상 Date 객체로 받게 됩니다. 변환 로직이 한 군데로 모이니 유지보수가 편해요.

제네릭 fetcher + zod = 안전한 합

api<T> 의 깔끔함과 zod의 안전함을 합치면 다음 형태가 됩니다.

검증을 강제하는 fetcher
import { z } from 'zod';
 
async function apiGet<T>(url: string, schema: z.ZodSchema<T>): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  return schema.parse(data);
}
 
// 사용처
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
});
 
const me = await apiGet('/api/me', UserSchema);
// me는 z.infer<typeof UserSchema> 로 추론됨

호출하는 쪽에서 스키마를 명시하는 것을 강제합니다. as T 로 컴파일러를 속일 자리를 아예 없앤 거예요. 손이 한 번 더 가지만, 그 자리에서 외부 데이터의 위험을 차단해 둡니다.

컴포넌트에서 쓰기 — useEffect 패턴

가장 단순한 패턴은 useEffect 안에서 fetch를 부르고 상태에 담는 거예요. 4편의 폼처럼 요청/성공/실패 세 가지 상태를 한 객체로 묶는 게 깔끔합니다.

요청 상태를 한 union으로
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };
 
function MePage() {
  const [state, setState] = useState<RequestState<User>>({ status: 'idle' });
 
  useEffect(() => {
    let cancelled = false;
    setState({ status: 'loading' });
 
    apiGet('/api/me', UserSchema)
      .then((data) => {
        if (!cancelled) setState({ status: 'success', data });
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setState({
            status: 'error',
            error: err instanceof Error ? err.message : '알 수 없는 오류',
          });
        }
      });
 
    return () => {
      cancelled = true;
    };
  }, []);
 
  if (state.status === 'idle' || state.status === 'loading') return <p>로딩 중...</p>;
  if (state.status === 'error') return <p>에러: {state.error}</p>;
 
  return <p>{state.data.name}</p>;     // 여기서 data는 User로 좁혀짐
}

status 를 discriminator로 쓰는 union이 핵심입니다. JSX 분기 안에서 state.datasuccess 가지에서만 접근 가능하니, "데이터 없는 상태에서 data를 읽음" 같은 사고가 컴파일 단계에서 잡힙니다.

cancelled 플래그는 모던 리액트 #4 에서 다룬 cleanup 패턴입니다. 컴포넌트가 언마운트된 뒤 setState 가 호출되어 메모리 누수/경고가 나는 것을 막아줘요.

더 큰 그림 — 데이터 페칭 라이브러리

위 패턴은 학습용으로는 좋지만, 실제 앱에서 직접 useEffect로 페칭을 짜는 일은 점점 줄어듭니다. 캐시, 재시도, 로딩 상태 동기화 같은 것이 매번 반복되거든요. 보통은 둘 중 하나로 갑니다.

  • TanStack Query — 클라이언트 사이드 데이터 페칭 표준. fetcher 함수만 주면 캐싱/재시도/로딩 상태를 알아서 관리. 타입스크립트와 매우 잘 어울립니다.
  • Server Components + Server Actions (Next.js) — 모던 리액트 시리즈에서 다룬 패턴. 서버에서 페칭이 끝나기 때문에 클라이언트의 fetch 코드가 거의 사라집니다.

어느 쪽이든 외부 데이터 → unknown → 스키마로 좁힘 → 타입 안전하게 사용이라는 흐름은 그대로입니다. zod 같은 검증층은 어떤 환경에서도 가치가 있어요.

환경 변수와 외부 설정도 같은 원칙

앱 시작 시점에 읽는 환경 변수, 외부 설정 파일도 정확히 같은 위험을 안고 있습니다. process.env.API_URL 의 타입은 string | undefined 인데, 막상 코드에서는 항상 string인 것처럼 다루다가 배포 후 사고가 나는 패턴을 흔히 봐요.

zod는 여기서도 빛납니다.

env도 검증
const EnvSchema = z.object({
  API_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
});
 
export const env = EnvSchema.parse(process.env);
// env.API_URL은 string, env.NODE_ENV는 좁은 union

앱 시작 시점에 한 번 검증하고, 그 뒤로는 타입스크립트가 보장하는 모양으로 사용합니다. 잘못된 환경에서 앱이 시작 자체를 못 하게 만드는 게 핵심이에요.

시리즈를 마치며

여섯 편을 거치며 우리가 정리한 핵심을 한 줄씩 다시 모으면:

  1. 셋업 — Vite + react-ts, strict 모드, 추론 신뢰 (#1)
  2. props/childrentype, ComponentProps, discriminated union, ReactNode (#2)
  3. hooks — 추론 신뢰, null 시작은 명시, reducer는 union + exhaustiveness (#3)
  4. 이벤트와 폼React.XXXEvent<엘리먼트>, currentTarget, FormData 좁히기 (#4)
  5. Context와 제네릭null + 헬퍼, state/dispatch 분리, 다형 컴포넌트 (#5)
  6. 외부 데이터fetchunknown, zod 스키마로 검증 + 타입 추론

이 흐름이 머리에 잡히면, 새 컴포넌트를 만들 때마다 같은 의사결정 패턴이 반복된다는 게 보입니다. 그게 익숙해진 시점이 타입스크립트가 거추장스러운 게 아니라 든든한 동료가 되는 지점이에요.

다음 시리즈에서는 한 단계 더 들어가 타입스크립트 자체의 심화 — conditional types, mapped types, infer, 타입 가드 패턴을 다룰 예정입니다. 실전에서 만나는 어려운 타이핑 문제를 풀 도구들이에요.