[나만무] Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기

2025. 6. 18. 16:10·크래프톤 정글/Equipped in 정글(나만무)

Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기

이어서 로그인/회원가입 기능을 구현하고 이를 기존에 있던 게시판에 붙이는 작업을 진행했습니다. Next.js에는 Nextauth.js라는 인증 라이브러리가 있어 JWT나 세션 방식, 그리고 Oauth(깃허브, 구글 기반 로그인) 인증을 손쉽게 구현할 수 있는데요. 저는 이 중 JWT 방식을 선택했습니다.

 

 

로그인 페이지

로그인 페이지는 유저 컴포넌트로 구현했습니다.

브라우저에서 보이는 로그인 페이지

 

page.js 코드

'use client'

import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import styles from './page.module.css'

export default function SignInPage() {
    const router = useRouter()
    const [id, setId] = useState('')
    const [password, setPassword] = useState('')

    const handleLogin = async (e) => {
        e.preventDefault();
        const res = await signIn('credentials', {
            redirect: false,
            id,
            password,
        })

        if (res.ok) {
            router.push('/')
        } else {
            alert('로그인 실패. 아이디 또는 비밀번호를 확인하세요.')
        }
    }

    return (
        <div className={styles.container}>
            <img src="/logo.png" alt="로고" className={styles.logo} />

            <form onSubmit={handleLogin}>
                <div className={styles.formBox}>
                    <input
                        type="text"
                        placeholder="아이디"
                        value={id}
                        onChange={(e) => setId(e.target.value)}
                        className={styles.input}
                    />
                    <input
                        type="password"
                        placeholder="비밀번호"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                        className={styles.input}
                    />
                    <button type='submit' className={styles.loginButton}>
                        로그인
                    </button>

                    <p className={styles.signupText}>
                        아직 회원이 아니신가요?{' '}
                        <button onClick={(e) => {
                            e.preventDefault();
                            router.push('/signup');
                        }} className={styles.signupButton}>
                            회원가입
                        </button>
                    </p>
                </div>
            </form>
        </div>
    )
}

page.module.css 코드

.container {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: #f9f9f9;
  padding: 1rem;
  padding-bottom: 150px;
}

.logo {
  width: 250px;
  margin-bottom: 1rem;
}

.formBox {
  width: 100%;
  max-width: 400px;
  background: #ffffff;
  border-radius: 12px;
  padding: 2rem;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.title {
  margin-bottom: 1.5rem;
  text-align: center;
  font-size: 1.5rem;
}

.input {
  width: 100%;
  padding: 0.75rem;
  margin-bottom: 1rem;
  font-size: 1rem;
  border: 1px solid #ccc;
  border-radius: 8px;
  box-sizing: border-box;
}

.loginButton {
  width: 100%;
  padding: 0.75rem;
  margin-top: 1rem;
  background-color: #4caf50;
  color: #fff;
  font-size: 1rem;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

.signupText {
  margin-top: 1.5rem;
  text-align: center;
  font-size: 0.9rem;
}

.signupButton {
  color: #0070f3;
  background: none;
  border: none;
  cursor: pointer;
  text-decoration: underline;
  padding: 0;
  font-size: 0.9rem;
}

 

 

회원가입 페이지

회원가입 페이지 또한 유저 컴포넌트로 만들었습니다.

브라우저에서 보이는 회원가입 페이지

 

page.js 코드

'use client'

import { useState } from 'react';
import styles from './page.module.css';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

export default function SignUpPage() {
    const [name, setName] = useState('')
    const [id, setId] = useState('')
    const [password, setPassword] = useState('')
    const [rePassword, setRePassword] = useState('')
    const [idCheckResult, setIdCheckResult] = useState(false)

    const router = useRouter();

    const handleIdCheck = async () => {
        if (id.trim() == '') {
            alert('아이디를 입력해주세요!');
            return;
        }

        try {
            const res = await fetch(`/api/auth/register/${id}`, { method: 'GET' })
            const resp = await res.json();
            console.log(resp);

            if (resp.exists) {
                let choice = confirm('사용할 수 있는 아이디입니다. 사용하시겠습니까?');

                if (choice) {
                    setIdCheckResult(true)
                } else {
                    setId('')
                }
            } else {
                alert('이미 사용중인 아이디입니다!');
            }
        } catch (err) {
            console.error(err)
            setIdCheckResult('오류가 발생했습니다.')
        }
    }

    const handleSubmit = async (e) => {
        e.preventDefault();
        // 회원가입 요청 로직
        if (password != rePassword) {
            alert("비밀번호가 서로 다릅니다!");
            return;
        } else if (!idCheckResult) {
            alert("아이디 중복확인을 해주세요!");
            return;
        }

        const res = await fetch(`/api/auth/register`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                name: name,
                userId: id,
                password: password,
                role: 'normal'
            })
        })
        const resp = await res.json();

        if (resp.success) {
            alert("가입 완료! 로그인페이지로 이동합니다.")
            router.push('/signin')
        }

    }

    return (
        <div className={styles.signup_container}>
            <div style={{ textAlign: 'center' }}>
                <img src="/logo.png" alt="로고" className={styles.logo} />
            </div>
            <h3 style={{ margin: '10px 0 30px 0' }}>가입하기</h3>
            <form onSubmit={handleSubmit}>
                <div className={styles.form_group}>
                    <label>이름</label><br></br>
                    <input type="text" value={name} onChange={e => setName(e.target.value)} required
                        placeholder='이름을 입력하세요' maxLength={5} />
                </div>

                <div className={styles.form_group}>
                    <label>아이디</label>
                    <div className={styles.id_check_group}>
                        <input type="text" value={id} onChange={e => setId(e.target.value)} required
                            placeholder='아이디를 입력하세요' />
                        <button type="button" onClick={handleIdCheck} className={styles.idCheckBtn}>중복확인</button>
                    </div>
                </div>

                <div className={styles.form_group}>
                    <label>비밀번호</label>
                    <input type="password" value={password} onChange={e => setPassword(e.target.value)} required
                        placeholder='비밀번호를 입력하세요' />
                </div>
                <div className={styles.form_group}>
                    <label>비밀번호 확인</label>
                    <input type="password" value={rePassword} onChange={e => setRePassword(e.target.value)} required
                        placeholder='비밀번호를 다시 입력하세요' />
                </div>

                <div className={styles.buttonBox}>
                    <Link href={'/signin'} style={{ marginRight: '15px', textDecoration: 'none' }}>돌아가기</Link>
                    <button type="submit" className={styles.registerBtn}>가입하기</button>
                </div>
            </form>
        </div>
    )
}

