Next.js redirects 설정과 URL 이동 처리

··설명 약 6분·작성: 해비·블로그 소개

Next.js 고정 redirects와 서버 redirect 함수는 언제 사용하나요?

옛 URL을 새 주소로 보내는 고정 규칙은 next.config의 redirects로, 서버 처리에 따른 이동은 redirect()로 구성합니다. 자동 추적을 끈 요청으로 상태 코드와 Location을 먼저 보면 뒤이어 발생한 로그인 이동과 구분할 수 있습니다.

적용 조건·범위: React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

본문 근거: redirects · redirect

독립 실습: 옛 URL의 상태 코드와 목적지 확인하기

React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

별도 빈 프로젝트의 next.config.ts에 적용하고 개발 서버를 재시작합니다. curl.exe -I http://localhost:3000/old-practice로 자동 추적 전 응답을 봅니다.

next.config.ts 전체 코드

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
  async redirects() {
    return [{ source: '/old-practice', destination: '/practice', permanent: false }];
  },
};
export default nextConfig;

app/practice/page.tsx 전체 코드

export default function Page() {
  return <h1>새 연습 주소</h1>;
}

기대 결과: 임시 redirect는 307과 /practice 목적지를 반환하며 브라우저 주소도 바뀝니다.

자가 확인: permanent: true로 바꾸면 308이 되는지 확인하되 기존 운영 URL에 실험을 적용하지 않습니다.

next.config redirect

import type {
  NextConfig,
} from 'next';

const nextConfig: NextConfig = {
  async redirects() {
    return [
      {
        source: '/todo',
        destination: '/tasks',
        permanent: true,
      },
    ];
  },
};

export default nextConfig;

서버 로직에서 redirect

import {
  redirect,
} from 'next/navigation';

export async function saveTask() {
  // 저장 완료
  redirect('/tasks');
}

rewrite와 다릅니다

redirect
브라우저 주소도 변경

rewrite
주소는 유지하고 내부 처리 대상만 변경

자주 발생하는 오류

  • rewrite와 redirect 혼동
  • 영구 redirect를 테스트 없이 적용
  • source/destination loop
  • 동적 segment 파라미터 매핑 오류
선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.

next.config.ts의 redirects에서 /todo를 /tasks로 308 이동시킵니다. /tasks는 인증 페이지이므로 비로그인 방문자는 이후 로그인으로 이동할 수 있습니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js Image remotePatterns 오류 해결의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: next.config.ts, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

독자 질문·직접 확인

질문: 옛 /todo 링크와 인증 이동은 어떤 순서로 확인하나요?

경로·데이터 결과: 개발 서버에서 리디렉션 자동 추적을 끈 HTTP 요청으로 /todo의 Location: /tasks와 308 상태를 확인합니다. 브라우저 비로그인 상태에서는 그 뒤 Proxy가 /tasks를 /login으로 옮길 수 있습니다. 로그인 상태에서는 최종 /tasks를 확인하세요.

오류 점검: /todo와 /tasks가 서로 가리키는 loop, destination 오타, 브라우저의 영구 redirect 캐시를 점검합니다. 이 체크포인트에는 별도의 Server Action redirect 변경이 없으며 config 규칙만 추가됩니다.

다음 학습: 30회차에서 페이지 이동과 데이터 갱신을 서로 다른 캐시 계층으로 분석합니다.

빌드·서버·브라우저 실행 검증은 수행하지 않아 미확인입니다.

완료 코드 ZIP 다운로드 — next-step-29-redirects

이전 체크포인트: next-step-28-image

이번 회차의 변경

next.config.ts의 redirects에서 /todo를 /tasks로 308 이동시킵니다. /tasks는 인증 페이지이므로 비로그인 방문자는 이후 로그인으로 이동할 수 있습니다. 경로 이동과 인증 이동은 다른 단계입니다.

변경·추가 파일:

  • next.config.ts
전체 파일·코드 보기

실행과 검증

Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.

npm install
npm run dev
npm run build
npm run start

MSW: 개발 시에만 NEXT_PUBLIC_API_MOCKING을 enabled로 설정합니다. npx msw init public/ --save로 worker를 만들 수 있습니다. Mock 데이터와 서버 데이터는 별개이며 17편부터 기본 흐름은 실제 Route Handler입니다.

저장소는 단일 Node 프로세스 메모리입니다. 서버 재시작·프로세스 분리·서버리스에서는 데이터 유지가 보장되지 않습니다. 운영 서비스는 DB 및 트랜잭션 저장소로 교체해야 합니다.

