React Router v7으로 시작하는 풀스택 웹 개발 (2부): 고급 상호작용과 사용자 경험

2025년 9월 28일Frontend30분 읽기

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 핵심 패턴

  1. fetcher.state 확인: idle 상태일 때만 서버 데이터 사용
  2. 토글 로직: 현재 상태의 반대로 즉시 표시
  3. 카운트 계산: 현재 상태에 따라 +1 또는 -1
  4. 에러 복구: 에러 발생 시 원래 상태로 되돌림
  5. 로딩 비활성화: 제출 중일 때 버튼 비활성화

🎛 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로 즉각적인 반응을 제공하며, 프로그램적 서브밋으로 자동화된 기능을 구현하면 됩니다!