목차
21 장

fetch와 API 응답 타이핑

외부 데이터를 안전하게 다루는 흐름 — fetch는 unknown, zod 스키마로 검증과 타입을 동시에. 그리고 RSC 환경에서 fetch가 가지는 의미까지 짚어 4부로 이어집니다.

20장까지로 컴포넌트 안쪽의 거의 모든 타이핑 결정을 다뤘습니다. 본 챕터는 3부의 마지막입니다. 가장 위험하고, 그래서 가장 자주 실수하는 부분인 외부에서 온 데이터의 타이핑을 살펴봅니다.

fetch().then(r => r.json())의 반환은 unknown입니다. 이걸 User라고 우리가 단정한다고 해서 진짜 User가 되는 건 아닙니다. 본 챕터에서는 그 위험을 정확히 짚고, 안전하게 좁히는 방법을 정리하겠습니다. 그리고 마지막 절에서는 4부 (모던 Next.js)의 Server Components 환경에서 fetch가 가지는 새로운 의미를 짚어, 3부에서 4부로 자연스럽게 이어지도록 하겠습니다.

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으로 받으면 다음 사용처에서 자연스럽게 “어떻게 좁힐 거야?“라는 질문이 나옵니다. TypeScript가 의도적으로 검증을 강제하는 부분입니다.

제네릭 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)을 끼워 넣음

TypeScript는 컴파일 타임 도구라 **런타임에 실제로 들어오는 데이터를 검증할 수 없습니다.**이 부분에는 별도의 도구가 필요합니다.

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

가장 의존성 없이 좁히는 방법은 타입 가드 함수입니다.

타입 가드 — 라이브러리 없이
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 설치
pnpm add 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 객체로 받게 됩니다. 변환 로직이 한 군데로 모이니 유지보수가 편합니다.

zod를 쓸 때의 선택지 #

도구의존성검증타입 추론적합한 경우
as T (캐스팅)없음컴파일러 속임임시 / 학습용
타입 가드 (value is T)없음✓ (직접 작성)엔티티 2~3개
zodzod 한 패키지✓ (z.infer)엔티티 다수, 변환 / 기본값 필요

이 책은 zod를 기본 선택지로 둡니다. 27장 (Server Actions와 폼)에서도 zod를 다시 만나고, 부록 A (옛 리액트 마이그레이션)에서도 옛 PropTypes / 손 검증 코드 → zod로의 전환을 다룹니다.

제네릭 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를 부르고 상태에 담는 것입니다. 17장의 discriminated union 모델과 결합하면 요청 / 성공 / 실패 세 가지 상태를 한 객체로 묶는 게 깔끔합니다.

요청 상태를 한 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 플래그는 10장 (useEffect)에서 다룬 cleanup 패턴입니다. 컴포넌트가 언마운트된 뒤 setState가 호출되어 메모리 누수 / 경고가 나는 것을 막아 줍니다.

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

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

1) TanStack Query — 클라이언트 사이드 데이터 페칭 #

fetcher 함수만 주면 캐싱 / 재시도 / 로딩 상태를 알아서 관리합니다. TypeScript와 매우 잘 어울립니다. SPA나 라우팅 안에서 클라이언트가 직접 데이터를 패칭해야 하는 경우(대시보드 같은)에 적합합니다.

이 책의 5부 / 6부에서 직접 도입하지는 않지만, 위의 useEffect + fetch 패턴은 사실 TanStack Query가 내부적으로 하는 일의 단순화 버전입니다. 손에 익혀 두면 라이브러리 학습이 빨라집니다.

2) Server Components + Server Actions (Next.js) — 4부의 모델 #

이 책의 4부에서 본격적으로 다룰 모델입니다. 서버에서 페칭이 끝나기 때문에 클라이언트의 fetch 코드가 거의 사라집니다.

4부에서 만날 모델 미리보기 (RSC)
// app/users/[userId]/page.tsx — Server Component
async function UserPage({ params }: { params: { userId: string } }) {
  const res = await fetch(`https://api.example.com/users/${params.userId}`);
  const data: unknown = await res.json();
  const user = UserSchema.parse(data);   // 서버에서 검증

  return <p>{user.name}</p>;
}

세 가지 차이가 한눈에 보입니다.

  • useState / useEffect가 없습니다. 함수 컴포넌트가 그냥 async입니다.
  • 로딩 상태를 손으로 다루지 않습니다. 서버에서 await가 끝난 뒤에야 렌더링이 시작됩니다 (Suspense가 그 사이를 메움 — 26장).
  • 검증은 같은 zod 스키마로 서버에서 합니다. 본 챕터의 UserSchema가 그대로 쓰입니다.

zod라는 외부 데이터 검증층은 클라이언트 영역과 서버 영역 모두에서 유효합니다. 본 챕터에서 정리한 흐름 — 외부 데이터 → unknown → 스키마로 좁힘 → 타입 안전하게 사용 — 은 어느 환경에서도 그대로 통합니다.