인증: npm run setup-auth로 로컬 학습 계정을 설정한 뒤 서버를 재시작합니다. 비밀번호 입력은 터미널에 보이므로 학습용 값을 사용하세요. 실제 운영 계정 시스템이 아니며 가입·복구·시도 제한 기능은 포함하지 않습니다. .env.local은 공유하지 마세요. Auth.js 5 beta 버전을 고정했습니다.

개인 작업·설정은 로그인 필수입니다. Proxy뿐 아니라 데이터 조회와 Server Action/API가 세션을 검사하고 소유자별로 접근합니다.

캐시 실습: 별도 터미널 npm run cache-api 실행 → /cache-lab 접속 → 공개 API에 POST하여 버전 증가 → 재검증 버튼과 다음 요청에서 갱신 확인. 개인 작업은 공유 Data Cache에 넣지 않습니다. Cache Components는 끈 구성입니다.

전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.

완료 체크포인트: next-step-29-redirects

실습 프로젝트 전체 코드

.gitignore

node_modules
.next
.env*
!.env.example
*.tsbuildinfo

.env.example

AUTH_SECRET=
DEMO_EMAIL=
DEMO_PASSWORD_SALT=
DEMO_PASSWORD_HASH=
SITE_URL=
NEXT_PUBLIC_API_MOCKING=

app/(app)/@modal/(.)tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import TaskActions from "@/app/ui/task-actions";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <Modal>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
      <TaskActions id={task.id} completed={task.completed} />
    </Modal>
  );
}

app/(app)/@modal/[…catchAll]/page.tsx

export default function ResetModal() {
  return null;
}

app/(app)/@modal/default.tsx

export default function Default() {
  return null;
}

app/(app)/layout.tsx

import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
import { requireUser } from "@/app/lib/session";
export const metadata = { robots: { index: false, follow: false } };
export default async function AppLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal: ReactNode;
}) {
  await requireUser();
  return (
    <>
      <SiteHeader />
      {children}
      {modal}
    </>
  );
}

app/(app)/settings/page.tsx

import ThemeSetting from "@/app/ui/theme-setting";
export default function Settings() {
  return (
    <main>
      <h1>설정</h1>
      <ThemeSetting />
    </main>
  );
}

app/(app)/tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import TaskActions from "@/app/ui/task-actions";
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  return {
    title: task?.title || "작업 없음",
    robots: { index: false, follow: false },
  };
}
export default async function TaskDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <main>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
      <TaskActions id={task.id} completed={task.completed} />
      <Link href="/tasks">목록</Link>
    </main>
  );
}

app/(app)/tasks/error.tsx

"use client";
export default function ErrorPage({
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <main>
      <h1>작업을 불러오지 못했습니다.</h1>
      <button onClick={reset}>다시 시도</button>
    </main>
  );
}

app/(app)/tasks/layout.tsx

import { cookies } from "next/headers";
import type { ReactNode } from "react";
export default async function TasksLayout({
  children,
}: {
  children: ReactNode;
}) {
  const cookieStore = await cookies();
  const density =
    cookieStore.get("taskflow-density")?.value === "compact"
      ? "compact"
      : "normal";
  return <section data-density={density}>{children}</section>;
}

app/(app)/tasks/loading.tsx

export default function Loading() {
  return (
    <main>
      <p>작업을 불러오는 중...</p>
    </main>
  );
}

app/(app)/tasks/not-found.tsx

export default function NotFound() {
  return (
    <main>
      <h1>작업을 찾을 수 없습니다.</h1>
    </main>
  );
}

app/(app)/tasks/page.tsx

export const dynamic = "force-dynamic";
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
import { Suspense } from "react";
import TaskStats from "@/app/ui/task-stats";
import ApiDemo from "@/app/ui/api-demo";
import MswProvider from "@/app/ui/msw-provider";
import ApiStatus from "@/app/ui/api-status";
import TaskForm from "@/app/ui/task-form";
import {
  QueryClient,
  dehydrate,
  HydrationBoundary,
} from "@tanstack/react-query";
import TaskListClient from "@/app/ui/task-list-client";
import { requireUser } from "@/app/lib/session";
export default async function TasksPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string | string[]; status?: string | string[] }>;
}) {
  const tasks = await getTasks();
  const params = await searchParams;
  const q = typeof params.q === "string" ? params.q : "";
  const status = typeof params.status === "string" ? params.status : "all";
  const user = await requireUser();
  const client = new QueryClient();
  await client.prefetchQuery({
    queryKey: ["tasks", user.id],
    queryFn: () => Promise.resolve(tasks),
    staleTime: 60000,
  });
  return (
    <main>
      <h1>작업 목록</h1>
      <Suspense fallback={<p>검색 준비 중...</p>}>
        <TaskSearch />
      </Suspense>
      <TaskForm />
      <HydrationBoundary state={dehydrate(client)}>
        <TaskListClient ownerId={user.id!} q={q} status={status} />
      </HydrationBoundary>
      <Suspense fallback={<p>통계 로딩 중...</p>}>
        <TaskStats />
      </Suspense>
      <MswProvider>
        <ApiDemo />
        <ApiStatus />
      </MswProvider>
      <p>
        <Link href="/cache-lab">공개 데이터 캐시 실험</Link>
      </p>
    </main>
  );
}

