13.3 인증 구현 — NextAuth.js(Auth.js), OAuth, JWT 세션, 미들웨어 보호
인증이란 무엇인가?
웹 애플리케이션에서 **인증(Authentication)**은 "이 사용자가 누구인가?"를 확인하는 과정이고, **인가(Authorization)**는 "이 사용자가 무엇을 할 수 있는가?"를 결정하는 과정입니다.
Next.js 앱에서 인증을 구현하는 방법은 여러 가지가 있지만, 가장 널리 사용되는 공식적인 솔루션은 **Auth.js(구 NextAuth.js)**입니다. Auth.js는 OAuth 공급자(Google, GitHub, Kakao 등), 이메일/패스워드, 매직 링크 등 다양한 인증 방식을 통합적으로 지원합니다.
인증 흐름 개요
사용자 → 로그인 페이지 → OAuth 공급자(Google 등) → 콜백 URL → 세션 발급 → 보호된 페이지 접근
| 개념 | 설명 |
|---|---|
| Session | 로그인 상태를 유지하는 사용자 정보 저장소 |
| JWT | JSON Web Token — 세션을 서버 없이 클라이언트 측에서 검증 |
| OAuth | 제3자 서비스(Google, GitHub 등)를 통해 인증을 위임하는 표준 프로토콜 |
| Middleware | 요청이 페이지에 도달하기 전에 인증 여부를 검사하는 계층 |
Auth.js 설치 및 기본 설정
설치
npm install next-auth@beta
Auth.js v5(beta)는 Next.js 15 App Router와 완전히 호환됩니다.
환경 변수 설정
# .env.local
AUTH_SECRET="your-random-secret-at-least-32-chars" # openssl rand -base64 32
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
auth.ts — 핵심 설정 파일
// auth.ts (프로젝트 루트)
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google,
GitHub,
],
callbacks: {
// JWT에 추가 정보 저장
jwt({ token, user }) {
if (user) {
token.role = user.role ?? 'user';
}
return token;
},
// 세션에 JWT 정보 노출
session({ session, token }) {
session.user.role = token.role as string;
return session;
},
},
});
Route Handler 등록
// app/api/auth/[...nextauth]/route.ts
export { handlers as GET, handlers as POST } from '@/auth';
Auth.js는 /api/auth/signin, /api/auth/signout, /api/auth/callback/* 등의 경로를 자동으로 처리합니다.
OAuth 공급자 연동
Google OAuth 설정
- Google Cloud Console에서 프로젝트 생성
- OAuth 2.0 클라이언트 ID 발급
- 승인된 리디렉션 URI 추가:
- 개발:
http://localhost:3000/api/auth/callback/google - 프로덕션:
https://yourdomain.com/api/auth/callback/google
- 개발:
GitHub OAuth 설정
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
- Authorization callback URL:
http://localhost:3000/api/auth/callback/github
커스텀 로그인 페이지
// app/login/page.tsx
import { signIn } from '@/auth';
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">
로그인
</h1>
{/* Google 로그인 */}
<form
action={async () => {
'use server';
await signIn('google', { redirectTo: '/dashboard' });
}}
>
<button
type="submit"
className="mb-3 flex w-full items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<svg className="h-5 w-5" viewBox="0 0 24 24">
{/* Google 아이콘 SVG path 생략 */}
</svg>
Google로 계속하기
</button>
</form>
{/* GitHub 로그인 */}
<form
action={async () => {
'use server';
await signIn('github', { redirectTo: '/dashboard' });
}}
>
<button
type="submit"
className="flex w-full items-center justify-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm font-medium text-white hover:bg-gray-700"
>
GitHub로 계속하기
</button>
</form>
</div>
</div>
);
}
Auth.js 설정에 커스텀 페이지 경로 등록:
// auth.ts 업데이트
export const { handlers, signIn, signOut, auth } = NextAuth({
pages: {
signIn: '/login',
error: '/login', // 에러도 로그인 페이지로
},
providers: [Google, GitHub],
// ...
});
JWT 세션 관리
세션 조회 — 서버 컴포넌트
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return (
<div>
<h1>안녕하세요, {session.user.name}님!</h1>
<p>이메일: {session.user.email}</p>
<p>역할: {session.user.role}</p>
<img
src={session.user.image ?? ''}
alt="프로필"
className="h-12 w-12 rounded-full"
/>
</div>
);
}
세션 조회 — 클라이언트 컴포넌트
// components/UserMenu.tsx
'use client';
import { useSession, signOut } from 'next-auth/react';
export default function UserMenu() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />;
}
if (!session) {
return <a href="/login">로그인</a>;
}
return (
<div className="relative">
<img
src={session.user?.image ?? ''}
alt="프로필"
className="h-8 w-8 cursor-pointer rounded-full"
/>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="mt-2 rounded bg-red-500 px-3 py-1 text-sm text-white"
>
로그아웃
</button>
</div>
);
}
SessionProvider 래핑
클라이언트에서 useSession을 사용하려면 최상위에 SessionProvider가 필요합니다.
// app/providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
// 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>
);
}
미들웨어로 라우트 보호
미들웨어(Middleware)는 Edge Runtime에서 실행되며, 요청이 실제 페이지나 API에 도달하기 전에 인증 여부를 확인할 수 있습니다. 가장 효율적인 보호 방법입니다.
기본 미들웨어 설정
// middleware.ts (프로젝트 루트)
export { auth as middleware } from '@/auth';
export const config = {
matcher: [
// 보호할 경로 패턴
'/dashboard/:path*',
'/admin/:path*',
'/profile/:path*',
'/api/protected/:path*',
],
};
역할 기반 접근 제어(RBAC)
// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const { pathname } = req.nextUrl;
const session = req.auth;
// 비로그인 사용자 처리
if (!session) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 관리자 전용 페이지 보호
if (pathname.startsWith('/admin') && session.user?.role !== 'admin') {
return NextResponse.redirect(new URL('/403', req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/profile/:path*'],
};
실전 예제 — 전체 인증 시스템 구축
프로젝트 구조
app/
(auth)/
login/
page.tsx ← 로그인 페이지
(protected)/
layout.tsx ← 인증 필수 레이아웃
dashboard/
page.tsx ← 대시보드
admin/
page.tsx ← 관리자 전용
api/
auth/
[...nextauth]/
route.ts ← Auth.js 핸들러
auth.ts ← Auth.js 설정
middleware.ts ← 라우트 보호
인증 필수 레이아웃
// app/(protected)/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import Navbar from '@/components/Navbar';
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) {
redirect('/login');
}
return (
<div className="min-h-screen bg-gray-50">
<Navbar session={session} />
<main className="container mx-auto px-4 py-8">{children}</main>
</div>
);
}
네비게이션 바
// components/Navbar.tsx
import { signOut } from '@/auth';
import { Session } from 'next-auth';
export default function Navbar({ session }: { session: Session }) {
return (
<nav className="border-b bg-white px-4 py-3">
<div className="container mx-auto flex items-center justify-between">
<a href="/dashboard" className="text-xl font-bold text-blue-600">
MyApp
</a>
<div className="flex items-center gap-4">
{session.user?.role === 'admin' && (
<a
href="/admin"
className="text-sm font-medium text-purple-600 hover:text-purple-800"
>
관리자
</a>
)}
<div className="flex items-center gap-2">
{session.user?.image && (
<img
src={session.user.image}
alt="프로필"
className="h-8 w-8 rounded-full"
/>
)}
<span className="text-sm text-gray-700">{session.user?.name}</span>
</div>
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/' });
}}
>
<button
type="submit"
className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
로그아웃
</button>
</form>
</div>
</div>
</nav>
);
}
관리자 페이지
// app/(protected)/admin/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const session = await auth();
// 이중 보호: 미들웨어 + 서버 컴포넌트
if (session?.user?.role !== 'admin') {
redirect('/403');
}
return (
<div>
<h1 className="mb-6 text-2xl font-bold">관리자 대시보드</h1>
<div className="grid grid-cols-3 gap-4">
<StatCard title="전체 사용자" value="1,234" />
<StatCard title="오늘 신규" value="42" />
<StatCard title="월간 활성" value="891" />
</div>
</div>
);
}
function StatCard({ title, value }: { title: string; value: string }) {
return (
<div className="rounded-xl bg-white p-6 shadow">
<p className="text-sm text-gray-500">{title}</p>
<p className="mt-1 text-3xl font-bold text-gray-900">{value}</p>
</div>
);
}
API Route 보호
// app/api/protected/data/route.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json(
{ error: '인증이 필요합니다.' },
{ status: 401 }
);
}
if (session.user?.role !== 'admin') {
return NextResponse.json(
{ error: '권한이 없습니다.' },
{ status: 403 }
);
}
// 보호된 데이터 반환
return NextResponse.json({ data: '관리자 전용 데이터' });
}
TypeScript 타입 확장
Auth.js의 Session 타입에 커스텀 필드(role)를 추가하려면 타입을 확장해야 합니다.
// types/next-auth.d.ts
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
role: string;
} & DefaultSession['user'];
}
interface User {
role?: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
role?: string;
}
}
고수 팁
1. 데이터베이스 어댑터로 세션 영속화
기본 JWT 세션 대신 데이터베이스에 세션을 저장하면 서버에서 세션을 강제 만료할 수 있습니다.
npm install @auth/prisma-adapter @prisma/client prisma
// auth.ts
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import Google from 'next-auth/providers/google';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'database' }, // JWT 대신 DB 세션
providers: [Google],
});
2. Credentials Provider (이메일/패스워드)
import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
});
3. 미들웨어 성능 최적화
미들웨어는 모든 요청마다 실행되므로, 정적 파일 경로는 제외합니다.
// middleware.ts
export const config = {
matcher: [
/*
* 다음 경로를 제외한 모든 요청에 적용:
* - _next/static (정적 파일)
* - _next/image (이미지 최적화)
* - favicon.ico
* - public 폴더
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
4. CSRF 보호
Auth.js v5는 기본적으로 CSRF 토큰을 자동 처리합니다. Server Action을 통한 signIn/signOut 호출은 자동으로 보호됩니다.
5. 세션 만료 시간 커스터마이징
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30일 (초 단위)
updateAge: 24 * 60 * 60, // 24시간마다 갱신
},
// ...
});
6. 이벤트 훅으로 로그 수집
export const { handlers, auth, signIn, signOut } = NextAuth({
events: {
async signIn({ user, account }) {
console.log(`[AUTH] 로그인: ${user.email} via ${account?.provider}`);
// 데이터베이스에 로그인 기록 저장
},
async signOut() {
console.log('[AUTH] 로그아웃');
},
},
// ...
});