React에서 데이터를 가져오는 가장 흔한 방식은 useEffect 안에서 fetch하는 것임.
function UserProfile() {const [user, setUser] = useState<User | null>(null);const [loading, setLoading] = useState(true);const [error, setError] = useState<Error | null>(null);useEffect(() => {fetch('/api/user/123').then(res => res.json()).then(data => setUser(data)).catch(err => setError(err)).finally(() => setLoading(false));}, []);if (loading) return <div>Loading...</div>;if (error) return <div>Error: {error.message}</div>;if (!user) return null;return <div>{user.name}</div>;}
이 방식은 동작하지만 몇 가지 문제가 있음.
이 흐름 때문에 사용자는 항상 "깜빡임"을 경험함. 페이지 이동할 때마다 스피너가 잠깐 보이는 현상.
React 공식 문서에서는 데이터 fetching을 useEffect에서 하는 것에 대해 이렇게 말함:
"Fetching data with Effects is a popular way to fetch data, but it's very manual and has significant downsides." — React 공식 문서: You Might Not Need an Effect"Effect로 데이터를 가져오는 것은 흔한 방법이지만, 매우 수동적이고 큰 단점들이 있습니다."
구체적인 문제점들:
Loading 화면에서 실제 콘텐츠로 바뀔 때 화면 높이가 달라지면 레이아웃이 밀립니다. 사용자 경험이 나빠지고 CLS(Cumulative Layout Shift) 점수가 나빠집니다.
사용자가 다른 페이지로 이동해도 이전 페이지의 fetch가 계속 진행됨. 그래서 cleanup 함수를 직접 구현해야 함
useEffect(() => {let cancelled = false;fetch('/api/user').then(res => res.json()).then(data => {
매번 이런 코드를 작성하는 것은 번거롭고 실수하기 쉽다!
React Router의 Loader는 이런 순서로 동작:
1. 사용자가 링크 클릭2. Loader 함수가 먼저 실행 → 데이터 가져오기3. 데이터가 준비되면4. 컴포넌트 렌더링 (데이터와 함께)
핵심 차이는 컴포넌트가 화면에 그려지기 전에 필요한 데이터를 미리 준비해둔다는 것!
"Route loaders are called before the route component renders and provide data to the component through useLoaderData." — React Router 공식 문서: loader번역: "Route loader는 컴포넌트가 렌더되기 전에 호출되고, useLoaderData를 통해 컴포넌트에 데이터를 제공합니다."
React Router v6.4 이상에서는 Data APIs라는 기능을 제공. Loader는 이 기능의 핵심…!
전체 흐름:
[사용자가 링크 클릭]↓[React Router가 URL과 매칭되는 route 찾기]↓[해당 route의 loader 함수 실행] ⭐ 요기가 핵심!↓[loader가 데이터를 반환하면 메모리에 저장]↓[컴포넌트 렌더링 시작]↓[컴포넌트에서 useLoaderData()로 데이터 접근]
Loader는 route 설정에 함수로 등록:
const router = createBrowserRouter([{path: '/user/:id',element: <UserProfile />,loader: userLoader, // ← 이 함수가 먼저 실행됨}]);
여러 route가 중첩되어 있으면?
부모와 자식 route의 loader가 동시에 실행
const router = createBrowserRouter([{path: '/',loader: rootLoader, // ← 이것과element: <Root />,children: [{path: 'user/:id'
순차적으로 기다리지 않고 동시에 실행되기 때문에 빠름.
"All route loaders are called in parallel to maximize data loading performance." — React Router 공식 문서: loader번역: "모든 route loader는 병렬로 호출되어 데이터 로딩 성능을 최대화합니다."
loader는 그냥 일반 함수. 데이터를 가져와서 반환하면 됨:
// src/loaders/userLoader.tsimport { LoaderFunctionArgs } from 'react-router-dom';export interface User {id: string;name: string;email: string;}
코드 설명:
컴포넌트에서는 useLoaderData() 훅으로 loader가 반환한 데이터를 받음:
// src/pages/UserProfile.tsximport { useLoaderData } from 'react-router-dom';import { User } from '../loaders/userLoader';export default function UserProfile() {const user = useLoaderData(
주의사항:
useLoaderData()는 unknown 타입을 반환해 TypeScript를 사용한다면 as User처럼 타입 단언이 필요.
loader와 컴포넌트를 route에 연결해야 함:
// src/App.tsximport { createBrowserRouter, RouterProvider } from 'react-router-dom';import UserProfile from './pages/UserProfile';import { userLoader } from './loaders/userLoader';const router = createBrowserRouter([
이제 사용자가 /user/123으로 이동하면:
loader에서 에러가 발생하면 errorElement가 렌더됨:
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';function ErrorPage() {const error = useRouteError();if (isRouteErrorResponse(error)) {
동작 방식:
loader에서 throw로 에러를 던지면 → React Router가 가장 가까운 errorElement를 찾아서 렌더함.
"When errors are thrown in loaders, React Router will render the route's errorElement." — React Router 공식 문서: errorElement번역: "loader에서 에러가 발생하면, React Router는 route의 errorElement를 렌더합니다."
useEffect 방식:
function UserProfile() {const [user, setUser] = useState<User | null>(null);const [loading, setLoading] = useState
실행 순서:
1. 컴포넌트 렌더 → "Loading..." 표시2. useEffect 실행3. fetch 시작4. 데이터 도착5. setState로 상태 업데이트6. 컴포넌트 다시 렌더 → 실제 데이터 표시
Loader 방식:
// loaderexport async function userLoader() {const response = await fetch('/api/user/123');return response.json();}// component
실행 순서:
1. 사용자가 링크 클릭2. loader 실행 → fetch 시작3. 데이터 도착4. 컴포넌트 렌더 (데이터와 함께)
| 항목 | useEffect | Loader |
| 실행 시점 | 컴포넌트 렌더 후 | 컴포넌트 렌더 전 |
| Loading 표시 | 직접 만들어야 함 | React Router가 자동 관리 |
| 에러 처리 | try-catch + state | errorElement |
| 취소 처리 | cleanup 함수 직접 구현 | 자동으로 취소됨 |
| 깜빡임 | 항상 발생 | 없음 |
| 병렬 로딩 | 직접 구현 필요 | 자동 |
React 공식 문서에서는 데이터 fetching에 대해 이렇게 말함:
"If you use a framework, use its data fetching mechanism. Modern React frameworks like Next.js and Remix have built-in data fetching mechanisms that are more efficient than fetching data in Effects." — React 공식 문서: Fetching Data번역: "프레임워크를 사용한다면, 프레임워크의 데이터 fetching 메커니즘을 사용하세요. Next.js나 Remix 같은 현대적인 React 프레임워크는 Effect에서 데이터를 가져오는 것보다 효율적인 내장 데이터 fetching 메커니즘을 가지고 있습니다."
React Router의 Loader가 바로 이런 "내장 메커니즘"이라고 할 수 있음.
loader 함수는 두 가지 유용한 정보를 받을 수 있음:
import { LoaderFunctionArgs } from 'react-router-dom';export async function productLoader({ params, request }: LoaderFunctionArgs) {// 1. URL params 읽기const productId = params.id; // /product/:id 의 :id
실제 사용 예시:
→ params.id = "123"→ searchParams.get('sort') = "price"→ searchParams.get('filter') = "sale"
모든 데이터가 다 준비될 때까지 기다리지 않고, 빠른 데이터는 먼저 보여주고 느린 데이터는 나중에 보여줄 수도 있음:
import { defer } from 'react-router-dom';export async function dashboardLoader() {// 빠른 데이터는 await로 기다림const userInfo = await fetch('/api/user').then(r => r.
컴포넌트에서는 Suspense로 감싸서 받음..!
import { Await, useLoaderData } from 'react-router-dom';import { Suspense } from 'react';function Dashboard() {const data = useLoaderData() as {
언제 유용한가?
"defer allows you to defer values returned from loaders by passing promises instead of resolved values." — React Router 공식 문서: defer번역: "defer는 resolved된 값 대신 promise를 전달하여 loader에서 반환하는 값을 지연시킬 수 있게 합니다."
loader에서 다른 페이지로 리다이렉트할 수 있습니다. 인증 확인 등에 유용합니다:
import { redirect, LoaderFunctionArgs } from 'react-router-dom';export async function protectedLoader({ request }: LoaderFunctionArgs) {const user = await getCurrentUser();
React Router 공식 문서:
"redirect is a helper that creates a Response that will redirect the user to another route." — React Router 공식 문서: redirect번역: "redirect는 사용자를 다른 route로 리다이렉트할 Response를 생성하는 헬퍼입니다."
1. 페이지를 보기 위해 꼭 필요한 데이터
사용자 프로필, 상품 상세 정보처럼 "이 데이터가 없으면 페이지를 보여줄 수 없는" 경우!
// Loader가 적합export async function productDetailLoader({ params }: LoaderFunctionArgs) {const product = await fetch(`/api/products/${params.id}`).then(
2. URL로 데이터가 결정되는 경우
블로그 포스트, 사용자 프로필 등 URL 파라미터로 어떤 데이터를 가져올지 결정되는 경우!
// Loader가 적합// /post/123 → 123번 포스트 가져오기export async function postLoader({ params }: LoaderFunctionArgs) {return fetch(`/api/posts/${params.id}`).then(
3. 인증이 필요한 페이지
로그인 확인과 데이터 로딩을 한번에 처리할 때..!
// Loader가 적합export async function dashboardLoader() {const user = await checkAuth();if (!user) {return redirect('/login');
4. 목록에서 상세로 이동
게시글 목록 → 게시글 상세, 상품 목록 → 상품 상세 같은 네비게이션도..!
// Loader가 적합const router = createBrowserRouter([{path: '/products',element: <ProductList />},{path: '/products/:id',element: <
1. 사용자가 무언가를 한 후에 데이터가 필요한 경우
검색, 필터링, 정렬처럼 사용자 인터랙션 후에 데이터를 가져오는 경우:
// useEffect가 적합function SearchPage() {const [query, setQuery] = useState('');const [results, setResults] = useState([]);
2. 실시간으로 업데이트되는 데이터
채팅, 주식 가격, 알림처럼 계속 변하는 데이터일 때
// useEffect가 적합function ChatRoom() {const [messages, setMessages] = useState<Message[]>([]);useEffect(()
3. 조건에 따라 데이터를 가져올지 말지 결정
토글을 켜야만 데이터를 가져오는 경우도 여기에 해당함
// useEffect가 적합function Dashboard() {const [showStats, setShowStats] = useState(false);const [stats, setStats] = useState(null);
4. 주기적으로 업데이트해야 하는 데이터
5초마다, 1분마다 새로운 데이터를 가져오는 경우!
// useEffect가 적합function StockPrice() {const [price, setPrice] = useState(0);useEffect(() => {const fetchPrice =
이렇게 판단하면 조금 더 간단:
| 질문 | Loader | useEffect |
| 이 데이터가 없으면 페이지를 못 보여주나? | ✅ | ❌ |
| URL이 바뀌면 데이터도 바뀌나? | ✅ | ❌ |
| 사용자가 버튼을 눌러야 데이터를 가져오나? | ❌ | ✅ |
| 데이터가 계속 변하나? (실시간) | ❌ | ✅ |
| 조건에 따라 데이터를 가져올지 말지 결정하나? | ❌ | ✅ |
가장 간단한 규칙:
Loader는 React Router가 페이지 이동을 처리할 때만 실행됨:
// ✅ Loader가 실행됨<Link to="/user/123">User Profile</Link>navigate('/user/123');// ❌ Loader가 실행 안 됨 (브라우저가 새로고침)<a href="/user/123">User Profile</a>window.location.href =
이유:
브라우저 새로고침은 서버에 새 요청을 보내는 것으로, React Router는 관여할 수 없다..!
loader 함수 안에서 React 훅을 사용할 수 없습니다:
// ❌ 이렇게 하면 에러export async function userLoader() {const user = useUser(); // ❌ 훅은 컴포넌트에서만!return user;}// ✅ 이렇게 해야 함export async function
useLoaderData()는 unknown 타입을 반환하므로 타입 단언이 필요:
// ❌ 타입 에러 발생function UserProfile() {const user = useLoaderData();return <div>{user.name}</div>; // ❌ user: unknown}
더 안전하게 하려면 타입 가드를 만들 수 있는데, 아래와 같이 만들 수 있음..!
function isUser(data: unknown): data is User {return (typeof data === 'object' &&data !== null &&'name' in data &&'email' in data
기본적으로 React Router는 매번 loader를 실행하게 됨..! 같은 페이지를 여러 번 방문하면 매번 API 요청이 발생함:
// 문제 상황<Link to="/user/123">Profile</Link> // API 요청!<Link to="/posts">Posts</Link><Link to="/user/123">Profile</Link> // 또 API 요청!
간단한 캐싱을 구현할 수 있음.:
// src/utils/cache.tsconst cache = new Map<string, {data: any;timestamp: number}>();const CACHE_TIME
// loader에서 사용import { getCached, setCache } from '../utils/cache';export async function userLoader({ params }: LoaderFunctionArgs) {const cacheKey = `user-${params
여러 loader가 있을 때 하나라도 실패하면 전체가 에러 페이지로 이동함
const router = createBrowserRouter([{path: '/dashboard',loader: essentialLoader, // 이게 실패하면element: <Dashboard />,errorElement: <ErrorPage />, // 여기로 이동children
해결 방법:
필수가 아닌 데이터는 defer + Suspense로 처리하면 부분 실패를 허용할 수 있다-!
export async function dashboardLoader() {// 필수 데이터는 awaitconst essential = await fetch('/api/essential').then(r => r.json());// 선택 데이터는 Promise로 (실패해도 OK)
const router = createBrowserRouter([{path: '/',loader: rootLoader, // ← 이것과element: <Root />,children: [{path: 'user/:id',loader: userLoader, // ← 이것이 병렬로 실행됨element: <UserProfile />,},],},]);
| 코드 양 | 많음 (boilerplate) | 적음 |