React Router v7으로 시작하는 풀스택 웹 개발 (2부): 고급 상호작용과 사용자 경험
React Router v7으로 시작하는 풀스택 웹 개발 (2부): 고급 상호작용과 사용자 경험
백그라운드 액션부터 Optimistic UI까지, 사용자 경험을 한 단계 끌어올리는 고급 패턴들
1부에서 React Router v7의 기본 Loader/Action 패턴을 익혔다면, 이제 더욱 동적이고 반응성 있는 사용자 인터페이스를 만들어보겠습니다. 이번 편에서는 페이지 새로고침 없이 백그라운드에서 작업을 처리하고, 사용자가 더욱 즉각적인 피드백을 받을 수 있는 고급 패턴들을 다뤄보겠습니다.
🔄 Fetcher.Form으로 백그라운드 액션 처리하기
언제 사용해야 할까요?
일반 <Form>은 페이지 이동이나 새로고침을 유발하지만, fetcher.Form은 현재 페이지를 유지하면서 백그라운드에서 서버 액션을 실행합니다:
✅ fetcher.Form이 완벽한 상황들:
- 업보트/좋아요: 게시글을 읽으면서 바로 반응 표시
- 북마크/팔로우: 목록을 유지하면서 개별 아이템 상태만 변경
- 빠른 액션: 사용자 플로우를 끊지 않는 간단한 동작들
기본 사용법
커뮤니티 포스트에서 업보트 기능을 구현해보겠습니다:
1import { useFetcher } from 'react-router'2 3export default function PostPage({ loaderData }: Route.ComponentProps) {4 const fetcher = useFetcher()5 const { post } = loaderData6 7 return (8 <div className="flex items-start gap-4">9 {/* 업보트 버튼 - 백그라운드에서 처리 */}10 <fetcher.Form method="post" action={`/community/${post.post_id}/upvote`}>11 <input type="hidden" name="postId" value={post.post_id} />12 <Button13 type="submit"14 variant="outline"15 className={post.is_upvoted ? 'text-primary border-primary' : ''}16 disabled={fetcher.state === 'submitting'}17 >18 <ChevronUpIcon className="size-4" />19 <span>{post.upvotes}</span>20 </Button>21 </fetcher.Form>22 23 {/* 게시글 내용 */}24 <div className="flex-1">25 <h2>{post.title}</h2>26 <p>{post.content}</p>27 </div>28 </div>29 )30}
전용 액션 핸들러 만들기
업보트 기능을 위한 별도 라우트를 생성합니다:
1// app/features/community/pages/upvote.tsx2import { redirect } from 'react-router'3import { getLoggedInUserId } from '~/features/auth/queries'4import { makeSSRClient } from '~/supa-client'5import { toggleUpvote } from '../mutations'6import type { Route } from './+types/upvote'7 8export async function action({ request, params }: Route.ActionArgs) {9 const { client } = makeSSRClient(request)10 const userId = await getLoggedInUserId(client)11 12 const formData = await request.formData()13 const postId = Number(formData.get('postId'))14 15 await toggleUpvote(client, { userId, postId })16 17 // 성공적인 백그라운드 처리를 위한 응답18 return new Response(null, { status: 200 })19}20 21// Loader는 메인 페이지로 리다이렉트 (직접 접근 방지)22export function loader() {23 return redirect('/community')24}
Form vs fetcher.Form 비교
1// ❌ 일반 Form: 페이지가 새로고침되거나 이동2<Form method="post" action="/upvote">3 <button type="submit">좋아요</button>4</Form>5 6// ✅ fetcher.Form: 백그라운드에서 처리, 페이지 유지7<fetcher.Form method="post" action="/upvote">8 <button type="submit">좋아요</button>9</fetcher.Form>
여러 fetcher 동시 사용하기
제품 목록에서 각각 독립적인 액션을 처리해보겠습니다:
1export default function ProductListPage({ loaderData }: Route.ComponentProps) {2 return (3 <div className="space-y-4">4 {loaderData.products.map((product) => {5 // 각 제품마다 독립적인 fetcher6 const bookmarkFetcher = useFetcher({ key: `bookmark-${product.id}` })7 const upvoteFetcher = useFetcher({ key: `upvote-${product.id}` })8 9 return (10 <div11 key={product.id}12 className="flex items-center gap-4 p-4 border rounded"13 >14 <h3>{product.name}</h3>15 16 {/* 북마크 액션 */}17 <bookmarkFetcher.Form18 method="post"19 action={`/products/${product.id}/bookmark`}20 >21 <Button22 type="submit"23 disabled={bookmarkFetcher.state === 'submitting'}24 variant={product.is_bookmarked ? 'default' : 'outline'}25 >26 {bookmarkFetcher.state === 'submitting'27 ? '처리 중...'28 : product.is_bookmarked29 ? '북마크됨'30 : '북마크'}31 </Button>32 </bookmarkFetcher.Form>33 34 {/* 업보트 액션 */}35 <upvoteFetcher.Form36 method="post"37 action={`/products/${product.id}/upvote`}38 >39 <Button40 type="submit"41 disabled={upvoteFetcher.state === 'submitting'}42 className={product.is_upvoted ? 'text-primary' : ''}43 >44 👍 {product.upvotes}45 </Button>46 </upvoteFetcher.Form>47 </div>48 )49 })}50 </div>51 )52}
핵심 개념
- 페이지 유지: 현재 페이지를 떠나지 않고 액션 실행
- 백그라운드 처리: 사용자가 다른 작업을 계속할 수 있음
- 자동 재검증: fetcher 완료 후 React Router가 현재 페이지의 loader를 자동 재실행
- 독립적 상태: 각 fetcher가 독립적인 로딩/에러 상태를 관리
🚀 Optimistic UI 패턴
Optimistic UI는 서버 응답을 기다리지 않고 먼저 UI를 업데이트하여 즉각적인 사용자 경험을 제공하는 패턴입니다.
언제 사용하면 좋을까요?
✅ 사용하면 좋은 경우:
- 간단한 토글 액션: 좋아요, 북마크, 팔로우/언팔로우
- 빈번한 상호작용: 채팅 메시지, 댓글 작성
- 실패 확률이 낮은 액션: 인증된 사용자의 기본 CRUD 작업
- 즉시성이 중요한 기능: 실시간 피드백이 필요한 UI
❌ 사용하지 말아야 할 경우:
- 결제/금융 거래: 정확성이 중요하고 실패 시 큰 문제가 되는 작업
- 복잡한 검증: 서버에서 복잡한 비즈니스 로직을 검증해야 하는 경우
- 파일 업로드: 네트워크나 서버 오류로 실패할 가능성이 높은 작업
- 데이터 삭제: 되돌리기 어려운 중요한 작업
기본 구현 패턴
업보트 기능에 Optimistic UI를 적용해보겠습니다:
1export default function PostCard({ id, votesCount, isUpvoted }: PostCardProps) {2 const fetcher = useFetcher()3 4 // 현재 상태에 따른 최적화된 상태 계산5 const optimisticVotesCount =6 fetcher.state === 'idle'7 ? votesCount // 서버와 동기화된 상태8 : isUpvoted9 ? votesCount - 1 // 업보트 취소로 예상10 : votesCount + 1 // 업보트 추가로 예상11 12 const optimisticIsUpvoted =13 fetcher.state === 'idle'14 ? isUpvoted // 서버와 동기화된 상태15 : !isUpvoted // 반대 상태로 예상16 17 const handleUpvote = (e: React.MouseEvent) => {18 e.preventDefault()19 fetcher.submit(null, {20 method: 'post',21 action: `/community/${id}/upvote`,22 })23 }24 25 return (26 <div className="post-card">27 <h3>게시글 제목</h3>28 29 <Button30 onClick={handleUpvote}31 className={optimisticIsUpvoted ? 'text-primary border-primary' : ''}32 disabled={fetcher.state === 'submitting'}33 >34 👍 {optimisticVotesCount}35 </Button>36 </div>37 )38}
서버 액션 구현
1// app/features/community/mutations.ts2export const toggleUpvote = async (3 client: SupabaseClient<Db>,4 { postId, userId }: { postId: number; userId: string },5) => {6 const { count } = await client7 .from('post_upvotes')8 .select('*', { count: 'exact', head: true })9 .eq('post_id', postId)10 .eq('profile_id', userId)11 12 if (count === 0) {13 // 업보트 추가14 await client.from('post_upvotes').insert({15 post_id: postId,16 profile_id: userId,17 })18 } else {19 // 업보트 제거20 await client21 .from('post_upvotes')22 .delete()23 .eq('post_id', postId)24 .eq('profile_id', userId)25 }26}
에러 처리가 포함된 고급 버전
1export default function AdvancedPostCard({ post }: { post: Post }) {2 const fetcher = useFetcher()3 4 // 에러 상태 확인5 const hasError = fetcher.data?.error6 7 // 에러 발생 시 원래 상태로 되돌림8 const optimisticVotesCount = hasError9 ? post.votesCount // 에러 시 원래 값10 : fetcher.state === 'idle'11 ? post.votesCount12 : post.isUpvoted13 ? post.votesCount - 114 : post.votesCount + 115 16 const optimisticIsUpvoted = hasError17 ? post.isUpvoted // 에러 시 원래 값18 : fetcher.state === 'idle'19 ? post.isUpvoted20 : !post.isUpvoted21 22 return (23 <div>24 <Button25 onClick={() =>26 fetcher.submit(null, {27 method: 'post',28 action: `/posts/${post.id}/upvote`,29 })30 }31 disabled={fetcher.state === 'submitting'}32 className={optimisticIsUpvoted ? 'text-primary' : ''}33 >34 👍 {optimisticVotesCount}35 </Button>36 37 {/* 에러 메시지 표시 */}38 {hasError && (39 <p className="text-red-500 text-sm mt-2">40 업보트 처리 중 오류가 발생했습니다. 다시 시도해주세요.41 </p>42 )}43 </div>44 )45}
Optimistic UI 핵심 패턴
- fetcher.state 확인:
idle상태일 때만 서버 데이터 사용 - 토글 로직: 현재 상태의 반대로 즉시 표시
- 카운트 계산: 현재 상태에 따라 +1 또는 -1
- 에러 복구: 에러 발생 시 원래 상태로 되돌림
- 로딩 비활성화: 제출 중일 때 버튼 비활성화
🎛 Fetcher로 프로그램적 서브밋하기
언제 사용하나요?
fetcher.submit()은 사용자의 직접적인 상호작용 없이 프로그램적으로 서버 액션을 실행할 때 사용합니다:
- 타이머 만료: 퀴즈나 설문조사에서 시간 초과 시 자동 제출
- 자동 저장: 일정 시간마다 임시 저장
- 조건부 액션: 특정 조건 달성 시 자동 실행
- 이벤트 기반 액션: 스크롤, 클릭 등 이벤트에 반응
타이머 기반 자동 제출
온라인 퀴즈 시스템에서 시간 만료 시 자동 제출하는 기능:
1import { useFetcher } from 'react-router'2import { useEffect, useState } from 'react'3 4export default function QuizPage({ loaderData }: Route.ComponentProps) {5 const fetcher = useFetcher()6 const [timeLeft, setTimeLeft] = useState(300) // 5분7 const [currentAnswers, setCurrentAnswers] = useState({})8 9 // 1초마다 시간 감소10 useEffect(() => {11 const timer = setInterval(() => {12 setTimeLeft((prev) => Math.max(0, prev - 1))13 }, 1000)14 15 return () => clearInterval(timer)16 }, [])17 18 // 타이머 만료 시 자동 제출19 useEffect(() => {20 if (timeLeft === 0) {21 fetcher.submit(22 {23 quizId: loaderData.quiz.id,24 answers: JSON.stringify(currentAnswers),25 timeExpired: 'true',26 },27 {28 method: 'post',29 action: '/quiz/submit',30 },31 )32 }33 }, [timeLeft])34 35 return (36 <div>37 <div className="bg-red-100 p-4 rounded mb-4">38 <h2>39 남은 시간: {Math.floor(timeLeft / 60)}분 {timeLeft % 60}초40 </h2>41 {fetcher.state === 'submitting' && (42 <p>시간이 만료되어 자동으로 제출 중입니다...</p>43 )}44 </div>45 46 <div className="quiz-content">47 {loaderData.quiz.questions.map((question, index) => (48 <QuestionComponent49 key={index}50 question={question}51 onAnswerChange={(answer) =>52 setCurrentAnswers((prev) => ({ ...prev, [index]: answer }))53 }54 />55 ))}56 </div>57 58 {/* 수동 제출 버튼 */}59 <Button60 onClick={() =>61 fetcher.submit(62 { answers: JSON.stringify(currentAnswers) },63 { method: 'post', action: '/quiz/submit' },64 )65 }66 disabled={fetcher.state === 'submitting' || timeLeft === 0}67 >68 {fetcher.state === 'submitting' ? '제출 중...' : '제출하기'}69 </Button>70 </div>71 )72}
자동 저장 기능
블로그 에디터에서 30초마다 임시 저장하는 기능:
1export default function BlogEditor({ loaderData }: Route.ComponentProps) {2 const fetcher = useFetcher()3 const [content, setContent] = useState(loaderData.draft?.content || '')4 const [lastSaved, setLastSaved] = useState<Date | null>(null)5 6 // 30초마다 자동 저장7 useEffect(() => {8 const interval = setInterval(() => {9 if (content.trim() && content !== loaderData.draft?.content) {10 fetcher.submit(11 {12 postId: loaderData.post.id,13 content,14 isDraft: 'true',15 },16 {17 method: 'post',18 action: '/blog/auto-save',19 },20 )21 }22 }, 30000) // 30초23 24 return () => clearInterval(interval)25 }, [content])26 27 // 자동 저장 완료 시 시간 업데이트28 useEffect(() => {29 if (fetcher.state === 'idle' && fetcher.data?.success) {30 setLastSaved(new Date())31 }32 }, [fetcher.state, fetcher.data])33 34 return (35 <div>36 <div className="mb-4 flex justify-between items-center">37 <h1>블로그 작성</h1>38 <div className="text-sm text-gray-500">39 {fetcher.state === 'submitting' && '저장 중...'}40 {lastSaved &&41 fetcher.state === 'idle' &&42 `마지막 저장: ${lastSaved.toLocaleTimeString()}`}43 </div>44 </div>45 46 <textarea47 value={content}48 onChange={(e) => setContent(e.target.value)}49 placeholder="블로그 내용을 입력하세요..."50 className="w-full h-96 p-4 border rounded"51 />52 53 {/* 수동 저장 버튼 */}54 <div className="mt-4">55 <Button56 onClick={() =>57 fetcher.submit(58 { content, isDraft: 'false' },59 { method: 'post', action: '/blog/publish' },60 )61 }62 disabled={fetcher.state === 'submitting'}63 >64 발행하기65 </Button>66 </div>67 </div>68 )69}
조건부 액션 실행
게임에서 특정 점수 달성 시 자동으로 레벨업 처리:
1export default function GamePage({ loaderData }: Route.ComponentProps) {2 const fetcher = useFetcher()3 const [score, setScore] = useState(0)4 const [level, setLevel] = useState(1)5 6 // 레벨업 조건 달성 시 자동 실행7 useEffect(() => {8 const levelUpThreshold = level * 10009 10 if (score >= levelUpThreshold && fetcher.state === 'idle') {11 fetcher.submit(12 {13 gameId: loaderData.game.id,14 newLevel: (level + 1).toString(),15 scoreAchieved: score.toString(),16 },17 {18 method: 'post',19 action: '/game/level-up',20 },21 )22 setLevel((prev) => prev + 1)23 }24 }, [score, level])25 26 return (27 <div>28 <div className="game-status mb-4">29 <h2>30 레벨: {level} | 점수: {score}31 </h2>32 {fetcher.state === 'submitting' && (33 <p className="text-green-500">레벨업 처리 중...</p>34 )}35 </div>36 37 <div className="game-content">38 <button39 onClick={() => setScore((prev) => prev + 100)}40 className="bg-blue-500 text-white px-4 py-2 rounded mr-2"41 >42 게임 플레이 (+100점)43 </button>44 </div>45 </div>46 )47}
🎭 여러 폼이 있는 페이지 관리
설정 페이지처럼 여러 개의 독립적인 폼이 있는 경우의 패턴입니다:
1export default function SettingsPage({ actionData }: Route.ComponentProps) {2 const navigation = useNavigation()3 4 // 각 폼의 로딩 상태를 구분5 const isProfileSubmitting =6 navigation.state === 'submitting' &&7 navigation.formData?.has('name') &&8 !navigation.formData?.has('avatar')9 10 const isAvatarSubmitting =11 navigation.state === 'submitting' && navigation.formData?.has('avatar')12 13 const isPasswordSubmitting =14 navigation.state === 'submitting' &&15 navigation.formData?.has('currentPassword')16 17 return (18 <div className="space-y-8">19 {/* 프로필 편집 폼 */}20 <section>21 <h2 className="text-xl font-semibold mb-4">프로필 정보</h2>22 <Form method="post" className="space-y-4">23 <input name="name" placeholder="이름" />24 {actionData?.fieldErrors?.name && (25 <p className="text-red-500">{actionData.fieldErrors.name}</p>26 )}27 28 <input name="bio" placeholder="소개" />29 {actionData?.fieldErrors?.bio && (30 <p className="text-red-500">{actionData.fieldErrors.bio}</p>31 )}32 33 <button34 type="submit"35 disabled={isProfileSubmitting}36 className="bg-blue-500 text-white px-4 py-2 rounded"37 >38 {isProfileSubmitting ? '업데이트 중...' : '프로필 수정'}39 </button>40 </Form>41 </section>42 43 {/* 아바타 업로드 폼 */}44 <section>45 <h2 className="text-xl font-semibold mb-4">프로필 이미지</h2>46 <Form method="post" encType="multipart/form-data" className="space-y-4">47 <input type="file" name="avatar" accept="image/*" />48 {actionData?.fieldErrors?.avatar && (49 <p className="text-red-500">{actionData.fieldErrors.avatar}</p>50 )}51 52 <button53 type="submit"54 disabled={isAvatarSubmitting}55 className="bg-green-500 text-white px-4 py-2 rounded"56 >57 {isAvatarSubmitting ? '업로드 중...' : '이미지 변경'}58 </button>59 </Form>60 </section>61 62 {/* 비밀번호 변경 폼 */}63 <section>64 <h2 className="text-xl font-semibold mb-4">비밀번호 변경</h2>65 <Form method="post" className="space-y-4">66 <input67 type="password"68 name="currentPassword"69 placeholder="현재 비밀번호"70 />71 <input type="password" name="newPassword" placeholder="새 비밀번호" />72 <input73 type="password"74 name="confirmPassword"75 placeholder="비밀번호 확인"76 />77 78 {actionData?.fieldErrors?.password && (79 <p className="text-red-500">{actionData.fieldErrors.password}</p>80 )}81 82 <button83 type="submit"84 disabled={isPasswordSubmitting}85 className="bg-red-500 text-white px-4 py-2 rounded"86 >87 {isPasswordSubmitting ? '변경 중...' : '비밀번호 변경'}88 </button>89 </Form>90 </section>91 </div>92 )93}
통합 Action 처리
1export async function action({ request }: Route.ActionArgs) {2 const { client } = makeSSRClient(request)3 const userId = await getLoggedInUserId(client)4 5 const formData = await request.formData()6 7 // 어떤 폼이 제출되었는지 확인8 if (formData.has('avatar')) {9 // 아바타 업로드 처리10 const avatar = formData.get('avatar') as File11 if (avatar.size > 2 * 1024 * 1024) {12 return { fieldErrors: { avatar: ['2MB 이하 이미지만 업로드 가능'] } }13 }14 await updateUserAvatar(client, { userId, avatar })15 } else if (formData.has('currentPassword')) {16 // 비밀번호 변경 처리17 const passwordData = Object.fromEntries(formData)18 const result = passwordSchema.safeParse(passwordData)19 if (!result.success) {20 return { fieldErrors: result.error.flatten().fieldErrors }21 }22 await updatePassword(client, { userId, ...result.data })23 } else {24 // 프로필 정보 업데이트25 const profileData = Object.fromEntries(formData)26 const result = profileSchema.safeParse(profileData)27 if (!result.success) {28 return { fieldErrors: result.error.flatten().fieldErrors }29 }30 await updateUserProfile(client, { userId, ...result.data })31 }32 33 return { ok: true }34}
다음 편에서는…
2부에서는 동적인 사용자 상호작용을 다뤘습니다. 3부에서는 더욱 심화된 백엔드 기능들을 살펴보겠습니다:
- 데이터베이스 뷰 생성 및 타입 관리 - 복잡한 쿼리를 재사용 가능하게 만들기
- RLS 정책과 auth 함수 - Supabase의 보안 기능 완전 활용
- AI API 통합 - OpenAI를 React Router와 안전하게 통합하기
- Admin Client 사용 가이드 - 시스템 레벨 작업을 위한 고급 패턴
- 실무 팁과 트러블슈팅 - 실제 운영 환경에서의 노하우
React Router v7으로 더욱 정교하고 사용자 친화적인 웹 애플리케이션을 완성해보세요! 🎯
💡 핵심 요약: fetcher.Form으로 백그라운드 액션을 처리하고, Optimistic UI로 즉각적인 반응을 제공하며, 프로그램적 서브밋으로 자동화된 기능을 구현하면 됩니다!