인증과 세션 — Auth.js v5 / OAuth / JWT
JWT와 DB session의 선택 기준, Auth.js v5 셋업, Credentials와 OAuth provider, RSC · Client · Server Action · middleware 네 곳에서 세션을 읽는 패턴.
31장에서 성능 측정과 개선을 다뤘습니다. 본 챕터에서는 풀스택 앱의 또 다른 첫 진입 장벽인 인증과 세션을 다루겠습니다. “회원가입 / 로그인 / 권한별 페이지"는 거의 모든 실제 앱에서 마주치는 문제고, 한 번 잘못 설계하면 보안 사고로 이어지는 영역입니다.
본 챕터의 결과물은 다음 34장 (풀스택 Todo 캡스톤)에서 그대로 사용됩니다. 그리고 27장 (Server Actions와 폼)에서 본 Server Action 안에 권한 검증을 어떻게 끼우는지, 30장 (Playwright)의 storageState가 어떤 세션 모델 위에서 동작하는지도 본 챕터에서 답하겠습니다.
세션 모델의 두 갈래 #
세션을 만드는 방식은 크게 두 가지입니다. **JWT (JSON Web Token)**와 DB session.
JWT — stateless #
서버가 사용자 정보를 담은 토큰을 서명해 클라이언트에 줍니다. 클라이언트는 매 요청마다 그 토큰을 보내고, 서버는 서명을 검증해 사용자를 식별합니다.
1. 로그인 성공 → 서버가 JWT 발급 (payload: { userId, exp })
2. 클라이언트가 JWT를 cookie 또는 localStorage에 저장
3. 매 요청에 JWT 첨부 → 서버가 서명 검증 → userId 추출
4. 서버에는 세션 저장소가 없음 (stateless)장점: 서버에 세션 저장소 안 필요, 수평 확장이 쉬움.
단점: 토큰을 강제로 무효화하기 어려움 (만료까지 기다리거나 deny list 필요), 토큰이 커지면 매 요청 비용 증가.
DB Session — stateful #
서버가 세션 ID만 cookie로 주고, 실제 사용자 정보는 DB에 저장합니다.
1. 로그인 성공 → 서버가 세션 row를 DB에 만듦 (id, userId, expires)
2. 세션 id를 cookie로 클라이언트에 줌
3. 매 요청에 cookie 첨부 → 서버가 DB에서 세션 조회 → userId 추출
4. 로그아웃 또는 강제 만료 시 DB row 삭제로 즉시 무효화 가능장점: 즉시 무효화 가능, 세션 정보를 자유롭게 확장 가능.
단점: 매 요청마다 DB 조회 (캐시로 완화 가능), 세션 저장소 운영 부담.
선택 기준 #
| 상황 | 권장 모델 |
|---|---|
| 마이크로서비스, 여러 도메인 SSO | JWT |
| 단일 앱, 즉시 로그아웃 가능해야 함 | DB session |
| OAuth만 쓰고 자체 비밀번호 안 다룸 | 어느 쪽이든 OK |
| 단순한 풀스택 앱 (이 책의 캡스톤) | DB session (Auth.js v5 + Drizzle adapter 권장) |
본 챕터의 예제는 DB session을 기본으로 다루겠습니다. 즉시 무효화와 세션 확장의 가치가 학습 단계에서 더 자주 필요하기 때문입니다. JWT가 필요한 경우는 같은 Auth.js 안에서 옵션 변경만으로 전환 가능합니다.
Auth.js v5 셋업 #
Next.js 15 App Router와 정합되는 인증 라이브러리는 여럿 있지만, 가장 보편적인 선택지가 Auth.js v5 (구 NextAuth.js)입니다.
pnpm add next-auth@beta(M5 시점에 v5의 stable 여부에 따라 버전 지정이 달라질 수 있습니다. 본문 예제는 v5의 API 모양을 기준으로 합니다.)
auth.ts — 단일 진입점 #
Auth.js v5는 설정과 헬퍼를 한 파일에 모읍니다.
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
import { verifyPassword } from '@/lib/password';
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: 'database' }, // JWT 원하면 'jwt'
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID!,
clientSecret: process.env.AUTH_GITHUB_SECRET!,
}),
Credentials({
credentials: {
email: { label: '이메일', type: 'email' },
password: { label: '비밀번호', type: 'password' },
},
async authorize(credentials) {
const user = await db.users.findByEmail(credentials.email as string);
if (!user) return null;
const ok = await verifyPassword(credentials.password as string, user.passwordHash);
return ok ? { id: user.id, email: user.email, name: user.name } : null;
},
}),
],
pages: {
signIn: '/login',
},
});핵심.
adapter로 DB 어댑터 연결 (Drizzle / Prisma / Supabase 등 선택).session.strategy로 JWT / DB 선택.providers에 OAuth (GitHub / Google / …)와 Credentials 등록.- 반환된
auth/handlers/signIn/signOut을 다른 파일에서 import해 씁니다.
API 라우트 핸들러 #
export { GET, POST } from '@/auth';(Auth.js v5의 handlers가 { GET, POST }를 노출합니다. 위는 그것을 그대로 라우트로 노출하는 한 줄짜리 핸들러입니다.)
Middleware로 보호된 경로 #
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth(req => {
const isLoggedIn = !!req.auth;
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
});
export const config = {
matcher: ['/dashboard/:path*'],
};auth가 middleware 시그니처도 함께 처리합니다. req.auth에 현재 세션이 들어옵니다.
Credentials provider — 직접 비밀번호 다루기 #
OAuth만으로 충분한 앱이라면 비밀번호를 안 다루는 게 보안상 가장 안전합니다. 다만 자체 회원가입을 받아야 하는 앱이라면 비밀번호 처리가 필요합니다.
해싱 — argon2 또는 bcrypt #
평문 저장은 절대 안 됩니다. 비밀번호는 단방향 해시로 변환해 저장합니다.
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}argon2id는 현재 OWASP 권장입니다. bcrypt도 여전히 합법적인 선택이지만, 새 프로젝트라면 argon2를 우선합니다.
해시 비용 파라미터는 보안과 성능의 트레이드오프입니다. 너무 가벼우면 brute-force에 약하고, 너무 무거우면 로그인이 느려집니다. 위 설정은 일반 서버에서 약 ~100ms 정도를 소비하는 권장 시작점입니다.
“비밀번호를 직접 다루지 말아야 하는 경우” #
다음 경우엔 OAuth만으로 가는 게 위험을 크게 줄입니다.
- 사용자 수가 적고 OAuth provider 한 두 개로 커버되는 앱
- 비밀번호 분실 / 재설정 flow를 자체 운영할 여력이 없는 팀
- 2FA 같은 추가 보안을 자체로 구현할 여력이 없는 경우
OAuth는 비밀번호 자체를 GitHub / Google이 다루고, 우리는 사용자 식별만 받습니다. 비밀번호 관련 사고의 대부분을 외주한 셈입니다.
OAuth provider — GitHub 예시 #
GitHub OAuth App 등록 #
- GitHub Settings → Developer settings → OAuth Apps → New OAuth App
- Application name: 앱 이름 (예:
modern-react-demo) - Homepage URL:
http://localhost:3000(개발), production은 실제 도메인 - Authorization callback URL:
http://localhost:3000/api/auth/callback/github - 등록 후 Client ID와 Client Secret을 받습니다.
환경변수 #
AUTH_SECRET=<openssl rand -base64 32 결과>
AUTH_GITHUB_ID=<Client ID>
AUTH_GITHUB_SECRET=<Client Secret>AUTH_SECRET은 JWT 서명과 session encryption에 사용됩니다. production에서는 절대 코드에 적지 마세요. 33장 (배포와 관측성)에서 다룰 환경변수 시스템에 두는 게 표준입니다.
로그인 버튼 #
import { signIn } from '@/auth';
export default function LoginPage() {
return (
<main style={{ padding: '24px' }}>
<h1>로그인</h1>
<form action={async () => {
'use server';
await signIn('github', { redirectTo: '/dashboard' });
}}>
<button type="submit">GitHub로 로그인</button>
</form>
</main>
);
}signIn은 27장의 Server Action 패턴과 자연스럽게 맞물립니다. 폼 안에서 server action으로 호출하면 progressive enhancement가 보장됩니다.
네 곳에서 세션 읽기 #
같은 세션 정보를 어디서 어떻게 읽느냐가 중요합니다. App Router에서는 네 곳이 있습니다.
1. Server Component — auth() 직접 호출
#
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect('/login');
return (
<main style={{ padding: '24px' }}>
<h1>대시보드</h1>
<p>안녕하세요, {session.user.name} 님.</p>
</main>
);
}Server Component 안에서 auth()를 직접 await합니다. 인증되지 않았으면 redirect. middleware로 큰 그림의 라우트 보호를, Server Component로 세밀한 권한 확인을 분담하는 패턴이 일반적입니다.
2. Client Component — <SessionProvider> + useSession
#
Client Component에서 세션을 쓰려면 SessionProvider를 root에 두고 useSession 훅을 사용합니다.
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body><Providers>{children}</Providers></body>
</html>
);
}'use client';
import { useSession } from 'next-auth/react';
export default function UserBadge() {
const { data: session, status } = useSession();
if (status === 'loading') return <span>...</span>;
if (!session?.user) return <a href="/login">로그인</a>;
return <span>{session.user.name}</span>;
}주의: Client Component에서는 세션이 비동기로 도착하니 status를 함께 검사해야 깜빡임이 없습니다.
3. Server Action — auth() + 권한 검증
#
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
export async function deleteTodo(id: string) {
const session = await auth();
if (!session?.user) throw new Error('로그인이 필요합니다');
const todo = await db.todos.findById(id);
if (!todo) throw new Error('항목을 찾을 수 없습니다');
if (todo.ownerId !== session.user.id) throw new Error('권한이 없습니다');
await db.todos.delete(id);
}Server Action 안의 권한 검증이 보안의 마지막 방어선입니다. 클라이언트의 UI를 우회해 직접 RPC를 호출당해도 권한 검증이 안 되면 데이터가 노출됩니다. Middleware는 라우트 단위 보호고, Server Action 안의 검증은 데이터 단위 보호입니다. 두 레벨 모두 필요합니다.
4. Middleware — 라우트 보호 #
위에서 만든 middleware.ts를 다시 봅시다.
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth(req => {
const isLoggedIn = !!req.auth;
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
});Middleware는 라우트가 그려지기 전에 가로채는 단계입니다. 보호된 라우트의 진입 자체를 막고 로그인 페이지로 보냅니다. 렌더링 비용을 아끼는 효과도 있습니다.
권한 모델 — 작은 시작 #
대부분의 앱은 처음에 다음 두 가지면 충분합니다.
Role-based — enum 한 컬럼 #
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
});const session = await auth();
if (session?.user.role !== 'admin') redirect('/');복잡한 RBAC 시스템 (Cerbos / Casbin 등)은 큰 조직에서는 가치 있지만, 작은 앱에서는 위처럼 단순한 enum 한 컬럼이 더 명확하고 운영 부담이 작습니다.
Row-level — owner_id 검증 #
자기 데이터만 다룰 수 있는 패턴. 위의 deleteTodo Server Action에서 본 모양입니다.
if (todo.ownerId !== session.user.id) throw new Error('권한이 없습니다');대부분의 SaaS 앱이 이 두 패턴 (role + row-level)만으로 운영됩니다. 복잡한 권한 체계는 진짜로 필요할 때 도입해도 늦지 않습니다.
흔한 함정 #
1. 클라이언트에서만 권한 확인 #
'use client';
export default function AdminButton() {
const { data: session } = useSession();
if (session?.user.role !== 'admin') return null;
return <button onClick={() => deleteEverything()}>전체 삭제</button>;
}UI를 숨기는 건 좋지만, 버튼이 안 보인다고 해서 동작이 막히는 건 아닙니다. deleteEverything Server Action 안에서도 다시 검증해야 합니다. 클라이언트 권한 확인은 UX, Server Action 권한 확인은 보안.
2. 비밀번호를 로그에 흘림 #
console.log('login attempt:', credentials); // 비밀번호 포함
로그를 PII (개인 식별 정보)와 함께 검토하지 않으면 사고가 납니다. 비밀번호와 토큰은 절대 로그에 찍지 마세요. 33장 (배포와 관측성)의 Sentry 셋업에서도 자동으로 빠지는 필드를 명시합니다.
3. AUTH_SECRET 노출 #
const AUTH_SECRET = 'my-secret-key'; // 절대 안 됨AUTH_SECRET이 노출되면 누구나 유효한 JWT를 만들 수 있습니다. 반드시 환경변수로, 그리고 production에서는 시크릿 관리 시스템을 통해서만 주입합니다.
4. cookie의 Secure / HttpOnly / SameSite #
Auth.js v5는 cookie 옵션을 안전한 디폴트로 설정하지만, 직접 cookie를 다루는 코드가 있다면 다음 옵션을 의식적으로 확인하세요.
HttpOnly: true— JS에서 cookie에 접근 못 함 (XSS 방어)Secure: true— HTTPS에서만 전송SameSite: 'lax'또는'strict'— CSRF 방어
직접 해보기 — 작은 보호된 페이지 #
다음 흐름을 직접 만들어 보세요.
- Auth.js 셋업: 위의
auth.ts를 그대로 만들고, GitHub OAuth App을 등록합니다..env.local에 환경변수. - 로그인 페이지 + 대시보드:
/login(서버 액션으로 GitHub 로그인)과/dashboard(Server Component에서auth()로 세션 확인)를 만듭니다. - middleware:
/dashboard를 middleware로 보호해 로그인 안 한 사용자가 직접 URL을 쳐도/login으로 가게 만듭니다. - Server Action 권한 검증: 대시보드에 “내 정보 삭제” Server Action을 만들고,
session.user.id만 자기 데이터를 지울 수 있게 row-level 검증을 넣습니다. - Playwright로 검증: 30장의
storageState패턴을 활용해 로그인된 상태와 안 된 상태 두 시나리오를 자동화합니다.
다섯 단계를 거치면 인증 + 권한 + middleware + Server Action + E2E가 한 흐름으로 정리됩니다.
연습문제 #
- JWT vs DB session 선택 연습. 다음 세 가지 앱에 어느 모델이 더 적합한지 답하고 이유를 적어 보세요. (a) 단일 도메인의 작은 블로그 + 댓글, (b) 여러 마이크로서비스가 같은 사용자를 공유하는 SaaS, (c) “악성 계정 즉시 차단"이 보안 요구사항인 결제 앱. 본문의 선택 기준 표를 참고합니다.
- 권한 검증 4곳 매핑. 다음 다섯 가지 요구사항을 본문의 4곳 (middleware / Server Component / Client Component / Server Action) 중 어느 위치에서 처리하는 게 적절한지 답하세요. (a) 로그인 안 된 사용자의
/dashboard접근 차단, (b) admin만 보이는 메뉴 항목 숨기기, (c) 자기 Todo만 삭제 가능, (d) admin만 다른 사용자 계정 비활성화 가능, (e) 로그인된 사용자의 이름을 헤더에 표시. - 세션 정보 확장. 세션의 user 객체에
role외에organizationId를 추가해야 한다고 가정합니다. Auth.js v5의 어느 위치 (auth.ts의 callbacks? adapter? Server Action?)에 그 로직을 두는 게 가장 자연스러운지 본문과 공식 문서를 참고해 답해 보세요.
한 줄 요약: 인증은 JWT (stateless)와 DB session (stateful) 두 모델이 있고, 작은 앱은 보통 DB session으로 시작한다. Auth.js v5는
auth.ts단일 진입점으로 OAuth / Credentials provider와 어댑터를 묶고, App Router에서는 네 곳 — middleware (라우트 보호), Server Component (auth()await), Client Component (useSession), Server Action (auth()+ row-level 검증) — 에서 세션을 읽는다. UI는 클라이언트에서 숨기되 보안의 마지막 방어선은 Server Action 안의 권한 검증이다. 비밀번호는 argon2id로 해시하고AUTH_SECRET은 절대 코드에 두지 않는다.
다음 챕터 #
다음 33장 배포와 관측성에서는 5부의 마지막 챕터로, 만든 앱을 production에 띄우고 무엇이 벌어지는지 보는 도구들을 정리하겠습니다. Vercel과 Cloudflare Pages의 선택 기준, 환경변수와 시크릿 관리, Sentry로 에러 추적, PostHog로 제품 분석. 본 챕터의 AUTH_SECRET이 어디로 가는지의 답도 거기서 다루겠습니다.