Search

loader 활용하기

리믹스(Remix)와 같은 서버 사이드 렌더링(SSR) 프레임워크로 개발할 때, 기존의 클라이언트 사이드 렌더링(CSR) 방식과 가장 큰 차이점은 초기 화면에 필요한 데이터를 다루는 방식입니다. SSR에서는 데이터베이스 조회나 API 응답으로 얻는 서버 데이터에 대한 접근과 처리가 화면의 서버 렌더링 시점에 미리 이루어질 수 있다는 점이 핵심입니다.
서버 사이드 렌더링(SSR)에서 웹 페이지가 만들어지는 과정을 다시 생각해 봅시다. 먼저, 서버에서 웹 페이지를 만들고 이를 브라우저로 보냅니다. 그 다음, 브라우저에서 페이지를 보여줍니다. 이 방식은 사용자가 웹사이트를 더 빨리 볼 수 있게 해줍니다.
리액트는 이런 SSR을 지원하기 위해 “하이드레이션”이라는 기능을 사용합니다. 이 기능은 무엇을 할까요? 서버에서 만든 페이지와 브라우저에서 만든 페이지를 비교합니다. 만약 두 페이지가 다르다면, 리액트는 오류나 경고 메시지를 보여줍니다. 이렇게 해서 페이지가 올바르게 표시되는지 확인합니다.
SSR의 초기 페이지 로딩 속도라는 장점을 최대한 활용하고 하이드레이션 과정에서 발생할 수 있는 문제를 방지하려면, 서버 렌더링과 클라이언트 렌더링 시점에서 동일한 데이터를 사용하는 것이 가장 좋습니다. 물론 클라이언트 렌더링 이후 useEffect()와 같은 훅을 사용해 API로 추가 데이터를 호출하여 화면을 구성할 수 있습니다. 하지만 이 방식은 화면 요소들이 움직이는 레이아웃 시프트 현상을 유발해 사용자 경험(UX)에 부정적인 영향을 미칠 수 있습니다.
클라이언트 렌더링때 데이터 조회를 통한 추가 화면 요소 생성은 CLS(Cumulative Layout Shift)를 증가시킵니다.
리믹스의 loader() 모듈 함수를 사용하면 서버와 클라이언트 렌더링 시점에 관계없이 필요한 데이터를 일관되게 사용할 수 있습니다. 이를 통해 서버 사이드에서 준비된 데이터로 레이아웃 시프트 없는 완성된 페이지를 브라우저에서 즉시 볼 수 있습니다.

 라우트에 loader 추가하고 사용하기

