NEXT.JS/Cock! 칵테일 프로젝트

[NEXT] 3. 로그인, 회원가입 구현하기 / Next-Auth, Prisma / `NextRouter` was not mounted 에러 해결

예글 2024. 2. 20. 17:49

🚀 Next-Auth로 로그인, 회원가입 구현 

모든 걸 나 혼자 정해야하니까 어떤 라이브러리로 해야 좋을지 고민하는 시간만 한 세월..

열심히 찾아보다가 next-auth가 일반 이메일 가입뿐만 아니라 구글, 네이버, 카카오 등 sns 로그인도 제공하고 있어서 결정!

나중에 sns 로그인도 추가할 예정이라,, ㅎㅎ

 

https://next-auth.js.org/

 

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으로 세션 정보를 가져온다는 것을 알았다.

여기에 담겨온 유저의 정보를 가져와서

유저 정보가 있으면 채운 사람 모양의 아이콘을, 없으면 안 채운 사람 모양의 아이콘을 보여주도록 하였다

 

유저 정보도 잘 담겨오고 아이콘도 잘 바뀌는 것을 확인할 수 있다!