app/(app)/template.tsx

import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

app/(public)/cache-lab/page.tsx

import { revalidateTag } from "next/cache";
export const dynamic = "force-dynamic";
async function refresh() {
  "use server";
  revalidateTag("public-stats", "max");
}
export default async function CacheLab() {
  let data: { revision: number; label: string } | null = null;
  try {
    const response = await fetch("http://127.0.0.1:4100/public-stats", {
      next: { revalidate: 60, tags: ["public-stats"] },
    });
    if (response.ok) data = await response.json();
  } catch {}
  return (
    <main>
      <h1>공개 데이터 캐시 실험</h1>
      <p>개인 작업은 캐시하지 않고, 별도 공개 통계만 60초 캐시합니다.</p>
      {data ? (
        <p>공개 통계 버전: {data.revision}</p>
      ) : (
        <p>별도 터미널에서 npm run cache-api를 실행하세요.</p>
      )}
      <form action={refresh}>
        <button>공개 통계 재검증 요청</button>
      </form>
      <p>
        max 프로필은 stale-while-revalidate이므로 다음 요청에서 이전 값이 잠깐
        보일 수 있습니다.
      </p>
    </main>
  );
}

app/(public)/page.tsx

import ImageExample from "@/app/ui/image-example";
import Link from "next/link";
export default function HomePage() {
  return (
    <main>
      <h1>TaskFlow Next.js</h1>
      <p>App Router 기반 작업 관리 서비스입니다.</p>
      <Link href="/tasks">작업 목록 보기</Link>
      <ImageExample />
    </main>
  );
}

app/_components/SiteHeader.tsx

import Link from "next/link";
import LogoutButton from "@/app/ui/logout-button";
export default function SiteHeader() {
  return (
    <header>
      <nav aria-label="주 메뉴">
        <Link href="/">홈</Link>
        <Link href="/tasks">작업</Link>
        <Link href="/settings">설정</Link>
        <Link href="/login">로그인</Link>
        <LogoutButton />
      </nav>
    </header>
  );
}

app/api/auth/[…nextauth]/route.ts

import { handlers } from "@/auth";
export const { GET, POST } = handlers;

app/api/tasks/route.ts

import { revalidatePath } from "next/cache";
import { listTasks, addTask } from "@/app/lib/store";
import { auth } from "@/auth";
export async function GET() {
  const session = await auth();
  if (!session?.user?.id)
    return Response.json({ message: "로그인이 필요합니다." }, { status: 401 });
  const owner = session.user.id;
  return Response.json(listTasks(owner), {
    headers: { "Cache-Control": "private, no-store" },
  });
}
export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user?.id)
    return Response.json({ message: "로그인이 필요합니다." }, { status: 401 });
  const owner = session.user.id;
  const origin = request.headers.get("origin");
  if (origin && origin !== new URL(request.url).origin)
    return Response.json(
      { message: "허용되지 않은 출처입니다." },
      { status: 403 },
    );
  try {
    const body: unknown = await request.json();
    const title =
      typeof body === "object" && body !== null && "title" in body
        ? body.title
        : undefined;
    const task = addTask(owner, title);
    revalidatePath("/tasks");
    return Response.json(task, { status: 201 });
  } catch {
    return Response.json(
      { message: "올바른 JSON과 2~100자 제목이 필요합니다." },
      { status: 400 },
    );
  }
}

