Post

NextAuth5를 사용하여 Next.js15 인증시스템 구축하기

NextAuth5를 사용하여 Next.js15 인증시스템 구축하기

NextAuth를 활용한 인증 시스템 구현하기

NextAuth는 Next.js 애플리케이션에서 인증 기능을 쉽게 구현할 수 있게 해주는 강력한 라이브러리입니다.

이번 포스트에서는 NextAuth를 사용하여 이메일 기반의 인증 시스템을 구축하는 방법을 상세히 알아보겠습니다.

1. 필요한 패키지 설치하기

1
npm install next-auth @auth/prisma-adapter bcryptj

2. 스키마 설정하기

먼저 사용자 인증에 필요한 유효성 검사 스키마를 정의해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { z } from "zod";

// 로그인 스키마 정의
export const LoginSchema = z.object({
  email: z.string().email({
    message: "이메일 형식이 올바르지 않습니다."
  }),
  password: z.string().min(1, {
    message: "비밀번호는 필수 입력 항목입니다."
  })
});

// 회원가입 스키마 정의
export const RegisterSchema = z.object({
  email: z.string().email({
    message: "이메일 형식이 올바르지 않습니다."
  }),
  password: z.string().min(6, {
    message: "비밀번호는 최소 6자 이상이어야 합니다."
  }),
  name: z
    .string()
    .min(1, {
      message: "이름은 필수 입력 항목입니다."
    })
    .max(20, {
      message: "이름은 최대 20자까지 입력할 수 있습니다."
    })
});

3. NextAuth 설정하기

3.1 API 라우트 설정([…nextauth])

1
2
3
// /auth/[…nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

3.2 기본 인증 설정(auth.ts)

1
2
3
4
5
6
7
8
9
10
11
12
13
import { db } from "./db";
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import authConfig from "./auth.config";

// NextAuth 기본 설정 및 핸들러 내보내기
export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(db), // Prisma 어댑터 연결
  session: {
    strategy: "jwt" // JWT 기반 세션 관리 사용
  },
  ...authConfig
});

3.3 상세 인증 설정(auth.config.ts)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import type { NextAuthConfig } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { compare } from "bcryptjs";
import { db } from "./db";

export default {
  providers: [
    // Google OAuth 제공자 설정
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: {
        params: {
          prompt: "consent",
          access_type: "offline",
          response_type: "code"
        }
      },
      profile(profile) {
        return {
          id: profile.sub,
          email: profile.email,
          name: profile.name,
          image: profile.picture
        };
      }
    }),

    // 이메일/비밀번호 인증 제공자 설정
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error("인증 정보가 필요합니다");
        }

        const user = await db.user.findUnique({
          where: { email: credentials.email }
        });

        if (!user || !user.password) {
          throw new Error("사용자를 찾을 수 없습니다");
        }

        const isPasswordValid = await compare(
          credentials.password,
          user.password
        );

        if (!isPasswordValid) {
          throw new Error("비밀번호가 일치하지 않습니다");
        }

        return user;
      }
    })
  ],

  pages: {
    signIn: "/auth/login",
    error: "/auth/error"
  },

  callbacks: {
    // 로그인 콜백
    async signIn({ user, account }) {
      if (!user?.email) {
        return false;
      }

      if (account?.provider === "google") {
        try {
          let dbUser = await db.user.findUnique({
            where: { email: user.email }
          });

          if (!dbUser) {
            dbUser = await db.user.create({
              data: {
                email: user.email,
                name: user.name!,
                image: user.image,
                accounts: {
                  create: {
                    type: account.type,
                    provider: account.provider,
                    providerAccountId: account.providerAccountId
                  }
                }
              }
            });
          }
          user.id = dbUser.id;
          return true;
        } catch (error) {
          console.error("Error creating user", error);
          return false;
        }
      }
      return true;
    },

    // JWT 토큰 생성/수정
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },

    // 세션 생성/수정
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id;
      }
      return session;
    }
  },

  secret: process.env.AUTH_SECRET
} satisfies NextAuthConfig;

4. 미들웨어 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "./lib/auth";

