모던 React 스택 완전 정복: Zustand + TanStack Query + React Hook Form + Zod + Tailwind + shadcn/ui

By | 2026년 3월 8일
Table of Contents

모던 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/utilities 3줄 대신 @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-animatetw-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-500text-muted-foreground, bg-gray-50bg-background, border-bdivide-border 등 shadcn 테마 변수를 활용하면 다크모드가 자동으로 동작합니다.

라이브러리 역할 정리

라이브러리 역할 다루는 상태
Tailwind CSS v4 유틸리티 기반 스타일링 (CSS-first 설정)
shadcn/ui 재사용 가능한 UI 컴포넌트 (OKLCH 색상)
Zod 스키마 정의 + 타입 추론
React Hook Form 폼 상태 + 유효성 검사 로컬(폼) 상태
TanStack Query 서버 데이터 패칭 + 캐싱 서버 상태
Zustand 전역 클라이언트 상태 클라이언트 상태

💡 핵심 원칙: TanStack Query는 서버에서 오는 데이터를, Zustand는 UI에만 존재하는 데이터를 관리합니다. 이 둘을 혼용하지 않는 것이 좋은 설계입니다.

참고 자료

Category: Web

답글 남기기