app/globals.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #171717;
  background: #fafafa;
  font-family: Arial, sans-serif;
  line-height: 1.6;
}
header,
main {
  width: min(880px, calc(100% - 32px));
  margin: 24px auto;
}
nav {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
}
a {
  color: inherit;
  text-underline-offset: 3px;
}
button,
input,
select {
  min-height: 44px;
  padding: 8px 12px;
  font: inherit;
  border: 1px solid #aaa;
  background: #fff;
  color: #171717;
  max-width: 100%;
}
button {
  cursor: pointer;
}
button:disabled {
  opacity: 0.6;
  cursor: wait;
}
input {
  display: block;
  width: 100%;
}
label {
  display: block;
}
form,
section,
aside {
  margin: 20px 0;
}
ul {
  padding: 0;
  list-style: none;
}
li {
  padding: 12px;
  margin: 12px 0;
  border: 1px solid #ccc;
  overflow-wrap: anywhere;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.controls form {
  margin: 0;
}
.modal {
  width: min(640px, calc(100% - 32px));
  border: 1px solid #777;
  padding: 24px;
  background: #fff;
  color: #171717;
}
.modal::backdrop {
  background: rgb(0 0 0 / 55%);
}
:focus-visible {
  outline: 3px solid #555;
  outline-offset: 3px;
}
[data-theme="dark"] {
  background: #202020;
  color: #fafafa;
  padding: 16px;
}
[data-density="compact"] li {
  padding: 8px;
  margin: 8px 0;
}
img {
  max-width: 100%;
  height: auto;
}

app/layout.tsx

import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
import Providers from "./providers";
export const metadata: Metadata = {
  title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
  description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
  metadataBase: new URL(process.env.SITE_URL || "http://localhost:3000"),
  openGraph: {
    title: "TaskFlow",
    description: "Next.js 학습 프로젝트",
    images: ["/opengraph-image"],
  },
};
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

app/lib/actions.ts

"use server";
import { requireUser } from "./session";
import { addTask, changeTask } from "./store";
import { revalidatePath } from "next/cache";
export async function createTask(formData: FormData) {
  const user = await requireUser();
  try {
    addTask(user.id!, formData.get("title"));
  } catch {
    return { ok: false, message: "제목은 2~100자로 입력해 주세요." };
  }
  revalidatePath("/tasks");
  return { ok: true, message: "작업을 추가했습니다." };
}
export async function updateTask(id: string, operation: "toggle" | "delete") {
  const user = await requireUser();
  if (operation !== "toggle" && operation !== "delete")
    throw new Error("잘못된 작업입니다.");
  changeTask(user.id!, id, operation);
  revalidatePath("/tasks");
}

app/lib/axios-client.ts

import axios from "axios";
export const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "",
  timeout: 5000,
});

app/lib/data.ts

import "server-only";
import { requireUser } from "./session";
import { listTasks } from "./store";
export async function getTasks() {
  const user = await requireUser();
  return listTasks(user.id!);
}
export async function getTask(id: string) {
  return (await getTasks()).find((t) => t.id === id);
}
export async function getTaskStats() {
  const tasks = await getTasks();
  return {
    total: tasks.length,
    completed: tasks.filter((t) => t.completed).length,
  };
}

app/lib/session.ts

import "server-only";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export async function requireUser() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");
  return session.user;
}

app/lib/store.ts

import "server-only";
import type { Task } from "./types";
const globalStore = globalThis as typeof globalThis & {
  taskflowStore?: Map<string, Task[]>;
};
const store: Map<string, Task[]> = (globalStore.taskflowStore ??= new Map<
  string,
  Task[]
>());
export function listTasks(ownerId: string) {
  if (!store.has(ownerId))
    store.set(ownerId, [
      {
        id: "task-1",
        title: "App Router 공부",
        description: "서버와 브라우저의 역할을 구분합니다.",
        completed: false,
        ownerId,
      },
      {
        id: "task-2",
        title: "TaskFlow 만들기",
        description: "누적 실습을 완성합니다.",
        completed: true,
        ownerId,
      },
    ]);
  return store.get(ownerId)!.map((task) => ({ ...task }));
}
export function validateTitle(value: unknown) {
  if (
    typeof value !== "string" ||
    value.trim().length < 2 ||
    value.trim().length > 100
  )
    throw new Error("제목은 2~100자로 입력해 주세요.");
  return value.trim();
}
export function addTask(ownerId: string, value: unknown) {
  const title = validateTitle(value);
  const task: Task = {
    id: crypto.randomUUID(),
    title,
    description: "학습 중 추가한 작업",
    completed: false,
    ownerId,
  };
  store.set(ownerId, [task, ...listTasks(ownerId)]);
  return task;
}
export function changeTask(
  ownerId: string,
  id: string,
  operation: "toggle" | "delete",
) {
  const tasks = listTasks(ownerId);
  const task = tasks.find((t) => t.id === id && t.ownerId === ownerId);
  if (!task) throw new Error("작업이 없거나 권한이 없습니다.");
  store.set(
    ownerId,
    operation === "delete"
      ? tasks.filter((t) => t.id !== id)
      : tasks.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
  );
}