export async function middleware(request: NextRequest) {
  const session = await auth();
  const { pathname } = request.nextUrl;

  // 보호된 경로 정의
  const protectedPaths = ["/gallery", "/settings", "/profile", "/social"];
  const authPublicPaths = ["/api/auth", "/auth/callback"];

  // 경로 및 인증 상태 확인
  const isLoggedIn = !!session?.user;
  const isProtectedPath = protectedPaths.some((path) =>
    pathname.startsWith(path)
  );
  const isOnAuthPage = pathname.startsWith("/auth");
  const isPublicPath = pathname === "/" || isOnAuthPage;
  const isAuthPublicPath = authPublicPaths.some((path) =>
    pathname.startsWith(path)
  );

  // API 경로 처리
  if (pathname.startsWith("/api/")) {
    return NextResponse.next();
  }

  // Auth 관련 public 경로 처리
  if (isAuthPublicPath) {
    return NextResponse.next();
  }

  // 보호된 경로 접근 제어
  if (isProtectedPath && !isLoggedIn) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  // 로그인 상태에서 인증 페이지 접근 제어
  if (isPublicPath && isLoggedIn) {
    return NextResponse.redirect(
      new URL(`/gallery/${session.user.id}`, request.url)
    );
  }

  return NextResponse.next();
}

// 미들웨어 적용 경로 설정
export const config = {
  matcher: [
    "/gallery/:path*",
    "/profile/:path*",
    "/social/:path*",
    "/auth/:path*",
    "/settings/:path*",
    "/",
    "/((?!api|_next/static|_next/image|favicon.ico).*)"
  ]
};

5. 서버 액션 구현하기

5.1 사용자 데이터 조회 액션

1
2
3
4
5
6
7
8
9
10
11
12
13
import { db } from "@/lib/db";

export const getUserByEmail = async (email: string) => {
  try {
    const user = await db.user.findUnique({
      where: { email }
    });
    return user;
  } catch (error) {
    console.error(error);
    return null;
  }
};

5.2 로그인 액션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"use server";

import { getUserByEmail } from "@/data/user";
import { signIn } from "@/lib/auth";
import { LoginSchema } from "@/schemas";
import bcrypt from "bcryptjs";
import { z } from "zod";

export const login = async (values: z.infer<typeof LoginSchema>) => {
  // 입력값 유효성 검사
  const validatedFields = LoginSchema.safeParse(values);

  if (!validatedFields.success) {
    return { error: "Invalid fields" };
  }

  const { email, password } = validatedFields.data;
  const existingUser = await getUserByEmail(email);

  // 사용자 존재 여부 확인
  if (!existingUser) {
    return { error: "이메일이 존재하지 않습니다." };
  }

  if (!existingUser?.password) {
    return { error: "이메일 또는 비밀번호가 일치하지 않습니다." };
  }

  // 비밀번호 검증
  const passwordsMatch = await bcrypt.compare(password, existingUser.password);

  if (!passwordsMatch) {
    return { error: "이메일 또는 비밀번호가 일치하지 않습니다." };
  }

  // 로그인 처리
  try {
    await signIn("credentials", {
      email,
      password,
      redirect: false
    });
    return { success: "로그인이 성공했습니다." };
  } catch (error) {
    console.log(error);
    return { error: "알 수 없는 오류가 발생했습니다." };
  }
};

5.3 회원가입 액션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
"use server";

import bcrypt from "bcryptjs";
import { RegisterSchema } from "@/schemas";
import { z } from "zod";
import { db } from "@/lib/db";
import { getUserByEmail } from "@/data/user";

export const register = async (values: z.infer<typeof RegisterSchema>) => {
  // 입력값 유효성 검사
  const validatedFields = RegisterSchema.safeParse(values);

  if (!validatedFields.success) {
    return { error: "Invalid fields" };
  }

  const { name, email, password } = validatedFields.data;
  const hashedPassword = await bcrypt.hash(password, 10);

  // 이메일 중복 확인
  const existingUser = await getUserByEmail(email);

  if (existingUser) {
    return { error: "User already exists" };
  }

  // 사용자 생성
  await db.user.create({
    data: {
      name,
      email,
      password: hashedPassword
    }
  });

  return { success: "Registered successfully" };
};

6. 클라이언트 컴포넌트 구현하기

6.1 로그인 폼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema } from "@/schemas";
import { useTransition } from "react";
import { login } from "@/actions/login";
import { useRouter } from "next/navigation";

const LoginForm = () => {
  const [isPending, startTransition] = useTransition();
  const router = useRouter();

  // React Hook Form 설정
  const form = useForm<z.infer<typeof LoginSchema>>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: "",
      password: ""
    }
  });

  // 폼 제출 처리
  const handleSubmit = async (data: z.infer<typeof LoginSchema>) => {
    try {
      startTransition(async () => {
        const response = await login(data);
        if (response.success) {
          router.push("/");
        } else {
          form.setError("email", { message: response.error });
        }
      });
    } catch (error) {
      console.log(data, error);
    }
  };

  return (
    <form
      className="flex flex-col w-full items-center justify-center text-black dark:text-white gap-6"
      onSubmit={form.handleSubmit(handleSubmit)}
    >
      <div className="flex flex-col gap-3 w-[70%]">
        <Input type="text" placeholder="Email" {...form.register("email")} />
        <Input
          type="password"
          placeholder="Password"
          {...form.register("password")}
        />
        {form.formState.errors.email && (
          <p className="text-red-500">{form.formState.errors.email.message}</p>
        )}
      </div>
      <Button
        variant="basic"
        type="submit"
        className="w-[70%]"
        disabled={isPending}
      >
        로그인하기
      </Button>
    </form>
  );
};

