데이터 로딩(Data Loading)

데이터 로딩(Data Loading)

서버에 있는 데이터를 컴포넌트로 가져오는 일은 웹 앱에서 가장 흔하면서도 번거로운 작업 중 하나예요. Remix의 큰 특징 중 하나가 바로 이 서버-컴포넌트 데이터 흐름을 관례 하나로 단순하게 만든다는 거예요. Remix가 약속한 관례를 따르면, 페이지를 서버에서 렌더링하고, JavaScript가 로드되지 못하는 네트워크 상황에도 강해지고, 화면의 바뀌는 부분만 골라 데이터를 로딩하는 등의 최적화까지 자동으로 얻을 수 있어요.

출처: Remix 공식 문서 — Data Loading

데이터 로딩을 따라 잡을 때 자동으로 얻는 것

관례대로 하면 Remix가 자동으로 챙겨주는 것들이 있어요.

  • 페이지를 서버 렌더링
  • JavaScript 로드 실패 같은 네트워크 상황에 강한 구조
  • 사용자 상호작용 때 바뀌는 화면 부분의 데이터만 로딩하는 최적화
  • 전환 시 데이터·JS 모듈·CSS·자산을 병렬로 가져와 렌더+fetch 폭포(캐스캐이드)를 피함
  • 액션 후 재검증(revalidate)으로 UI와 서버 데이터를 동기화
  • 뒤로/앞으로(심지어 도메인을 넘어서도) 훌륭한 스크롤 복원
  • 에러 바운더리로 서버 오류 처리
  • "Not Found"·"Unauthorized" 같은 경우도 에러 바운더리로 견고한 UX
  • 행복한 경로(happy path)를 행복하게 유지

기본: loader와 useLoaderData

각 라우트 모듈은 컴포넌트와 loader를 export할 수 있고, useLoaderData가 로더의 데이터를 컴포넌트에 전달해요.

import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
  return json([
    { id: "1", name: "Pants" },
    { id: "2", name: "Jacket" },
  ]);
};