app/lib/types.ts

export type Task = {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  ownerId: string;
};

app/login/page.tsx

import { signIn } from "@/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
export const metadata = {
  title: "로그인",
  robots: { index: false, follow: false },
};
export default async function Login({
  searchParams,
}: {
  searchParams: Promise<{ error?: string }>;
}) {
  const { error } = await searchParams;
  async function login(data: FormData) {
    "use server";
    try {
      await signIn("credentials", {
        email: data.get("email"),
        password: data.get("password"),
        redirectTo: "/tasks",
      });
    } catch (error) {
      if (error instanceof AuthError) redirect("/login?error=credentials");
      throw error;
    }
  }
  return (
    <main>
      <h1>로그인</h1>
      <p>README의 로컬 계정 설정을 먼저 완료하세요.</p>
      {error ? (
        <p role="alert">로그인 정보 또는 로컬 인증 설정을 확인해 주세요.</p>
      ) : null}
      <form action={login}>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          name="email"
          type="email"
          autoComplete="username"
          required
        />
        <label htmlFor="password">비밀번호</label>
        <input
          id="password"
          name="password"
          type="password"
          autoComplete="current-password"
          required
        />
        <button>로그인</button>
      </form>
    </main>
  );
}

app/opengraph-image.tsx

import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
  return new ImageResponse(
    <div
      style={{
        display: "flex",
        width: "100%",
        height: "100%",
        alignItems: "center",
        justifyContent: "center",
        background: "#181818",
        color: "#fff",
        fontSize: 72,
      }}
    >
      TaskFlow Next.js
    </div>,
    size,
  );
}

app/providers.tsx

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export default function Providers({ children }: { children: ReactNode }) {
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

app/ui/api-demo.tsx

"use client";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export default function ApiDemo() {
  const router = useRouter();
  const query = useQueryClient();
  const [message, setMessage] = useState("API 응답 확인 전");
  const [busy, setBusy] = useState(false);
  async function request(method: "GET" | "POST") {
    setBusy(true);
    try {
      const response = await fetch("/api/tasks", {
        method,
        headers: { "Content-Type": "application/json" },
        ...(method === "POST"
          ? { body: JSON.stringify({ title: "API 실습 작업" }) }
          : {}),
      });
      const data = await response.json();
      if (response.ok && method === "POST") {
        await query.invalidateQueries({ queryKey: ["tasks"] });
        router.refresh();
      }
      setMessage(
        response.ok
          ? `${response.status}: ${Array.isArray(data) ? data.length + "개 작업" : data.title}`
          : `${response.status}: ${data.message}`,
      );
    } catch {
      setMessage("API 요청 실패");
    } finally {
      setBusy(false);
    }
  }
  return (
    <section>
      <h2>API 실습</h2>
      <p>
        Mock 저장소와 서버 저장소는 별개입니다. 17편부터 실제 Route Handler를
        사용합니다.
      </p>
      <div className="controls">
        <button disabled={busy} onClick={() => request("GET")}>
          목록 API 조회
        </button>
        <button disabled={busy} onClick={() => request("POST")}>
          API 작업 추가
        </button>
      </div>
      <p role="status">{message}</p>
    </section>
  );
}

app/ui/api-status.tsx

"use client";
import { useState } from "react";
import { apiClient } from "../lib/axios-client";
export default function ApiStatus() {
  const [message, setMessage] = useState("확인 전");
  const handleCheck = async () => {
    try {
      await apiClient.get("/api/tasks");
      setMessage("API 연결 성공");
    } catch {
      setMessage("API 연결 실패");
    }
  };
  return (
    <section>
      <button onClick={handleCheck}>API 상태 확인</button>
      <p>{message}</p>
    </section>
  );
}

app/ui/image-example.tsx

import Image from "next/image";
export default function ImageExample() {
  return (
    <figure>
      <Image
        src="https://images.unsplash.com/photo-1499750310107-5fef28a66643"
        alt="책상 위 학습 도구"
        width={800}
        height={533}
        sizes="(max-width: 880px) 100vw, 880px"
      />
      <figcaption>외부 이미지 요청은 네트워크가 필요합니다.</figcaption>
    </figure>
  );
}

app/ui/logout-button.tsx

import { signOut } from "@/auth";
export default function LogoutButton() {
  return (
    <form
      action={async () => {
        "use server";
        await signOut({
          redirectTo: "/",
        });
      }}
    >
      <button>로그아웃</button>
    </form>
  );
}

app/ui/modal.tsx

"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDialogElement>(null);
  const router = useRouter();
  useEffect(() => {
    const dialog = ref.current;
    if (dialog && !dialog.open) dialog.showModal();
    return () => {
      dialog?.close();
    };
  }, []);
  return (
    <dialog
      className="modal"
      ref={ref}
      aria-label="작업 상세"
      onCancel={(event) => {
        event.preventDefault();
        router.back();
      }}
    >
      <button type="button" onClick={() => router.back()}>
        닫기
      </button>
      {children}
    </dialog>
  );
}