loader() 모듈은 추가하려는 페이지 라우트 파일에서 아래 코드처럼 “loader”라는 명칭의 함수를 정의해서 사용 합니다. 이 때 정의한 loader는 반드시 export 형태로 선언해야 적용됩니다. loader는 반드시 반환하는 값이 있어야 하고 반환 값이 없을 경우 에러가 발생하게 됩니다.
export const loader = () => { // 로드할 데이터 정의 const todoList = [/* ... */]; // 데이터를 리턴 return { todoList }; };
TypeScript
복사
loader(), action()과 같은 리믹스의 모든 라우트 모듈은 프로젝트의 /app/routes 경로에 있는 라우트 파일에서만 선언할 수 있습니다. 단일 파일로 된 경로라면 해당 파일에서 선언할 수 있고 폴더 형태의 경로라면 폴더 내의 route.tsx 파일에서만 선언할 수 있습니다.
리믹스의 useLoaderData() 훅을 통해 loader에서 반환된 데이터를 리액트 컴포넌트에서 사용할 수 있습니다. 리믹스의 페이지 라우트 컴포넌트는 기본적으로 서버 렌더링을 우선 수행하기 때문에, 아래 코드의 todoList 데이터는 서버 렌더링 시점에 미리 로드되어 사용됩니다.
import { useLoaderData } from '@remix-run/react'; export const loader = () => { const todoList = ['코딩', '운동', '공부']; // 데이터를 리턴 return { todoList }; }; export default function SomeRouteComponent() { // useLoaderData 훅으로 서버사이드 loader 데이터를 컴포넌트로 가져와 사용 const { todoList } = useLoaderData(); return ( <section> {todoList.map(todo => (<p>{todo}</p>))} {/* 코딩 ... */} </section> ); }
TypeScript
복사
컴포넌트에서 loader 데이터를 사용할 때, 원본 데이터의 타입을 컴포넌트에서도 동일하게 적용하려면 useLoaderData() 호출 시 제네릭으로 loader의 반환 타입을 지정하면 됩니다. 이는 useLoaderData<typeof loader>()와 같은 형태로 적용합니다.
import { useLoaderData } from '@remix-run/react'; export const loader = () => { const todoList = ['코딩', '운동', '공부']; return { todoList }; }; export default function SomeRouteComponent() { // loader의 반환 데이터 타입을 컴포넌트에서도 그대로 적용 const { todoList } = useLoaderData<typeof loader>(); return ( <section> {todoList.map(todo => (<p>{todo}</p>))} {/* 코딩 ... */} </section> ); }
TypeScript
복사
useLoaderData()으로 가져온 loader 데이터는 서버 사이드, 클라이언트 사이드 모두 유효합니다. 클라이언트 렌더링 시점에서도 loader 데이터를 동일하게 사용할 수 있습니다.
예를 들면, 클라이언트 렌더링 시점에서 호출되는 useState(), useEffect()와 같은 리액트 훅에서도 loader 데이터에 대한 동일한 접근이 가능합니다.
import { useLoaderData } from '@remix-run/react'; import { useEffect, useState } from 'react'; export const loader = () => { const todoList = ['코딩', '운동', '공부']; return { todoList }; }; export default function SomeRouteComponent() { const { todoList } = useLoaderData<typeof loader>(); // todoList로 workList 상태 초기화 const [workList, setWorkList] = useState(() => todoList.map(todo => `${todo} 하자!`)); // todoList에 따라 setWorks 처리 useEffect(() => { setWorkList(todoList.map(todo => `${todo} 하자!`)); }, [todoList]); return ( <section> <div> {todoList.map(todo => (<p>{todo}</p>))} {/* 코딩 ... */} </div> <div> {workList.map(work => (<p>{work}</p>))} {/* 코딩 하자! ... */} </div> </section> ); }
TypeScript
복사

쿼리스트링(Search Params), URL 파라미터 가져오기

웹 페이지에 접속할 때 URL에 쿼리스트링이나 파라미터를 전달하여 추가 처리를 하는 경우가 흔합니다. loader() 모듈 함수를 선언할 때 request와 params 인자를 받으면, 이를 통해 쿼리스트링과 URL 파라미터를 쉽게 확인할 수 있습니다.
쿼리스트링의 경우 loader 함수 인자에서 request 값을 가져와 아래 코드처럼 쿼리스트링에 대한 처리를 할 수 있습니다.
// 파일: /app/routes/reservations.tsx // 페이지 접속 경로가 /reservations?region=seoul import type { LoaderFunctionArgs } from '@remix-run/node'; export const loader = async ({ request }: LoaderFunctionArgs) => { // request.url에서 쿼리스트링 데이터 가져오기 let searchParams = new URL(request.url).searchParams; searchParams = Object.fromEntries(urlObj); // DB에서 조회 const reservations = await db.reservations.find({ region: searchParams.region }); return { reservations }; };
TypeScript
복사
loader에서 비동기 처리가 필요한 경우 async 함수로 선언하면 됩니다.
URL 파라미터의 경우 loader 함수 인자의 params에서 가져올 수 있습니다.
// 파일: /app/routes/reservation.$id.tsx // 페이지 접속 경로가 /reservation/abc123 import type { LoaderFunctionArgs } from '@remix-run/node'; export const loader = async ({ params }: LoaderFunctionArgs) => { // params에서 id 데이터 가져오기 const { id } = params; // DB에서 조회 const reservation = await db.reservations.findOne({ id }); return { reservation }; };
TypeScript
복사

json 직렬화된 데이터 응답

데이터를 반환할 때 json() 함수로 처리하면 응답 헤더에 application/json이 적용되고, 데이터는 JSON.stringify()와 동일하게 JSON 형식으로 직렬화됩니다. string, number, boolean 타입은 그대로 유지되지만, Date 타입은 문자열로 변환됩니다.
import { json } from '@remix-run/node'; import { useLoaderData } from '@remix-run/react'; export const loader = () => { const user = { id: 'user001', age: 24, isDeleted: false, createdAt: new Date(2006, 0, 2, 15, 4, 5), }; return json({ user }); }; export default function SomeRouteComponent() { const { user } = useLoaderData<typeof loader>(); return ( <div> <p>{user.id}</p> {/* 'user001' (string 타입) */} <p>{user.age}</p> {/* 24 (number 타입) */} <p>{user.isDeleted}</p> {/* false (boolean 타입) */} <p>{user.createdAt}</p> {/* '2006-01-02T15:04:05.000Z' (string 타입) */} </div> ); }
TypeScript
복사
리믹스의 다음 버전인 V3에서 도입되는 Single Fetch 기능을 특별히 활성화하지 않은 경우, 리믹스는 기본적으로 loader()action()의 모든 반환 값에 자동으로 json()을 적용합니다. 따라서 json() 적용을 명시적으로 하지 않아도 기본적으로 적용됩니다.

defer 지연 응답과 리액트 Suspense의 사용

리믹스의 지연 응답 기능을 사용하려면 데이터 반환 시 defer()를 적용하면 됩니다. defer() 함수가 적용된 반환 값은 응답을 스트리밍 형태로 변환합니다. loader에서 async 함수를 처리할 때, await를 사용하지 않은 대기(pending) 상태의 Promise 데이터만 스트리밍되어 지연 응답됩니다. 반면, await를 사용해 Promise를 이행(fulfilled)시키면 스트리밍이 적용되지 않습니다.
아래 코드는 이를 이해하기 위한 예시입니다. comments 댓글 데이터는 DB 조회 비동기 함수가 await를 하지 않았기 때문에 지연 응답이 적용되고, content 내용 데이터는 DB 조회 비동기 함수가 await를 했기 때문에 지연 응답이 적용되지 않게 됩니다.
import type { LoaderFunctionArgs } from '@remix-run/node'; import { defer } from '@remix-run/react'; export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = params; // comments는 DB 조회 async를 await하지 않았기 때문에 Promise가 대기 상태 const comments = db.comments.find({ postId: id }); // content는 DB 조회 async를 await했기 때문에 Promise가 완료 상태 const content = await db.posts.findOne({ id }); // 반환 데이터에 defer 함수로 지연 응답 적용 return defer({ content, // await를 했기 때문에 지연 적용 X comments, // await를 하지 않았기 때문에 지연 적용 O }); };
TypeScript
복사
지연 응답을 효율적으로 사용하기 위해서는 await 하지 않는 비동기 함수를 먼저 호출하고, await 하는 비동기 함수를 뒤에 호출하는 것이 좋습니다.
지연 응답이 적용된 데이터는 리액트 컴포넌트에서 Promise 타입으로 반환되므로, 이를 사용하기 위해서는 리액트의 <Suspense>와 리믹스의 <Await>컴포넌트를 함께 활용해야 합니다. <Suspense>는 Promise 타입의 데이터가 해결되어 반환되기 전까지 fallback 컴포넌트를 표시하는 역할을 합니다. <Await>는 async 함수의 await와 유사하게, useLoaderData() 훅으로 가져온 데이터에서 Promise를 기다렸다가 완료되면 화면에 나타나도록 하는 역할을 수행합니다.
import type { LoaderFunctionArgs } from '@remix-run/node'; import { Await, defer, useLoaderData } from '@remix-run/react'; import { Suspense } from 'react'; export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = params; const comments = db.comments.find({ postId: id }); const content = await db.posts.findOne({ id }); // 반환 데이터에 defer 함수로 지연 응답 적용 return defer({ content, // await를 했기 때문에 지연 적용 X comments, // await를 하지 않았기 때문에 지연 적용 O }); }; export default function SomeRouteComponent() { const { content, comments } = useLoaderData<typeof loader>(); return ( <section> <div> {/* content는 지연 응답이 아니므로 바로 사용 */} {content} </div> <div> {/* comments는 지연 응답이므로 Suspense와 Await로 사용 */} {/* comments가 아직 대기 중일 때 fallback인 '댓글 로딩...'이 화면이 나오고, */} {/* comments가 응답되면 데이터가 화면에 출력 */} <Suspense fallback={<p>댓글 로딩...</p>}> <Await resolve={comments}> {(comments) => comments.map(comment => <p>{comment}</p>)} </Await> </Suspense> </div> </section> ); }
TypeScript
복사
defer() 지연 응답은 loader에서 비동기 데이터 처리에 시간이 오래 걸릴 때 유용합니다. 이 기능을 사용하면 처리 시간이 긴 작업의 응답을 지연시켜 페이지의 초기 로딩 속도를 개선할 수 있습니다. 결과적으로, 사용자는 전체 데이터가 준비되기 전에도 페이지의 일부를 먼저 볼 수 있게 됩니다.
제가 개발 중인 페이지에 defer를 적용한 예시 화면입니다. 트렌딩 컬렉션 블록은 데이터의 비동기 처리를 await했기 때문에 페이지 접속 시 즉시 표시됩니다. 반면 컬렉션 랭킹 블록은 데이터의 비동기 처리를 await하지 않았기 때문에 지연 응답이 적용되어, <Suspense>의 fallback 스켈레톤 UI가 먼저 나타납니다. 이후 Promise가 이행되면 <Await>에 구현한 결과 결과 화면으로 전환됩니다.
defer 지연 적용은 서버 사이드에서 처리 시간이 오래걸리는 작업으로 인해 페이지 전체 접속시간이 늦어지게 되는 경우, 오래 걸리는 작업에 지연 응답을 적용함으로써 페이지 접속 시간을 보다 빠르게 할 수 있습니다.

 중첩 라우트

Remix 목록