SHIN
Next.js App Router로 풀스택 블로그 만들기
9편v5는 App Router에 맞게 전면 재작성되었습니다. 설정 파일 하나에서 핸들러, 헬퍼, 미들웨어를 모두 내보냅니다.
// src/lib/auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize({ email, password }) {
if (!email || !password) return null
const user = await prisma.user.findUnique({ where: { email: String(email) } })
if (!user?.password) return null
const ok = await bcrypt.compare(String(password), user.password)
if (!ok) return null
return { id: user.id, name: user.name, email: user.email, role: user.role }
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.role = (user as any).role
return token
},
session({ session, token }) {
if (session.user) (session.user as any).role = token.role
return session
},
},
pages: { signIn: '/admin/login' },
})// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers단 두 줄로 모든 인증 엔드포인트가 생성됩니다.
// src/middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isAdminRoute = req.nextUrl.pathname.startsWith('/admin')
const isLoginPage = req.nextUrl.pathname === '/admin/login'
if (isAdminRoute && !isLoginPage && !req.auth) {
return NextResponse.redirect(new URL('/admin/login', req.url))
}
if (isLoginPage && req.auth) {
return NextResponse.redirect(new URL('/admin', req.url))
}
})
export const config = {
matcher: ['/admin/:path*'],
}미들웨어는 Edge Runtime에서 실행되어 응답 속도가 매우 빠릅니다.
// src/app/admin/login/page.tsx
'use client'
import { signIn } from 'next-auth/react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [error, setError] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const result = await signIn('credentials', {
email: fd.get('email'),
password: fd.get('password'),
redirect: false,
})
if (result?.error) {
setError('이메일 또는 비밀번호가 올바르지 않습니다.')
} else {
router.push('/admin')
}
}
return (
<form onSubmit={handleSubmit} className="max-w-sm mx-auto mt-20 space-y-4">
<h1 className="text-2xl font-bold">관리자 로그인</h1>
{error && <p className="text-red-400 text-sm">{error}</p>}
<input name="email" type="email" placeholder="이메일" className="input w-full" />
<input name="password" type="password" placeholder="비밀번호" className="input w-full" />
<button type="submit" className="btn-primary w-full">로그인</button>
</form>
)
}import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
if (!session) redirect('/admin/login')
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-8">{children}</main>
</div>
)
}미들웨어와 레이아웃 양쪽에서 인증을 확인하면 더 안전합니다.
import bcrypt from 'bcryptjs'
// 저장 시
const hashed = await bcrypt.hash(plainPassword, 12) // cost factor 12
// 검증 시
const match = await bcrypt.compare(plainPassword, hashed)cost factor가 높을수록 안전하지만 해싱 시간이 길어집니다. 12가 일반적인 균형점입니다.
다음 편에서는 관리자 대시보드의 포스트 작성/수정/삭제 기능을 구현합니다.