app/ui/msw-provider.tsx

"use client";
import { useState, useEffect, type ReactNode } from "react";
export default function MswProvider({ children }: { children: ReactNode }) {
  const enabled =
    process.env.NODE_ENV === "development" &&
    process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
  const [ready, setReady] = useState(!enabled);
  const [error, setError] = useState("");
  useEffect(() => {
    if (!enabled) return;
    let active = true;
    import("@/src/mocks/browser")
      .then(({ startMockWorker }) => startMockWorker())
      .then(() => {
        if (active) setReady(true);
      })
      .catch(() => {
        if (active)
          setError("Mock API 초기화에 실패했습니다. worker 파일을 확인하세요.");
      });
    return () => {
      active = false;
    };
  }, [enabled]);
  return error ? (
    <p role="alert">{error}</p>
  ) : ready ? (
    <>{children}</>
  ) : (
    <p>Mock API 준비 중...</p>
  );
}

app/ui/task-actions.tsx

"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateTask } from "@/app/lib/actions";
export default function TaskActions({
  id,
  completed,
}: {
  id: string;
  completed: boolean;
}) {
  const router = useRouter();
  const query = useQueryClient();
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  async function run(op: "toggle" | "delete") {
    setBusy(true);
    try {
      await updateTask(id, op);
      await query.invalidateQueries({ queryKey: ["tasks"] });
      if (op === "delete") router.replace("/tasks");
      router.refresh();
    } catch {
      setError("변경에 실패했습니다. 권한과 연결을 확인하세요.");
    } finally {
      setBusy(false);
    }
  }
  return (
    <section>
      <div className="controls">
        <button disabled={busy} onClick={() => run("toggle")}>
          {completed ? "진행 중으로 변경" : "완료로 변경"}
        </button>
        <button disabled={busy} onClick={() => run("delete")}>
          삭제
        </button>
      </div>
      <p role="alert">{error}</p>
    </section>
  );
}

app/ui/task-form.tsx

"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createTask } from "@/app/lib/actions";
export default function TaskForm() {
  const router = useRouter();
  const query = useQueryClient();
  const [message, setMessage] = useState("");
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<{ title: string }>();
  return (
    <form
      onSubmit={handleSubmit(async ({ title }) => {
        const data = new FormData();
        data.set("title", title);
        try {
          const result = await createTask(data);
          setMessage(result.message);
          if (result.ok) {
            reset();
            await query.invalidateQueries({ queryKey: ["tasks"] });
            router.refresh();
          }
        } catch {
          setMessage("저장에 실패했습니다. 다시 시도해 주세요.");
        }
      })}
    >
      <label htmlFor="task-title">새 작업</label>
      <input
        id="task-title"
        aria-invalid={!!errors.title}
        aria-describedby="task-error"
        {...register("title", {
          setValueAs: (v) => v.trim(),
          required: "제목이 필요합니다.",
          minLength: { value: 2, message: "2자 이상 입력해 주세요." },
          maxLength: { value: 100, message: "100자 이하로 입력해 주세요." },
        })}
      />
      <p id="task-error" role="alert">
        {errors.title?.message}
      </p>
      <button disabled={isSubmitting}>작업 추가</button>
      <p role="status">{message}</p>
    </form>
  );
}

app/ui/task-list-client.tsx