export default LoginForm;

6.2 회원가입 폼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"use client";

import { useTransition, useState } from "react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { RegisterSchema } from "@/schemas";
import { register } from "@/actions/register";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

const RegisterForm = () => {
  const [isPending, startTransition] = useTransition();
  const [step, setStep] = useState<number>(1);
  const router = useRouter();

  // React Hook Form 설정
  const form = useForm<z.infer<typeof RegisterSchema>>({
    resolver: zodResolver(RegisterSchema),
    defaultValues: {
      email: "",
      password: "",
      name: ""
    },
    mode: "onChange"
  });

  const { errors } = form.formState;

  // 단계별 폼 제출 처리
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const values = form.getValues();

    // 이메일 입력 단계
    if (step === 1) {
      const emailSchema = z.object({
        email: RegisterSchema.shape.email
      });

      const result = emailSchema.safeParse({ email: values.email });
      if (!result.success) {
        form.setError("email", { message: "이메일 형식이 올바르지 않습니다." });
        return;
      }
      setStep(2);
      return;
    }

    // 비밀번호 입력 단계
    if (step === 2) {
      const passwordSchema = z.object({
        password: RegisterSchema.shape.password
      });

      const result = passwordSchema.safeParse({ password: values.password });
      if (!result.success) {
        form.setError("password", {
          message: "비밀번호는 6자 이상이어야 합니다."
        });
        return;
      }
      setStep(3);
      return;
    }

    // 이름 입력 및 최종 제출 단계
    if (step === 3) {
      const result = RegisterSchema.safeParse(values);
      if (!result.success) {
        form.setError("name", {
          message: "이름은 필수 입력 항목입니다."
        });
        return;
      }

      startTransition(async () => {
        try {
          await register(values);
          router.push("/auth/login");
        } catch (error) {
          console.log(error);
        }
      });
    }
  };

  return (
    <form
      onSubmit={handleSubmit}
      className="flex flex-col w-full items-center justify-center text-black dark:text-white gap-4"
    >
      {/* 단계별 입력 폼 렌더링 */}
      {step === 1 && (
        <>
          <h2 className="text-xl font-semibold">이메일을 입력해주세요</h2>
          <Input
            {...form.register("email")}
            type="email"
            placeholder="Email"
            className="w-[70%]"
          />
          {errors.email && (
            <span className="text-red-500 text-sm">{errors.email.message}</span>
          )}
        </>
      )}

      {step === 2 && (
        <>
          <h2 className="text-xl font-semibold">비밀번호를 입력하세요</h2>
          <Input
            {...form.register("password")}
            type="password"
            placeholder="Password"
            className="w-[70%]"
          />
          {errors.password && (
            <span className="text-red-500 text-sm">
              {errors.password.message}
            </span>
          )}
        </>
      )}

      {step === 3 && (
        <>
          <h2 className="text-xl font-semibold">이름을 입력하세요</h2>
          <Input
            {...form.register("name")}
            type="text"
            placeholder="Name"
            className="w-[70%]"
          />
          {errors.name && (
            <span className="text-red-500 text-sm">{errors.name.message}</span>
          )}
        </>
      )}

      <div className="flex w-[70%] justify-end gap-2">
        {step > 1 && (
          <Button
            type="button"
            variant="secondary"
            onClick={() => setStep(step - 1)}
          >
            이전
          </Button>
        )}
        <Button type="submit" variant="basic" disabled={isPending}>
          {step === 3 ? "가입하기" : "다음"}
        </Button>
      </div>
    </form>
  );
};

export default RegisterForm;

마무리

이렇게 NextAuth를 활용하여 완전한 인증 시스템을 구축해보았습니다. 주요 구현 내용을 정리하면 다음과 같습니다:

  • Zod를 활용한 데이터 유효성 검사 스키마 구현
  • NextAuth 설정을 통한 다중 인증 제공자(Google OAuth, Credentials) 지원
  • JWT 기반의 세션 관리 구현
  • 미들웨어를 통한 보호된 라우트 관리
  • 서버 액션을 통한 안전한 인증 처리
  • React Hook Form을 활용한 단계별 회원가입 폼 구현
This post is licensed under CC BY 4.0 by the author.