[TypeScript] Parse, Don't Validate - 프론트엔드에서의 적용
작성자 : 오예환 | 작성일 : 2025-12-18 | 수정일 : 2025-12-18
1. Parse, Don't Validate란?
"Parse, Don't Validate"는 Alexis King이 2019년에 발표한 타입 주도 설계 원칙입니다. 핵심은 단순히 검증(validate)만 하고 정보를 버리지 말고, 검증 결과를 타입에 인코딩하여 보존(parse)하라는 것입니다.
Validate vs Parse
[Validate - 정보를 버림]
입력 데이터 → 검증 → boolean (true/false)
↓
"검증했다"는 정보가 타입에 남지 않음
[Parse - 정보를 보존]
입력 데이터 → 파싱 → 더 구체적인 타입
↓
"검증 완료"가 타입에 인코딩됨
왜 Validate가 문제인가?
// ❌ Validate 방식 - 검증 후 정보 손실
function validateUser(data: unknown): boolean {
return typeof data === "object" && data !== null && "id" in data && "email" in data;
}
function processUser(data: unknown) {
if (validateUser(data)) {
// data는 여전히 unknown!
// TypeScript는 검증했다는 사실을 모름
console.log(data.email); // ❌ 타입 에러
}
}// ✅ Parse 방식 - 검증 결과가 타입에 반영
interface User {
id: number;
email: string;
}
function parseUser(data: unknown): User {
if (
typeof data === "object" &&
data !== null &&
"id" in data &&
"email" in data &&
typeof (data as any).id === "number" &&
typeof (data as any).email === "string"
) {
return data as User; // 검증 완료 → 타입 변환
}
throw new Error("Invalid user data");
}
function processUser(data: unknown) {
const user = parseUser(data); // User 타입으로 파싱
console.log(user.email); // ✅ 타입 안전
}핵심 원칙 정리
| 원칙 | 설명 |
|---|---|
| 정보 보존 | 검증 결과를 타입에 인코딩하여 보존 |
| 경계에서 파싱 | 외부 데이터가 들어오는 시점에 파싱 |
| 불가능한 상태 제거 | 타입으로 잘못된 상태를 표현 불가능하게 |
2. 프론트엔드에서 Parse가 필요한 이유
프론트엔드는 신뢰할 수 없는 외부 데이터를 다루는 경계(boundary)입니다.
[프론트엔드의 신뢰 경계]
┌─────────────────────────────────────────────────┐
│ 프론트엔드 │
│ │
│ API 응답 ──→ 파싱 ──→ 안전한 타입의 데이터 │
│ URL params ──→ 파싱 ──→ 안전한 타입의 데이터 │
│ localStorage ──→ 파싱 ──→ 안전한 타입의 데이터 │
│ FormData ──→ 파싱 ──→ 안전한 타입의 데이터 │
│ │
└─────────────────────────────────────────────────┘
↑
신뢰 경계 (Trust Boundary)
TypeScript의 한계
TypeScript는 컴파일 타임에만 타입 체크를 합니다. 런타임에는 타입 정보가 사라집니다.
// API 응답을 그냥 믿으면 위험
interface User {
id: number;
name: string;
email: string;
}
// 컴파일러는 통과하지만, 런타임에 서버가 다른 형태를 보내면?
const response = await fetch("/api/user");
const user: User = await response.json(); // 실제로는 unknown
// 서버가 { id: "abc", name: null } 을 보내면?
console.log(user.name.toUpperCase()); // 💥 런타임 에러!3. Zod로 API 응답 파싱하기
Zod는 "Parse, Don't Validate" 철학을 TypeScript에서 구현한 대표적인 라이브러리입니다.
기본 사용법
import { z } from "zod";
// 스키마 정의 = 파서 정의
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// 스키마에서 타입 추출
type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string; createdAt: string }
// 파싱 - 실패 시 에러 throw
const user = UserSchema.parse(unknownData);
// 안전한 파싱 - 실패 시 에러 객체 반환
const result = UserSchema.safeParse(unknownData);
if (result.success) {
console.log(result.data); // User 타입
} else {
console.log(result.error); // ZodError
}API 응답 파싱 패턴
// schemas/user.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]),
profile: z
.object({
avatar: z.string().url().nullable(),
bio: z.string().optional(),
})
.optional(),
});
export type User = z.infer<typeof UserSchema>;
// 배열 스키마
export const UsersResponseSchema = z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
});
export type UsersResponse = z.infer<typeof UsersResponseSchema>;// api/user.ts
import { UserSchema, UsersResponseSchema } from "../schemas/user";
export async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
// 🔑 경계에서 파싱 - 여기서부터는 타입 안전
return UserSchema.parse(json);
}
export async function fetchUsers(page: number) {
const response = await fetch(`/api/users?page=${page}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
return UsersResponseSchema.parse(json);
}백엔드가 필드를 추가하면? (Zod의 추가 필드 처리)
백엔드가 프론트엔드 몰래 새로운 필드를 추가하면 어떻게 될까요?
결론: Zod의 기본 동작은 추가 필드를 무시하고 통과시킵니다.
// 프론트엔드에서 정의한 스키마
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
// 백엔드가 몰래 필드를 추가한 응답
const response = {
id: 1,
name: "John",
email: "john@test.com", // 새로 추가된 필드
role: "admin", // 새로 추가된 필드
};
// 기본 동작: 통과함! (추가 필드는 무시됨)
const result = UserSchema.parse(response);
// result = { id: 1, name: "John" } ← email, role은 제거됨Zod의 세 가지 모드:
| 모드 | 동작 | 사용법 |
|---|---|---|
| 기본 (strip) | 추가 필드 제거하고 통과 | z.object({...}) |
| strict | 추가 필드 있으면 에러 | z.object({...}).strict() |
| passthrough | 추가 필드 그대로 유지 | z.object({...}).passthrough() |
// strict 모드: 추가 필드가 있으면 에러 발생
const StrictUserSchema = z
.object({
id: z.number(),
name: z.string(),
})
.strict();
StrictUserSchema.parse(response);
// ❌ ZodError: Unrecognized key(s) in object: 'email', 'role'
// passthrough 모드: 추가 필드도 그대로 통과
const PassthroughUserSchema = z
.object({
id: z.number(),
name: z.string(),
})
.passthrough();
PassthroughUserSchema.parse(response);
// ✅ { id: 1, name: "John", email: "john@test.com", role: "admin" }안정적이고 확장성 있는 스키마 설계
핵심: 기본 모드(strip)를 쓰되, 필요한 필드만 정의하라.
// ✅ 좋은 패턴: 필요한 필드만 정의
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// 백엔드가 필드 추가해도 → 통과 (strip)
// 백엔드가 필드 삭제/변경하면 → 에러 (의도한 동작)조심해야 할 경우:
| 상황 | Zod 동작 | 결과 |
|---|---|---|
| 새 필드 추가 | 무시됨 (strip) | 문제없음 |
| 필수 필드 삭제 | 파싱 실패 | API 변경 감지됨 |
| 필드 타입 변경 | 파싱 실패 | API 변경 감지됨 |
| 필드명 변경 | 파싱 실패 | API 변경 감지됨 |
실무 권장 패턴:
// 1. 기본 스키마 (필수 필드만)
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
// 2. 확장 가능한 필드는 optional로
const UserSchemaExtended = z.object({
id: z.number(),
name: z.string(),
email: z.string().email().optional(), // 있으면 좋고 없어도 됨
avatar: z.string().url().optional(),
});
// 3. API 응답 래퍼 (재사용 가능)
const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema,
// message, timestamp 등은 정의 안 해도 됨 (무시됨)
});
// 사용
const response = ApiResponseSchema(UserSchema).parse(apiResponse);정리:
- 기본 모드(strip) = 하위 호환성 확보
- 프론트가 쓰는 필드만 스키마에 정의
- 백엔드가 필드 추가 → 문제없음
- 백엔드가 필드 삭제/변경 → 에러로 즉시 감지
4. Zod 프로젝트 구조
Zod를 효과적으로 사용하려면 스키마를 어디에 두고 어떻게 구성할지가 중요합니다.
권장 폴더 구조
src/
├── schemas/ # Zod 스키마 정의
│ ├── user.ts
│ ├── post.ts
│ ├── auth.ts
│ └── index.ts # 스키마 re-export
│
├── api/ # API 호출 함수 (fetch + 파싱)
│ ├── user.ts
│ ├── post.ts
│ └── index.ts
│
├── queries/ # TanStack Query 옵션
│ ├── user.ts
│ ├── post.ts
│ └── index.ts
│
├── hooks/ # 커스텀 훅
│ ├── useTypedParams.ts
│ └── usePersistedState.ts
│
├── lib/ # 유틸리티
│ ├── storage.ts # localStorage 파싱 유틸
│ └── fetch.ts # fetch 래퍼
│
└── components/
└── ...
스키마 파일 구성
// schemas/user.ts
import { z } from "zod";
// 기본 스키마
export const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]),
createdAt: z.string().datetime(),
});
// 타입 추출
export type User = z.infer<typeof UserSchema>;
// 관련 스키마들
export const UserListSchema = z.array(UserSchema);
export type UserList = z.infer<typeof UserListSchema>;
export const UserResponseSchema = z.object({
user: UserSchema,
token: z.string().optional(),
});
export type UserResponse = z.infer<typeof UserResponseSchema>;
// 페이지네이션 응답
export const UsersPageSchema = z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type UsersPage = z.infer<typeof UsersPageSchema>;// schemas/post.ts
import { z } from "zod";
import { UserSchema } from "./user";
export const PostSchema = z.object({
id: z.number(),
title: z.string(),
content: z.string(),
author: UserSchema.pick({ id: true, name: true }), // 일부 필드만 사용
tags: z.array(z.string()),
publishedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});
export type Post = z.infer<typeof PostSchema>;// schemas/index.ts - 모든 스키마 re-export
export * from "./user";
export * from "./post";
export * from "./auth";API 함수 구성
// api/user.ts
import { UserSchema, UsersPageSchema, type User, type UsersPage } from "@/schemas";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export async function fetchUser(id: number): Promise<User> {
const response = await fetch(`${BASE_URL}/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
return UserSchema.parse(json); // 파싱
}
export async function fetchUsers(page: number, limit = 10): Promise<UsersPage> {
const response = await fetch(`${BASE_URL}/users?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
return UsersPageSchema.parse(json); // 파싱
}
export async function createUser(data: Omit<User, "id" | "createdAt">): Promise<User> {
const response = await fetch(`${BASE_URL}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
return UserSchema.parse(json);
}TanStack Query 옵션 구성
// queries/user.ts
import { queryOptions } from "@tanstack/react-query";
import { fetchUser, fetchUsers } from "@/api/user";
export const userQueryOptions = (userId: number) =>
queryOptions({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
export const usersQueryOptions = (page: number) =>
queryOptions({
queryKey: ["users", { page }],
queryFn: () => fetchUsers(page),
});실제 사용 예시
// app/users/[id]/page.tsx
import { userQueryOptions } from "@/queries/user";
import { useSuspenseQuery } from "@tanstack/react-query";
export default function UserPage({ params }: { params: { id: string } }) {
const userId = Number(params.id);
const { data: user } = useSuspenseQuery(userQueryOptions(userId));
// user는 User 타입 - 완전히 타입 안전
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<span>{user.role}</span>
</div>
);
}구조 설계 원칙
| 원칙 | 설명 |
|---|---|
| 스키마와 타입 함께 | 스키마 정의 옆에 z.infer로 타입 추출 |
| API 함수에서 파싱 | fetch 함수 내에서 파싱하여 반환 타입 보장 |
| 쿼리는 API 함수 호출만 | queryFn은 API 함수만 호출, 파싱 로직 분리 |
| 스키마 재사용 | .pick(), .omit(), .extend()로 스키마 조합 |
| index.ts로 re-export | 깔끔한 import 경로 유지 |
스키마 조합 패턴
// schemas/user.ts
// 기본 스키마
export const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
password: z.string(),
role: z.enum(["admin", "user"]),
createdAt: z.string().datetime(),
});
// 응답용 (password 제외)
export const UserPublicSchema = UserSchema.omit({ password: true });
export type UserPublic = z.infer<typeof UserPublicSchema>;
// 생성용 (id, createdAt 제외)
export const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
export type CreateUser = z.infer<typeof CreateUserSchema>;
// 수정용 (모든 필드 optional)
export const UpdateUserSchema = UserSchema.partial().omit({ id: true });
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
// 다른 스키마에서 일부만 사용
export const UserSummarySchema = UserSchema.pick({ id: true, name: true });
export type UserSummary = z.infer<typeof UserSummarySchema>;
// 확장
export const UserWithPostsSchema = UserPublicSchema.extend({
posts: z.array(
z.object({
id: z.number(),
title: z.string(),
}),
),
});
export type UserWithPosts = z.infer<typeof UserWithPostsSchema>;5. TanStack Query와 함께 사용하기
TanStack Query v5와 Zod를 조합하면 타입 안전한 데이터 페칭이 가능합니다.
queryOptions 패턴
// queries/user.ts
import { queryOptions } from "@tanstack/react-query";
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
// queryOptions로 쿼리 옵션 정의
export const userQueryOptions = (userId: number) =>
queryOptions({
queryKey: ["user", userId],
queryFn: async (): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
// 🔑 queryFn 내에서 파싱
return UserSchema.parse(json);
},
});
export const usersQueryOptions = (page: number) =>
queryOptions({
queryKey: ["users", { page }],
queryFn: async () => {
const response = await fetch(`/api/users?page=${page}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
return z
.object({
users: z.array(UserSchema),
total: z.number(),
})
.parse(json);
},
});컴포넌트에서 사용
// components/UserProfile.tsx
import { useQuery } from "@tanstack/react-query";
import { userQueryOptions } from "../queries/user";
function UserProfile({ userId }: { userId: number }) {
const { data, isPending, isError, error } = useQuery(userQueryOptions(userId));
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
// data는 User 타입으로 추론됨 - 타입 안전!
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}Zod 에러를 명확하게 처리하기
import { ZodError } from "zod";
export const userQueryOptions = (userId: number) =>
queryOptions({
queryKey: ["user", userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
try {
return UserSchema.parse(json);
} catch (e) {
if (e instanceof ZodError) {
// 파싱 실패 = API 계약 위반
console.error("API 응답 형식 오류:", e.errors);
throw new Error("서버 응답 형식이 올바르지 않습니다.");
}
throw e;
}
},
});5. Suspense + ErrorBoundary와 함께 사용하기
파싱 에러를 ErrorBoundary로 우아하게 처리할 수 있습니다.
QueryErrorBoundary 설정
// components/QueryErrorBoundary.tsx
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
export function QueryErrorBoundary({ children, fallback }: Props) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="error-container">
<h2>문제가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}useSuspenseQuery 사용
// components/UserProfile.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import { userQueryOptions } from "../queries/user";
function UserProfileContent({ userId }: { userId: number }) {
// useSuspenseQuery는 data가 항상 존재 (undefined 아님)
const { data } = useSuspenseQuery(userQueryOptions(userId));
// data는 User 타입 - isPending, isError 체크 불필요
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}전체 구조
// pages/UserPage.tsx
import { Suspense } from "react";
import { QueryErrorBoundary } from "../components/QueryErrorBoundary";
import UserProfileContent from "../components/UserProfileContent";
export default function UserPage({ userId }: { userId: number }) {
return (
<QueryErrorBoundary>
<Suspense fallback={<div>Loading user...</div>}>
<UserProfileContent userId={userId} />
</Suspense>
</QueryErrorBoundary>
);
}동작 흐름:
1. 컴포넌트 렌더링
↓
2. useSuspenseQuery 호출 → Suspense가 로딩 UI 표시
↓
3-a. 파싱 성공 → data 사용 가능, 컴포넌트 정상 렌더링
3-b. 파싱 실패 (ZodError) → throw → ErrorBoundary가 에러 UI 표시
3-c. 네트워크 에러 → throw → ErrorBoundary가 에러 UI 표시
6. URL Parameters 파싱
URL 파라미터도 외부 입력이므로 파싱이 필요합니다.
Next.js App Router 예시
// lib/params.ts
import { z } from "zod";
export const UserPageParamsSchema = z.object({
id: z.coerce.number().positive(), // string → number 변환
});
export const SearchParamsSchema = z.object({
page: z.coerce.number().positive().default(1),
sort: z.enum(["name", "date", "email"]).default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
});// app/users/[id]/page.tsx
import { UserPageParamsSchema, SearchParamsSchema } from "@/lib/params";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function UserPage({ params, searchParams }: Props) {
const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
// params 파싱
const paramsResult = UserPageParamsSchema.safeParse(resolvedParams);
if (!paramsResult.success) {
notFound(); // 잘못된 id는 404
}
// searchParams 파싱
const searchResult = SearchParamsSchema.safeParse(resolvedSearchParams);
const { page, sort, order } = searchResult.success
? searchResult.data
: { page: 1, sort: "name" as const, order: "asc" as const };
const userId = paramsResult.data.id; // number 타입
return (
<div>
<h1>User {userId}</h1>
<p>
Page: {page}, Sort: {sort}, Order: {order}
</p>
</div>
);
}React Router 예시
// hooks/useTypedParams.ts
import { useParams, useSearchParams } from "react-router-dom";
import { z } from "zod";
export function useTypedParams<T extends z.ZodType>(schema: T) {
const params = useParams();
return schema.parse(params) as z.infer<T>;
}
export function useTypedSearchParams<T extends z.ZodType>(schema: T) {
const [searchParams] = useSearchParams();
const obj = Object.fromEntries(searchParams.entries());
return schema.parse(obj) as z.infer<T>;
}// pages/UserPage.tsx
import { useTypedParams } from "../hooks/useTypedParams";
import { z } from "zod";
const ParamsSchema = z.object({
userId: z.coerce.number(),
});
function UserPage() {
const { userId } = useTypedParams(ParamsSchema);
// userId는 number 타입!
return <div>User ID: {userId}</div>;
}7. Form 데이터 파싱
폼 데이터도 사용자 입력이므로 파싱이 필요합니다.
react-hook-form + Zod
// components/UserForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const UserFormSchema = z.object({
name: z.string().min(2, "이름은 2자 이상이어야 합니다"),
email: z.string().email("올바른 이메일 형식이 아닙니다"),
age: z.coerce
.number()
.min(0, "나이는 0 이상이어야 합니다")
.max(150, "나이는 150 이하여야 합니다"),
role: z.enum(["admin", "user"]),
});
type UserFormData = z.infer<typeof UserFormSchema>;
export function UserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(UserFormSchema),
defaultValues: {
name: "",
email: "",
age: 0,
role: "user",
},
});
const onSubmit = (data: UserFormData) => {
// data는 이미 파싱된 타입 안전한 데이터
console.log(data.name); // string
console.log(data.age); // number
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register("name")} placeholder="이름" />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input {...register("email")} placeholder="이메일" />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input {...register("age")} type="number" placeholder="나이" />
{errors.age && <span>{errors.age.message}</span>}
</div>
<div>
<select {...register("role")}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit">제출</button>
</form>
);
}8. localStorage/sessionStorage 파싱
저장된 데이터도 외부 입력으로 취급해야 합니다.
// lib/storage.ts
import { z } from "zod";
export function getStorageItem<T extends z.ZodType>(key: string, schema: T): z.infer<T> | null {
try {
const item = localStorage.getItem(key);
if (!item) return null;
const parsed = JSON.parse(item);
return schema.parse(parsed);
} catch {
// 파싱 실패 시 null 반환 (또는 기본값)
localStorage.removeItem(key); // 잘못된 데이터 정리
return null;
}
}
export function setStorageItem<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}// hooks/usePersistedState.ts
import { useState, useEffect } from "react";
import { z } from "zod";
import { getStorageItem, setStorageItem } from "../lib/storage";
export function usePersistedState<T extends z.ZodType>(
key: string,
schema: T,
defaultValue: z.infer<T>,
) {
const [state, setState] = useState<z.infer<T>>(() => {
const stored = getStorageItem(key, schema);
return stored ?? defaultValue;
});
useEffect(() => {
setStorageItem(key, state);
}, [key, state]);
return [state, setState] as const;
}// 사용 예시
const ThemeSchema = z.enum(["light", "dark", "system"]);
function ThemeToggle() {
const [theme, setTheme] = usePersistedState("theme", ThemeSchema, "system");
return (
<select value={theme} onChange={e => setTheme(e.target.value as any)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}9. 정리: Parse, Don't Validate 체크리스트
프론트엔드에서 Parse, Don't Validate 원칙을 적용할 때 확인해야 할 항목들입니다.
파싱이 필요한 곳
| 경계 | 파싱 필요 여부 | 이유 |
|---|---|---|
| API 응답 | 필수 | 서버 응답은 신뢰할 수 없음 |
| URL params | 필수 | 사용자가 직접 조작 가능 |
| URL searchParams | 필수 | 사용자가 직접 조작 가능 |
| localStorage | 권장 | 데이터 손상/변조 가능 |
| sessionStorage | 권장 | 데이터 손상/변조 가능 |
| Form 입력 | 필수 | 사용자 입력은 신뢰할 수 없음 |
| WebSocket 메시지 | 필수 | 외부 데이터 |
| postMessage | 필수 | 다른 출처의 데이터 |
적용 순서
1. 스키마 정의 (Zod)
↓
2. 타입 추출 (z.infer)
↓
3. 경계에서 파싱 (queryFn, 이벤트 핸들러 등)
↓
4. 파싱된 타입으로 비즈니스 로직 작성
↓
5. 에러 처리 (ErrorBoundary, try-catch)
핵심 원칙
- 경계에서 파싱: 외부 데이터가 들어오는 시점에 즉시 파싱
- 타입에 정보 보존: 파싱 결과를 구체적인 타입으로 표현
- 실패는 빠르게: 잘못된 데이터는 경계에서 바로 에러 처리
- 스키마 재사용: 같은 데이터 구조는 스키마를 공유