"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import type { Task } from "@/app/lib/types";
export default function TaskListClient({
  ownerId,
  q,
  status,
}: {
  ownerId: string;
  q: string;
  status: string;
}) {
  const {
    data = [],
    isError,
    isFetching,
    refetch,
  } = useQuery<Task[]>({
    queryKey: ["tasks", ownerId],
    queryFn: async () => {
      const response = await fetch("/api/tasks", { cache: "no-store" });
      if (!response.ok) throw new Error("조회 실패");
      return response.json();
    },
    staleTime: 60000,
  });
  const tasks = data.filter(
    (t) =>
      t.title.toLowerCase().includes(q.toLowerCase()) &&
      (status === "done"
        ? t.completed
        : status === "active"
          ? !t.completed
          : true),
  );
  return (
    <section>
      {isError ? <p role="alert">목록 조회에 실패했습니다.</p> : null}
      <button disabled={isFetching} onClick={() => void refetch()}>
        목록 새로 조회
      </button>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link href={"/tasks/" + task.id}>{task.title}</Link> ·{" "}
            {task.completed ? "완료" : "진행 중"}
          </li>
        ))}
      </ul>
      {tasks.length === 0 ? <p>조건에 맞는 작업이 없습니다.</p> : null}
    </section>
  );
}

app/ui/task-search.tsx

"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function TaskSearch() {
  const search = useSearchParams();
  const router = useRouter();
  const path = usePathname();
  return (
    <form
      key={search.toString()}
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        const params = new URLSearchParams(search);
        for (const key of ["q", "status"]) {
          const value = String(data.get(key) || "");
          if (value) params.set(key, value);
          else params.delete(key);
        }
        router.replace(path + "?" + params.toString());
      }}
    >
      <label htmlFor="q">작업 검색</label>
      <input id="q" name="q" defaultValue={search.get("q") || ""} />
      <label htmlFor="status">상태</label>
      <select
        id="status"
        name="status"
        defaultValue={search.get("status") || "all"}
      >
        <option value="all">전체</option>
        <option value="active">진행 중</option>
        <option value="done">완료</option>
      </select>
      <button>검색 적용</button>
    </form>
  );
}

app/ui/task-stats.tsx

import { getTaskStats } from "@/app/lib/data";
export default async function TaskStats() {
  await new Promise((resolve) => setTimeout(resolve, 600));
  const s = await getTaskStats();
  return (
    <aside aria-label="작업 통계">
      <p>
        전체: {s.total} · 완료: {s.completed}
      </p>
    </aside>
  );
}

app/ui/theme-setting.tsx

"use client";
import { useState, useEffect } from "react";
export default function ThemeSetting() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  useEffect(() => {
    try {
      const saved = localStorage.getItem("taskflow-theme");
      if (saved === "light" || saved === "dark") setTheme(saved);
    } catch {}
  }, []);
  function change(value: "light" | "dark") {
    setTheme(value);
    try {
      localStorage.setItem("taskflow-theme", value);
    } catch {}
  }
  return (
    <section data-theme={theme}>
      <p>현재 테마: {theme}</p>
      <div className="controls">
        <button onClick={() => change("light")}>밝게</button>
        <button onClick={() => change("dark")}>어둡게</button>
      </div>
    </section>
  );
}

auth.ts

import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { scryptSync, timingSafeEqual } from "node:crypto";
export const { handlers, auth, signIn, signOut } = NextAuth({
  pages: { signIn: "/login" },
  session: { strategy: "jwt" },
  providers: [
    Credentials({
      credentials: { email: { type: "email" }, password: { type: "password" } },
      async authorize(input) {
        const email = process.env.DEMO_EMAIL;
        const salt = process.env.DEMO_PASSWORD_SALT;
        const hash = process.env.DEMO_PASSWORD_HASH;
        if (
          !email ||
          !salt ||
          !hash ||
          typeof input.email !== "string" ||
          typeof input.password !== "string" ||
          input.password.length > 256 ||
          input.email !== email
        )
          return null;
        const expected = Buffer.from(hash, "hex");
        const actual = scryptSync(input.password, salt, 64);
        if (
          expected.length !== actual.length ||
          !timingSafeEqual(expected, actual)
        )
          return null;
        return { id: "demo", name: "TaskFlow 학습자", email };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) token.sub = user.id;
      return token;
    },
    session({ session, token }) {
      if (session.user && token.sub) session.user.id = token.sub;
      return session;
    },
  },
});

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />

next.config.ts

import type { NextConfig } from "next";
const config: NextConfig = {
  async redirects() {
    return [{ source: "/todo", destination: "/tasks", permanent: true }];
  },
  cacheComponents: false,
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
    ],
  },
};
export default config;

package.json

{
  "name": "taskflow-next",
  "version": "1.0.29",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start",
    "typecheck": "tsc --noEmit",
    "setup-auth": "node scripts/setup-auth.mjs",
    "cache-api": "node scripts/cache-api.mjs"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "axios": "1.20.0",
    "server-only": "0.0.1",
    "react-hook-form": "7.88.0",
    "next-auth": "5.0.0-beta.32",
    "@tanstack/react-query": "5.103.1"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.13.6",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "msw": "2.15.0"
  }
}

