React Router
React Router v7
설치
npx create-react-router@latest my-react-router-app
cd my-react-router-app
pnpm install
pnpm run dev라우터: Routes
1. 라우트 설정 (Configuring Routes)
app/routes.ts에서 URL 패턴과 실행할 파일(Route Module)을 연결합니다.
import { type RouteConfig, route, index, layout, prefix } from "@react-router/dev/routes";
export default [
// 1. 인덱스 라우트 (루트 페이지)
index("./home.tsx"),
// 2. 일반 라우트
route("about", "./about.tsx"),
// 3. 레이아웃 라우트 (URL에 영향 없음)
layout("./auth/layout.tsx", [
route("login", "./auth/login.tsx"),
route("register", "./auth/register.tsx"),
]),
// 4. 접두사(Prefix) 사용
...prefix("concerts", [
index("./concerts/home.tsx"),
route(":city", "./concerts/city.tsx"),
]),
] satisfies RouteConfig;2. 라우트 모듈 (Route Modules)
라우트 파일은 데이터 로딩(loader)과 화면 렌더링(Component)을 담당합니다.
import type { Route } from "./+types/team";
// 데이터 로직: 컴포넌트 렌더링 전 실행
export async function loader({ params }: Route.LoaderArgs) {
let team = await fetchTeam(params.teamId);
return { name: team.name };
}
// UI 로직: loader에서 받은 데이터 사용
export default function Component({ loaderData }: Route.ComponentProps) {
return <h1>Team Name: {loaderData.name}</h1>;
}3. 중첩 라우팅 (Nested Routes)
부모 라우트 안에 자식 라우트를 배치하며, 부모 컴포넌트의 <Outlet /> 위치에 자식이 렌더링됩니다.
설정 (routes.ts):
route("dashboard", "./dashboard.tsx", [
index("./dashboard-home.tsx"), // /dashboard
route("settings", "./settings.tsx"), // /dashboard/settings
])부모 컴포넌트 (dashboard.tsx):
import { Outlet } from "react-router";
export default function Dashboard() {
return (
<div>
<h1>Dashboard Header</h1>
<Outlet /> {/* 자식 컴포넌트들이 여기에 나타남 */}
</div>
);
}4. 동적 및 선택적 세그먼트 (Dynamic Segments)
URL의 가변적인 부분을 파라미터로 처리합니다.
// 동적 파라미터: /teams/123 -> params.teamId
route("teams/:teamId", "./team.tsx"),
// 선택적 파라미터: /categories 또는 /en/categories 모두 매칭
route(":lang?/categories", "./categories.tsx"),
// 여러 파라미터 조합
route("c/:catId/p/:prodId", "./product.tsx"),5. Splat (Catch-all) 라우트
*를 사용하여 일치하는 라우트가 없을 때의 처리를 정의합니다.
route("files/*", "./files.tsx"), // /files/any/path/here
route("*", "./catchall.tsx"), // 404 처리용파라미터 추출:
export async function loader({ params }: Route.LoaderArgs) {
const splat = params["*"]; // "any/path/here"
}6. 컴포넌트 라우트 (Component Routes)
파일 기반 라우팅 시스템 밖에서 단순 UI 전환이 필요할 때 사용합니다.
import { Routes, Route } from "react-router";
function Wizard() {
return (
<div>
<Routes>
<Route index element={<StepOne />} />
<Route path="step-2" element={<StepTwo />} />
</Routes>
</div>
);
}핵심 요약:
routes.ts: 전체 지도를 그립니다.loader: 필요한 데이터를 미리 준비합니다.<Outlet />: 중첩된 자식 페이지가 들어갈 자리를 만듭니다.params: URL에서 필요한 변수를 추출합니다.
데이터 로딩: Loader
1. 데이터 로딩 개요
React Router에서 데이터는 loader와 clientLoader를 통해 컴포넌트에 전달됩니다.
- 직렬화(Serialization): 로더에서 반환된 데이터(문자열, 숫자, Promise, Map, Set, Date 등)는 자동으로 직렬화되어 컴포넌트의
loaderData로 전달됩니다. - 타입 안전성:
+types파일을 통해loaderData의 타입이 자동으로 생성되어 안전하게 사용할 수 있습니다.
2. 클라이언트 데이터 로딩 (clientLoader)
브라우저에서만 데이터를 가져오고 싶을 때 사용합니다. SPA(Single Page App) 방식에 익숙한 패턴입니다.
import type { Route } from "./+types/product";
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
// 브라우저의 fetch API 사용
const res = await fetch(`/api/products/${params.pid}`);
return await res.json();
}
// 데이터를 불러오는 동안 보여줄 UI
export function HydrateFallback() {
return <div>로딩 중...</div>;
}
export default function Product({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>{loaderData.name}</h1>
<p>{loaderData.description}</p>
</div>
);
}3. 서버 데이터 로딩 (loader)
SSR(서버 사이드 렌더링) 환경에서 사용됩니다. 첫 페이지 로드 시 서버에서 실행되며, 이후 클라이언트 탐색 시에도 자동으로 서버를 호출합니다.
import type { Route } from "./+types/product";
import { fakeDb } from "../db";
export async function loader({ params }: Route.LoaderArgs) {
// 서버 전용 API나 DB 직접 접근 가능 (클라이언트 번들에는 포함되지 않음)
const product = await fakeDb.getProduct(params.pid);
return product;
}
export default function Product({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>{loaderData.name}</h1>
<p>{loaderData.description}</p>
</div>
);
}4. 정적 데이터 로딩 (Static Data Pre-rendering)
빌드 시점에 데이터를 미리 가져와 HTML을 생성(Pre-render)할 때 사용합니다.
라우트 파일 (app/product.tsx):
export async function loader({ params }: Route.LoaderArgs) {
// 빌드 시점에 실행됨
return await getProductFromCSVFile(params.pid);
}설정 파일 (react-router.config.ts):
import type { Config } from "@react-router/dev/config";
export default {
async prerender() {
// 미리 렌더링할 URL 목록을 반환
let products = await readProductsFromCSVFile();
return products.map((p) => `/products/${p.id}`);
},
} satisfies Config;5. 두 로더 함께 사용하기 (loader + clientLoader)
서버 로더와 클라이언트 로더를 조합하여 하이브리드 방식으로 데이터를 구성할 수 있습니다.
export async function loader({ params }: Route.LoaderArgs) {
// 초기 서버 데이터
return { serverMessage: "Hello from Server" };
}
export async function clientLoader({ serverLoader, params }: Route.ClientLoaderArgs) {
// 1. 서버 로더 실행 결과를 가져옴
const serverData = await serverLoader();
// 2. 클라이언트에서 추가 데이터 호출
const clientRes = await fetch(`/api/products/${params.pid}`);
const clientData = await clientRes.json();
// 두 데이터 합치기
return { ...serverData, ...clientData };
}
// 하이드레이션 시점에 clientLoader 강제 실행 설정
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <div>초기화 중...</div>;
}💡 핵심 포인트 요약
loader: 서버 측 작업(DB 접근 등) 및 SSR에 최적화.clientLoader: 브라우저 전용 API 사용 및 클라이언트 측 탐색 최적화.HydrateFallback: 클라이언트 로딩 중 사용자 경험(UX) 개선을 위한 필수 요소.serverLoader():clientLoader안에서 서버 데이터를 호출할 때 사용하는 유용한 함수.
액션: Actions
1. 액션(Actions) 개요
액션은 데이터를 변경(Mutation)할 때 사용합니다. 액션이 완료되면 페이지의 모든 loader 데이터가 자동으로 **재검증(Revalidation)**되어, 별도의 코드 작성 없이도 UI와 서버 데이터를 동기화해 줍니다.
action: 서버에서만 실행되며, 서버 전용 API나 DB 접근이 가능합니다.clientAction: 브라우저에서 실행되며, 클라이언트 측 로직이나 외부 API 호출에 사용됩니다.
2. 클라이언트 액션 (clientAction)
브라우저에서 실행되며, 서버 액션과 함께 정의된 경우 클라이언트 액션이 우선권을 가집니다.
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { someApi } from "./api";
export async function clientAction({ request }: Route.ClientActionArgs) {
let formData = await request.formData();
let title = formData.get("title");
// 클라이언트 측 API 호출
let project = await someApi.updateProject({ title });
return project;
}
export default function Project({ actionData }: Route.ComponentProps) {
return (
<div>
<Form method="post">
<input type="text" name="title" />
<button type="submit">수정</button>
</Form>
{actionData && <p>{actionData.title} 업데이트 완료!</p>}
</div>
);
}3. 서버 액션 (action)
서버에서만 실행되며 클라이언트 번들에는 포함되지 않습니다. 보안이 중요한 작업에 적합합니다.
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { fakeDb } from "../db";
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
let title = formData.get("title") as string;
// DB 직접 업데이트
let project = await fakeDb.updateProject({ title });
return project;
}
export default function Project({ actionData }: Route.ComponentProps) {
return (
<Form method="post">
<input type="text" name="title" />
<button type="submit">저장</button>
</Form>
);
}4. 클라이언트 액션과 서버 액션 함께 사용하기
request 객체의 Body(Stream)는 단 한 번만 읽을 수 있습니다. clientAction에서 await request.formData()를 호출해 버리면 스트림이 소비되어, 이후 serverAction()이 서버로 보낼 데이터가 사라지게 됩니다.
이 문제를 해결하고 양쪽에서 모두 데이터를 쓰려면 다음과 같은 방법을 사용해야 합니다.
1. 해결 방법: request.clone() 사용
가장 정석적인 방법은 request 객체를 **복사(clone)**하여 하나는 클라이언트에서 소비하고, 원본(또는 복사본)은 serverAction에 넘겨주는 것입니다.
export async function clientAction({
request,
serverAction,
}: Route.ClientActionArgs) {
// 1. 요청을 복사합니다.
const clonedRequest = request.clone();
// 2. 복사본으로 클라이언트 로직 처리
const formData = await clonedRequest.formData();
const name = formData.get("name");
console.log("클라이언트에서 확인한 이름:", name);
// 3. 원본 request는 건드리지 않은 상태로 serverAction에 전달
// (React Router 내부적으로 이 request를 서버로 보냅니다)
const data = await serverAction();
return data;
}2. 왜 이렇게 해야 하나요?
Request 객체는 표준 Web API를 따릅니다. 이 객체의 본문(body)은 스트림 형태라서 한 번 읽기 시작하면 끝까지 소비되고 다시 되돌릴 수 없습니다.
- 잘못된 예:
await request.formData()호출 후serverAction()호출 → 서버는 비어있는 요청을 받게 됨. - 올바른 예:
request.clone()으로 복사본을 만들어 클라이언트에서 쓰고, 원본은 서버 전송용으로 보존.
3. 다른 대안: serverAction에 직접 넘기기
만약 formData를 이미 변수에 담았다면, serverAction을 호출할 때 수정된 데이터를 직접 보낼 수도 있습니다. (단, 이 경우 request 객체를 새로 구성해야 하므로 clone이 훨씬 간편합니다.)
Tip: 클라이언트에서 유효성 검사만 할 목적이라면
request.formData()를 쓰기보다는, 폼 데이터를 읽기 전에 체크하거나clone()을 생활화하는 것이 안전합니다.
요약
clientAction에서 formData를 쓰는 순간 request는 "이미 읽힘" 상태가 됩니다. 서버에서도 쓰고 싶다면 반드시 request.clone()을 먼저 호출하세요!
5. 액션을 호출하는 방법
A. <Form> 사용 (선언적 방식)
가장 표준적인 방법입니다. 제출 시 브라우저 히스토리에 새 기록이 추가됩니다.
import { Form } from "react-router";
function MyComponent() {
return (
<Form action="/projects/123" method="post">
<button type="submit">프로젝트 삭제</button>
</Form>
);
}B. useSubmit 사용 (명령적 방식)
타이머 종료나 특정 이벤트 발생 시 프로그래밍 방식으로 액션을 실행할 때 사용합니다.
import { useSubmit } from "react-router";
function Quiz() {
let submit = useSubmit();
// 예: 시간이 초과되었을 때 자동으로 제출
const onTimeout = () => {
submit(
{ quizTimedOut: true },
{ action: "/end-quiz", method: "post" }
);
};
}C. Fetcher 사용 (페이지 이동 없는 방식)
현재 페이지의 상태나 히스토리를 변경하지 않고 데이터를 전송하고 싶을 때 사용합니다. (예: 좋아요 버튼, 할 일 체크 등)
import { useFetcher } from "react-router";
function Task() {
let fetcher = useFetcher();
let isSaving = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action="/update-task/1">
<button type="submit">{isSaving ? "저장 중..." : "저장"}</button>
</fetcher.Form>
);
}💡 핵심 요약
- 자동 업데이트: 액션이 끝나면
loader가 다시 돌아 데이터를 최신화합니다. FormvsFetcher: 페이지 이동이 필요하면Form, 배경에서 처리하려면Fetcher를 선택하세요.- 데이터 접근:
request.formData()를 통해 전송된 데이터를 쉽게 꺼낼 수 있습니다. - 타입 지원:
actionData를 통해 액션의 결과값을 타입 안전하게 컴포넌트에서 쓸 수 있습니다.
6. Form Data 사용하기
A. GET 요청 시 Form 데이터 읽기
GET 요청은 Body가 없으므로 formData()를 호출하면 빈 FormData가 반환됩니다. 실제 데이터는 URL의 Query Parameter에 담겨 있습니다.
// GET 요청 시 데이터 확인 방법
export async function loader({ request }: Route.LoaderArgs) {
// 1. URLSearchParams로 파라미터 추출
const url = new URL(request.url);
const searchTerm = url.searchParams.get("q");
// 2. 또는 FormData를 생성해서 URL 기반으로 채우기
const formData = await FormData.from(url);
const searchTermFromForm = formData.get("q");
// ... 로딩 로직 ...
return { searchResults };
}B. POST/PUT 요청 시 Form 데이터 읽기
POST나 PUT 요청은 본문에 데이터를 담아 보내므로 formData()가 실제 데이터를 반환합니다.
export async function action({ request }: Route.ActionArgs) {
// POST 요청의 본문에서 데이터 읽기
const formData = await request.formData();
const name = formData.get("name"); // 문자열로 반환
const age = formData.get("age"); // 문자열로 반환 (숫자로 변환 필요)
// JSON 데이터 처리 (Content-Type: application/json)
if (request.headers.get("Content-Type") === "application/json") {
const jsonBody = await request.json();
return { ...jsonBody, processed: true };
}
// ... 처리 로직 ...
}7. FormData 다루기
FormData 객체에 데이터를 직접 추가하거나 수정하는 방법은 아주 간단합니다. 기본적으로 append나 set 메서드를 사용하면 됩니다.
상황별로 가장 자주 쓰이는 패턴들을 정리해 드릴게요.
1. FormData에 데이터 추가/수정하기
append(key, value): 동일한 키가 있어도 데이터를 추가합니다. (하나의 키에 여러 값을 가질 때 유용)set(key, value): 기존 키가 있다면 값을 덮어쓰고, 없으면 새로 생성합니다. (보통 이 방식을 가장 많이 씁니다)
const formData = new FormData();
// 데이터 추가
formData.append("name", "Gemini");
formData.append("tags", "AI");
formData.append("tags", "Adaptive"); // tags는 이제 ['AI', 'Adaptive'] 두 값을 가짐
// 데이터 수정 (덮어쓰기)
formData.set("name", "New Name"); // name은 이제 'New Name'이 됨2. clientAction 내에서 serverAction에 보낼 데이터 조작하기
앞서 말했듯이 request.clone()을 사용한 뒤, 클라이언트에서 데이터를 가공하여 서버로 넘기고 싶을 때는 다음과 같은 패턴을 사용합니다.
export async function clientAction({ request, serverAction }: Route.ClientActionArgs) {
// 1. 기존 요청에서 FormData 추출
const formData = await request.formData();
// 2. 직접 데이터 추가 (예: 클라이언트에서 계산된 값, 타임스탬프 등)
formData.set("clientTimestamp", new Date().toISOString());
formData.append("extraInfo", "added-in-client");
// 3. 서버로 보낼 때는 fetch 등을 통해 직접 제어하거나,
// serverAction에 변경된 formData를 전달해야 하는데
// React Router v7의 serverAction()은 기본적으로 원본 request를 사용합니다.
// 만약 수정한 formData를 서버로 보내야 한다면 아래와 같이 새로운 Request를 생성합니다.
const modifiedRequest = new Request(request, {
method: "POST",
body: formData,
});
// 4. 수정한 요청을 서버로 전달
const data = await serverAction(modifiedRequest);
return data;
}3. 주요 메서드 요약
| 메서드 | 설명 |
|---|---|
formData.append(name, value) | 지정된 이름으로 새로운 값을 추가합니다. |
formData.set(name, value) | 값을 덮어씁니다. 없으면 새로 만듭니다. |
formData.delete(name) | 특정 키와 연결된 모든 값을 삭제합니다. |
formData.get(name) | 특정 키의 첫 번째 값을 반환합니다. |
formData.getAll(name) | 특정 키에 연결된 모든 값을 배열로 반환합니다. |
💡 팁: 객체(Object)를 FormData로 한 번에 넣기
수동으로 하나씩 넣기 귀찮다면 반복문을 활용해 보세요.
const myData = { id: 1, role: "admin", status: "active" };
Object.entries(myData).forEach(([key, value]) => {
formData.append(key, value);
});주의사항: FormData의 값(Value)은 기본적으로 문자열(String) 또는 **블롭(Blob/File)**이어야 합니다. 숫자나 객체를 넣으면 자동으로 문자열화 되므로, 복잡한 객체는 JSON.stringify(obj)를 해서 넣는 것이 안전합니다.
8. 클라이언트 단에서 폼 데이터에 직접 값을 추가하기
페이지(컴포넌트) 단에서 서버로 데이터를 보낼 때 직접 값을 추가하고 싶다면, 크게 두 가지 방법이 있습니다. <form> 안에 숨겨진 필드를 사용하는 방법과, useSubmit 훅을 사용하여 자바스크립트로 직접 제어하는 방법입니다.
1. 가장 쉬운 방법: <input type="hidden"> 사용
HTML 표준 방식을 이용하는 것으로, 코드가 간결하고 JavaScript가 로드되지 않은 환경에서도 작동합니다.
export default function MyPage() {
return (
<form method="post">
{/* 사용자에게 보이지 않지만 formData에 포함됨 */}
<input type="hidden" name="timestamp" value={new Date().toISOString()} />
<input type="hidden" name="isAdmin" value="true" />
<input type="text" name="title" placeholder="제목" />
<button type="submit">저장</button>
</form>
);
}2. 프로그래밍 방식: useSubmit 훅 사용
버튼 클릭 시점에 동적으로 데이터를 계산해서 넣거나, 특정 이벤트(예: 체크박스 변경 시 바로 전송)에서 데이터를 추가하고 싶을 때 사용합니다.
import { useSubmit } from "react-router"; // 또는 "react-router-dom"
export default function MyPage() {
const submit = useSubmit();
const handleCustomSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// 1. 기존 폼 데이터 가져오기
const formData = new FormData(event.currentTarget);
// 2. 직접 데이터 추가
formData.set("customData", "이 데이터는 제출 직전에 추가되었습니다.");
formData.append("extraParam", 12345);
// 3. 제출 (이때 action 또는 clientAction이 호출됨)
submit(formData, { method: "post" });
};
return (
<form onSubmit={handleCustomSubmit}>
<input type="text" name="userName" />
<button type="submit">보내기</button>
</form>
);
}3. FormData 없이 객체(JSON)로 바로 보내기
React Router의 submit 함수는 FormData뿐만 아니라 일반 객체도 받을 수 있습니다. 이 경우 자동으로 FormData로 변환되어 전달됩니다.
const submit = useSubmit();
const handleClick = () => {
const myData = {
id: "admin-01",
role: "manager",
token: "abc-123"
};
// 객체를 두 번째 인자로 넘기면 내부적으로 FormData를 생성해줍니다.
submit(myData, { method: "post" });
};💡 요약: 어떤 것을 써야 할까?
- 고정된 값(예: 페이지 ID, 고정된 카테고리):
<input type="hidden">이 가장 깔끔합니다. - 동적인 값(예: 현재 선택된 위도/경도, 로컬 스토리지 데이터, 계산된 결과값):
useSubmit을 사용하여 제출 직전에formData.set()을 하는 것이 유리합니다.
만약 React Hook Form 같은 라이브러리를 쓰고 계신다면, 해당 라이브러리의 handleSubmit 안에서 submit(data, { method: "post" })를 호출하는 방식이 가장 일반적입니다.
9. 새로고침 후에도 actionData 유지하기(창별)
actionData는 본질적으로 POST 요청에 대한 일회성 응답입니다. 새로고침(GET 요청)을 하면 브라우저는 이전의 POST 응답을 버리고 다시 서버에 데이터를 요청하기 때문에 actionData는 날아가는 것이 정상적인 동작입니다.
이를 새로고침 후에도 유지하고 싶다면, 데이터를 **클라이언트 저장소(SessionStorage/LocalStorage)**에 기록하거나, **URL 상태(Query Parameter)**로 변환해야 합니다.
1. SessionStorage를 사용하는 방법 (가장 권장)
새로고침 시에도 유지되지만, 브라우저 탭을 닫으면 사라집니다. 가장 깔끔한 방식입니다.
import { useActionData, useSubmit } from "react-router";
import { useEffect, useState } from "react";
export default function MyPage() {
const actionData = useActionData();
const [persistedData, setPersistedData] = useState(null);
// 1. actionData가 들어오면 스토리지에 저장
useEffect(() => {
if (actionData) {
sessionStorage.setItem("my_action_result", JSON.stringify(actionData));
setPersistedData(actionData);
}
}, [actionData]);
// 2. 컴포넌트 마운트 시(새로고침 포함) 스토리지에서 복구
useEffect(() => {
const saved = sessionStorage.getItem("my_action_result");
if (saved) {
setPersistedData(JSON.parse(saved));
}
}, []);
return (
<div>
{persistedData && <div>유지되는 데이터: {persistedData.message}</div>}
<button onClick={() => {
sessionStorage.removeItem("my_action_result");
setPersistedData(null);
}}>데이터 지우기</button>
</div>
);
}2. URL Query Parameter로 변환 (PRG 패턴)
Post-Redirect-Get 패턴입니다. action에서 결과를 리턴하는 대신, 결과 데이터를 URL에 담아 redirect 시킵니다. 데이터가 공개되어도 상관없고, 주소를 공유해도 같은 화면이 나와야 할 때 씁니다.
// action.ts
export async function action({ request }) {
// 처리 로직...
const result = "success";
// 데이터를 쿼리 파라미터에 담아 해당 페이지로 리다이렉트
return redirect(`/my-page?result=${result}`);
}
// component.tsx
export default function MyPage() {
const [searchParams] = useSearchParams();
const result = searchParams.get("result"); // 새로고침해도 URL에 남아있으므로 유지됨
return <div>결과: {result}</div>;
}3. v7 clientAction에서 로컬 스토리지 제어
v7의 clientAction을 사용하면 폼 제출 시점에 아예 데이터를 가로채서 저장소에 넣고 시작할 수 있습니다.
export async function clientAction({ serverAction }: Route.ClientActionArgs) {
const data = await serverAction();
if (data) {
// 서버 응답을 받자마자 로컬스토리지에 백업
localStorage.setItem("last_action_backup", JSON.stringify(data));
}
return data;
}⚠️ 주의사항: "일회성" 데이터의 늪
데이터를 강제로 유지하게 만들면 다음과 같은 부작용이 생길 수 있습니다:
- 중복 알림: 성공 메시지를 스토리지에 저장해두면, 사용자가 새로고침할 때마다 "저장되었습니다!" 토스트 메시지가 계속 뜰 수 있습니다.
- 데이터 오염: 이전 작업의 결과값이 다음 작업에 영향을 줄 수 있습니다.
해결책: 데이터를 사용한 직후 또는 특정 시점에 storage.removeItem()을 호출하여 수동으로 청소해주는 로직을 반드시 포함하세요.
요약
- 세션 내 유지:
useEffect+sessionStorage사용. - 주소 공유/영구 유지:
redirect+URLSearchParams사용. - 보안이 중요한 데이터: 새로고침 시 날아가게 두는 것이 정석이며, 필요하다면
loader에서 서버 데이터를 다시 불러와 동기화하는 방식을 고려하세요.
10. 서버 세션과 쿠키를 이용한 상태 관리
서버 세션과 쿠키를 이용한 상태 관리는 클라이언트(브라우저)에 데이터를 저장하는 대신, 서버가 보관 중인 정보에 접근할 수 있는 **'열쇠(Session ID)'**를 쿠키에 담아 주고받는 방식입니다.
React Router v7(Remix 기반)은 이 과정을 더 안전하고 구조적으로 처리할 수 있는 전용 API를 제공합니다.
1. 일반적인 방식 (전통적 Node.js/Express 등)
일반적인 웹 서버 환경에서는 세션 미들웨어를 사용하여 자동으로 처리합니다.
- 로그인 성공: 서버 메모리나 DB(Redis 등)에 세션 객체를 생성하고 고유한
Session ID를 만듭니다. - 쿠키 발행:
Set-Cookie헤더를 통해 브라우저에Session ID를 보냅니다. - 상태 유지: 이후 모든 요청마다 브라우저는 쿠키에 담긴
Session ID를 서버로 보냅니다. - 검증: 서버는 해당 ID로 세션 저장소에서 사용자 정보를 찾아
req.session에 할당합니다.
2. React Router v7 방식 (Session Storage API)
React Router v7은 SessionStorage 인터페이스를 제공하여 쿠키의 생성, 읽기, 파기를 표준화된 방식으로 처리합니다.
A. 세션 스토리지 설정 (app/sessions.ts)
서버 측에서 쿠키를 어떻게 구울지 정의합니다.
import { createCookieSessionStorage } from "react-router";
// 쿠키 설정 정의
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
cookie: {
name: "__session", // 쿠키 이름
httpOnly: true, // JS로 접근 불가 (보안 필수)
maxAge: 60 * 60 * 24, // 1일 유지
path: "/", // 모든 경로에서 유효
sameSite: "lax", // CSRF 보호
secrets: ["s3cret1"], // 쿠키 암호화 키
secure: true, // HTTPS에서만 전송
},
});B. Action에서 세션 저장 (로그인 시)
로그인 성공 후 데이터를 세션에 넣고 쿠키를 헤더에 실어 보냅니다.
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
// ... 로그인 유효성 검사 로직 ...
// 1. 기존 세션 가져오기 (없으면 새로 생성)
const session = await getSession(request.headers.get("Cookie"));
// 2. 세션에 데이터 저장
session.set("userId", "student_123");
session.set("userName", "홍길동");
// 3. 리다이렉트 시점에 쿠키를 헤더에 포함 (commitSession)
return redirect("/dashboard", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}C. Loader에서 세션 읽기 (상태 확인)
페이지에 접속할 때마다 쿠키를 읽어 로그인 상태를 확인합니다. 새 창을 열어도 같은 브라우저라면 이 정보가 공유됩니다.
export async function loader({ request }: Route.LoaderArgs) {
// 1. 쿠키에서 세션 읽기
const session = await getSession(request.headers.get("Cookie"));
// 2. 세션 데이터 확인
if (!session.has("userId")) {
return redirect("/login"); // 로그인 안 되어 있으면 튕겨내기
}
return {
userId: session.get("userId"),
userName: session.get("userName")
};
}3. 왜 이 방식이 '새 창' 문제를 해결하나요?
| 특징 | localStorage / sessionStorage | 서버 세션 쿠키 |
|---|---|---|
| 저장 위치 | 브라우저 저장소 (JS로 제어) | 브라우저 쿠키 저장소 (HTTP 헤더로 제어) |
| 새 창 공유 | 탭 간 공유 안 됨 (sessionStorage 기준) | 동일 브라우저 내 모든 탭에서 자동 공유 |
| 보안성 | XSS 공격에 취약 (JS가 읽을 수 있음) | httpOnly 설정 시 JS가 접근 불가하여 안전 |
| 새로고침 | 유지됨 | 유지됨 |
💡 요약하자면
action에서commitSession으로 쿠키를 굽는다.- 브라우저는 이 쿠키를 탭이나 창에 상관없이 도메인별로 저장한다.
- 어느 창에서든
loader가 실행될 때마다getSession으로 서버가 이 쿠키를 읽어 상태를 판별한다.
이 방식을 사용하면 actionData라는 휘발성 응답값에 의존하지 않고, "로그인된 상태" 그 자체를 서버와 클라이언트가 안정적으로 공유하게 됩니다. 현재 겪고 계신 actionData 유실 문제나 새 창 데이터 호환 문제를 해결하는 가장 표준적인 방법입니다.
11. 서브 도메인 간 쿠키 공유
서브 도메인 간(aaa.example.com, bbb.example.com)에 쿠키를 공유하여 세션을 유지하려면 쿠키의 Domain 속성을 설정해야 합니다. 기본적으로 쿠키는 생성된 특정 서브 도메인에만 한정되지만, 이를 상위 도메인으로 확장하면 모든 서브 도메인이 동일한 쿠키를 읽고 쓸 수 있습니다.
1. 핵심 원리: 와일드카드 도메인 설정
쿠키를 설정할 때 도메인을 .example.com (또는 example.com)으로 지정하면 브라우저는 이를 해당 도메인과 모든 하위 서브 도메인에서 유효한 것으로 간주합니다.
- 설정 전:
domain미지정 시 쿠키는student.example.com에서만 유효. - 설정 후:
domain: "example.com"지정 시teacher.example.com,admin.example.com모두에서 유효.
2. React Router v7 설정 방법
createCookieSessionStorage 설정 시 cookie 옵션 안에 domain 속성을 추가합니다.
// app/sessions.ts
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
cookie: {
name: "__session",
// 핵심 설정: 메인 도메인을 지정합니다.
// 로컬 개발 시에는 설정하지 않거나 'localhost'를 사용해야 합니다.
domain: process.env.NODE_ENV === "production" ? "example.com" : undefined,
httpOnly: true,
maxAge: 60 * 60 * 24,
path: "/", // 도메인 내 모든 경로에서 접근 가능
sameSite: "lax",
secrets: ["s3cret1"],
secure: true,
},
});3. 기술적 제약 사항 (중요)
쿠키 공유를 구현할 때 반드시 알아야 할 제약 사항들입니다.
| 항목 | 내용 |
|---|---|
| 상위 도메인 일치 | example.com과 other.com 사이에는 쿠키 공유가 절대 불가능합니다. 오직 서브 도메인끼리만 가능합니다. |
| SameSite 설정 | 서브 도메인 간 이동 시 쿠키가 누락되지 않도록 sameSite: "lax" 혹은 "none"(Secure 필수)을 권장합니다. |
| 점(.) 표기법 | 과거 브라우저에서는 .example.com처럼 앞에 점을 찍어야 했으나, 최신 표준은 example.com만 써도 하위 도메인을 포함합니다. |
4. 로컬 개발 환경에서의 테스트 (Tips)
로컬(localhost)에서는 서브 도메인 테스트가 어렵습니다. 이럴 때는 운영체제의 hosts 파일을 수정하여 테스트 환경을 구성하세요.
- hosts 파일 수정 (
/etc/hosts또는C:\Windows\System32\drivers\etc\hosts)127.0.0.1 example.dev 127.0.0.1 student.example.dev 127.0.0.1 teacher.example.dev - 설정 적용
쿠키의
domain을example.dev로 설정하면 로컬에서도 서브 도메인 간 세션 공유를 확인할 수 있습니다.
5. 보안 주의사항: 세션 고정 공격(Session Fixation)
모든 서브 도메인이 쿠키를 공유한다는 것은, 보안이 취약한 하나의 서브 도메인이 전체 도메인의 세션 쿠키를 탈취하거나 조작할 수 있음을 의미합니다.
- 민감한 정보가 있는 서브 도메인은 별도의 쿠키 이름을 사용하거나,
- 공유 세션에는 최소한의 식별 정보만 담고 실제 권한 확인은 서버에서 매번 수행하는 것이 안전합니다.
요약
서브 도메인 간 호환을 위해서는 cookie.domain 속성을 최상위 도메인(example.com)으로 설정하는 것이 정답입니다. 이렇게 하면 어느 서브 도메인에서 action을 일으켜 쿠키를 구워도, 다른 서브 도메인의 loader가 그 쿠키를 읽어들일 수 있습니다.
네비게이션: Navigating
1. 탐색(Navigating) 개요
사용자가 앱 내에서 페이지를 이동하는 방법은 크게 5가지입니다:
<NavLink>: 현재 활성 상태(Active)를 표시해야 하는 링크<Link>: 단순 페이지 이동 링크<Form>: 검색어 등을 URL 파라미터로 전달하며 이동redirect: 로더나 액션 내부에서 서버 측 이동useNavigate: 특정 이벤트 발생 시 프로그래밍 방식으로 이동
2. NavLink (상태가 있는 링크)
메뉴바나 탭처럼 "현재 어디에 있는지" 시각적으로 보여줘야 할 때 사용합니다.
import { NavLink } from "react-router";
export function Navbar() {
return (
<nav>
{/* end 속성은 경로가 정확히 일치할 때만 active 클래스를 붙입니다 */}
<NavLink to="/" end>Home</NavLink>
<NavLink to="/concerts">Concerts</NavLink>
</nav>
);
}자동 생성되는 CSS 클래스:
.active: 현재 경로와 일치할 때.pending: 이동 중(데이터 로딩 중)일 때.transitioning: 뷰 전환 애니메이션이 진행 중일 때
함수형 스타일링:
<NavLink
to="/messages"
style={({ isActive }) => ({
fontWeight: isActive ? "bold" : "normal",
color: isActive ? "red" : "black",
})}
>
Messages
</NavLink>3. Link (단순 링크)
활성 상태 스타일이 필요 없는 일반적인 텍스트 링크에 사용합니다.
import { Link } from "react-router";
export function Footer() {
return (
<p>
계정이 없으신가요? <Link to="/signup">회원가입</Link>
</p>
);
}4. Form (검색 및 파라미터 이동)
입력값을 URLSearchParams로 변환하여 페이지를 이동시킵니다.
<Form action="/search">
<input type="text" name="q" placeholder="검색어 입력..." />
<button type="submit">검색</button>
</Form>- 사용자가 "react"를 입력하면
/search?q=react로 이동합니다.
5. redirect (서버 측 이동)
loader나 action 함수 안에서 특정 조건(로그인 여부 등)에 따라 사용자를 다른 페이지로 보낼 때 사용합니다.
import { redirect } from "react-router";
// 로더에서 권한 체크 후 리다이렉트
export async function loader({ request }) {
const user = await getUser(request);
if (!user) return redirect("/login");
return { user };
}
// 액션에서 데이터 생성 후 상세 페이지로 이동
export async function action({ request }) {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
}6. useNavigate (프로그래밍 방식 이동)
사용자의 직접적인 클릭 없이 코드로 페이지를 이동시켜야 할 때 사용합니다. (최후의 수단으로 권장)
import { useNavigate } from "react-router";
export function InactivityLogout() {
let navigate = useNavigate();
// 예: 10분간 활동이 없으면 자동 로그아웃 페이지로 이동
onInactivity(() => {
navigate("/logout");
});
return null;
}💡 핵심 요약
- 메뉴/네비게이션:
NavLink를 사용하여 현재 위치를 표시하세요. - 일반 본문 링크:
Link를 사용하세요. - 검색창:
Form의 GET 방식(기본값)을 활용하세요. - 로그인 체크/저장 후 이동:
redirect를 사용하세요. - 특수 상황(타이머 등):
useNavigate를 활용하세요.
대기 중 UI: Pending UI
1. 대기 중 UI(Pending UI) 개요
사용자가 새로운 경로로 이동하거나 데이터를 제출할 때, UI는 즉각적으로 반응해야 합니다. 로더(Loader)가 데이터를 가져오는 동안이나 액션(Action)이 처리되는 동안 사용자에게 "작업 중"임을 알려주는 상태를 의미합니다.
2. 전역 대기 상태 (Global Pending Navigation)
새로운 URL로 이동할 때 다음 페이지의 로더가 완료될 때까지 기다립니다. 이때 useNavigation 훅을 사용하여 앱 전체의 로딩 상태를 표시할 수 있습니다.
import { useNavigation, Outlet } from "react-router";
export default function Root() {
const navigation = useNavigation();
// 현재 이동 중인 위치(location)가 있으면 true
const isNavigating = Boolean(navigation.location);
return (
<html>
<body>
{/* 페이지 상단에 전역 스피너 표시 */}
{isNavigating && <GlobalSpinner />}
<Outlet />
</body>
</html>
);
}3. 지역 대기 상태 (Local Pending Navigation)
전체 화면이 아닌, 클릭한 링크 자체에 로딩 상태를 표시할 수 있습니다. NavLink의 children, className, style 속성에서 isPending 값을 받아 처리합니다.
import { NavLink } from "react-router";
function Navbar() {
return (
<nav>
<NavLink to="/home">
{({ isPending }) => (
<span>홈 {isPending && <Spinner />}</span>
)}
</NavLink>
<NavLink
to="/about"
style={({ isPending }) => ({
color: isPending ? "gray" : "black",
})}
>
소개
</NavLink>
</nav>
);
}4. 폼 제출 대기 상태 (Pending Form Submission)
폼을 제출할 때 버튼의 텍스트를 "저장 중..."으로 바꾸는 등의 처리가 필요합니다.
A. Fetcher 사용 (권장)
useFetcher는 페이지 이동 없이 데이터를 주고받으므로 독립적인 상태 관리에 최적화되어 있습니다.
import { useFetcher } from "react-router";
function NewProjectForm() {
const fetcher = useFetcher();
// fetcher.state가 "idle"이 아니면 제출 중
const isSubmitting = fetcher.state !== "idle";
return (
<fetcher.Form method="post">
<input type="text" name="title" />
<button type="submit">
{isSubmitting ? "제출 중..." : "제출"}
</button>
</fetcher.Form>
);
}B. 일반 Form 사용
페이지 이동이 발생하는 일반 폼의 경우 useNavigation을 사용합니다.
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/projects/new";5. 낙관적 UI (Optimistic UI)
서버의 응답을 기다리지 않고, 사용자가 입력한 데이터를 바탕으로 성공할 것이라 가정하여 UI를 즉시 업데이트하는 방식입니다. 체감 속도를 극적으로 높여줍니다.
function Task({ task }) {
const fetcher = useFetcher();
// 기본값은 서버에서 온 데이터
let isComplete = task.status === "complete";
// 만약 현재 폼이 제출 중이라면, 폼에 담긴 데이터를 우선 UI에 반영 (낙관적 업데이트)
if (fetcher.formData) {
isComplete = fetcher.formData.get("status") === "complete";
}
return (
<div>
<span>{task.title} (상태: {isComplete ? "완료" : "진행 중"})</span>
<fetcher.Form method="post">
<button name="status" value={isComplete ? "incomplete" : "complete"}>
{isComplete ? "취소하기" : "완료하기"}
</button>
</fetcher.Form>
</div>
);
}💡 핵심 요약
useNavigation: 앱 전체의 이동 상태나 폼 제출 상태를 알 수 있습니다.NavLink: 개별 메뉴 링크의 로딩 상태를 시각화합니다.useFetcher: 페이지 전환 없는 데이터 변경 작업의 상태 관리에 유리합니다.- 낙관적 UI:
fetcher.formData를 읽어 서버 응답 전에 화면을 미리 바꿔줌으로써 최고의 UX를 제공합니다.