React Router v7으로 시작하는 풀스택 웹 개발 (1부): 기초와 핵심 패턴

2025년 9월 28일Frontend30분 읽기

React Router v7으로 시작하는 풀스택 웹 개발 (1부): 기초와 핵심 패턴

React Router v7 + Supabase로 만드는 현대적인 웹 애플리케이션 개발 가이드

노마드 코더의 “Maker 마스터클래스” 라는 강의를 들으면서 나중에 많이 활용할 수 있겠다 싶은 내용들을 정리해 보았습니다. 강의를 듣다가 필요한 부분들은 클로드를 활용해서 지속적으로 하나의 md 파일에 업데이트 했는데 내용이 많아져서 3개의 파트로 분리하게 되었습니다.

🚀 프로젝트 소개 및 기술 스택

무엇을 만들까요?

이 가이드에서는 wemake라는 스타트업 커뮤니티 플랫폼을 예시로 들어 설명합니다. 사용자가 제품을 등록하고, 커뮤니티에 참여하며, AI 기반 아이디어를 생성할 수 있는 종합적인 플랫폼입니다.

기술 스택

Frontend

  • React 19 + React Router v7 + TypeScript
  • TailwindCSS + Shadcn/ui (디자인 시스템)

Backend & Database

  • Supabase (PostgreSQL + 인증)
  • Drizzle ORM (스키마 관리) + Supabase Client (쿼리)

검증 & 유틸리티

  • Zod (타입 안전 검증)
  • Luxon (날짜 처리)
  • React Email + Resend (이메일)

프로젝트 구조

1app/2├── features/           # 기능별 모듈3│   ├── auth/          # 로그인, 회원가입4│   ├── products/      # 제품 관리5│   └── users/         # 사용자 관리6├── common/            # 공통 요소7│   ├── components/    # UI 컴포넌트8│   └── layouts/       # 레이아웃9└── sql/               # 데이터베이스10    ├── migrations/    # 마이그레이션11    └── views/         # 커스텀 뷰

Feature-based 구조를 채택하여 각 기능이 독립적으로 관리될 수 있도록 설계했습니다.

📄 React Router v7의 혁신: Loader와 Action

기존 방식의 문제점

React Router v6 이전에는 다음과 같은 방식으로 개발했습니다:

1// 😩 기존 방식: 클라이언트에서 모든 것을 처리2function ProductListPage() {3  const [products, setProducts] = useState([])4  const [loading, setLoading] = useState(true)5 6  useEffect(() => {7    // 컴포넌트가 마운트된 후 데이터 페치8    fetchProducts()9      .then(setProducts)10      .finally(() => setLoading(false))11  }, [])12 13  const handleSubmit = async (formData) => {14    setLoading(true)15    await createProduct(formData)16    await fetchProducts() // 수동으로 재조회17    setLoading(false)18  }19 20  if (loading) return <div>로딩 중...</div>21 22  return (23    <div>24      {products.map(...)}25      <form onSubmit={handleSubmit}>...</form>26    </div>27  )28}

React Router v7의 해답

v7에서는 서버사이드 패턴을 도입하여 이 모든 문제를 해결합니다:

1// 🎉 React Router v7: 서버에서 처리2import { Form, useNavigation } from 'react-router'3import type { Route } from './+types/product-list'4 5// 1. 서버에서 데이터 미리 준비6export async function loader() {7  const products = await getProducts()8  return { products }9}10 11// 2. 서버에서 폼 처리12export async function action({ request }: Route.ActionArgs) {13  const formData = await request.formData()14  const name = formData.get('name')15 16  await createProduct({ name })17  return redirect('/products') // 자동으로 loader 재실행18}19 20// 3. UI 컴포넌트는 렌더링에만 집중21export default function ProductListPage({ loaderData }: Route.ComponentProps) {22  const navigation = useNavigation()23  const isSubmitting = navigation.state === 'submitting'24 25  return (26    <div>27      <h1>제품 목록</h1>28 29      {/* 서버에서 준비된 데이터 바로 사용 */}30      {loaderData.products.map((product) => (31        <div key={product.id}>{product.name}</div>32      ))}33 34      {/* 서버로 직접 전송되는 폼 */}35      <Form method="post">36        <input name="name" placeholder="제품명" required />37        <button type="submit" disabled={isSubmitting}>38          {isSubmitting ? '추가 중...' : '제품 추가'}39        </button>40      </Form>41    </div>42  )43}

왜 이렇게 해야 할까요?

🏃‍♂️ 더 빠른 페이지 로딩

  • Loader가 서버에서 데이터를 미리 준비 → 화면이 즉시 표시됩니다
  • 클라이언트에서 “로딩 중…” 화면을 볼 일이 거의 없어집니다

🔒 향상된 보안

  • API 키, 데이터베이스 연결 등이 서버에서만 처리됩니다
  • 민감한 비즈니스 로직이 클라이언트에 노출되지 않습니다