export default function Products() {
  const products = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Products</h1>
      {products.map((product) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

컴포넌트는 서버와 브라우저 양쪽에서 렌더링되지만, 로더는 서버에서만 실행돼요. 그래서 예시의 하드코딩된 products 배열은 브라우저 번들에 들어가지 않고, 데이터베이스·결제 처리·CMS처럼 서버 전용 API/SDK를 써도 안전해요.

라우트 파라미터 (Route Params)

파일명에 $를 쓰면(app/routes/users.$userId.tsx처럼) $로 시작하는 동적 세그먼트가 URL에서 파싱돼 params 객체로 로더에 전달돼요.

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({ params }: LoaderFunctionArgs) => {
  console.log(params.userId);
  console.log(params.projectId);
};
URL params.userId params.projectId
/users/123/projects/abc "123" "abc"
/users/aec34g/projects/22cba9 "aec34g" "22cba9"

이 파라미터들은 데이터 조회에 가장 유용해요. fakeDb.project.findMany({ where: { userId: params.userId, projectId: params.projectId } })처럼 쓰면 되죠.

파라미터 타입 안전성 — 파라미터는 소스 코드가 아니라 URL에서 오기 때문에 정의되어 있다고 보장할 수 없어요. 그래서 파라미터 키의 타입이 string | undefined로 잡혀요. 특히 TypeScript에서 타입 안전을 얻으려면 그 전에 검증하는 게 좋고, tiny-invariant가 이를 쉽게 만들어줘요.

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import invariant from "tiny-invariant";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.userId, "Expected params.userId");
  invariant(params.projectId, "Expected params.projectId");

  params.projectId; // <-- TypeScript now knows this is a string
};

invariant가 실패할 때 에러를 던지는 게 어색해 보일 수 있는데, Remix에선 그 사용자가 결국 에러 바운더리로 가서 깨진 UI 대신 복구할 수 있다는 점을 기억하세요.

데이터 소스 연결

외부 API — Remix는 서버에 fetch API를 폴리필해주니 기존 JSON API에서 데이터를 가져오기 쉽고, 상태·에러·경쟁 상태를 직접 관리하는 대신 서버 로더에서 fetch하고 나머지는 Remix에 맡기면 돼요.

export async function loader() {
  const res = await fetch("https://api.github.com/gists");
  return json(await res.json());
}

데이터베이스 — Remix가 서버에서 돌기 때문에 라우트 모듈에서 데이터베이스에 직접 연결할 수 있어요. Postgres에 Prisma로 붙는 예시를 볼게요.

import { PrismaClient } from "@prisma/client";

const db = new PrismaClient();

export { db };

그리고 라우트에서 이 db를 import해 쿼리하고, TypeScript면 Prisma Client 생성 타입을 추론해 useLoaderData에 써서 더 나은 타입 안전과 인텔리센스를 누릴 수 있어요.

Cloudflare KV — 환경으로 Cloudflare Pages나 Workers를 골랐다면, Key Value 저장소로 엣지에서 데이터를 정적 자원처럼 유지할 수 있어요. Pages 로컬 개발이면 package.json 태스크에 --kv PRODUCTS_KV 같은 네임스페이스 파라미터를 추가해야 해요. 그러면 로더 context에서 context.PRODUCTS_KV.get(...)처럼 쓸 수 있어요.

Not Found 처리

데이터 로딩 중에 레코드를 "찾을 수 없음"으로 처리하는 건 아주 흔한 일이에요. 컴포넌트를 기대대로 그릴 수 없다는 걸 아는 순간 응답을 던져서 현재 로더의 코드 실행을 멈추고 가장 가까운 에러 바운더리로 넘어가게 해요.

export const loader = async ({ params, request }: LoaderFunctionArgs) => {
  const product = await db.product.findOne({ where: { id: params.productId } });

  if (!product) {
    // we know we can't render the component
    // so throw immediately to stop executing code
    // and show the not found page
    throw new Response("Not Found", { status: 404 });
  }

  const cart = await getCart(request);
  return json({ product, inCart: cart.includes(product.id) });
};

URL 검색 파라미터 (URL Search Params)

? 뒤의 부분을 검색 파라미터(쿼리 문자열)라고 해요. request.urlURL 객체를 만들고 searchParams로 값을 읽어요.

URL url.searchParams.get("term")
/products?term=stretchy+pants "stretchy pants"
/products?term= ""
/products null

여기엔 웹 플랫폼 타입 몇 개가 관여해요. request 객체의 url 속성, URL 문자열을 객체로 파싱하는 URL 생성자, 그리고 그 검색 문자열을 읽고 조작하기 쉽게 만든 URLSearchParams 인스턴스가 그것이죠.

데이터 리로드 — 중첩 라우트가 렌더링 중일 때 검색 파라미터가 바뀌면 모든 라우트가 다시 로딩돼요. 검색 파라미터는 어느 로더에도 영향을 줄 수 있는 "횡단 관심사"라서 그렇죠. 일부 라우트가 이때 리로드되지 않게 하려면 shouldRevalidate를 써요.

컴포넌트에서 검색 파라미터 다루기

설정하기 — 가장 흔한 방법은 폼으로 사용자가 검색 파라미터를 제어하게 하는 거예요. <Form method="get">은 GET 제출이라 결과가 URL 검색 문자열에 남아요.

export default function ProductFilters() {
  return (
    <Form method="get">
      <label htmlFor="nike">Nike</label>
      <input type="checkbox" id="nike" name="brand" value="nike" />
      <label htmlFor="adidas">Adidas</label>
      <input type="checkbox" id="adidas" name="brand" value="adidas" />
      <button type="submit">Update</button>
    </Form>
  );
}

체크박스 둘 다 name="brand"라서 URL에 ?brand=nike&brand=adidas처럼 brand가 반복돼요. 로더에서 이 모든 값을 얻으려면 searchParams.getAll("brand")를 쓰면 돼요.

링크로 제어 — 개발자가 검색 파라미터가 담긴 URL로 링크를 걸 수도 있어요. <Link to="?brand=nike">Nike (only)</Link>처럼 링크하면 현재 URL의 검색 문자열을 링크의 것으로 교체해요.

컴포넌트에서 읽기 — 로더에서 읽는 것 외에 컴포넌트에서도 접근해야 할 때 useSearchParams 훅을 쓰면 돼요.

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");
  // ...
}

필드가 바뀔 때마다 폼을 자동 제출하고 싶다면 useSubmit을 써요.

import { useSubmit, useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get" onChange={(e) => submit(e.currentTarget)}>
      {/* ... */}
    </Form>
  );
}

명령형으로 설정 — 드물지만 아무 때나 이유를 들고 setSearchParams로 명령형으로 설정할 수도 있어요. 단, 이건 특이한 경우에만 쓰죠.

검색 파라미터와 제어 입력(Controlled Inputs) — 체크박스 같은 입력을 URL의 검색 파라미터와 동기화하고 싶다면 React의 제어 컴포넌트 개념 때문에 좀 까다로워질 수 있어요. checked를 쓰면 링크 클릭이 URL·체크박스를 다 바꾸지만 체크박스가 더는 동작하지 않게 되고, defaultChecked를 쓰면 체크박스는 되지만 링크가 URL만 바꾸는 문제가 생기죠. 해법은 두 가지예요.

  1. 체크박스 클릭 시 폼 자동 제출onChange={(e) => submit(e.currentTarget.form)}처럼. (폼 onChange에도 자동 제출을 걸었다면 e.stopPropagation()으로 이벤트가 폼까지 타고 올라가 이중 제출되는 걸 막아야 해요.)
  2. "반쯤 제어" 상태 만들기 — 검색 파라미터에서 초기 state를 만들고, 체크박스 클릭 때 state를 갱신하며, 검색 파라미터가 바뀔 때(폼 제출·링크 클릭) state를 URL에 맞게 갱신하는 식이에요.