자세한 RSC 모델과 데이터 페칭 / 캐싱 / Suspense의 짝은 25장 (데이터 페칭과 캐싱), 26장 (Suspense와 use())에서 다룹니다.

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

앱 시작 시점에 읽는 환경 변수, 외부 설정 파일도 정확히 같은 위험을 안고 있습니다. 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

앱 시작 시점에 한 번 검증하고, 그 뒤로는 TypeScript가 보장하는 모양으로 씁니다. 잘못된 환경에서 앱이 시작 자체를 못 하게 만드는 게 핵심입니다.

33장 (배포와 관측성)에서 실제 운영 환경변수 관리 절차를 다루는데, 위 패턴이 그 안에 자연스럽게 들어갑니다.

직접 해보기 #

GitHub API에서 사용자 정보를 가져오는 작은 예제로 본 챕터의 흐름을 손에 익혀 봅니다.

src/api.ts:

src/api.ts
import { z } from 'zod';

export const GitHubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  name: z.string().nullable(),
  bio: z.string().nullable(),
  public_repos: z.number(),
});

export type GitHubUser = z.infer<typeof GitHubUserSchema>;

export async function fetchGitHubUser(username: string): Promise<GitHubUser> {
  const res = await fetch(`https://api.github.com/users/${username}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  return GitHubUserSchema.parse(data);
}

src/UserCard.tsx:

src/UserCard.tsx
import { useEffect, useState } from 'react';
import { fetchGitHubUser, type GitHubUser } from './api';

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function UserCard({ username }: { username: string }) {
  const [state, setState] = useState<RequestState<GitHubUser>>({ status: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setState({ status: 'loading' });

    fetchGitHubUser(username)
      .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;
    };
  }, [username]);

  if (state.status === 'idle' || state.status === 'loading') return <p>불러오는 ...</p>;
  if (state.status === 'error') return <p>에러: {state.error}</p>;

  const { data } = state;
  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>{data.login}{data.name ? ` (${data.name})` : ''}</h2>
      <p>저장소 : {data.public_repos}</p>
      {data.bio && <p>{data.bio}</p>}
    </div>
  );
}

export default UserCard;

src/App.tsx:

src/App.tsx
import UserCard from './UserCard';

function App() {
  return (
    <>
      <UserCard username="curtis" />
      <UserCard username="존재하지않는사용자xyzabc" />
    </>
  );
}

export default App;

저장하고 동작을 확인해 보세요. 첫 카드는 정상 데이터, 두 번째는 에러 메시지가 표시됩니다. GitHubUserSchema에서 한 필드를 잘못 정의 (예: id: z.string()) 해 보고, 들어온 데이터가 스키마와 안 맞을 때 어떻게 잡히는지도 관찰해 보세요.

연습문제 #

  1. GitHubUserSchematransform을 추가해 bio: z.string().nullable().transform((b) => b ?? '소개가 없습니다')로 만들고, 컴포넌트 안에서는 항상 string으로 다룰 수 있도록 바꿔 보세요. zod의 transform이 클라이언트 코드를 어떻게 단순하게 만드는지 직접 손에 익힙니다.
  2. apiGet<T>(url, schema) 같은 제네릭 fetcher를 만들고, 두 가지 다른 엔티티 (GitHubUser, GitHubRepo)를 같은 fetcher로 호출해 보세요. 호출하는 쪽에서 스키마를 명시하는 것이 강제됨을 직접 느껴 봅니다.
  3. 환경변수 검증 패턴. import.meta.env.VITE_API_URLEnvSchema = z.object({ VITE_API_URL: z.string().url() })로 검증하는 작은 모듈을 만들어 보세요. .env에 잘못된 값 (URL 아님)을 넣어 두면 앱 시작 시점에 에러가 나는 것을 확인합니다.

한 줄 요약: 외부 데이터는 unknown으로 받는다. as T 캐스팅은 컴파일러를 속이는 것뿐 안전하지 않다. zod 스키마로 검증 + 타입 추론 (z.infer)을 한 곳에서 정의하면 컴파일 타임 / 런타임이 같이 안전해진다. 요청 상태는 idle | loading | success | error discriminated union으로 묶는다. 4부의 RSC 환경에서는 useEffect + fetch가 사라지고 서버에서 직접 페칭하지만, zod 검증층은 그대로 살아남는다.

다음 챕터 #

본 챕터로 3부 TypeScript와 함께가 마무리됩니다. 1~2부의 리액트 핵심 빌딩 블록 위에 TypeScript의 안전망을 6 챕터에 걸쳐 입혔습니다. props · hooks · 이벤트 · 폼 · Context · 외부 데이터까지 — 새 컴포넌트를 만들 때 만나는 거의 모든 타이핑 결정이 손에 익었습니다.

다음 22장 왜 Next.js와 Server Components 인가부터 4부가 시작됩니다. 본 챕터의 마지막 절에서 미리 본 RSC 모델 — 서버에서 직접 페칭하고, 클라이언트의 useEffect + fetch가 거의 사라지는 모델 — 의 배경과 전환점을 한곳에 묶어 정리하겠습니다. 이 책의 회전점이 되는 부입니다.

X