밝을희 클태

[ Next.JS ] Admin 페이지를 만들어보자 본문

KEYNUT 프로젝트

[ Next.JS ] Admin 페이지를 만들어보자

huipark 2024. 7. 10. 20:52

Next.JS 14 App Router

 

회원 관리나 상품 관리를 편하게 하기 위해 어드민 페이지를 만들려고 한다.

어드민 페이지의 기능은 아래와 같이 4가지 기능이 있다.

  1. 전체 유저 조회
  2. 정지된 유저 조회
  3. 전체 상품 조회
  4. 신고가 들어온 상품 조회

로직 :

  1. URL의 queryString을 읽어서 page, 검색어를 받아옴
  2. queryString의 값으로 DB를 조회
  3. page나 검색어가 변경이 되면 URL에 반영

hook으로 안 하고 queryString으로 하는 이유는 hook으로 구현을 하면 새로고침시 현재 보던 페이지, 검색 키워드, 카테고리 등이 다 초기화가 된다. 그리고 내가 지금 보는 화면을 다른 사람과 공유를 할 수 없어서 queryString을 읽어서 구현을 했다.

 

 어드민 페이지를 만들다가 기존의 레이아웃들 때문에 작업이 힘들어서 폴더 구조를 아예 바꿔주어 별도의 AdminLayout을 만들어 줬다.

route-group을 사용해서 adminmain 폴더를 () 괄호로 묶어 그룹을 지어주어서 특정 경로일 때 해당 레이아웃을 사용하게 구조를 변경해 줬다.

- js 말고 jsx 또는 tsx 확장자를 사용할 수 있다.
- <html>, <body> 태그는 RootLayout에서만 사용할 수 있다.
📦app
 ┣ 📂(admin)
 ┃ ┗ 📂admin
 ┃ ┃ ┣ 📜layout.js
 ┃ ┃ ┗ 📜page.jsx
 ┣ 📂(main)
 ┃ ┣ 📜layout.js
 ┃ ┗ 📜page.jsx
 ┣ 📂api
 ┣ 📜globals.css
 ┗ 📜layout.js

RootLayout.js

import { Inter } from 'next/font/google';
import './globals.css';
import Nav from './(main)/_components/Nav';
import BottomNav from './(main)/_components/BottomNav/BottomNav';
import Footer from './(main)/_components/Footer';
import AuthProvider from '@/lib/next-auth';
import RQProvider from './(main)/_components/RQProvider';
import Script from 'next/script';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'KEYNUT | 커스텀 키보드',
  description: 'Generated by create next app',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <meta name="format-detection" content="telephone=no" />
      </head>
      <body className={inter.className + 'flex flex-col justify-center items-center max-md:mb-bottom-nav-heigth'}>
        <Script
          src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.2/kakao.min.js"
          integrity="sha384-TiCUE00h649CAMonG018J2ujOgDKW/kVWlChEuu4jK2vxfAAD0eZxzCKakxg55G4"
          crossorigin="anonymous"
        ></Script>
        <RQProvider>
          <AuthProvider>{children}</AuthProvider>
        </RQProvider>
      </body>
    </html>
  );
}

MainLayout.js

import Footer from './_components/Footer';
import BottomNav from './_components/BottomNav/BottomNav';
import Nav from './_components/Nav';

export default function MainLayout({ children }) {
  return (
    <>
      <Nav />
      <main className="relative main-1280 max-md:pt-0">{children}</main>
      <Footer />
      <BottomNav />
    </>
  );
}

AdminLayout.js

export default function AdminLayout({ children }) {
  return <>{children}</>;
}

 

