[NEXT] 3. 로그인, 회원가입 구현하기 / Next-Auth, Prisma / `NextRouter` was not mounted 에러 해결
🚀 Next-Auth로 로그인, 회원가입 구현
모든 걸 나 혼자 정해야하니까 어떤 라이브러리로 해야 좋을지 고민하는 시간만 한 세월..
열심히 찾아보다가 next-auth가 일반 이메일 가입뿐만 아니라 구글, 네이버, 카카오 등 sns 로그인도 제공하고 있어서 결정!
나중에 sns 로그인도 추가할 예정이라,, ㅎㅎ
NextAuth.js
Authentication for Next.js
next-auth.js.org
next.js app-router에 관한 정보도 별로 없을 뿐더러 app-router에 next-auth를 적용한 사례도 많지 않아서 괜찮을까 하는 중에 아주 좋은 블로그를 발견해서 그 글을 참고해 개발을 하려고 한다!
https://mycodings.fly.dev/blog/2023-05-31-nextjs-nextauth-tutorial-1-setup
NextAuth 사용법 1편 - Setup, Credentials
NextAuth 사용법 1편 - Setup, Credentials
mycodings.fly.dev
나의 한 줄기 빛,,
1️⃣ 로그인, 회원가입 페이지 퍼블리싱
뭐,, 퍼블리싱은 식은 죽 먹기지 ㅎㅎㅎ
후다닥 끝내주고 next-auth로 구현하자
2️⃣ next-auth 사용하기
1. next-auth 설치
npm install next-auth
2. app/api/auth/[...nextauth]/route.ts 파일 추가
import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
const handler = NextAuth({
providers: [
// 이메일과 패스워드 방식으로 사용자가 직접 DB 부분을 컨트롤할 수 있음
CredentialsProvider({
name: "Credentials",
// 로그인 form 내용
credentials: {
username: { label: "이메일", type: "text", placeholder: "이메일 입력" },
password: { label: "비밀번호", type: "password" },
},
// 이메일, 패스워드 부분을 체크해서
// 맞으면 user 객체 리턴
// 틀리면 null 리턴
async authorize(credentials, req) {
const user = { id: "1", name: "J Smith", email: "jsmith@example.com" };
if (user) {
return user;
} else {
return null;
}
},
}),
],
});
export { handler as GET, handler as POST };
providers 부분에 카카오, 구글, 네이버 등을 추가할 수 있다
3. localhost:3000/api/auth/signin 으로 이동
위 주소로 이동하면 이렇게 next-auth에서 기본으로 제공하는 폼이 나온다
하지만 난 직접 퍼블리싱한 걸 쓸 거기에 이 부분은 확인용!
4. .env 파일 수정
Sign In with Credentials 버튼을 누르면 이상한 주소로 가게 되는데 .env 파일을 이렇게 바꿔주면 된다
NEXTAUTH_SECRET에는 아무거나 적어줘도 무방
3️⃣ Prisma 사용하기
📚 Prisma란?
- 데이터베이스 ORM
- ORM: Object-relational mapping. DB데이터(schema)를 객체(Object)로 매핑해주는 역할.
- 모델링된 객체와 관계를 바탕으로 SQL을 자동으로 생성해주는 도구
1. prisma 설치
// prisma 설치
npm i prisma -D
// prisma 초기화, 데이터베이스 설치
npx prisma init --datasource-provider sqlite
이렇게 하게 되면 자동으로
- prisma/schema.prisma가 생성됨
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// 가장 기본이 되는 DB의 뼈대를 만드는 파일
// 클라이언트 쪽에서 Prisma DB를 연결하려면 prisma-client가 필요
generator client {
provider = "prisma-client-js"
}
// 어떤 종류의 DB를 쓰는지, 해당 파일에 대한 url
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// 테이블 === model
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
password String
posts Post[]
}
// 간단한 게시물 작성할 때 사용
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
}
📔 User 모델의 posts --- Post 모델의 author란 User 모델 (서로 연결)
- .env 파일에 이 부분이 추가 됨
DATABASE_URL="file:./dev.db"
2. prisma Migration
- Prisma 스키마와 코드 연결
- sqlite 파일도 만들고 그 파일 안에 테이블도 만들어야 함
npx prisma migrate dev --name init
이렇게 하면 자동으로 dev.dv파일과 migrations 폴더 생성
3. prisma sutdio 설치
npx prisma studio
-> 웹 상에서 데이터를 수동으로 조작할 수 있는 툴 제공
Prisma Studio is up on http://localhost:5555
이 url로 들어가면 조작 가능
4. prisma client 설치
- Next.js에서 Prisma를 사용할 수 있게 하는 SDK 같은 것을 설치
npm i @prisma/client
- app/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
4️⃣ 회원가입 구현
1. app/api/user/route.ts
import prisma from "@/app/lib/prisma";
import * as bcrypt from "bcrypt";
interface RequestBody {
name: string;
email: string;
password: string;
}
export async function POST(request: Request) {
// request.json() 형식으로 body 부분 추출
const body: RequestBody = await request.json();
// DB User 테이블에 데이터 넣기
const user = await prisma.user.create({
data: {
name: body.name,
email: body.email,
password: await bcrypt.hash(body.password, 10),
},
});
// user 객체에서 password 부분을 제외하고 나머지 부분만 최종적으로 response 해주기
const { password, ...result } = user;
return new Response(JSON.stringify(result));
}
- bcrypt 라이브러리로 비밀번호 암호화해서 저장
2. insomnia에서 데이터 test
- prisma stuido에서 확인
3. app/signup
"use client";
import { FormEvent, useState } from "react";
import * as styles from "./index.css";
import { useRouter } from "next/navigation";
const SignUpPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordCheck, setPasswordCheck] = useState("");
const [userName, setUserName] = useState("");
const router = useRouter();
const onChangeId = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const onChangePw = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
const onChangePwCheck = (e: React.ChangeEvent<HTMLInputElement>) => {
setPasswordCheck(e.target.value);
};
const onChangeUserName = (e: React.ChangeEvent<HTMLInputElement>) => {
setUserName(e.target.value);
};
const onChangeNickName = (e: React.ChangeEvent<HTMLInputElement>) => {
setNickName(e.target.value);
};
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (password !== passwordCheck) {
alert("비밀번호가 일치하지 않습니다.");
return;
}
try {
// 회원가입 데이터를 JSON 형태로 만듭니다.
const userData = {
email,
password,
userName,
};
// 회원가입 API에 데이터를 POST 요청으로 보냅니다.
fetch("http://localhost:3000/api/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userData),
}).then((res) => {
if (res.ok) {
alert("회원가입이 완료되었습니다.");
router.push("/signin");
}
});
} catch {}
};
return (
<div className={styles.root}>
<h2 className={styles.h1}>회원가입</h2>
<form onSubmit={onSubmit}>
{/* 아이디 */}
<div className={styles.inputBox2}>
<label htmlFor="id" className={styles.p}>
아이디
</label>
<input
type="text"
id="id"
name="id"
value={email}
onChange={onChangeId}
required
/>
<button className={styles.idBtn}>중복확인</button>
</div>
{/* 비밀번호 */}
<div className={styles.inputBox}>
<label htmlFor="password" className={styles.p}>
비밀번호
</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={onChangePw}
required
/>
</div>
{/* 비밀번호 확인 */}
<div className={styles.inputBox}>
<label htmlFor="passwordCheck" className={styles.p}>
비밀번호 확인
</label>
<input
type="password"
id="passwordCheck"
name="passwordCheck"
value={passwordCheck}
onChange={onChangePwCheck}
required
/>
{password !== passwordCheck && "비밀번호가 일치하지 않습니다."}
</div>
{/* 이름 */}
<div className={styles.inputBox}>
<label htmlFor="name" className={styles.p}>
이름
</label>
<input
type="text"
id="name"
name="name"
value={userName}
onChange={onChangeUserName}
required
/>
</div>
{/* 회원가입 버튼 */}
<div className={styles.buttonBox}>
<button type="submit">회원가입</button>
</div>
</form>
</div>
);
};
export default SignUpPage;
🚨 이 부분에서 계속 `NextRouter` was not mounted 라는 에러가 발생하였다.
이것저것 구글링해보고 블로그도 들어가서 보고 gpt한테도 물어봤지만 해결이 안 되었다,,
근데!! 역시나 갓공식문서라고 공식문서에 들어가보니까
App Router's useRouter from next/navigation has different behavior to the useRouter hook in pages.
이렇게 적혀있었다!
import 할 때
import { useRouter } from "next/navigation";
이렇게 해주니까 에러 해결!
4. 화면에서 확인
- 회원가입 버튼 누르면 alert로 알림창 띄워주기
- 네트워크에서 데이터 잘 가는지 확인
5. db에서 확인
6. 이미 가입된 이메일이라면 알림창 띄우기
7. 로그인 화면으로 이동
- alert창 확인버튼 누르면 로그인화면으로 이동
5️⃣ 로그인 구현
1. app/api/auth/login/route.ts
import prisma from "@/app/lib/prisma";
import * as bcrypt from "bcrypt";
interface RequestBody {
username: string;
password: string;
}
export async function POST(request: Request) {
const body: RequestBody = await request.json();
// findFirst
// where부분에 email을 넣으면 이메일에 해당하는 user 정보 중 첫 번째를 찾음
const user = await prisma.user.findFirst({
where: {
email: body.username,
},
});
// username과 password부분을 불러와 DB에 있는 username과 password와 비교
// 맞으면 user 정보 리턴
// 틀리면 null 리턴
if (user && (await bcrypt.compare(body.password, user.password))) {
const { password, ...userWithoutPass } = user;
return new Response(JSON.stringify(userWithoutPass));
} else return new Response(JSON.stringify(null));
}
2. 커스텀 로그인을 사용하기 위해
import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
const handler = NextAuth({
providers: [
// 이메일과 패스워드 방식으로 사용자가 직접 DB 부분을 컨트롤할 수 있음
CredentialsProvider({
name: "Credentials",
// 로그인 form 내용
credentials: {
username: { label: "이메일", type: "text", placeholder: "이메일 입력" },
password: { label: "비밀번호", type: "password" },
},
// 이메일, 패스워드 부분을 체크해서
// 맞으면 user 객체 리턴
// 틀리면 null 리턴
async authorize(credentials, req) {
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: credentials?.username,
password: credentials?.password,
}),
});
const user = await res.json();
console.log(user);
if (user) {
return user;
} else {
return null;
}
},
}),
],
// accessToken 관리
callbacks: {
async jwt({ token, user }) {
return { ...token, ...user };
},
async session({ session, token }) {
session.user = token as any;
return session;
},
},
// 이 부분 추가
pages: {
signIn: "/signin",
},
});
export { handler as GET, handler as POST };
3. app/signin/page
"use client";
import Link from "next/link";
import * as styles from "./index.css";
import { FormEvent, useState } from "react";
import { signIn } from "next-auth/react";
const SignInPage = () => {
const [id, setId] = useState("");
const [password, setPassword] = useState("");
const [blankId, setBlankId] = useState(false);
const [blankPw, setBlankPw] = useState(false);
const onChangeId = (e: React.ChangeEvent<HTMLInputElement>) => {
setId(e.target.value);
};
const onChangePw = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (id.length <= 0) setBlankId(true);
if (password.length <= 0) setBlankId(true);
if (!blankId && !blankPw) {
await signIn("credentials", {
username: id,
password: password,
redirect: true,
callbackUrl: "/",
}).then(() => {
alert("로그인이 완료되었습니다.");
});
}
};
return (
<div className={styles.root}>
<h2 className={styles.h1}>로그인</h2>
<form onSubmit={onSubmit}>
{/* 아이디 */}
<div className={styles.inputBox}>
<p className={styles.p}>ID</p>
<input
type="text"
name="id"
value={id}
onChange={onChangeId}
required
/>
</div>
{/* 비밀번호 */}
<div className={styles.inputBox}>
<p className={styles.p}>PASSWORD</p>
<input
type="password"
name="password"
value={password}
onChange={onChangePw}
required
/>
</div>
{/* 로그인 버튼 */}
<div className={styles.buttonBox}>
<button type="submit">로그인</button>
<div className={styles.navi}>
<Link href="/">ID/PW 찾기</Link>
<Link href="/sign/up">회원가입</Link>
</div>
</div>
</form>
</div>
);
};
export default SignInPage;
- 터미널로 확인
- null은 유저 정보가 안 맞을 때 반환
- 아이디와, 비밀번호가 맞다면 해당 user 반환되는 것 확인!
- 화면으로 확인
- 로그인이 완료되면 alert를 띄우게 설정
- 확인 또는 엔터로 누르면 메인페이지로 이동한다
4. Header 수정
"use client";
...
import { useSession } from "next-auth/react";
const Header = () => {
const { data: session } = useSession();
console.log(session);
return (
<div className={styles.root}>
...
{session ? (
<Link href="/">
<Image className={styles.person_fill} src={person_fill} alt="사람" />
</Link>
) : (
<Link href="/signin">
<Image className={styles.person} src={person} alt="사람" />
</Link>
)}
</div>
);
};
export default Header;
useSession으로 세션 정보를 가져온다는 것을 알았다.
여기에 담겨온 유저의 정보를 가져와서
유저 정보가 있으면 채운 사람 모양의 아이콘을, 없으면 안 채운 사람 모양의 아이콘을 보여주도록 하였다
유저 정보도 잘 담겨오고 아이콘도 잘 바뀌는 것을 확인할 수 있다!