로더(Loader): 서버에서 데이터를 컴포넌트로

로더(Loader): 서버에서 데이터를 컴포넌트로

브라우저에서 화면에 필요한 데이터를 일일이 fetch하고 상태로 관리하다 보면 로딩·에러·경쟁 상태 같은 걸 직접 신경 써야 해요. Remix의 **로더(loader)**는 이 역할을 서버로 옮겨요. 각 라우트는 loader 함수를 export해서, 그 라우트를 렌더링할 때 쓸 데이터를 준비할 수 있어요. 이 함수는 서버에서만 실행되고, 화면에는 그 결과만 내려가니까 데이터베이스 연결이나 서버 전용 비밀값처럼 브라우저에 노출되면 안 되는 일을 안심하고 할 수 있어요.

출처: Remix 공식 문서 — loader

기본 사용

로더는 서버에서만 실행돼요. 최초 서버 렌더링 때는 HTML 문서에 데이터를 담아 주고, 브라우저에서 네비게이션이 일어나면 Remix가 브라우저의 fetch를 통해 이 함수를 다시 호출해요. UI를 그리는 데 쓰이지 않는 코드는 컴파일러가 브라우저 번들에서 제거해 버리죠.

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

export const loader = async () => {
  return json({ ok: true });
};

Prisma 같은 ORM으로 데이터베이스에 직접 붙는 예시를 볼게요. Prisma는 로더에서만 쓰이니까 브라우저 번들에 포함되지 않아요.

import { useLoaderData } from "@remix-run/react";
import { prisma } from "../db";

export async function loader() {
  return json(await prisma.user.findMany());
}

export default function Users() {
  const data = useLoaderData<typeof loader>();
  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

loader에서 돌려준 값은 컴포넌트가 그리지 않더라도 클라이언트에 그대로 노출된다는 점을 기억하세요. 로더는 공개 API 엔드포인트처럼 취급하는 게 안전해요.

타입 안전성

useLoaderData<typeof loader>()로 네트워크 너머까지 타입 안전을 얻을 수 있어요. 다만 주의할 게 하나 있어요.

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

export async function loader() {
  return json({ name: "Ryan", date: new Date() });
}

export default function SomeRoute() {
  const data = useLoaderData<typeof loader>();
}

data.name은 문자열임을 알 수 있고, data.date 역시 우리가 Date 객체를 넘겼는데도 문자열로 잡혀요. 클라이언트 전환 시 데이터가 JSON.stringify로 직렬화돼 네트워크를 타고 가기 때문에, 그 직렬화 사실까지 타입이 알고 있는 거죠.

로더가 받는 인자

params — 라우트 파라미터는 라우트 파일명으로 정의돼요. 파일명 세그먼트가 $로 시작하면(예: $invoiceId) 그 세그먼트의 URL 값이 로더에 전달돼요. 사용자가 /invoices/123을 방문했다면:

// if the user visits /invoices/123
export async function loader({ params }: LoaderFunctionArgs) {
  params.invoiceId; // "123"
}

보통 ID로 레코드를 조회할 때 쓰죠. 조회 결과가 없으면 throw new Response("", { status: 404 })처럼 곧바로 404 응답을 던질 수 있어요.

request — Fetch Request 인스턴스예요. 로더에서 가장 흔한 용도는 쿠키 같은 헤더를 읽거나 URL 쿼리 문자열(URLSearchParams)을 다루는 거예요.

export async function loader({ request }: LoaderFunctionArgs) {
  // read a cookie
  const cookie = request.headers.get("Cookie");

  // parse the search params for `?q=`
  const url = new URL(request.url);
  const query = url.searchParams.get("q");
}

context — 서버 어댑터의 getLoadContext()가 넘겨주는 값이에요. 어댑터의 request/response API와 Remix 앱 사이를 잇는 다리 역할을 하죠. Express 어댑터 예시를 보면, getLoadContext에서 돌려준 값이 로더의 context가 되는 걸 알 수 있어요. 이 API는 탈출구(escape hatch) 성격이라 평소엔 잘 쓰지 않아요.

Response 반환하기

로더는 Fetch Response를 돌려줘야 해요. Response를 직접 만들 수도 있고, json 헬퍼가 이 작업을 간단히 해줘요. 두 예시는 사실상 같은 결과예요.

export async function loader() {
  const users = await db.users.findMany();
  const body = JSON.stringify(users);
  return new Response(body, {
    headers: {
      "Content-Type": "application/json",
    },
  });
}
import { json } from "@remix-run/node"; // or cloudflare/deno

export const loader = async () => {
  const users = await fakeDb.users.findMany();
  return json(users);
};

json은 상태 코드나 헤더도 같이 넣을 수 있어요. 예를 들어 데이터를 못 찾았을 때 404 상태로 반환하는 식이죠.

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const project = await fakeDb.project.findOne({ where: { id: params.id } });

  if (!project) {
    return json("Project not found", { status: 404 });
  }
  return json(project);
};

로더에서 응답 던지기(Throwing Responses)

응답을 반환하는 것뿐 아니라 로더 안에서 Response 객체를 던질(throw) 수도 있어요. 이러면 콜 스택을 뚫고 나가서 두 가지 중 하나를 하게 돼요.

  • 다른 URL로 리다이렉트
  • ErrorBoundary를 통해 맥락 있는 대체 UI 보여주기

redirect, json 같은 헬퍼는 Response 객체를 반환하니까 그대로 던질 수 있어요. 세션 없는 사용자를 로그인 화면으로 보내는 예시를 볼게요.

import { redirect } from "@remix-run/node"; // or cloudflare/deno
import { getSession } from "./session";

export async function requireUserSession(request) {
  const session = await getSession(request.headers.get("cookie"));

  if (!session) {
    // redirect 응답이면 다른 URL로 보내고,
    // 그 외 응답이면 ErrorBoundary에 그려질 UI를 트리거해요.
    throw redirect("/login", 302);
  }

  return session.get("user");
}

로더에서 권한·조회 실패를 던지고, ErrorBoundary에서 상태 코드별로 다른 안내 UI를 그리는 전체 예시도 살펴볼게요. isRouteErrorResponse로 던져진 응답인지 구분하고, useRouteError로 그 값을 받아요.

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import {
  isRouteErrorResponse,
  useLoaderData,
  useRouteError,
} from "@remix-run/react";
import { getInvoice } from "~/db";
import { requireUserSession } from "~/http";

export const loader = async ({ params, request }: LoaderFunctionArgs) => {
  const user = await requireUserSession(request);
  const invoice = getInvoice(params.invoiceId);

  if (!invoice.userIds.includes(user.id)) {
    throw json({ invoiceOwnerEmail: invoice.owner.email }, { status: 401 });
  }

  return json(invoice);
};

export default function InvoiceRoute() {
  const invoice = useLoaderData<typeof loader>();
  return <InvoiceView invoice={invoice} />;
}

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    switch (error.status) {
      case 401:
        return (
          <div>
            <p>You don't have access to this invoice.</p>
            <p>Contact {error.data.invoiceOwnerEmail} to get access</p>
          </div>
        );
      case 404:
        return <div>Invoice not found!</div>;
    }

    return (
      <div>
        Something went wrong: {error.status} {error.statusText}
      </div>
    );
  }

  return (
    <div>
      Something went wrong: {error?.message || "Unknown Error"}
    </div>
  );
}

더 알아보기