Ch 03. 회원가입과 로그인 구현
비밀번호를 데이터베이스에 그대로 저장하는 것은 위험합니다. 데이터베이스가 유출되면 모든 사용자의 비밀번호가 노출됩니다. 비밀번호는 반드시 해싱해서 저장해야 합니다. 해시는 단방향이라 원래 값을 알 수 없습니다. 로그인할 때는 입력한 비밀번호를 같은 방식으로 해싱해서 저장된 해시와 비교합니다.
bcryptjs 설치
npm install bcryptjsnpm install -D @types/bcryptjs```text### 회원가입 Server Action```typescript// 파일: lib/actions.ts"use server";import { prisma } from "@/lib/prisma";import bcrypt from "bcryptjs";import { redirect } from "next/navigation";import { z } from "zod";// 입력 유효성 검사 스키마const registerSchema = z.object({ name: z.string().min(2, "이름은 2자 이상이어야 합니다"), email: z.string().email("올바른 이메일 형식이 아닙니다"), password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다"),});export type ActionResult = { error?: string; success?: boolean;};export async function registerUser( formData: FormData): Promise<ActionResult> { const raw = { name: formData.get("name") as string, email: formData.get("email") as string, password: formData.get("password") as string, }; // 유효성 검사 const parsed = registerSchema.safeParse(raw); if (!parsed.success) { return { error: parsed.error.errors[0].message }; } const { name, email, password } = parsed.data; // 이미 존재하는 이메일인지 확인 const existing = await prisma.user.findUnique({ where: { email } }); if (existing) { return { error: "이미 사용 중인 이메일입니다" }; } // 비밀번호 해싱 (salt rounds: 12) const hashedPassword = await bcrypt.hash(password, 12); // 사용자 생성 await prisma.user.create({ data: { name, email, password: hashedPassword }, }); redirect("/login?registered=true");}```text### 회원가입 페이지```typescript// 파일: app/register/page.tsximport { registerUser } from "@/lib/actions";export default function RegisterPage({ searchParams,}: { searchParams: Promise<{ registered?: string }>;}) { return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="w-full max-w-md p-8 bg-white rounded-xl shadow-sm"> <h2 className="text-2xl font-bold mb-8 text-center">회원가입</h2> <form action={registerUser} className="space-y-4"> <div> <label htmlFor="name" className="block text-sm font-medium mb-1"> 이름 </label> <input id="name" name="name" type="text" required minLength={2} className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="홍길동" /> </div> <div> <label htmlFor="email" className="block text-sm font-medium mb-1"> 이메일 </label> <input id="email" name="email" type="email" required className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="[email protected]" /> </div> <div> <label htmlFor="password" className="block text-sm font-medium mb-1" > 비밀번호 </label> <input id="password" name="password" type="password" required minLength={8} className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="8자 이상" /> </div> <button type="submit" className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium" > 가입하기 </button> </form> <p className="text-center text-sm text-gray-500 mt-6"> 이미 계정이 있으신가요?{" "} <a href="/login" className="text-blue-600 hover:underline"> 로그인 </a> </p> </div> </div> );}```text### NextAuth credentials provider에 DB 로그인 연결PART 06에서 만든 `auth.ts`를 실제 데이터베이스 조회로 업데이트합니다.```typescript// 파일: auth.tsimport NextAuth from "next-auth";import Credentials from "next-auth/providers/credentials";import { prisma } from "@/lib/prisma";import bcrypt from "bcryptjs";export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ Credentials({ credentials: { email: { label: "이메일", type: "email" }, password: { label: "비밀번호", type: "password" }, }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { return null; } // DB에서 사용자 조회 const user = await prisma.user.findUnique({ where: { email: credentials.email as string }, }); if (!user) return null; // 비밀번호 검증 const isValid = await bcrypt.compare( credentials.password as string, user.password ); if (!isValid) return null; return { id: user.id, name: user.name, email: user.email, role: user.role, }; }, }), ], callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id; token.role = (user as { role?: string }).role; } return token; }, async session({ session, token }) { if (token) { session.user.id = token.id as string; (session.user as { role?: string }).role = token.role as string; } return session; }, }, pages: { signIn: "/login" }, session: { strategy: "jwt" },});```text### 로그인 상태에 따른 헤더 UI```typescript// 파일: components/Header.tsximport { auth } from "@/auth";import Link from "next/link";import { signOut } from "@/auth";export async function Header() { const session = await auth(); async function handleLogout() { "use server"; await signOut({ redirectTo: "/" }); } return ( <header className="sticky top-0 z-50 bg-white border-b"> <div className="container mx-auto px-4 h-16 flex items-center justify-between"> <Link href="/" className="text-xl font-bold text-gray-900"> 나의 블로그 </Link> <nav className="flex items-center gap-6"> <Link href="/posts" className="text-sm text-gray-600 hover:text-gray-900" > 글 목록 </Link> {session ? ( <> <Link href="/posts/new" className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700" > 글 쓰기 </Link> <div className="flex items-center gap-3"> <span className="text-sm text-gray-600"> {session.user?.name} </span> <form action={handleLogout}> <button type="submit" className="text-sm text-gray-500 hover:text-gray-700" > 로그아웃 </button> </form> </div> </> ) : ( <div className="flex items-center gap-3"> <Link href="/login" className="text-sm text-gray-600 hover:text-gray-900" > 로그인 </Link> <Link href="/register" className="text-sm px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50" > 회원가입 </Link> </div> )} </nav> </div> </header> );}
이 헤더는 Server Component입니다. auth()를 직접 호출해 세션을 가져옵니다. 클라이언트 측 하이드레이션 없이 서버에서 이미 올바른 UI로 렌더링됩니다.
다음 챕터에서는 Server Actions를 활용해 글 작성, 수정, 삭제 기능을 구현합니다.