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()의 타입 시그니처를 보면 다음과 같습니다.
interface Body {
json(): Promise<any>; // 사실은 unknown으로 다뤄야 안전
}any로 정의되어 있어 그냥 받으면 어떤 모양이든 통과합니다. 그래서 좋은 습관은 첫 단계에서 unknown으로 받아내는 것입니다.
const res = await fetch('/api/me');
const data: unknown = await res.json();
// data를 그대로 쓰면 거의 모든 작업이 막힘 — 좁혀야 함
unknown으로 받으면 다음 사용처에서 자연스럽게 “어떻게 좁힐 거야?“라는 질문이 나옵니다. TypeScript가 의도적으로 검증을 강제하는 부분입니다.
제네릭 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는 한 번 적은 스키마에서 타입을 추론해 주고, 런타임 검증도 같은 코드로 해 줍니다.
pnpm add zodimport { 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 객체로 바꿔 받고 싶다면:
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개 |
| zod | zod 한 패키지 | ✓ | ✓ (z.infer) | 엔티티 다수, 변환 / 기본값 필요 |
이 책은 zod를 기본 선택지로 둡니다. 27장 (Server Actions와 폼)에서도 zod를 다시 만나고, 부록 A (옛 리액트 마이그레이션)에서도 옛 PropTypes / 손 검증 코드 → zod로의 전환을 다룹니다.
제네릭 fetcher + zod = 안전한 합 #
api<T>의 깔끔함과 zod의 안전함을 합치면 다음 형태가 됩니다.
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 모델과 결합하면 요청 / 성공 / 실패 세 가지 상태를 한 객체로 묶는 게 깔끔합니다.
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.data가 success가지에서만 접근 가능하니, “데이터 없는 상태에서 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 코드가 거의 사라집니다.
// 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는 여기서도 빛납니다.
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:
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:
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:
import UserCard from './UserCard';
function App() {
return (
<>
<UserCard username="curtis" />
<UserCard username="존재하지않는사용자xyzabc" />
</>
);
}
export default App;저장하고 동작을 확인해 보세요. 첫 카드는 정상 데이터, 두 번째는 에러 메시지가 표시됩니다. GitHubUserSchema에서 한 필드를 잘못 정의 (예: id: z.string()) 해 보고, 들어온 데이터가 스키마와 안 맞을 때 어떻게 잡히는지도 관찰해 보세요.
연습문제 #
GitHubUserSchema에transform을 추가해bio: z.string().nullable().transform((b) => b ?? '소개가 없습니다')로 만들고, 컴포넌트 안에서는 항상string으로 다룰 수 있도록 바꿔 보세요. zod의transform이 클라이언트 코드를 어떻게 단순하게 만드는지 직접 손에 익힙니다.apiGet<T>(url, schema)같은 제네릭 fetcher를 만들고, 두 가지 다른 엔티티 (GitHubUser, GitHubRepo)를 같은 fetcher로 호출해 보세요. 호출하는 쪽에서 스키마를 명시하는 것이 강제됨을 직접 느껴 봅니다.- 환경변수 검증 패턴.
import.meta.env.VITE_API_URL을EnvSchema = z.object({ VITE_API_URL: z.string().url() })로 검증하는 작은 모듈을 만들어 보세요..env에 잘못된 값 (URL 아님)을 넣어 두면 앱 시작 시점에 에러가 나는 것을 확인합니다.
한 줄 요약: 외부 데이터는
unknown으로 받는다.as T캐스팅은 컴파일러를 속이는 것뿐 안전하지 않다. zod 스키마로 검증 + 타입 추론 (z.infer)을 한 곳에서 정의하면 컴파일 타임 / 런타임이 같이 안전해진다. 요청 상태는idle | loading | success | errordiscriminated union으로 묶는다. 4부의 RSC 환경에서는useEffect + fetch가 사라지고 서버에서 직접 페칭하지만, zod 검증층은 그대로 살아남는다.
다음 챕터 #
본 챕터로 3부 TypeScript와 함께가 마무리됩니다. 1~2부의 리액트 핵심 빌딩 블록 위에 TypeScript의 안전망을 6 챕터에 걸쳐 입혔습니다. props · hooks · 이벤트 · 폼 · Context · 외부 데이터까지 — 새 컴포넌트를 만들 때 만나는 거의 모든 타이핑 결정이 손에 익었습니다.
다음 22장 왜 Next.js와 Server Components 인가부터 4부가 시작됩니다. 본 챕터의 마지막 절에서 미리 본 RSC 모델 — 서버에서 직접 페칭하고, 클라이언트의 useEffect + fetch가 거의 사라지는 모델 — 의 배경과 전환점을 한곳에 묶어 정리하겠습니다. 이 책의 회전점이 되는 부입니다.