page.module.css 코드

.signup_container {
  margin: auto;
  padding: 2rem;
  position: relative;
  margin: 23px auto 63px auto;
  width: 500px;
  border: 1px solid black;
  border-radius: 20px;
}

.form_group {
  margin-bottom: 1.2rem;
}
.id_check_group {
  display: flex;
  gap: 0.5rem;
}
.signup_container input[type="text"],
.signup_container input[type="password"] {
  flex: 1;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  width: 400px;
}
.idCheckBtn {
  padding: 0.5rem 1rem;
  border: none;
  background: #0070f3;
  color: white;
  border-radius: 4px;
  cursor: pointer;
}
.id_check_result {
  margin-top: 0.5rem;
  font-size: 0.9rem;
  color: #555;
}

.buttonBox {
  text-align: right;
}

.registerBtn {
  padding: 0.5rem 1rem;
  border: none;
  background: green;
  color: white;
  border-radius: 4px;
  cursor: pointer;
}

.logo {
  width: 250px;
  margin-bottom: 1rem;
}

 

설정 파일

[...nextauth].js 코드

import { connectDB } from "@/util/database";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from 'bcrypt';

export const authOptions = {
    providers: [
        CredentialsProvider({
            // 로그인페이지 폼 자동생성
            name: "Jungle-Board",
            credentials: {
                id: { label: "ID", type: "text" },
                password: { label: "비밀번호", type: "password" },
            },

            // 로그인 요청 시 실행
            // 직접 DB에서 아이디, 비밀번호를 비교
            // 아이디,비밀번호가 맞으면 return user, 틀리면 return null
            async authorize(credentials) {
                let db = (await connectDB).db('board');
                let user = await db.collection('user').findOne({ userId: credentials.id })
                if (!user) {
                    return null
                }
                const pwcheck = await bcrypt.compare(credentials.password, user.password);
                if (!pwcheck) {
                    return null
                }
                return user
            }
        })
    ],

    //3. 로그인 방식 jwt, jwt 만료일설정
    session: {
        strategy: 'jwt',
        maxAge: 30 * 24 * 60 * 60 //30일
    },


    callbacks: {
        // jwt 만들 때 실행되는 코드 
        // user변수는 DB의 유저정보가 담겨있고 token.user에 필요한 정보를 저장하면 jwt에 들어간다.
        jwt: async ({ token, user }) => {
            if (user) {
                token.user = {};
                token.user.name = user.name
                token.user.userId = user.userId
                token.user.role = user.role
            }
            return token;
        },
        // 유저 세션이 조회될 때 마다 실행되는 코드
        session: async ({ session, token }) => {
            session.user = token.user;
            return session;
        },
    },
    secret: process.env.NEXTAUTH_SECRET,

    pages: {
        signIn: '/signin',
    },
};
export default NextAuth(authOptions);

 

 