체크박스 같은 걸 추상화하고 싶다면 SearchCheckbox 컴포넌트로 useState + useEffect로 URL과 동기화하는 패턴을 만들 수도 있어요. 그리고 key prop 꼼수로 입력을 날려버리고 리마운트하는 "안 좋은 방법"도 있는데, 이건 React가 노드를 제거하면서 사용자가 포커스를 잃어 접근성 문제를 일으키니 하지 말아야 해요.

Remix 최적화

Remix는 네비게이션에서 바뀌는 화면 부분의 데이터만 로딩해서 사용자 경험을 최적화해요. 지금 이 문서의 사이드 네비바를 예로 들면, 부모 라우트가 모든 문서의 동적 메뉴를, 자식 라우트가 지금 읽는 문서를 가져왔어요. 사이드바의 링크를 클릭하면 부모 라우트는 화면에 남는데 자식 문서만 바뀌니, Remix는 부모 라우트의 데이터를 다시 가져오지 않아요.

데이터 전체를 다시 로딩해야 하는 상황도 Remix에 내장돼 있어요. 액션이 호출될 때마다(폼 제출, useSubmit, fetcher.submit) 페이지의 모든 라우트를 자동으로 다시 로딩해서 생긴 변화를 반영해요. 사용자가 상호작용하며 캐시가 만료되거나 데이터를 과다하게 가져올 걱정을 할 필요가 없죠.

세 가지 경우에 Remix가 모든 라우트를 다시 로딩해요.

  • 액션 이후(폼, useSubmit, fetcher.submit)
  • URL 검색 파라미터가 바뀔 때(어느 로더든 쓸 수 있으니까)
  • 사용자가 이미 있는 것과 정확히 같은 URL로 링크를 클릭할 때(이 경우 히스토리 스택의 현재 항목도 교체)

이 동작들은 모두 브라우저 기본 동작을 흉내 낸 거예요. 이 상황들에선 Remix가 코드를 충분히 몰라서 로딩을 최적화하지 못하니까, shouldRevalidate로 직접 최적화할 수 있어요.

데이터 라이브러리

Remix의 데이터 관례와 중첩 라우팅 덕분에, 보통은 React Query·SWR·Apollo·Relay·urql 같은 클라이언트 데이터 라이브러리를 쓸 필요가 없어요. redux 같은 전역 상태 라이브러리도 서버 데이터 상호작용이 목적이라면 굳이 필요 없을 공산이 커요. 물론 Remix가 이들을 막지는 않아요(번들러 통합이 필요한 경우만 빼고). 처음 서버 렌더링은 Remix로 하고, 이후 상호작용은 선호하는 라이브러리로 전환하는 식도 가능하죠.

다만 외부 데이터 라이브러리를 들여와서 Remix의 데이터 관례를 우회하면, 아까 나열한 자동 혜택(서버 렌더링, 네트워크 내성, 부분 로딩 최적화, 병렬 fetch, 액션 후 재검증 동기화, 스크롤 복원, 에러 바운더리 처리 등)을 잃게 되고, 좋은 사용자 경험을 만들려면 그만큼 추가 작업이 필요해져요. 그래도 Remix는 어떤 사용자 경험이든 설계할 수 있게 열려 있고, 외부 라이브러리가 여전히 필요하다 싶으면 쓰는 게 틀린 일은 아니에요.

Remix를 배우다 보면 "클라이언트 상태로 생각하기"에서 "URL로 생각하기"로 사고가 옮겨가고, 그러면서 꽤 많은 편의를 공짜로 얻게 돼요.

주의할 점 (Gotchas)

로더는 서버에서만 호출되고 브라우저의 fetch를 통해 데이터가 JSON.stringify로 직렬화되어 네트워크를 타고 가요. 그래서 데이터는 직렬화 가능해야 해요.

// This won't work!
export async function loader() {
  return {
    date: new Date(),
    someMethod() {
      return "hello!";
    },
  };
}

위 예시는 someMethod 함수가 직렬화되지 못해 클라이언트로 전달되지 않아요. FaunaDB처럼 메서드를 가진 객체를 반환하는 데이터베이스도, 로더에서 반환하기 전에 직렬화를 신경 써야 해요. 내 데이터가 네트워크를 타고 간다는 걸 이해하는 게 중요하죠.

또한 Remix가 로더를 알아서 호출하니 직접 로더를 호출해선 안 돼요. 컴포넌트에서 loader()를 직접 호출하는 건 동작하지 않아요.

더 알아보기