iBetter Books
수정

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를 활용해 글 작성, 수정, 삭제 기능을 구현합니다.