상세 컨텐츠

본문 제목

[Node.js] sparamin 1 - 공사중

projects/node.js

by 서울의볼 2024. 1. 31. 00:47

본문

Spartstore에 이어 REST API를 이용한 두 번째 개발 프로젝트임.

 

sparamin은 사람인을 오마주 하였으며 이력서를 올리고 피드백(거창하지만 그냥 댓글임)을 주고 받는 플랫폼임.

  • ***난 어떤 프로젝트를 하던 시간 대비 최대 효용//이라기 보단 의미를 뽑고자 하는 마음이 큼.
    뭔가 의미가 조금이라도 더 있었으면 하는 바램이 있기에 차별화 포인트를 조금이라도 둘 수 있으면 좋겠다는 생각이 항상 있음.
    본 프로젝트는 잠재적 수요자인 기업/인사팀 보단 취준생들의 커뮤니티 느낌을 더욱 살려 이력서에 대한 사람들의 피드백을 자유롭게 주고받을 수 있도록 댓글 기능을 추가하고자 함.***

 

이번엔 거대한 테마 하나로 쿠키와 세션을 이용하여 인증/인가의 개념이 도입되어 회원가입 기능이 추가되었고, 기존의 NoSQL(aka MongoDB)이 아닌 MySQL을 통해 DB를 구성하였음.

Tools Used:
- AWS RDS
- AWS EC2
- Thunder Client
- Notion (API 명세 작성)
- ERD Cloud
Tech Stack (incl. minor ones):
- Node.js/Express.js
- MySQL
- Prisma
- yarn
- JWT
- prettier (formatter)
- nodemon (for server)
- pm2 (for server)
- bcrypt (library)
- winston (library)

 

