모던 React 스택 완전 정복: Zustand + TanStack Query + React Hook Form + Zod + Tailwind + shadcn/ui
실무에서 가장 많이 쓰이는 6가지 라이브러리를 단계별 샘플 코드와 함께 설명합니다.
각 단계마다 이전 코드에 새 라이브러리를 덧붙이는 방식으로 진행합니다.
이 문서는 Tailwind CSS v4 기준으로 작성되었습니다.
0. 프로젝트 세팅
npx create-next-app@latest my-app --typescript --eslint --app --no-tailwind --src-dir
cd my-app
npm run dev
브라우저에서 http://localhost:3000 으로 접속하면 됩니다.
1. Tailwind CSS (v4)
설치
npm install tailwindcss @tailwindcss/postcss postcss
v4는
tailwind.config.ts파일이 필요 없습니다. 모든 설정은 CSS 파일에서 직접 합니다.
postcss.config.mjs 파일을 프로젝트 루트에 생성합니다:
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
src/app/globals.css에 Tailwind를 임포트합니다:
@import "tailwindcss";
v4에서는
@tailwind base/components/utilities3줄 대신@import "tailwindcss"한 줄이면 됩니다.
커스텀 테마 설정
v4에서는 tailwind.config.ts 대신 CSS 파일 안의 @theme 블록에서 디자인 토큰을 정의합니다.
/* src/app/globals.css */
@import "tailwindcss";
@theme {
/* 커스텀 색상 */
--color-brand: oklch(0.62 0.19 255);
/* 커스텀 폰트 */
--font-sans: "Pretendard", sans-serif;
/* 커스텀 간격 */
--spacing-18: 4.5rem;
}
@theme에 정의한 토큰은 자동으로 CSS 변수로 노출되고, bg-brand, font-sans 같은 유틸리티 클래스로 바로 사용할 수 있습니다.
사용법
Tailwind 유틸리티 클래스 사용법은 v3와 동일합니다.
// src/app/page.tsx - Step 1: Tailwind만 사용한 사용자 폼 UI
export default function Page() {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-md w-full max-w-md p-8">
<h1 className="text-2xl font-bold text-gray-800 mb-6">회원가입</h1>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
이름
</label>
<input
type="text"
placeholder="홍길동"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
이메일
</label>
<input
type="email"
placeholder="hong@example.com"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
비밀번호
</label>
<input
type="password"
placeholder="최소 8자 이상"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold
py-2 rounded-lg transition-colors"
>
가입하기
</button>
</div>
</div>
</main>
);
}
핵심 포인트
| 개념 | 클래스 예시 | 설명 |
|---|---|---|
| 레이아웃 | flex, grid, items-center |
Flexbox / Grid |
| 간격 | p-4, m-2, space-y-4 |
padding / margin / 자식 간격 |
| 반응형 | sm:, md:, lg: 접두사 |
브레이크포인트 |
| 상태 | hover:, focus:, disabled: |
인터랙션 스타일 |
| 다크모드 | dark: 접두사 |
다크 테마 |
2. shadcn/ui
개요
shadcn/ui는 라이브러리가 아닙니다. 컴포넌트 코드를 직접 프로젝트에 복사해 넣는 방식으로, Radix UI + Tailwind 기반의 완성도 높은 UI를 제공합니다. Tailwind v4와 React 19를 완전히 지원합니다.
설치
npx shadcn@latest init
CLI가 자동으로
globals.css에 shadcn 테마 변수를 추가합니다. Tailwind v4 방식(@theme inline)을 사용합니다.
필요한 컴포넌트를 개별 설치:
npx shadcn@latest add button input label card form
globals.css — shadcn + Tailwind v4 구조
npx shadcn init 실행 후 globals.css는 아래와 같은 구조가 됩니다:
/* src/app/globals.css */
@import "tailwindcss";
@import "tw-animate-css"; /* shadcn 애니메이션 (v4에서 tailwindcss-animate 대체) */
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
/* ... */
}
/* v4 핵심: @theme inline으로 CSS 변수를 Tailwind 유틸리티에 연결 */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@theme inline이 핵심입니다.:root에서 정의한 CSS 변수를 Tailwind 유틸리티(bg-background,text-primary등)로 사용할 수 있게 연결합니다.
커스텀 색상 추가
새 색상을 추가할 때도 동일한 패턴을 따릅니다:
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
이후 bg-warning, text-warning-foreground 클래스로 바로 사용할 수 있습니다.
사용법
// src/app/page.tsx - Step 2: shadcn/ui 컴포넌트 적용
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function Page() {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>회원가입</CardTitle>
<CardDescription>
아래 정보를 입력해 계정을 만드세요.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">이름</Label>
<Input id="name" placeholder="홍길동" />
</div>
<div className="space-y-1">
<Label htmlFor="email">이메일</Label>
<Input id="email" type="email" placeholder="hong@example.com" />
</div>
<div className="space-y-1">
<Label htmlFor="password">비밀번호</Label>
<Input id="password" type="password" placeholder="최소 8자 이상" />
</div>
<Button className="w-full">가입하기</Button>
</div>
</CardContent>
</Card>
</main>
);
}
자주 쓰는 컴포넌트
npx shadcn@latest add dialog sonner select checkbox badge table
v4에서는
toast대신sonner를 사용합니다.tailwindcss-animate도tw-animate-css로 대체되었습니다.
3. Zod
개요
Zod는 TypeScript-first 스키마 선언 & 유효성 검사 라이브러리입니다. 스키마를 정의하면 타입 추론이 자동으로 됩니다.
설치
npm install zod
사용법
폼에 사용할 스키마를 별도 파일로 분리합니다.
// lib/schemas/auth.ts - Step 3: Zod 스키마 정의
import { z } from "zod";
export const signUpSchema = z
.object({
name: z
.string()
.min(2, "이름은 2자 이상이어야 합니다.")
.max(20, "이름은 20자 이하여야 합니다."),
email: z
.string()
.email("올바른 이메일 형식이 아닙니다."),
password: z
.string()
.min(8, "비밀번호는 8자 이상이어야 합니다.")
.regex(
/^(?=.*[a-zA-Z])(?=.*[0-9])/,
"비밀번호는 영문과 숫자를 포함해야 합니다."
),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
// 스키마에서 타입 자동 추론
export type SignUpInput = z.infer<typeof signUpSchema>;
Zod 핵심 API
import { z } from "zod";
// 기본 타입
z.string()
z.number()
z.boolean()
z.date()
z.undefined()
z.null()
// 문자열 검증
z.string().min(1).max(100).email().url().uuid().regex(/pattern/)
// 숫자 검증
z.number().min(0).max(100).int().positive().nonnegative()
// 배열 & 객체
z.array(z.string()).min(1)
z.object({ key: z.string() })
// 선택 & 기본값
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().default("기본값")
// 커스텀 검증
z.string().refine((val) => val !== "금지어", {
message: "금지된 단어가 포함되어 있습니다.",
})
// 타입 추출
type MyType = z.infer<typeof mySchema>
4. React Hook Form + Zod
개요
React Hook Form(RHF)은 비제어 컴포넌트 방식으로 폼 상태를 관리합니다. 리렌더링을 최소화해 성능이 뛰어납니다. Zod와 결합하면 스키마 기반 유효성 검사가 완성됩니다.
설치
npm install react-hook-form @hookform/resolvers
@hookform/resolvers는 RHF와 Zod를 연결해주는 어댑터입니다.
form.tsx 수동생성
form 이 설치가 되지 않아서 수동으로 생성해 줍니다.
"use client"
import * as React from "react"
import { Slot } from "radix-ui"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot.Root
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({
className,
...props
}: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, children, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormField,
FormMessage,
}
사용법
// app/page.tsx - Step 4: React Hook Form + Zod 연결
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { signUpSchema, type SignUpInput } from "@/lib/schemas/auth";
export default function Page() {
const form = useForm<SignUpInput>({
resolver: zodResolver(signUpSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onSubmit = (data: SignUpInput) => {
// 유효성 검사 통과 후 실행
console.log("폼 데이터:", data);
};
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>회원가입</CardTitle>
<CardDescription>
아래 정보를 입력해 계정을 만드세요.
</CardDescription>
</CardHeader>
<CardContent>
{/* shadcn Form은 RHF FormProvider를 내부적으로 사용 */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>이름</FormLabel>
<FormControl>
<Input placeholder="홍길동" {...field} />
</FormControl>
<FormMessage /> {/* 에러 메시지 자동 표시 */}
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>이메일</FormLabel>
<FormControl>
<Input type="email" placeholder="hong@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>비밀번호</FormLabel>
<FormControl>
<Input type="password" placeholder="최소 8자 이상" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>비밀번호 확인</FormLabel>
<FormControl>
<Input type="password" placeholder="비밀번호 재입력" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "처리 중..." : "가입하기"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</main>
);
}
RHF 핵심 API
const form = useForm<T>({
resolver: zodResolver(schema), // Zod 연결
defaultValues: { ... }, // 초기값
mode: "onChange", // 검증 시점: onChange | onBlur | onSubmit
});
// 폼 상태
form.formState.isSubmitting // 제출 중 여부
form.formState.errors // 에러 객체
form.formState.isDirty // 값이 변경됐는지
form.formState.isValid // 유효성 통과 여부
// 유틸
form.reset() // 폼 초기화
form.setValue("field", value) // 값 설정
form.getValues() // 현재 값 읽기
form.watch("field") // 실시간 값 구독
form.setError("field", { message: "..." }) // 수동 에러 설정
5. TanStack Query
개요
TanStack Query(구 React Query)는 서버 상태(server state) 관리 라이브러리입니다. 데이터 패칭, 캐싱, 동기화, 배경 갱신을 자동으로 처리합니다.
설치
npm install @tanstack/react-query @tanstack/react-query-devtools
Provider 설정
// app/providers.tsx - QueryClient Provider 설정
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분 동안 fresh 상태 유지
retry: 1, // 실패 시 1번 재시도
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.tsx - Provider 적용
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
API 함수 분리
// lib/api/auth.ts - API 함수 정의
import { SignUpInput } from "@/lib/schemas/auth";
export interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
// 사용자 목록 조회
export async function fetchUsers(): Promise<User[]> {
const res = await fetch("/api/users");
if (!res.ok) throw new Error("사용자 목록을 불러오지 못했습니다.");
return res.json();
}
// 회원가입 API
export async function signUp(data: Omit<SignUpInput, "confirmPassword">): Promise<User> {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "회원가입에 실패했습니다.");
}
return res.json();
}
useQuery & useMutation 적용
// app/page.tsx - Step 5: TanStack Query 연결
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from "@/components/ui/card";
import { signUpSchema, type SignUpInput } from "@/lib/schemas/auth";
import { fetchUsers, signUp } from "@/lib/api/auth";
export default function Page() {
const queryClient = useQueryClient();
// ✅ 데이터 조회 - 자동 캐싱, 백그라운드 갱신
const { data: users, isLoading, isError } = useQuery({
queryKey: ["users"], // 캐시 키 (배열)
queryFn: fetchUsers, // 데이터 패칭 함수
staleTime: 5 * 60 * 1000, // 5분 동안 fresh 상태
});
// ✅ 데이터 변경 - 성공/실패 핸들링
const signUpMutation = useMutation({
mutationFn: signUp,
onSuccess: (newUser) => {
// 캐시 무효화 → users 쿼리 자동 재패칭
queryClient.invalidateQueries({ queryKey: ["users"] });
alert(`${newUser.name}님, 환영합니다!`);
form.reset();
},
onError: (error: Error) => {
form.setError("root", { message: error.message });
},
});
const form = useForm<SignUpInput>({
resolver: zodResolver(signUpSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
});
const onSubmit = (data: SignUpInput) => {
const { confirmPassword, ...payload } = data;
signUpMutation.mutate(payload);
};
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-6">
{/* 사용자 목록 */}
<Card>
<CardHeader>
<CardTitle>가입한 사용자</CardTitle>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-gray-500">불러오는 중...</p>}
{isError && <p className="text-sm text-red-500">불러오기 실패</p>}
{users?.map((user) => (
<div key={user.id} className="text-sm py-1 border-b last:border-0">
<span className="font-medium">{user.name}</span>
<span className="text-gray-500 ml-2">{user.email}</span>
</div>
))}
</CardContent>
</Card>
{/* 회원가입 폼 */}
<Card>
<CardHeader>
<CardTitle>회원가입</CardTitle>
<CardDescription>아래 정보를 입력해 계정을 만드세요.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{form.formState.errors.root && (
<p className="text-sm text-red-500">
{form.formState.errors.root.message}
</p>
)}
<FormField control={form.control} name="name"
render={({ field }) => (
<FormItem>
<FormLabel>이름</FormLabel>
<FormControl><Input placeholder="홍길동" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="email"
render={({ field }) => (
<FormItem>
<FormLabel>이메일</FormLabel>
<FormControl><Input type="email" placeholder="hong@example.com" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="password"
render={({ field }) => (
<FormItem>
<FormLabel>비밀번호</FormLabel>
<FormControl><Input type="password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>비밀번호 확인</FormLabel>
<FormControl><Input type="password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={signUpMutation.isPending}
>
{signUpMutation.isPending ? "처리 중..." : "가입하기"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
</main>
);
}
TanStack Query 핵심 API
// useQuery - 데이터 조회
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["users", userId], // 키가 바뀌면 자동 재패칭
queryFn: () => fetchUser(userId),
enabled: !!userId, // 조건부 실행
staleTime: 1000 * 60, // 캐시 유효 시간 (ms)
gcTime: 1000 * 60 * 5, // 가비지 컬렉션 시간 (구 cacheTime)
select: (data) => data.users, // 데이터 변환
});
// useMutation - 데이터 변경
const mutation = useMutation({
mutationFn: createPost,
onMutate: (variables) => {}, // 요청 전 (낙관적 업데이트에 활용)
onSuccess: (data, variables) => {},
onError: (error, variables, context) => {},
onSettled: () => {}, // 성공/실패 상관없이 항상 실행
});
mutation.mutate(payload);
mutation.mutateAsync(payload); // Promise 반환
// 캐시 조작
queryClient.invalidateQueries({ queryKey: ["users"] }) // 무효화 → 재패칭
queryClient.setQueryData(["users"], newData) // 직접 캐시 업데이트
queryClient.prefetchQuery({ queryKey, queryFn }) // 미리 패칭
6. Zustand
개요
Zustand는 클라이언트 전역 상태 관리 라이브러리입니다. Redux보다 훨씬 간단하고, Context API보다 성능이 좋습니다. TanStack Query가 서버 상태를 담당한다면, Zustand는 UI 상태(모달, 테마, 사이드바 등)를 담당합니다.
설치
npm install zustand
스토어 정의
// store/useAuthStore.ts - Step 6: Zustand 스토어
import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";
interface AuthUser {
id: string;
name: string;
email: string;
}
interface AuthStore {
// 상태
user: AuthUser | null;
isAuthenticated: boolean;
// 액션
setUser: (user: AuthUser) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
devtools( // Redux DevTools 연동
persist( // localStorage에 자동 저장
(set) => ({
user: null,
isAuthenticated: false,
setUser: (user) =>
set({ user, isAuthenticated: true }, false, "setUser"),
logout: () =>
set({ user: null, isAuthenticated: false }, false, "logout"),
}),
{
name: "auth-storage", // localStorage 키
partialize: (state) => ({ user: state.user }), // 저장할 상태 선택
}
)
)
);
// store/useUIStore.ts - UI 상태 스토어 예시
import { create } from "zustand";
interface UIStore {
isSignUpModalOpen: boolean;
theme: "light" | "dark";
openSignUpModal: () => void;
closeSignUpModal: () => void;
toggleTheme: () => void;
}
export const useUIStore = create<UIStore>((set) => ({
isSignUpModalOpen: false,
theme: "light",
openSignUpModal: () => set({ isSignUpModalOpen: true }),
closeSignUpModal: () => set({ isSignUpModalOpen: false }),
toggleTheme: () =>
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
}));
Zustand 핵심 패턴
// ✅ 필요한 상태/액션만 구독 (성능 최적화)
const user = useAuthStore((state) => state.user);
const setUser = useAuthStore((state) => state.setUser);
// ✅ 여러 값 동시 구독
const { user, isAuthenticated } = useAuthStore(
(state) => ({ user: state.user, isAuthenticated: state.isAuthenticated })
);
// ✅ 스토어 외부에서 직접 접근 (React 컴포넌트 밖)
const { logout } = useAuthStore.getState();
// ✅ 미들웨어 조합
import { create } from "zustand";
import { persist, devtools, immer } from "zustand/middleware";
const useStore = create<State>()(
devtools(
persist(
immer((set) => ({
// immer를 쓰면 불변성 없이 직접 수정 가능
users: [],
addUser: (user) =>
set((state) => {
state.users.push(user); // 직접 push 가능
}),
})),
{ name: "my-storage" }
)
)
);
7. 최종 완성 코드
모든 라이브러리를 통합한 최종 코드입니다.
파일 구조
my-app/
├── src/
│ ├── app/
│ │ ├── globals.css ← Tailwind v4 + shadcn 테마
│ │ ├── layout.tsx ← QueryClient Provider
│ │ ├── page.tsx ← 메인 페이지
│ │ └── providers.tsx ← 전역 Provider 모음
│ ├── components/
│ │ └── ui/ ← shadcn 컴포넌트
│ ├── lib/
│ │ ├── api/
│ │ │ └── auth.ts ← API 함수
│ │ └── schemas/
│ │ └── auth.ts ← Zod 스키마
│ └── store/
│ ├── useAuthStore.ts ← Zustand 인증 스토어
│ └── useUIStore.ts ← Zustand UI 스토어
├── postcss.config.mjs ← @tailwindcss/postcss 설정
└── (tailwind.config.ts 없음) ← v4에서는 불필요
src/app/globals.css — 완성본
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000, retry: 1 } },
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
src/app/page.tsx — 최종 완성
// src/app/api/auth/routes.tsx
import { NextRequest, NextResponse } from "next/server";
import { users } from "@/lib/store";
export async function POST(req: NextRequest) {
const { name, email, password } = await req.json();
if (users.find((u) => u.email === email)) {
return NextResponse.json({ message: "이미 사용 중인 이메일입니다." }, { status: 409 });
}
const newUser = {
id: crypto.randomUUID(),
name,
email,
createdAt: new Date().toISOString(),
};
users.push(newUser);
return NextResponse.json(newUser, { status: 201 });
}
// src/app/users/routes.tsx
import { NextResponse } from "next/server";
import { users } from "@/lib/store";
export async function GET() {
return NextResponse.json(users);
}
// src/lib/store.tsx
// 서버 메모리 임시 저장소 (개발용)
export const users: { id: string; name: string; email: string; createdAt: string }[] = [];
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from "@/components/ui/card";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { signUpSchema, type SignUpInput } from "@/lib/schemas/auth";
import { fetchUsers, signUp } from "@/lib/api/auth";
import { useAuthStore } from "@/store/useAuthStore";
import { useUIStore } from "@/store/useUIStore";
// ─────────────────────────────────────────
// 회원가입 폼 컴포넌트
// ─────────────────────────────────────────
function SignUpForm({ onSuccess }: { onSuccess: () => void }) {
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
const form = useForm<SignUpInput>({
resolver: zodResolver(signUpSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
});
const signUpMutation = useMutation({
mutationFn: signUp,
onSuccess: (newUser) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setUser(newUser); // Zustand에 로그인 유저 저장
onSuccess();
},
onError: (error: Error) => {
form.setError("root", { message: error.message });
},
});
const onSubmit = (data: SignUpInput) => {
const { confirmPassword, ...payload } = data;
signUpMutation.mutate(payload);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{form.formState.errors.root && (
<p className="text-sm text-destructive">
{form.formState.errors.root.message}
</p>
)}
{(["name", "email", "password", "confirmPassword"] as const).map((fieldName) => (
<FormField
key={fieldName}
control={form.control}
name={fieldName}
render={({ field }) => (
<FormItem>
<FormLabel>
{{ name: "이름", email: "이메일", password: "비밀번호", confirmPassword: "비밀번호 확인" }[fieldName]}
</FormLabel>
<FormControl>
<Input
type={fieldName.toLowerCase().includes("password") ? "password" : "text"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
<Button type="submit" className="w-full" disabled={signUpMutation.isPending}>
{signUpMutation.isPending ? "처리 중..." : "가입하기"}
</Button>
</form>
</Form>
);
}
// ─────────────────────────────────────────
// 메인 페이지
// ─────────────────────────────────────────
export default function Page() {
// Zustand: 전역 상태 읽기
const { user, isAuthenticated, logout } = useAuthStore();
const { isSignUpModalOpen, openSignUpModal, closeSignUpModal } = useUIStore();
// TanStack Query: 서버 상태 조회
const { data: users, isLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
return (
<main className="min-h-screen bg-background p-8">
<div className="max-w-2xl mx-auto space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-foreground">모던 React 스택</h1>
<div className="flex items-center gap-3">
{isAuthenticated ? (
<>
<Badge variant="secondary">{user?.name}</Badge>
<Button variant="outline" onClick={logout}>로그아웃</Button>
</>
) : (
<Dialog open={isSignUpModalOpen} onOpenChange={(open) => open ? openSignUpModal() : closeSignUpModal()}>
<DialogTrigger asChild>
<Button onClick={openSignUpModal}>회원가입</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>회원가입</DialogTitle>
</DialogHeader>
<SignUpForm onSuccess={closeSignUpModal} />
</DialogContent>
</Dialog>
)}
</div>
</div>
{/* 사용자 목록 */}
<Card>
<CardHeader>
<CardTitle>가입한 사용자</CardTitle>
<CardDescription>
총 {users?.length ?? 0}명
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">불러오는 중...</p>
) : (
<ul className="divide-y divide-border">
{users?.map((u) => (
<li key={u.id} className="flex items-center justify-between py-2">
<div>
<span className="font-medium text-sm text-foreground">{u.name}</span>
<span className="text-muted-foreground text-sm ml-2">{u.email}</span>
</div>
{u.id === user?.id && (
<Badge>나</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</main>
);
}
v4 변경 포인트:
text-gray-500→text-muted-foreground,bg-gray-50→bg-background,border-b→divide-border등 shadcn 테마 변수를 활용하면 다크모드가 자동으로 동작합니다.
라이브러리 역할 정리
| 라이브러리 | 역할 | 다루는 상태 |
|---|---|---|
| Tailwind CSS v4 | 유틸리티 기반 스타일링 (CSS-first 설정) | — |
| shadcn/ui | 재사용 가능한 UI 컴포넌트 (OKLCH 색상) | — |
| Zod | 스키마 정의 + 타입 추론 | — |
| React Hook Form | 폼 상태 + 유효성 검사 | 로컬(폼) 상태 |
| TanStack Query | 서버 데이터 패칭 + 캐싱 | 서버 상태 |
| Zustand | 전역 클라이언트 상태 | 클라이언트 상태 |
💡 핵심 원칙: TanStack Query는 서버에서 오는 데이터를, Zustand는 UI에만 존재하는 데이터를 관리합니다. 이 둘을 혼용하지 않는 것이 좋은 설계입니다.