Next.js에서 Auth.js 로그인과 세션 읽기, 로그아웃을 어떻게 연결하나요?
로그인은 입력 폼과 Auth.js 인증·세션 읽기를 연결하는 작업입니다. 로컬 학습 계정으로 틀린 입력·로그인 성공·로그아웃 결과를 따로 비교합니다. 화면 이동과 서버에서 읽은 세션이 같은 상태를 나타내는지 확인합니다.
적용 조건·범위: Auth.js 5 beta API를 사용합니다. 4.x 설치 명령과 섞지 않습니다. 아래 전체 코드의 auth.ts는 서버 환경변수의 계정과 비밀번호 해시를 확인합니다. 실제 값은 로컬에서 생성하고 코드·ZIP에 넣지 않습니다.
본문 근거: Auth.js Next.js 초기 설정 · Auth.js
독립 실습: 로컬 학습 계정의 로그인·세션·로그아웃
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
import { auth } from '@/auth';
import LogoutButton from '../ui/logout-button';
import Link from 'next/link';
export default async function Page() {
const session = await auth();
return <main>
<p>{session?.user ? `세션: ${session.user.name}` : '로그인되지 않음'}</p>
<Link href="/login">로그인</Link><LogoutButton />
</main>;
}
기대 결과: /practice는 로그인되지 않음, /login의 실패 입력은 오류 안내, 올바른 로컬 입력은 세션 표시, 로그아웃은 다시 로그인되지 않음입니다. TaskFlow·DB·외부 OAuth 계정은 필요 없습니다.
직접 실행: 환경 준비와 전체 파일
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 이전 TaskFlow 체크포인트나 DB 없이 Auth.js 로컬 학습 계정을 준비하는 독립 예제입니다.
npm install next-auth@5.0.0-beta.32 후 아래 파일을 저장합니다. @/* alias는 프로젝트 루트를 가리키도록 환경 준비 기본 설정을 유지합니다. node scripts/setup-auth.mjs를 실행해 로컬용 이메일과 학습용 비밀번호를 입력하고 서버를 재시작합니다. 스크립트는 기존 .env.local을 덮어쓰지 않습니다. 입력 내용이 터미널에 보이므로 실제 계정 비밀번호를 사용하지 않습니다.
app/api/auth/[…nextauth]/route.ts 전체 코드
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: "/practice",
});
} catch (error) {
if (error instanceof AuthError) redirect("/login?error=credentials");
throw error;
}
}
return (
<main>
<h1>로그인</h1>
<p>node scripts/setup-auth.mjs로 로컬 학습 계정 설정을 먼저 완료하세요.</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/ui/logout-button.tsx 전체 코드
import { signOut } from "@/auth";
export default function LogoutButton() {
return (
<form
action={async () => {
"use server";
await signOut({
redirectTo: "/practice",
});
}}
>
<button>로그아웃</button>
</form>
);
}
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: "독립 인증 학습자", 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;
},
},
});
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("로컬 인증 설정을 저장했습니다. 서버를 재시작하세요.");
app/practice/page.tsx 전체 코드
자가 확인: Auth.js 버전·AUTH_SECRET 설정·서버 재시작과 세션 표시를 순서대로 확인합니다. 가입·복구·시도 제한이 없는 로컬 인증 실습을 운영 인증으로 간주하지 않습니다.
설치
npm install next-auth@5.0.0-beta.32
auth.ts 기본 구조
Auth.js 5 beta API를 사용합니다. 4.x 설치 명령과 섞지 않습니다. 아래 전체 코드의 auth.ts는 서버 환경변수의 계정과 비밀번호 해시를 확인합니다. 실제 값은 로컬에서 생성하고 코드·ZIP에 넣지 않습니다.
npm run setup-auth
Route Handler 연결
import {
handlers,
} from '@/auth';
export const {
GET,
POST,
} = handlers;
서버에서 session 확인
import { auth } from '@/auth';
export default async function Page() {
const session = await auth();
return (
<p>
{session?.user?.name ??
'로그인되지 않음'}
</p>
);
}
클라이언트 session
클라이언트에서 세션을 읽어야 한다면 SessionProvider와 useSession을 사용합니다.
자주 발생하는 오류
- Client session만 믿고 서버 권한 처리
- secret·환경변수 누락
- 인증과 사용자 데이터 모델을 동일하게 취급
- session에 민감정보를 과도하게 넣음
독립 예제는 서버의 auth()로 세션을 읽는 흐름을 사용합니다. Client Component가 useSession으로 세션에 따라 UI를 바꿔야 하는 경우에 SessionProvider를 추가합니다. 세션 표시가 있다는 사실만으로 개인 데이터의 소유권 검사까지 제공되지는 않습니다.
원리 확인: Auth.js Next.js 초기 설정 (2026-10-12). 아래는 기대 결과이며 이 편집에서 실제 실행은 확인하지 않았습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
Auth.js 5 beta 설치 버전을 고정합니다. npm run setup-auth로 로컬 학습 계정을 생성한 뒤 서버를 재시작합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js Server Actions React Hook Form 검증의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: .env.example, app/_components/SiteHeader.tsx, app/api/auth/[...nextauth]/route.ts, app/login/page.tsx, app/ui/logout-button.tsx, auth.ts, package.json, scripts/setup-auth.mjs. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자 질문·직접 확인
질문: 로컬 학습 계정으로 로그인하면 어디에 세션이 보이고 로그아웃 후 무엇이 바뀌나요?
경로와 결과: npm install 후 npm run setup-auth로 로컬 학습 계정과 secret을 준비하고 개발 서버를 재시작합니다. /login에서 틀린 자격 증명과 맞는 자격 증명을 각각 제출해 실패 메시지와 로그인 후 세션 표시를 확인하고 로그아웃합니다. auth.ts는 서버 환경변수의 해시를 확인하고 JWT 세션을 사용합니다.
오류 점검: .env.local의 AUTH_SECRET·학습 계정 값 누락, 설정 후 서버 재시작, 4.x/5 beta API 혼용을 확인합니다. 비밀번호와 .env.local을 공유하지 마세요. 이 회차의 로그인 UI가 개인 작업 권한까지 보장하는 것은 아닙니다.
빌드·서버·브라우저 실행 검증은 수행하지 않아 미확인입니다.
완료 코드 ZIP 다운로드 — next-step-19-auth
이전 체크포인트: next-step-18-server-actions
이번 회차의 변경
Auth.js 5 beta 설치 버전을 고정합니다. npm run setup-auth로 로컬 학습 계정을 생성한 뒤 서버를 재시작합니다. 설정 파일은 ZIP에 포함하지 않습니다. 로그인·실패 메시지·로그아웃을 먼저 배우며 개인 작업 접근 제한은 다음 회차에서 적용합니다. 이 예제는 가입·복구·시도 제한이 없는 로컬 인증 실습입니다.
변경·추가 파일:
app/_components/SiteHeader.tsx.env.exampleauth.tsapp/api/auth/[...nextauth]/route.tsscripts/setup-auth.mjsapp/login/page.tsxapp/ui/logout-button.tsx
전체 파일·코드 보기
실행과 검증
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 버전을 고정했습니다.
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-19-auth
실습 프로젝트 전체 코드
.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 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>
</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";
export default async function AppLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
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";
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>
<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/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";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>작업 목록</h1>
<TaskSearch />
<TaskForm />
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={"/tasks/" + task.id}>{task.title}</Link>
<span> · {task.completed ? "완료" : "진행 중"}</span>
</li>
))}
</ul>
<Suspense fallback={<p>통계 로딩 중...</p>}>
<TaskStats />
</Suspense>
<MswProvider>
<ApiDemo />
<ApiStatus />
</MswProvider>
</main>
);
}
app/(app)/template.tsx
import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
return <>{children}</>;
}
app/(public)/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
</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";
export async function GET() {
const owner = "demo";
return Response.json(listTasks(owner), {
headers: { "Cache-Control": "private, no-store" },
});
}
export async function POST(request: Request) {
const owner = "demo";
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";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
app/lib/actions.ts
"use server";
import { addTask } from "./store";
import { revalidatePath } from "next/cache";
export async function createTask(formData: FormData) {
try {
addTask("demo", formData.get("title"));
} catch {
return { ok: false, message: "제목은 2~100자로 입력해 주세요." };
}
revalidatePath("/tasks");
return { ok: true, message: "작업을 추가했습니다." };
}
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 { listTasks } from "./store";
export async function getTasks() {
return listTasks("demo");
}
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/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/ui/api-demo.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function ApiDemo() {
const router = useRouter();
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") 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/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-form.tsx
"use client";
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 [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();
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-search.tsx
"use client";
import { useState } from "react";
export default function TaskSearch() {
const [keyword, setKeyword] = useState("");
return (
<label>
작업 검색
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어 입력"
/>
</label>
);
}
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" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.19",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit",
"setup-auth": "node scripts/setup-auth.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"
},
"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"
}
}
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",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
글 소개·학습 준비·전체 목차

Next.js · 19/38 · TaskFlow 누적 실습
Next.js Auth.js 로그인 세션 로그아웃
Next.js · 19 / 38 · 필수 실무 · Auth.js
Auth.js에서 로그인·세션·로그아웃은 어떻게 연결될까요? signIn이 자격 증명을 확인하고 서버 auth()가 현재 세션을 읽으며 signOut은 세션을 종료합니다. 로컬 학습 계정으로 실패 입력·성공 입력·로그아웃 후 화면을 비교해 세 동작을 확인합니다.
Auth.js의 패키지 설치 방식과 API는 버전에 따라 달라질 수 있으므로 사용 중인 버전과 공식 문서를 함께 확인합니다.
이번 글에서 배우는 내용
- Auth.js 설치
- auth.ts
- Provider 개념
- 서버 session
- 클라이언트 session
- signIn
- signOut
목차
다음 학습
다음 글에서는 로그인 여부에 따른 빠른 redirect와 서버 최종 권한 검증을 분리해 Proxy 접근 제어 구조를 만듭니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.