이번엔 step by step으로 개발을 진행하며 개발 순서와 발생한 문제점을 좀 더 체계적이고 자세하게 기록해보고자 함:

 

  1. API 명세서 및 ERD 작성
    • API 명세는 노션을 통해 작성함 (익숙치 않아서 많이 헤맴 ㅠ): --- 수정 및 추가예정
  2. 필요 package 다운로드 및 디렉토리 생성
    • yarn을 패키지 모델로하여 개발 예정이므로 yarn init -y
    • yarn add express prisma @prisma/client cookie-parser jsonwebtoken로 관련 모듈 설치함
    • yarn add -D nodemon을 통해 서버 껐다 키고 반복 안해도 됨(개꿀)
      • 근데 또 "error Error: EPERM: operation not permitted, rmdir \.bin" 이슈가 발생하여 node_modules를 지우고 재설치 하였음
    • npx prisma init으로 prisma schema 생성 완료함
    • yarn add -D prettier 해주고, 관련 서식을 .prettierrc.json파일을 만들어 추가해줌
      • package.json에 format script도 추가 후 yarn run format 해줌으로써 갓벽하게 적용됨
    • yarn add bcrypt도 설치했음 --> 비밀번호 암호화를 위함
    • 클라이언트의 요청 사항들을 기록하고 서버 상태 모니터링을 위해 yarn add winston 라이브러리 사용함
  3. 프리즈마 모델링
    • 위 API 명세에 맞춰 프리즈마 모델링 결과임:
      generator client {
        provider = "prisma-client-js"
      }
      
      datasource db {
        provider = "mysql"
        url      = env("DATABASE_URL")
      }
      
      model Users {
        userId    Int      @id @default(autoincrement()) @map("userId")
        email     String   @unique @map("email")
        password  String   @map("password")
        createdAt DateTime @default(now()) @map("createdAt")
        updatedAt DateTime @updatedAt @map("updatedAt")
      
        @@map("Users")
      }
      
      model Resumes {
        resumeId  Int      @id @default(autoincrement()) @map("resumeId")
        status    String   @map("status")
        title     String   @map("title")
        intro     String   @map("intro") @db.Text
        exp       String?  @map("exp") @db.Text
        skill     String?  @map("skill") @db.Text
        createdAt DateTime @default(now()) @map("createdAt")
        updatedAt DateTime @updatedAt @map("updatedAt")
      
        @@map("Resumes")
      }
      
      model UserInfos {
        userInfoId   Int      @id @default(autoincrement()) @map("userInfoId")
        name         String   @map("name")
        age          Int      @map("age")
        gender       String   @map("gender")
        profileImage String?  @map("profileImage")
        createdAt    DateTime @default(now()) @map("createdAt")
        updatedAt    DateTime @updatedAt @map("updatedAt")
      
        @@map("UserInfos")
      }
      
      model Comments {
        commentId Int      @id @default(autoincrement()) @map("commentId")
        content   String   @map("content")
        createdAt DateTime @default(now()) @map("createdAt")
        updatedAt DateTime @updatedAt @map("updatedAt")
      
        @@map("Comments")
      }
    • Resumes, Comments 테이블은 UserInfos와 달리 1:N 관계이기에 userId 등 외래키 정의시 unique제약 조건이 없음 주의
    • 외래키도 설정한 거 참고할 것
      • 아래 서두의 user는 user라는 식으로 연관관계를 맺겠다는 의미
        user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)
  4. 모델링한 prisma AWS RDS에 연결
    • 좀 어이없긴 한데 인스턴스를 못 찾겠음. EC2도 사라지고 AWS에 다른 두(2) 컴퓨터로 접속해보았는데 인스턴스가 생겼다 말았다 아무튼 이상함...
    • 각설하고, 새로운 DB를 만들어서 npx prisma db push 명령어를 통해 RDS와 연결하였고 아래와 같이 잘 작동되는 것도 확인함:
      Resumes 테이블 DESC 찍어봄
  5. app.js & prisma 초기화 작업
    • app.js router 추가 전 기본 세팅:
      import express from 'express';
      import cookieParser from 'cookie-parser';
      
      const app = express();
      const PORT = 3018;
      
      app.use(express.json());
      app.use(cookieParser());
      
      app.listen(PORT, () => {
        console.log(PORT, '포트로 서버가 열렸어요!');
      });
    •  prisma 세팅:
      import { PrismaClient } from '@prisma/client';
      
      export const prisma = new PrismaClient({
        // Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
        log: ['query', 'info', 'warn', 'error'],
      
        // 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
        errorFormat: 'pretty',
      }); // PrismaClient 인스턴스를 생성합니다.​
  6. 사용자 router 만들기
    • users 라우터에서 사용자 회원가입, 로그인, 조회 API를 구현함
    • 여기서 bcrypt를 사용하여 비밀번호는 암호화 해줌
      암호화되어 출력됨 (feat. Thunder Client // 개인적으로 insomnia보다 좋음)
    • 그리고 JWT 토큰을 활용하여 로그인 시 DB 검증이 완료된 다음 JWT를 담고있는 쿠키를 생성하고 반환하도록 함
      Bearer 쿠키 짜잔
      import express from "express";
      import { prisma } from "../models/index.js";
      import bcrypt from "bcrypt";
      import jwt from "jsonwebtoken";
      
      const router = express.Router();
      
      /** 사용자 회원가입 API **/
      router.post("/sign-up", async (req, res, next) => {
        const { email, password, name, age, gender, profileImage } = req.body;
        const isExistUser = await prisma.users.findFirst({
          where: { email },
        });
      
        if (isExistUser) {
          return res.status(409).json({ message: "이미 존재하는 이메일입니다." });
        }
      
        // 사용자 비밀번호를 암호화합니다.
        const hashedPassword = await bcrypt.hash(password, 10);
        // Users 테이블에 사용자를 추가합니다.
        const user = await prisma.users.create({
          data: { email, password: hashedPassword },
        });
        // UserInfos 테이블에 사용자 정보를 추가합니다.
        const userInfo = await prisma.userInfos.create({
          data: {
            userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
            name,
            age,
            gender,
            profileImage,
          },
        });
      
        return res.status(201).json({ message: "회원가입이 완료되었습니다." });
      });
      
      /** 로그인 API **/
      router.post("/sign-in", async (req, res, next) => {
        const { email, password } = req.body;
        const user = await prisma.users.findFirst({ where: { email } });
      
        if (!user)
          return res.status(401).json({ message: "존재하지 않는 이메일입니다." });
        // 입력받은 사용자의 비밀번호와 데이터베이스에 저장된 비밀번호를 비교합니다.
        else if (!(await bcrypt.compare(password, user.password)))
          return res.status(401).json({ message: "비밀번호가 일치하지 않습니다." });
      
        // 로그인에 성공하면, 사용자의 userId를 바탕으로 토큰을 생성합니다.
        const token = jwt.sign({ userId: user.userId }, "custom-secret-key");
      
        // authotization key값을 가진 쿠키에 Berer 토큰 형식으로 JWT를 저장합니다.
        res.cookie("authorization", `Bearer ${token}`);
        return res.status(200).json({ message: "로그인 성공" });
      });
      
      export default router;​
  7. 사용자 인증 미들웨어 만들고 사용자 조회 API 만듦
    • 인증 미들웨어 (auth):
      import jwt from "jsonwebtoken"; // jwt 라이브러리 사용
      import { prisma } from "../models/index.js"; // Prisma client 가져오기
      
      export default async function (req, res, next) {
        try {
          const { authorization } = req.cookies;
          if (!authorization) throw new Error("토큰이 존재하지 않습니다.");
      
          const [tokenType, token] = authorization.split(" ");
      
          if (tokenType !== "Bearer")
            throw new Error("토큰 타입이 일치하지 않습니다.");
      
          const decodedToken = jwt.verify(token, "custom-secret-key"); // 에러 나면 catch로 내려감
          const userId = decodedToken.userId;
      
          const user = await prisma.users.findFirst({
            where: { userId: +userId },
          }); // 유저 실제 조회
          if (!user) {
            res.clearCookie("authorization");
            throw new Error("토큰 사용자가 존재하지 않습니다.");
          }
      
          // req.user에 사용자 정보를 저장합니다.
          req.user = user;
      
          next();
        } catch (error) {
          res.clearCookie("authorization");
      
          // 토큰이 만료되었거나, 조작되었을 때, 에러 메시지를 다르게 출력합니다.
          switch (error.name) {
            case "TokenExpiredError":
              return res.status(401).json({ message: "토큰이 만료되었습니다." });
            case "JsonWebTokenError":
              return res.status(401).json({ message: "토큰이 조작되었습니다." });
            default:
              return res
                .status(401)
                .json({ message: error.message ?? "비정상적인 요청입니다." });
          }
        }
      }​
    • 중첩 select를 활용하여 1:1 관계의 두 테이블 값을 가져옴 (SQL의 join과 같음)
      import authMiddleware from "../middlewares/auth.middleware.js";
      
      /** 사용자 조회 API **/
      router.get("/users", authMiddleware, async (req, res, next) => {
        const { userId } = req.user;
      
        const user = await prisma.users.findFirst({
          where: { userId: +userId },
          select: {
            userId: true,
            email: true,
            createdAt: true,
            updatedAt: true,
            userInfos: {
              // 1:1 관계를 맺고있는 UserInfos 테이블을 조회합니다.
              select: {
                name: true,
                age: true,
                gender: true,
                profileImage: true,
              },
            },
          },
        });
      
        return res.status(200).json({ data: user });
      });​​
    • Thunder Client로 다시 로그인 후 조회 해본 결과 JWT 토큰이 전달되고 사용자 정보가 반환되는 것을 확인 할 수 있음
  8. 미들웨어들 추가 생성 (error-handling, log)
    • 위에 추가한 winston라이브러리로 로그 미들웨어 생성함:
      import winston from 'winston';
      
      const logger = winston.createLogger({
        level: 'info', // 로그 레벨을 'info'로 설정합니다.
        format: winston.format.json(), // 로그 포맷을 JSON 형식으로 설정합니다.
        transports: [
          new winston.transports.Console(), // 로그를 콘솔에 출력합니다.
        ],
      });
      
      export default function (req, res, next) {
        // 클라이언트의 요청이 시작된 시간을 기록합니다.
        const start = new Date().getTime();
      
        // 응답이 완료되면 로그를 기록합니다.
        res.on('finish', () => {
          const duration = new Date().getTime() - start;
          logger.info(
            `Method: ${req.method}, URL: ${req.url}, Status: ${res.statusCode}, Duration: ${duration}ms`,
          );
        });
      
        next();
      }​
    • 에러 핸들링 미들웨어를 아래와 같이 작성해준 뒤 try catch를 이용하여 회원가입시 에러가 발생하면 미들웨어로 받아주도록 추가/수정하였음
      export default function (err, req, res, next) {
        // 에러를 출력합니다.
        console.error(err);
      
        // 클라이언트에게 에러 메시지를 전달합니다.
        res.status(500).json({ errorMessage: "서버 내부 에러가 발생했습니다." });
      }​​
       
    • 에러처리 미들웨어는 클라이언트 요청이 실패했을 때 가장 마지막에 실행되어야 해서 app.use를 이용한 전역 미들웨어 중 가장 최하단에 위치시킴
      • 참고로, 해당 미들웨어는 대략 추상적으로 "서버에서 에러가 발생함~~~" 이런식으로 전달하도록 구현하는데 이유는 에러를 상세하게 클라이언트에 제공하면 악의적인 사용자에게 공격 받을 수 있기 때문이라 함
  9.  이력서 router 만들기
    • 위에서 정의했듯, authorization 쿠키를 request header 값으로 받아야 하기에 사용자는 로그인 된 상태로만 이력서 작성이 가능함
    • 이력서를 생성하고자 하니, 기존에 정의했던 프리즈마 스키마를 고칠 필요성이 생김. 이력서 목록을 조회할 때 userInfos 테이블에서 프로필사진, 이름, 성별을 가지고 와야하는데 프리즈마에 Resumes와 userInfos의 관계 정립이 안되어 있어 살짝의 공사가 필요했음 (관계 정의 안해놨는지도 모르고 실행하다 엄청난 에러를 만남)
      model Resumes {
        resumeId  Int      @id @default(autoincrement()) @map("resumeId")
        userId    Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
        userInfoId Int     @map("userInfoId") // UserInfos 테이블 참조 외래키
        status    String   @map("status")
        title     String   @map("title")
        intro     String   @map("intro") @db.Text
        exp       String?  @map("exp") @db.Text
        skill     String?  @map("skill") @db.Text
        createdAt DateTime @default(now()) @map("createdAt")
        updatedAt DateTime @updatedAt @map("updatedAt")
      
        // Users 테이블과 관계를 설정합니다.
        user     Users     @relation(fields: [userId], references: [userId], onDelete: Cascade)
        comments Comments[] // 게시글(Resumes) 테이블과 댓글(Comments) 테이블이 1:N 관계를 맺습니다.
        userInfos UserInfos @relation(fields: [userInfoId], references: [userInfoId]) // TODO: onUpdate 시도
      
        @@map("Resumes")
      }
      
      model UserInfos {
        userInfoId   Int      @id @default(autoincrement()) @map("userInfoId")
        userId       Int      @unique @map("userId") // 사용자(Users) 테이블 참조 외래키
        name         String   @map("name")
        age          Int      @map("age")
        gender       String   @map("gender")
        profileImage String?  @map("profileImage")
        createdAt    DateTime @default(now()) @map("createdAt")
        updatedAt    DateTime @updatedAt @map("updatedAt")
      
        // Users 테이블과 관계를 설정합니다.
        user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)
        resumes Resumes[] // Resumes 테이블과 1:N 관계 맺기
      
        @@map("UserInfos")
      }​
      ↑수정된 프리즈마 스키마 일부
    • 아래는 라우터 파일 전체 코드임
      import express from "express";
      import { prisma } from "../models/index.js";
      import authMiddleware from "../middlewares/auth.middleware.js";
      
      const router = express.Router();
      
      /** 이력서 생성 API **/
      router.post("/resumes", authMiddleware, async (req, res, next) => {
        // 로그인된 사용자인지 검증하기 위해 중간에 authMW 추가
        const { userId } = req.user;
        const { status, title, intro, exp, skill } = req.body;
      
        //   UserInfos 테이블도 가져오기
        const userInfos = await prisma.userInfos.findFirst({
          where: {
            userId: +userId,
          },
          select: {
            userInfoId: true,
          },
        });
      
        // UserInfos 정보 중에서 userInfoId 선택
        const userInfoId = userInfos?.userInfoId;
      
        const resume = await prisma.resumes.create({
          data: {
            userId: +userId,
            userInfoId: +userInfoId,
            status,
            title,
            intro,
            exp,
            skill,
          },
        });
      
        return res.status(201).json({ data: resume });
      });
      
      /** 이력서 목록 조회 API **/
      router.get("/resumes", async (req, res, next) => {
        const resumes = await prisma.resumes.findMany({
          select: {
            resumeId: true,
            userId: true,
            userInfoId: true,
            status: true,
            title: true,
            userInfos: {
              select: {
                profileImage: true,
                name: true,
              },
            },
            createdAt: true,
            updatedAt: true,
          },
          orderBy: {
            createdAt: "desc", // 이력서를 최신순으로 정렬합니다.
          },
        });
      
        return res.status(200).json({ data: resumes });
      });
      
      /** 이력서 상세 조회 API **/
      router.get("/resumes/:resumeId", async (req, res, next) => {
        const { resumeId } = req.params;
        const resume = await prisma.resumes.findFirst({
          where: {
            resumeId: +resumeId,
          },
          select: {
            resumeId: true,
            userId: true,
            userInfoId: true,
            status: true,
            title: true,
            userInfos: {
              select: {
                name: true,
                age: true,
                gender: true,
                profileImage: true,
              },
            },
            intro: true,
            exp: true,
            skill: true,
            createdAt: true,
            updatedAt: true,
          },
        });
      
        return res.status(200).json({ data: resume });
      });
      
      export default router;​
  10.  피드백(댓글) router 만들기
    • 대체로 응용 위 라우터들의 응용이라 코드만 슥 붙여넣겠음:
      import express from "express";
      import { prisma } from "../models/index.js";
      import authMiddleware from "../middlewares/auth.middleware.js";
      
      const router = express.Router();
      
      /** 댓글 생성 API **/
      router.post(
        "/resumes/:resumeId/comments",
        authMiddleware,
        async (req, res, next) => {
          const { resumeId } = req.params;
          const { userId } = req.user;
          const { content } = req.body;
      
          const resume = await prisma.resumes.findFirst({
            where: {
              resumeId: +resumeId,
            },
          });
          if (!resume)
            return res.status(404).json({ message: "이력서가 존재하지 않습니다." });
      
          const comment = await prisma.comments.create({
            data: {
              userId: +userId, // 댓글 작성자 ID
              resumeId: +resumeId, // 댓글 작성 이력서 ID
              content: content,
            },
          });
      
          return res.status(201).json({ data: comment });
        },
      );
      
      /** 댓글 조회 API **/
      router.get("/resumes/:resumeId/comments", async (req, res, next) => {
        const { resumeId } = req.params;
      
        const resume = await prisma.resumes.findFirst({
          where: {
            resumeId: +resumeId,
          },
        });
        if (!resume)
          return res.status(404).json({ message: "이력서가 존재하지 않습니다." });
      
        const comments = await prisma.comments.findMany({
          where: {
            resumeId: +resumeId,
          },
          orderBy: {
            createdAt: "desc",
          },
        });
      
        return res.status(200).json({ data: comments });
      });
      
      /** 댓글 수정 API **/
      router.patch(
        "/resumes/:resumeId/comments/:commentId",
        authMiddleware,
        async (req, res, next) => {
          const { resumeId, commentId } = req.params;
          const { userId } = req.user;
          const { content } = req.body;
      
          const comment = await prisma.comments.findFirst({
            where: {
              commentId: +commentId,
              userId: +userId,
            },
          });
      
          if (!comment) return res.status(404).json({ message: "수정 안돼용" });
      
          const updatedComment = await prisma.comments.update({
            where: {
              commentId: +commentId,
            },
            data: {
              content: content,
            },
          });
      
          return res.status(200).json({ data: updatedComment });
        },
      );
      
      /** 댓글 삭제 API **/
      router.delete(
        "/resumes/:resumeId/comments/:commentId",
        authMiddleware,
        async (req, res, next) => {
          const { resumeId, commentId } = req.params;
          const { userId } = req.user;
      
          const comment = await prisma.comments.findFirst({
            where: {
              commentId: +commentId,
              userId: +userId,
            },
          });
      
          if (!comment) return res.status(404).json({ message: "삭제 못해영" });
      
          await prisma.comments.delete({
            where: {
              commentId: +commentId,
            },
          });
      
          return res.status(200).json({ message: "댓글이 삭제되었습니다." });
        },
      );
      
      export default router;​
  11. 사용자 생성과 사용자 정보 생성을 트랜잭션으로 묶기
    • isolationLevel을 보면 알 수 있듯이 트랜잭션의 격리수준도 설정해줘야 함. 이렇게 하면 해당 트랜잭션이 callback함수 내에서 비즈니스 로직이 수행되고, 격리수준을 ReadCommitted로 설정하여 모든 비즈니스 로직이 동일하게 동작해야지만 결과값을 반환할 수 있도록 함
      const [user, userInfo] = await prisma.$transaction(
            async (tx) => {
              const user = await tx.users.create({
                data: { email, password: hashedPassword },
              });
              // UserInfos 테이블에 사용자 정보를 추가합니다.
              const userInfo = await tx.userInfos.create({
                data: {
                  userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
                  name,
                  age,
                  gender,
                  profileImage,
                },
              });
      
              return [user, userInfo];
            },
            {
              isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
            },
          );​​
      터미널에서도 tx 격리수준 잘 설정되어 찍힘 (feat. winston)
  12. 변경내역 로깅을 위한 User History 테이블 생성
    • 우선 프리즈마 스키마를 추가함 (여기서 uuid는 여러 정보를 담고있는 더 포괄적인 식별자):
      uuid
      DB에도 수정 내역이 잘 나옴
      // Users테이블과 관계설정은 해두었음
      
      model UserHistories {
        userHistoryId String   @id @default(uuid()) @map("userHistoryId")
        userId        Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
        changedField  String   @map("changedField") // 변경된 필드명
        oldValue      String?  @map("oldValue") // 변경 전 값
        newValue      String   @map("newValue") // 변경 후 값
        changedAt     DateTime @default(now()) @map("changedAt")
      
        // Users 테이블과 관계를 설정합니다.
        user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)
      
        @@map("UserHistories")
      }​
    • 사용자 정보 변경 API는 아래와 같이 구현함:
      /** 사용자 정보 변경 API **/
      router.patch("/users/", authMiddleware, async (req, res, next) => {
        try {
          const { userId } = req.user;
          const updatedData = req.body;
      
          const userInfo = await prisma.userInfos.findFirst({
            where: { userId: +userId },
          });
          if (!userInfo)
            return res
              .status(404)
              .json({ message: "사용자 정보가 존재하지 않습니다." });
      
          await prisma.$transaction(
            async (tx) => {
              // 트랜잭션 내부에서 사용자 정보를 수정합니다.
              await tx.userInfos.update({
                data: {
                  ...updatedData,
                },
                where: {
                  userId: userInfo.userId,
                },
              });
      
              // 변경된 필드만 UseHistories 테이블에 저장합니다.
              for (let key in updatedData) {
                if (userInfo[key] !== updatedData[key]) {
                  await tx.userHistories.create({
                    data: {
                      userId: userInfo.userId,
                      changedField: key,
                      oldValue: String(userInfo[key]),
                      newValue: String(updatedData[key]),
                    },
                  });
                }
              }
            },
            {
              isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
            },
          );
      
          return res
            .status(200)
            .json({ message: "사용자 정보 변경에 성공하였습니다." });
        } catch (err) {
          next(err);
        }
      });​
  13. Session 수정 및 추가예정 (축구 봐야함)

 

 

 

 

 

 

 

 

 

관련글 더보기