$ yh.log
[TypeScript] Parse, Don't Validate - 프론트엔드에서의 적용

[TypeScript] Parse, Don't Validate - 프론트엔드에서의 적용

TypeScriptReactZodTanStack Query

작성자 : 오예환 | 작성일 : 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)

핵심 원칙

  1. 경계에서 파싱: 외부 데이터가 들어오는 시점에 즉시 파싱
  2. 타입에 정보 보존: 파싱 결과를 구체적인 타입으로 표현
  3. 실패는 빠르게: 잘못된 데이터는 경계에서 바로 에러 처리
  4. 스키마 재사용: 같은 데이터 구조는 스키마를 공유

참고 자료