목차
32 장

인증과 세션 — 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 #

서버가 사용자 정보를 담은 토큰을 서명해 클라이언트에 줍니다. 클라이언트는 매 요청마다 그 토큰을 보내고, 서버는 서명을 검증해 사용자를 식별합니다.

JWT 흐름
1. 로그인 성공 → 서버가 JWT 발급 (payload: { userId, exp })
2. 클라이언트가 JWT를 cookie 또는 localStorage에 저장
3. 매 요청에 JWT 첨부 → 서버가 서명 검증 → userId 추출
4. 서버에는 세션 저장소가 없음 (stateless)

장점: 서버에 세션 저장소 안 필요, 수평 확장이 쉬움.

단점: 토큰을 강제로 무효화하기 어려움 (만료까지 기다리거나 deny list 필요), 토큰이 커지면 매 요청 비용 증가.

DB Session — stateful #

서버가 세션 ID만 cookie로 주고, 실제 사용자 정보는 DB에 저장합니다.

DB Session 흐름
1. 로그인 성공 → 서버가 세션 row를 DB에 만듦 (id, userId, expires)
2. 세션 id를 cookie로 클라이언트에 줌
3. 매 요청에 cookie 첨부 → 서버가 DB에서 세션 조회 → userId 추출
4. 로그아웃 또는 강제 만료 시 DB row 삭제로 즉시 무효화 가능

장점: 즉시 무효화 가능, 세션 정보를 자유롭게 확장 가능.

단점: 매 요청마다 DB 조회 (캐시로 완화 가능), 세션 저장소 운영 부담.

선택 기준 #

상황권장 모델
마이크로서비스, 여러 도메인 SSOJWT
단일 앱, 즉시 로그아웃 가능해야 함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는 설정과 헬퍼를 한 파일에 모읍니다.

src/auth.ts
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 라우트 핸들러 #

src/app/api/auth/[...nextauth]/route.ts
export { GET, POST } from '@/auth';

(Auth.js v5의 handlers{ GET, POST }를 노출합니다. 위는 그것을 그대로 라우트로 노출하는 한 줄짜리 핸들러입니다.)

Middleware로 보호된 경로 #

src/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));
  }
});

export const config = {
  matcher: ['/dashboard/:path*'],
};

auth가 middleware 시그니처도 함께 처리합니다. req.auth에 현재 세션이 들어옵니다.

Credentials provider — 직접 비밀번호 다루기 #

OAuth만으로 충분한 앱이라면 비밀번호를 안 다루는 게 보안상 가장 안전합니다. 다만 자체 회원가입을 받아야 하는 앱이라면 비밀번호 처리가 필요합니다.

해싱 — argon2 또는 bcrypt #

평문 저장은 절대 안 됩니다. 비밀번호는 단방향 해시로 변환해 저장합니다.

src/lib/password.ts
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 등록 #

  1. GitHub Settings → Developer settings → OAuth Apps → New OAuth App
  2. Application name: 앱 이름 (예: modern-react-demo)
  3. Homepage URL: http://localhost:3000 (개발), production은 실제 도메인
  4. Authorization callback URL: http://localhost:3000/api/auth/callback/github
  5. 등록 후 Client ID와 Client Secret을 받습니다.

환경변수 #

.env.local
AUTH_SECRET=<openssl rand -base64 32 결과>
AUTH_GITHUB_ID=<Client ID>
AUTH_GITHUB_SECRET=<Client Secret>

AUTH_SECRET은 JWT 서명과 session encryption에 사용됩니다. production에서는 절대 코드에 적지 마세요. 33장 (배포와 관측성)에서 다룰 환경변수 시스템에 두는 게 표준입니다.

로그인 버튼 #

src/app/login/page.tsx (Server Component)
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() 직접 호출 #

src/app/dashboard/page.tsx (Server Component)
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 훅을 사용합니다.

src/app/providers.tsx
'use client';

import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';

export function Providers({ children }: { children: ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}
src/app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body><Providers>{children}</Providers></body>
    </html>
  );
}
Client Component에서 세션 읽기
'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() + 권한 검증 #

src/app/todos/actions.ts
'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를 다시 봅시다.

src/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 한 컬럼 #

users 테이블 스키마
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에서 본 모양입니다.

row-level 권한 검증
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 노출 #

🚫 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 방어

직접 해보기 — 작은 보호된 페이지 #

다음 흐름을 직접 만들어 보세요.

  1. Auth.js 셋업: 위의 auth.ts를 그대로 만들고, GitHub OAuth App을 등록합니다. .env.local에 환경변수.
  2. 로그인 페이지 + 대시보드: /login (서버 액션으로 GitHub 로그인)과 /dashboard (Server Component에서 auth()로 세션 확인)를 만듭니다.
  3. middleware: /dashboard를 middleware로 보호해 로그인 안 한 사용자가 직접 URL을 쳐도 /login으로 가게 만듭니다.
  4. Server Action 권한 검증: 대시보드에 “내 정보 삭제” Server Action을 만들고, session.user.id만 자기 데이터를 지울 수 있게 row-level 검증을 넣습니다.
  5. Playwright로 검증: 30장의 storageState 패턴을 활용해 로그인된 상태와 안 된 상태 두 시나리오를 자동화합니다.

다섯 단계를 거치면 인증 + 권한 + middleware + Server Action + E2E가 한 흐름으로 정리됩니다.

연습문제 #

  1. JWT vs DB session 선택 연습. 다음 세 가지 앱에 어느 모델이 더 적합한지 답하고 이유를 적어 보세요. (a) 단일 도메인의 작은 블로그 + 댓글, (b) 여러 마이크로서비스가 같은 사용자를 공유하는 SaaS, (c) “악성 계정 즉시 차단"이 보안 요구사항인 결제 앱. 본문의 선택 기준 표를 참고합니다.
  2. 권한 검증 4곳 매핑. 다음 다섯 가지 요구사항을 본문의 4곳 (middleware / Server Component / Client Component / Server Action) 중 어느 위치에서 처리하는 게 적절한지 답하세요. (a) 로그인 안 된 사용자의 /dashboard 접근 차단, (b) admin만 보이는 메뉴 항목 숨기기, (c) 자기 Todo만 삭제 가능, (d) admin만 다른 사용자 계정 비활성화 가능, (e) 로그인된 사용자의 이름을 헤더에 표시.
  3. 세션 정보 확장. 세션의 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이 어디로 가는지의 답도 거기서 다루겠습니다.

X