지난 글에서 언급했듯 코드드림의 어드민 서비스를 개발하기 시작했습니다.
팀원들과 모듈을 나누어 개발을 하기로 했고, 저는 로그인 모듈을 맡았습니다. Spring + Spring Security를 활용한 로그인은 많이 구현해봤지만 Next는 처음 해보게 되었습니다. 문법도 너무 어색하고 이게 어떻게 되는거지 싶은게 많았는데요. 기능이 잘 구현되니 꽤 재밌었습니다.
이번 포스팅에서는 로그인 기능을 구현한 과정을 소개합니다. DB를 연결해 사용하고 있기 때문에 지난 글을 통해 DB 연동을 하고 진행하시면 예제를 따라하는데 훨씬 수월하실거라 생각됩니다.
예제에 사용된 버전은 다음과 같습니다.
- Next.js 14
- Mongoose 8.0.3
- Typescript 5
- NextAuth 4
먼저 로그인을 구현하기 전에 사용중인 MongoDB에 계정을 만들어야했습니다.
현재 기획중인 서비스에 회원가입 기능은 따로 존재하지 않아서 계정 추가 기능을 넣기로 했습니다.
프로젝트의 디렉토리 구조는 다음과 같습니다.
admin.codedream
├─ .eslintrc.json
├─ .git
├─ .gitignore
├─ .vscode
├─ app
│ ├─ api
│ │ ├─ auth
│ │ │ └─ [...nextauth]
│ │ │ └─ route.ts
│ │ └─ user
│ │ ├─ join
│ │ │ └─ route.ts
│ │ └─ login
│ │ └─ route.ts
│ ├─ components
│ ├─ constants
│ ├─ favicon.ico
│ ├─ globals.css
│ ├─ join
│ │ └─ page.tsx
│ ├─ layout.tsx
│ ├─ lib
│ │ └─ dbConnect.ts
│ ├─ login
│ │ ├─ login.module.scss
│ │ └─ page.tsx
│ ├─ models
│ │ └─ user.ts
│ ├─ page.tsx
│ └─ providers.tsx
├─ middleware.ts
├─ next.config.js
├─ package-lock.json
├─ package.json
├─ postcss.config.js
├─ public
├─ README.md
├─ tailwind.config.ts
└─ tsconfig.json
1. 컬렉션 및 스키마 설정
1-1) MongoDB 사이트에 접속해 컬렉션을 추가합니다.
1-2) 스키마 설정
어드민 서비스기 때문에 사용자 정보는 단순화했습니다.
- 아이디
- 패스워드
- 이름
- 계정 생성일
models/users
import { Schema, InferSchemaType } from "mongoose";
const mongoose = require("mongoose");
const UserSchema = new Schema({
userId: { type: String, required: true },
userPw: { type: String, required: true },
name: String,
createDate: { type: Date, default: Date.now },
});
type UserType = InferSchemaType<typeof UserSchema>;
export default mongoose.models.Users || mongoose.model("Users", UserSchema);
export type { UserType };
2. 계정 추가
2-1) 암호화 라이브러리
계정에 사용될 패스워드는 bcrypt를 이용해 암호화했습니다.
bcrypt는 단방향 암호화 알고리즘으로 내부적으로 랜덤 salt를 이용해 암호화를 하기 때문에 보안이 좋다는 것을 확인했습니다. 문서에 사용법도 잘 나와있어 사용하기가 간편합니다.
bcrypt를 설치합니다.
npm install bcrypt
2-1) 계정 추가 API
계정을 추가하기 위한 API를 추가합니다.
1-2)에서 생성한 User 모델을 통해 계정 정보를 추가합니다.
api/user/join/route.ts
import dbConnect from "../../../lib/dbConnect";
import User from "../../../models/user";
import { UserType } from "../../../models/user";
const bcrypt = require("bcrypt");
/**
* Hash 횟수를 의미하며, 횟수가 높아질수록 보안은 좋아지지만 시간이 오래 걸릴 수 있습니다.
* 공식문서에 나와있는 기본값을 사용합니다.
*/
const saltRounds = 10;
export async function POST(req: Request, res: Response) {
try {
await dbConnect();
const data: UserType = await req.json();
const userPassword: String = data.userPw;
const hashPassword: String = await bcrypt.hash(userPassword, saltRounds);
const userInfo = new User({ userId: data.userId, userPw: hashPassword, name: data.name });
// 계정 추가
await userInfo.save();
return Response.json({ message: "Save Success" });
} catch (error) {
return Response.error();
}
}
2-2) 페이지 생성
API를 호출할 페이지를 생성합니다. 필요한 데이터를 입력받을 수 있게 단순하게 폼을 추가했습니다.
join/page.tsx
export default function Join() {
async function onSubmit(formData: FormData) {
"use server";
const params = {
userId: formData.get("userId"),
userPw: formData.get("userPw"),
name: formData.get("userName"),
};
const response = await fetch(`${process.env.NEXT_PUBLIC_DEV_URL}/api/user/join`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
const data = await response.json();
// ...
}
return (
<form action={onSubmit}>
<main>
<div>
<p>ID</p>
<input type="text" name="userId"></input>
<p>PASSWORD</p>
<input type="password" name="userPw"></input>
<p>이름</p>
<input type="text" name="userName"></input>
<hr />
<button type="submit">가입하기</button>
</div>
</main>
</form>
);
}
2-3) 계정 추가
localhost:3000/user/join 에 접속해 계정을 추가해봅니다.
후속 처리를 따로 하지 않았기 때문에 파라미터가 전송되었는지 확인하고, DB에도 추가되었는지 확인합니다.
컬렉션을 조회해보면 아래와 같이 계정 정보가 입력된 것을 확인할 수 있습니다.
전달된 패스워드가 암호화된 해시값으로 추가된 것을 확인할 수 있습니다.
최상단에 위치한 _id값은 MongoDB에서 자체적으로 생성되는 도큐먼트의 유일한 값입니다.
이제 추가된 계정을 통해 로그인을 해보면 될 것 같습니다.
3. 로그인
Spring에서는 Spring Security라는 보안(인증과 권한)과 관련된 기능을 제공하는 프레임워크가 있습니다. 비슷한 패키지가 없을까 검색하다가 Next에는 NextAuth가 있는 것을 알게 되었습니다.
NextAuth는 Next.js를 위해 개발된 인증 패키지로 Credentials라는 기능을 통해 개발자가 직접 로그인에 필요한 정보를 구성하여 만들 수 있도록 제공합니다. 뿐만 아니라 소셜 로그인 기능도 설정이 가능합니다.
어드민 서비스 성격상 "/login" 경로를 제외한 모든 경로는 로그인이 필요하도록 했습니다. 이 부분은 인터셉터 기능을 하는 middleware와 SessionProvider를 이용해 기능을 구현할 수 있었습니다.
한 단계씩 진행해보겠습니다.
3-1) 계정 조회 API 추가
로그인 프로세스는 다음과 같습니다.
1. 사용자가 화면을 통해 로그인을 시도하면 입력된 아이디를 통해 DB에 계정을 조회합니다.
2. 계정이 존재하지 않다면 결과를 리턴합니다.
3. 계정이 존재한다면 입력된 패스워드를 bcrypt.compare 함수로 패스워드를 비교하고 결과를 리턴합니다.
api/user/login/route.ts
import dbConnect from "../../../lib/dbConnect";
import User from "../../../models/user";
import { UserType } from "../../../models/user";
const bcrypt = require("bcrypt");
export async function POST(req: Request, res: Response) {
try {
await dbConnect();
const data: UserType = await req.json();
const loginPw: String = data.userPw;
const userInfo: UserType = await User.findOne({ userId: data.userId }).exec();
if (userInfo == null) {
return Response.json({ message: "계정이 존재하지 않습니다", result: "" });
}
const isMatched: boolean = await bcrypt.compare(loginPw, userInfo.userPw);
return Response.json({
message: isMatched ? "OK" : "아이디 혹은 비밀번호를 확인해주세요.",
});
} catch (error) {
return Response.error();
}
}
3-2) NextAuth 설치
npm install next-auth
설치 후 env.local 파일에 다음 내용을 추가합니다.
기본 NEXTAUTH_URL이 다르게 되어있어 설정이 필요합니다. NEXTAUTH_SECRET은 토큰 정보를 암/복호화할때 사용되는 값으로 openssl rand -base64 32 명령어로 만들 수 있습니다. (Git Bash 활용)
# Next auth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=9e1736c43d19118e6ce4302118af337109491ecc52757dfb949bad6a7940b0c2
3-3) API 추가
api 디렉토리에 /auth/[...nextauth] 디렉토리를 추가하고 route.ts 파일을 생성합니다.
본 예제에서는 커스텀한 로그인 페이지를 사용하기 위해 pages 옵션을 추가했습니다.
api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
const handler = NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" },
},
// 로그인 실행
async authorize(credentials, req) {
const params = {
userId: credentials?.username,
userPw: credentials?.password,
};
const response = await fetch(`${process.env.NEXT_PUBLIC_DEV_URL}/api/user/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
const res = await response.json();
if (res.message == "OK") {
return res;
} else {
throw new Error(res.message);
}
},
}),
],
// 커스텀 로그인 페이지 사용
pages: {
signIn: "/login",
},
});
export { handler as GET, handler as POST };
3-4) react-hook-form 설치
로그인 페이지를 생성하기 전에 폼 상태와 유효성 검사를 하기 위해 react-hook-form을 설치합니다.
초기에는 state를 이용해 유효성 체크를 했지만 이후에 react-hook-form이라는 것을 알게 되었고, 코드가 훨씬 간결해지고 성능도 좋아 적용하게 되었습니다.
npm install react-hook-form
3-5) 페이지 생성
login/page.tsx
"use client";
import { useForm, SubmitHandler, FieldErrors } from "react-hook-form";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import styles from "./login.module.scss";
interface FormValue {
loginId: string;
loginPw: string;
}
export default function Login() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValue>();
const onSubmitHandler: SubmitHandler<FormValue> = async (data) => {
await signIn("credentials", {
username: data.loginId,
password: data.loginPw,
redirect: false,
callbackUrl: "/",
}).then((result) => {
console.log(result!.error);
if (result!.error) {
alert(result?.error);
return;
}
router.push("/");
});
};
return (
<form onSubmit={handleSubmit(onSubmitHandler)}>
<main>
<div className={styles.login_wrap}>
<div className={styles.inner_login}>
<div className={styles.ipt_text}>
<div className={styles.ipt_item}>
<input
{...register("loginId", {
required: "아이디를 입력해 주세요.",
})}
/>
</div>
</div>
<div className={styles.ipt_text}>
<div className={styles.ipt_item}>
<input
type="password"
{...register("loginPw", {
required: "비밀번호를 입력해 주세요.",
})}
/>
</div>
</div>
<div className={`${styles.popup} ${errors.loginId || errors.loginPw ? styles.visible : ""}`}>
{errors.loginId && errors.loginId.type === "required" && <span>{errors.loginId.message}</span>}
{!errors.loginId && errors.loginPw && errors.loginPw.type === "required" && (
<span>{errors.loginPw.message}</span>
)}
</div>
<div className={styles.btn_login_wrap}>
<button type="submit">로그인</button>
</div>
</div>
</div>
</main>
</form>
);
}
login/login.module.scss
스타일 참고
.login_wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
input {
-webkit-appearance: none;
background-color: transparent;
border: 0;
border-radius: 0;
outline: 0;
padding: 0;
resize: none;
}
.inner_login {
min-width: 360px;
margin: 0 auto;
padding: 60px 0 160px;
.popup {
min-width: 360px;
background-color: #eeeff1;
border-radius: 8px;
color: #555;
position: fixed;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
padding: 20px;
visibility: hidden;
opacity: 0;
text-align: center;
transition: transform 0.5s ease-in-out, opacity 0.5s ease-in-out;
}
.visible {
visibility: visible;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.ipt_text {
padding: 10px 0 10px;
position: relative;
border-bottom: 1px solid #ebebeb;
.ipt_item {
width: 100%;
position: relative;
display: table;
input {
display: table-cell;
font-size: 15px;
letter-spacing: -0.15px;
line-height: 22px;
padding-top: 10px;
width: 100%;
}
}
}
.btn_login_wrap {
margin-top: 28px;
button {
width: 100%;
padding: 14px 0 14px;
border-radius: 8px;
border: solid 1px rgba(0, 0, 0, 0.15);
color: #fff;
background-color: #384b60;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
}
}
핵심 기능을 살펴보겠습니다.
const onSubmitHandler: SubmitHandler<FormValue> = async (data) => {
await signIn("credentials", {
username: data.loginId,
password: data.loginPw,
redirect: false,
callbackUrl: "/",
}).then((result) => {
console.log(result!.error);
if (result!.error) {
alert(result?.error);
return;
}
router.push("/");
});
};
<form onSubmit={handleSubmit(onSubmitHandler)}>
submit을 통해 onSubmitHandler 함수가 실행됩니다.
signIn 함수가 실행되면 3-3)에서 설정한 NextAuth에 authorize 함수가 작동합니다. 여기 (data)를 통해 loginId, loginPw를 가져올 수 있는 이유는 react-hook-form의 register() 함수 덕분입니다. input element에 등록된 register에 name을 통해 값을 가져옵니다.
<input
{...register("loginId", {
required: "아이디를 입력해 주세요.",
})}
/>
register에는 다양한 옵션이 있고 필수값 체크, 입력 길이, 입력값 검증(숫자만 입력과 같은)을 설정할 수 있습니다.
required 옵션을 통해 필수값 체크를 할 수 있고 true/false 입력과 문자열 메세지 등록이 가능합니다. 문자열 메세지를 등록하게 되면 error를 통해 메세지를 사용할 수 있습니다.
{errors.loginId && errors.loginId.type === "required" && <span>{errors.loginId.message}</span>}
여기까지 구현된 로그인 기능을 테스트해보겠습니다.
폼 입력값 검사와 로그인 모두 정상적으로 되었습니다.
로그인 후에 쿠키값(next-auth.session-token)이 추가되는 것을 확인할 수 있습니다.
NextAuth에서는 로그인 여부를 세션으로 판단합니다. 그리고 로그인 후 세션은 브라우저 쿠키(next-auth.session-token)에 저장됩니다. 이제는 NextAuth의 SessionProvider를 이용해 로그인 상태에 따라 페이지가 리다이렉트되게 해보겠습니다.
4. SessionProvider 적용
app 경로에 providers.tsx를 추가하고 Provider를 layout 파일에 적용합니다. 애플리케이션의 최상위인 layout에 Provider를 감싸게 되면 전역으로 로그인 상태를 공유하게 됩니다.
providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
import React, { ReactNode } from "react";
interface Props {
children: ReactNode;
}
function Providers({ children }: Props) {
return <SessionProvider>{children}</SessionProvider>;
}
export default Providers;
/layout.tsx
import type { Metadata } from "next";
import Providers from "./providers";
import "./globals.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
5. 미들웨어 적용
이제 인증되지 않은 사용자는 메인 페이지("/")에 접속할 수 없도록 해보겠습니다.
Next.js에서 제공하는 middleware를 이용해 인터셉터 기능을 구현할 수 있었습니다. 프로젝트 경로에 middelware.ts 파일을 추가합니다.
/middleware.ts
export { default } from "next-auth/middleware";
export const config = {
matcher: ["/", "/join/:path*", "/((?!api|login|$).*)"],
};
matcher를 통해 선언된 경로로 미들웨어가 동작하고 "/join", "/" 경로 접속시 인증되지 않았다면 Provider에 의해 로그인 페이지로 리다이렉트 됩니다.
"/((?!api|login|$).*)" 는 해당 경로는 미들웨어를 제외시킨다는 의미로 사용되었습니다.
최종 테스트를 통해 기능을 확인해보겠습니다.
이렇게 단순 로그인 기능을 구현해봤습니다.
처음에 [...nextauth] 라는 디렉토리명에 조금 당황했습니다. 이런 식으로 사용을 한다고? 싶어서요. 개발을 하면서 특이하면서도 편리하구나 생각이 많이 들었습니다. 유효성 체크를 하는 코드도 굉장히 간결해서 개발하기 참 편리하다 싶었구요.
단순 로그인 기능은 여기까지이며, api 호출시에 토큰 방식의 인증 처리도 필요해보이네요.
다음에 또 도움이 될만한 내용으로 포스팅해보겠습니다.
저희는 스터디를 통해 글을 기록하고 있습니다. 피드백은 언제나 환영입니다 :)
참고문서
'프론트엔드' 카테고리의 다른 글
AWS S3 이미지 업로드하기 (with NextJS) (0) | 2024.01.30 |
---|---|
Vercel로 배포하기 (0) | 2024.01.30 |
MongoDB는 무엇인가? (0) | 2024.01.10 |
물러가라! Next.js (기초) (0) | 2024.01.10 |
Next.js + MongoDB (1) | 2024.01.09 |