React Router v7으로 시작하는 풀스택 웹 개발 (1부): 기초와 핵심 패턴
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로 검증하고, 에러와 로딩 상태를 적절히 표시하면 됩니다!