유저 관리

 유저를 관리할 수 있는 기능을 가장 먼저 만들었는데, 특히 회원 탈퇴 기능이 생각보다 복잡해서 애를 먹었다. 탈퇴할 때 사용자의 상품, 해당 상품을 북마크 한 유저, 유저의 북마크에 있는 상품, 조회 기록 등등 지워야 할 데이터가 엄청 많았다. 이 지우는 부분에는 분명히 문제가 없었는데도, 탈퇴를 시키면 일부 기록들이 지워지지 않는 문제가 발생했다.

 문제는 Promise 부분에 있었다. 기록들을 지울 때 비동기로 처리하면서 Promise 배열을 받아서 코드의 가장 아래에서 Promise.all을 사용해 모든 작업이 끝날 때까지 대기했다. 그런데 이 부분에서 작업이 다 끝나지 않았는데도 기다리지 않고 코드가 끝나버려 문제가 발생한 것이었다. 그래서 모든 Promise를 마지막에 한꺼번에 기다리지 않고, 묶음 단위로 나눠서 기다리는 방식으로 변경했다.

유저를 탈퇴시키면 상품도 삭제가 됨

게시물 관리

 게시물 관리 기능은 처음에 user와 같이 검색기능을 구현했다가 조금 더 세분화해서 상품을 검색하면 좋을 것 같다고 생각이 들어서 유저의 닉네임과 상품 제목, 가격으로 상세하게 상품을 검색할 수 있다.

검색 API

import { NextResponse } from 'next/server';
import { connectDB } from '@/lib/mongodb';
import getUserSession from '@/lib/getUserSession';

export async function GET(req) {
  try {
    const session = await getUserSession();
    if (!session.admin) return NextResponse.json({ message: 'Not authenticated' }, { status: 401 });

    const { searchParams } = new URL(req.url, process.env.NEXTAUTH_URL);
    const offset = parseInt(searchParams.get('offset')) || 0;
    const limit = parseInt(searchParams.get('limit')) || 10;
    const nickname = searchParams.get('nickname');
    const keyword = searchParams.get('keyword');
    const price = searchParams.get('price');

    const client = await connectDB;
    const db = client.db(process.env.MONGODB_NAME);

    let searchQuery = {};

    if (nickname) {
      const user = await db.collection('users').findOne({
        nickname: { $regex: nickname, $options: 'i' },
      });
      if (!user) {
        return NextResponse.json({ message: 'User not found' }, { status: 404 });
      }

      if (keyword) {
        searchQuery = {
          userId: user._id,
          $or: [
            { title: { $regex: keyword, $options: 'i' } },
            { tags: { $elemMatch: { $regex: keyword, $options: 'i' } } },
          ],
        };
      } else {
        searchQuery.userId = user._id;
      }
    } else if (keyword) {
      searchQuery = {
        $or: [
          { title: { $regex: keyword, $options: 'i' } },
          { tags: { $elemMatch: { $regex: keyword, $options: 'i' } } },
        ],
      };
    }

    if (price) {
      const priceConditions = price
        .split(' ')
        .map(condition => {
          const operator = condition[0];
          const value = parseInt(condition.substring(1), 10);
          if (operator === '>') {
            return { price: { $gte: value } };
          } else if (operator === '<') {
            return { price: { $lte: value } };
          } else {
            return null;
          }
        })
        .filter(Boolean);

      if (priceConditions.length > 0) {
        searchQuery.$and = (searchQuery.$and || []).concat(priceConditions);
      }
    }

    const pipeline = [
      { $match: searchQuery },
      { $skip: offset },
      { $limit: limit },
      {
        $lookup: {
          from: 'users',
          localField: 'userId',
          foreignField: '_id',
          as: 'userInfo',
        },
      },
      {
        $addFields: {
          nickname: { $arrayElemAt: ['$userInfo.nickname', 0] },
        },
      },
      {
        $project: {
          userInfo: 0,
        },
      },
    ];

    const products = await db.collection('products').aggregate(pipeline).toArray();
    const total = await db.collection('products').countDocuments(searchQuery);

    return NextResponse.json(
      { products, total },
      {
        status: 200,
      },
    );
  } catch (error) {
    return NextResponse.json(error, { status: 500 });
  }
}

 

 상단에 Taskbar는 스크롤을 했을 때 다시 최상단으로 올리지 않고 바로 사용할 수 있게 sticky를 사용해서 스크롤되다가 고정이 되게 했다.