🔄 자동 데이터 동기화

  • Action 완료 후 Loader가 자동으로 재실행됩니다
  • 수동으로 상태를 관리할 필요가 없어집니다

📱 진보된 폼 처리

  • 브라우저 네이티브 폼 기능 활용
  • JavaScript 없이도 기본 동작이 가능합니다

✅ Zod로 안전한 폼 검증하기

기본 검증 패턴

React Router v7에서 폼 검증은 Action에서 서버사이드에서 처리합니다:

1import { z } from 'zod'2 3// 1. 검증 규칙 정의4const productSchema = z.object({5  name: z.string().min(2, '제품명은 2글자 이상이어야 합니다'),6  price: z.coerce.number().min(0, '가격은 0 이상이어야 합니다'),7  category: z.enum(['electronics', 'clothing', 'books']),8})9 10// 2. Action에서 검증11export async function action({ request }: Route.ActionArgs) {12  const formData = await request.formData()13  const result = productSchema.safeParse(Object.fromEntries(formData))14 15  if (!result.success) {16    return {17      fieldErrors: result.error.flatten().fieldErrors,18    }19  }20 21  // 검증 통과 시 비즈니스 로직 실행22  await createProduct(result.data)23  return redirect('/products')24}25 26// 3. 컴포넌트에서 에러 표시27export default function CreateProductPage({28  actionData,29}: Route.ComponentProps) {30  return (31    <Form method="post">32      <input name="name" />33      {actionData?.fieldErrors?.name && (34        <p className="text-red-500">{actionData.fieldErrors.name}</p>35      )}36 37      <input name="price" type="number" />38      {actionData?.fieldErrors?.price && (39        <p className="text-red-500">{actionData.fieldErrors.price}</p>40      )}41 42      <select name="category">43        <option value="electronics">전자제품</option>44        <option value="clothing">의류</option>45        <option value="books">도서</option>46      </select>47      {actionData?.fieldErrors?.category && (48        <p className="text-red-500">{actionData.fieldErrors.category}</p>49      )}50 51      <button type="submit">제품 만들기</button>52    </Form>53  )54}

Zod refine으로 고급 검증하기

기본 Zod 검증으로 처리하기 어려운 복잡한 조건들은 refine을 사용합니다:

1const submitProductSchema = z.object({2  name: z.string().min(1),3  tagline: z.string().min(1).max(60),4  url: z.string().url(),5 6  // 파일 업로드 검증7  icon: z.instanceof(File).refine(8    (file) => {9      // 파일 크기 체크 (2MB 이하)10      if (file.size > 2 * 1024 * 1024) return false11      // 이미지 파일 타입 체크12      if (!file.type.startsWith('image/')) return false13      return true14    },15    {16      message: '아이콘은 2MB 이하의 이미지 파일이어야 합니다',17    },18  ),19 20  // 조건부 검증21  price: z.coerce22    .number()23    .optional()24    .refine(25      (price) => {26        // 가격이 있다면 0보다 커야 함27        if (price !== undefined && price <= 0) return false28        return true29      },30      {31        message: '가격은 0보다 큰 값이어야 합니다',32      },33    ),34 35  // 비동기 검증 (이메일 중복 체크)36  email: z37    .string()38    .email()39    .refine(40      async (email) => {41        const exists = await checkEmailExists(email)42        return !exists43      },44      {45        message: '이미 사용 중인 이메일입니다',46      },47    ),48})

refine 활용 시나리오

언제 refine을 사용해야 할까요?

파일 업로드 검증: 파일 크기, 타입, 확장자 체크 ✅ 조건부 검증: 다른 필드 값에 따른 검증
복합 조건: 여러 조건을 동시에 만족해야 하는 경우 ✅ 외부 API 검증: 비동기 검증이 필요한 경우

1// 여러 refine 체이닝2password: z.string()3  .min(8, '비밀번호는 8글자 이상')4  .refine((pwd) => /[A-Z]/.test(pwd), {5    message: '대문자가 포함되어야 합니다',6  })7  .refine((pwd) => /[0-9]/.test(pwd), {8    message: '숫자가 포함되어야 합니다',9  })

🔐 인증 처리 패턴

기본 인증 확인

인증이 필요한 페이지에서는 Loader와 Action 모두에서 사용자 확인을 해야 합니다:

1import { getLoggedInUserId, makeSSRClient } from '~/supa-client'2 3export async function loader({ request }: Route.LoaderArgs) {4  const { client } = makeSSRClient(request)5  const userId = await getLoggedInUserId(client) // 자동 로그인 페이지 이동6 7  // 인증된 사용자의 데이터 조회8  const myProducts = await getUserProducts(client, userId)9  return { myProducts }10}11 12export async function action({ request }: Route.ActionArgs) {13  const { client } = makeSSRClient(request)14  const userId = await getLoggedInUserId(client) // 여기서도 확인 필요15 16  // 폼 처리...17  const formData = await request.formData()18  await createUserProduct(client, { userId, ...formData })19 20  return redirect('/my-products')21}

왜 Loader와 Action 모두에서 확인해야 할까요?

  • Loader: 페이지 진입 시 인증 상태 확인
  • Action: 직접 폼을 POST로 호출하는 경우에 대비

🎯 실전 예제: 댓글 시스템

실제 운영 중인 서비스에서 자주 사용되는 패턴인 댓글 시스템을 구현해보겠습니다:

1import { useRef, useEffect } from 'react'2 3const commentSchema = z.object({4  content: z.string().min(1, '댓글을 입력해주세요'),5})6 7// 댓글 작성 후 현재 페이지 유지8export async function action({ request }: Route.ActionArgs) {9  const { client } = makeSSRClient(request)10  const userId = await getLoggedInUserId(client)11 12  const formData = await request.formData()13  const result = commentSchema.safeParse(Object.fromEntries(formData))14 15  if (!result.success) {16    return { fieldErrors: result.error.flatten().fieldErrors }17  }18 19  await createComment(client, { ...result.data, userId })20  return { ok: true } // redirect 하지 않음 → 현재 페이지 유지21}22 23export default function PostPage({24  loaderData,25  actionData,26}: Route.ComponentProps) {27  const formRef = useRef<HTMLFormElement>(null)28  const navigation = useNavigation()29  const isSubmitting = navigation.state === 'submitting'30 31  // 댓글 작성 성공 시 폼 초기화32  useEffect(() => {33    if (actionData?.ok) {34      formRef.current?.reset()35    }36  }, [actionData?.ok])37 38  return (39    <div>40      {/* 게시글 내용 */}41      <article>42        <h1>{loaderData.post.title}</h1>43        <p>{loaderData.post.content}</p>44      </article>45 46      {/* 기존 댓글 목록 */}47      <div className="comments">48        {loaderData.comments.map((comment) => (49          <div key={comment.id} className="comment">50            <p>{comment.content}</p>51            <small>by {comment.author.username}</small>52          </div>53        ))}54      </div>55 56      {/* 댓글 작성 폼 */}57      <Form method="post" ref={formRef}>58        <textarea59          name="content"60          placeholder="댓글을 작성하세요..."61          disabled={isSubmitting}62        />63        {actionData?.fieldErrors?.content && (64          <p className="text-red-500">{actionData.fieldErrors.content}</p>65        )}66        <button type="submit" disabled={isSubmitting}>67          {isSubmitting ? '작성 중...' : '댓글 작성'}68        </button>69      </Form>70    </div>71  )72}

Action 분기 처리의 핵심

1// redirect('/other-page') → 다른 페이지로 이동2export async function action({ request }: Route.ActionArgs) {3  await createProduct(data)4  return redirect('/products') // 제품 목록으로 이동5}6 7// { ok: true } → 현재 페이지 유지하면서 loader 재실행8export async function action({ request }: Route.ActionArgs) {9  await createComment(data)10  return { ok: true } // 댓글이 추가된 최신 상태로 페이지 업데이트11}

📋 개발 체크리스트

새로운 기능을 개발할 때 확인해야 할 사항들입니다:

페이지 개발 시

  • app/features/[기능명]/pages/ 에 페이지 컴포넌트 작성
  • Loader로 필요한 데이터 로딩
  • Action으로 폼 처리 (필요 시)
  • Zod로 입력 검증
  • 에러 메시지 표시
  • 로딩 상태 처리 (useNavigation())
  • 타입 검사 통과: npm run typecheck

필수 명령어

1# 개발 중2npm run dev3npm run typecheck4 5# 데이터베이스 작업 후6npm run db:generate  # 마이그레이션 파일 생성7npm run db:migrate   # 데이터베이스에 적용8npm run db:typegen   # TypeScript 타입 생성

다음 편에서는…

1부에서는 React Router v7의 기본기를 다뤘습니다. 2부에서는 더욱 흥미진진한 고급 기능들을 다룰 예정입니다:

  • Fetcher로 백그라운드 액션 처리하기 - 페이지 이동 없이 업보트, 북마크 등 처리
  • Optimistic UI 패턴 - 서버 응답 전에 UI 먼저 업데이트하여 즉각적인 반응 제공
  • 프로그램적 서브밋 - 타이머, 스크롤, 조건 달성 시 자동 액션 실행
  • 복수 폼이 있는 페이지 관리 - 프로필 편집 + 아바타 업로드 등

React Router v7로 더욱 동적이고 사용자 친화적인 웹 애플리케이션을 만드는 여정을 계속해보세요! 🚀


💡 핵심 요약: Loader로 데이터를 서버에서 미리 준비하고, Action으로 폼을 서버에서 안전하게 처리하며, Zod로 검증하고, 에러와 로딩 상태를 적절히 표시하면 됩니다!