API 구현하기

register.js (회원가입) 코드

import { connectDB } from '@/util/database';
import bcrypt from 'bcrypt';

export default async function register_handler(request, response) {
    const db = (await connectDB).db('board')

    if (request.method == 'POST') {
        let hash = await bcrypt.hash(request.body.password, 10);
        request.body.password = hash;

        const result = await db.collection('user').insertOne(request.body);

        if (!result.acknowledged || !result.insertedId) {
            return response.status(500).json({ success: 'true', message: 'DB 에러!' });
        }
        return response.status(200).json({ success: 'true', message: '가입 완료!' });
    }
}

register/[id].js (회원가입 시 ID 중복 체크) 코드

import { connectDB } from '@/util/database';

export default async function register_handler(request, response) {
    const db = (await connectDB).db('board')

    if (request.method == 'GET') {
        const id = request.query.id;

        const result = await db.collection('user').findOne({ userId: id })

        if (result == null) {
            return response.status(200).json({ exists: true });
        }
        return response.status(200).json({ exists: false });
    }
}

 

 

로그인 유저 정보에 따라 수정/삭제 제한하기

글쓰기 페이지에서는 로그인 유저 정보에 따라 ~~님의 글을 정상적으로 표시하고, 해당 유저를 writer로 DB에 글을 작성할 수 있게 바꿨습니다. 그리고 상세페이지에서는 본인이 작성한 글에 대해서만 수정, 삭제가 가능하게끔 변경했습니다.

 

유저 컴포넌트에서 session 정보 제공하기

/* provider.js 코드 */
'use client'

import { SessionProvider } from 'next-auth/react'

export default function Providers({ children }) {
    return <SessionProvider>{children}</SessionProvider>
}
/* app 최상단 layout.js */
import Providers from "./providers";

<Providers>
  <div className="navbar">
    <div style={{ marginLeft: '20px', fontWeight: 'bold' }}>Jungle-Board</div>
    <LogoutButton />
  </div>
  {children}
</Providers>

글 작성페이지 코드(유저 컴포넌트) 수정

import { useSession } from 'next-auth/react';

/* function 내부 */
const { data: session, status } = useSession();
const writer = session?.user.userId;

글 상세페이지 코드(서버 컴포넌트) 수정

import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';

/* function 내부 */
const session = await getServerSession(authOptions);

/* return 내부 */
{
    session && (session.user.userId == result.writer || session.user.role == 'admin') &&
    <div className={styles.rightButtonBox}>
        <DeleteButton id={_id} />
        <Link href={`/edit/${_id}`} style={{ color: 'white', textDecoration: 'none' }}>
            <button type="button" className={styles.corBtn}>수정</button>
        </Link>
    </div>
}

 

'크래프톤 정글 > Equipped in 정글(나만무)' 카테고리의 다른 글

[나만무] 타입스크립트(TS) 필수 지식 학습하기 by 코딩애플  (0) 2025.06.27
[나만무] Jungle-Board 댓글 기능 및 검색, 페이지네이션 구현 및 배포하기  (1) 2025.06.20
[나만무] 06.17 TIL - Next.js 학습 by 코딩애플  (0) 2025.06.17
[나만무] Jungle-Board 기본 CRUD 구현하기  (0) 2025.06.17
[나만무] 06.16 TIL - Next.js 학습 by 코딩애플  (0) 2025.06.16
'크래프톤 정글/Equipped in 정글(나만무)' 카테고리의 다른 글
  • [나만무] 타입스크립트(TS) 필수 지식 학습하기 by 코딩애플
  • [나만무] Jungle-Board 댓글 기능 및 검색, 페이지네이션 구현 및 배포하기
  • [나만무] 06.17 TIL - Next.js 학습 by 코딩애플
  • [나만무] Jungle-Board 기본 CRUD 구현하기
그냥사람_
그냥사람_
IT 관련 포스팅을 합니다. 크래프톤 정글 8기 정경호
  • 그냥사람_
    그냥코딩
    그냥사람_
  • 전체
    오늘
    어제
    • 글 전체보기
      • 크래프톤 정글
        • 로드 투 정글(입학시험)
        • CS기초(키워드, 개념정리)
        • 컴퓨터구조(CSAPP)
        • Code 정글(C언어)
        • Equipped in 정글(나만무)
        • 마이 정글(WIL, 에세이)
      • 자료구조&알고리즘
        • 자료구조
        • 알고리즘
      • 일상
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • hELLO· Designed By정상우.v4.10.3
그냥사람_
[나만무] Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기
상단으로

티스토리툴바