proxy.ts

import { auth } from "@/auth";
import { NextResponse } from "next/server";
export const proxy = auth((request) => {
  if (!request.auth?.user)
    return NextResponse.redirect(new URL("/login", request.url));
  return NextResponse.next();
});
export const config = { matcher: ["/tasks/:path*", "/settings/:path*"] };

scripts/cache-api.mjs

import { createServer } from "node:http";
let revision = 1;
createServer((req, res) => {
  if (req.url !== "/public-stats") {
    res.writeHead(404).end();
    return;
  }
  if (req.method === "POST") revision++;
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ revision, label: "공개 통계 실습" }));
}).listen(4100, "127.0.0.1", () => console.log("공개 통계 실습 API 준비 완료"));

scripts/setup-auth.mjs

import { randomBytes, scryptSync } from "node:crypto";
import { writeFile, access } from "node:fs/promises";
import { createInterface } from "node:readline/promises";
try {
  await access(".env.local");
  throw new Error(".env.local이 이미 있습니다. 기존 설정을 보존합니다.");
} catch (error) {
  if (error.code !== "ENOENT") throw error;
}
const input = createInterface({ input: process.stdin, output: process.stdout });
const email = (await input.question("학습용 이메일: ")).trim();
const password = await input.question(
  "학습용 비밀번호(입력 내용이 화면에 보입니다): ",
);
input.close();
if (!email.includes("@") || password.length < 12)
  throw new Error("이메일과 12자 이상 학습용 비밀번호가 필요합니다.");
const salt = randomBytes(16).toString("hex");
const values = {
  AUTH_SECRET: randomBytes(32).toString("hex"),
  DEMO_EMAIL: email,
  DEMO_PASSWORD_SALT: salt,
  DEMO_PASSWORD_HASH: scryptSync(password, salt, 64).toString("hex"),
};
await writeFile(
  ".env.local",
  Object.entries(values)
    .map(([key, value]) => key + "=" + JSON.stringify(value))
    .join("\n") + "\n",
  { mode: 0o600 },
);
console.log("로컬 인증 설정을 저장했습니다. 서버를 재시작하세요.");

src/mocks/browser.ts

import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);

let startPromise: ReturnType<typeof worker.start> | undefined;

export function startMockWorker() {
  if (!startPromise) {
    startPromise = worker.start({ onUnhandledRequest: "bypass" }).catch((error) => {
      startPromise = undefined;
      throw error;
    });
  }
  return startPromise;
}

src/mocks/handlers.ts

import { http, HttpResponse } from "msw";
let tasks = [
  {
    id: "task-1",
    title: "Mock API 연결",
    completed: false,
  },
];
export const handlers = [
  http.get("/api/tasks", () => {
    return HttpResponse.json(tasks);
  }),
  http.post("/api/tasks", async ({ request }) => {
    const body = (await request.json()) as {
      title?: string;
    };
    if (typeof body.title !== "string" || body.title.trim().length < 2) {
      return HttpResponse.json(
        {
          message: "제목을 입력해 주세요.",
        },
        {
          status: 400,
        },
      );
    }
    const newTask = {
      id: crypto.randomUUID(),
      title: body.title.trim(),
      completed: false,
    };
    tasks = [newTask, ...tasks];
    return HttpResponse.json(newTask, {
      status: 201,
    });
  }),
];

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

글 소개·학습 준비·전체 목차

Next.js redirects 설정: next.config.js에서 URL 이동 처리 핵심 개념을 설명하는 첫 번째 본문 이미지

Next.js · 29/38 · TaskFlow 누적 실습

Next.js redirects 설정과 URL 이동 처리

Next.js · 29 / 38 · 필수 학습 · redirects

URL 구조가 바뀌면 사용자가 과거 링크로 들어왔을 때 새 주소로 연결해야 합니다. 정적 규칙은 next.config redirects로, 서버 로직의 실행 결과에 따른 이동은 redirect() 같은 API로 처리할 수 있습니다.

이번 글에서 배우는 내용

  • next.config redirects
  • source / destination
  • permanent
  • redirect()
  • rewrite와 redirect 차이
  • redirect loop 방지

목차

다음 학습

다음 글에서는 Request Memoization, Data Cache, Full Route Cache, Router Cache를 서로 다른 계층으로 구분합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 39개 · 필수 38개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기