상세 컨텐츠

본문 제목

[Node.js] sparamin 2 - Draft (AWS RDS / Express / MySQL / Prisma / yarn / JWT / winston / bcrypt / Thunder Client / ERD Cloud)

projects/node.js

by 서울의볼 2024. 2. 2. 16:12

본문

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으로 개발을 진행하며 개발 순서와 발생한 문제점을 좀 더 체계적이고 자세하게 기록해보고자 함:

Notion으로 작성한 API 명세 (테이블만 깔끔하게 어떻게 복사함? ㅠㅠ)

 

ERD Cloud로 작성한 ERD

  1. 필요 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 라이브러리 사용함
    • session 설치하기 위한 yarn add express-session
    • 외부 세션 스토리지용 yarn add express-mysql-session
    • 환경변수 설정은 yarn add -D dotenv
  2. 프리즈마 모델링
    • 위 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)
  3. 모델링한 prisma AWS RDS에 연결
    • 좀 어이없긴 한데 인스턴스를 못 찾겠음. EC2도 사라지고 AWS에 다른 두(2) 컴퓨터로 접속해보았는데 인스턴스가 생겼다 말았다 아무튼 이상함...
      • 이거 이유를 알았음: 딱 들어갔을 때 region이 잘 못 설정되어 있었던 것... 역시 허무 허탈의 연속임
      • 핵꿀팁) linux환경에서 특정 EC2 instance에 접속하고자 하면 ssh-i ~~~서버 접속 해서 하면 됨. 나오는 건 그냥 exit 치면 나와짐.
      • 그리고 인스턴스 중지했다가 들어가면 안되는 경우가 있는데, 이건 IP가 매번 바껴서 그럼. 그래서 가비아든 어디든 도메인 site에서 새로운 public IP에 연결해주는 작업이 필요함!
      • 마지막으로, 도메인을 주소창에 치고 들어갔는데 안돼서 한참 헤맸는데, 내가 배포한 도메인 접속은 https프로토콜이 아닌 http:// 이었음. 이것도 주의해야함!
    • 각설하고, 새로운 DB를 만들어서 npx prisma db push 명령어를 통해 RDS와 연결하였고 아래와 같이 잘 작동되는 것도 확인함:
      Resumes 테이블 DESC 찍어봄
  4. 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 인스턴스를 생성합니다.​
  5. 사용자 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;​
  6. 사용자 인증 미들웨어 만들고 사용자 조회 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 토큰이 전달되고 사용자 정보가 반환되는 것을 확인 할 수 있음
  7. 미들웨어들 추가 생성 (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를 이용한 전역 미들웨어 중 가장 최하단에 위치시킴
      • 참고로, 해당 미들웨어는 대략 추상적으로 "서버에서 에러가 발생함~~~" 이런식으로 전달하도록 구현하는데 이유는 에러를 상세하게 클라이언트에 제공하면 악의적인 사용자에게 공격 받을 수 있기 때문이라 함
  8.  이력서 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")
      }​
      ↑수정된 프리즈마 스키마 일부
    • 아래는 라우터 파일 전체 코드임 (수정과 삭제는 authMiddleware를 넣음으로써 직접 작성한 사용자만 권한을 가질 수 있게 구현함)
      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 });
      });
      
      /** 이력서 수정 API **/
      router.patch("/resumes/:resumeId", authMiddleware, async (req, res, next) => {
        const { resumeId } = req.params;
        const { userId } = req.user;
        const { status, title, intro, exp, skill } = req.body;
      
        const resume = await prisma.resumes.findFirst({
          where: {
            resumeId: +resumeId,
            userId: +userId,
          },
        });
      
        if (!resume) {
          return res.status(404).json({ error: "이력서를 찾을 수 없습니다." });
        }
      
        const updatedResume = await prisma.resumes.update({
          where: {
            resumeId: +resumeId,
          },
          data: {
            status,
            title,
            intro,
            exp,
            skill,
          },
        });
      
        return res.status(200).json({ data: updatedResume });
      });
      
      /** 이력서 삭제 API **/
      router.delete("/resumes/:resumeId", authMiddleware, async (req, res, next) => {
        const { resumeId } = req.params;
        const { userId } = req.user;
      
        const resume = await prisma.resumes.findFirst({
          where: {
            resumeId: +resumeId,
            userId: +userId,
          },
        });
      
        if (!resume) {
          return res.status(404).json({ error: "이력서를 찾을 수 없습니다." });
        }
      
        await prisma.resumes.delete({
          where: {
            resumeId: +resumeId,
          },
        });
      
        return res.status(200).json({ message: "이력서가 삭제되었습니다." });
      });
      
      export default router;
  9.  피드백(댓글) 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;​
  10. 사용자 생성과 사용자 정보 생성을 트랜잭션으로 묶기
    • 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)
  11. 변경내역 로깅을 위한 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);
        }
      });​
  12. Session
    • express session library 설치 후 app.js파일 내 설정은 아래와 같음:
      app.use(
        expressSession({
          secret: "customized_secret_key", // 세션을 암호화하는 비밀 키를 설정
          resave: false, // 클라이언트의 요청이 올 때마다 세션을 새롭게 저장할 지 설정, 변경사항이 없어도 다시 저장
          saveUninitialized: false, // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
          cookie: {
            // 세션 쿠키 설정
            maxAge: 1000 * 60 * 60 * 24, // 쿠키의 만료 기간을 1일로 설정합니다.
          },
        }),
      );
    • 이후 router파일에서 기존의 jwt를 반환하던 방식 대신 session id를 반환하도록 설정하였으며 사용자 인증 미들웨어에서도 이와 같이 개선하여 코드 복잡도를 줄임.
    • 서버가 꺼지면 세션이 날라가는 것을 방지하기 위한 외부 세션 스토리지도 구현하였음.
    • 근데 express-mysql-session 모듈의 가장 큰 문제점은 세션 ID로 정보를 조회할 때마다 MySQL의 조회 쿼리를 매번 실행된다는 점이라 함. 그래서 이전에 사용한 JWT 쿠키를 이용하는 것이 하나의 방법이고, 외부 세션 스토리지를 캐시 메모리 데이터베이스인 Redis로 변경하는 것도 가능한 해결책이라 함. 나는 Redis를 사용해보기로 하였으나...................
    • 기존의 요구 사항대로 토큰으로 구현하라는 주문이 있어 토큰으로 다시 바꿨음 그래서 세션 구현한 자료 화면이 없음 ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ x 100,000,000
  13. dotenv로 환경변수 설정
    • 세션 완료 후 환경변수를 설정하였음. 위에서 설치한 dotenv로 민감한 정보(= DB URL, Access Token Secret Key)를 가렸음.
      .env:
      
          DATABASE_URL="mysql://root:password@sparamin_db.specificURLs25kim3.ap-northeast-3.rds.amazonaws.com:3307/Sparamin_DB"
      
          ACCESS_TOKEN_SECRET_KEY=isSecretKey
          REFRESH_TOKEN_SECRET_KEY=isRefreshKey
      
      app.js:
          import dotenv from "dotenv";
      
          dotenv.config();
      
      사용자인증 미들웨어 && 라우터에 적용:
          const ACCESS_TOKEN_SECRET_KEY = process.env.ACCESS_TOKEN_SECRET_KEY;
          const REFRESH_TOKEN_SECRET_KEY = process.env.REFRESH_TOKEN_SECRET_KEY;
      • .gitignore에 .env파일과 node module파일을 넣어 민감하거나 불필요한 정보가 공유되지 않도록 함.
    • 유저 라우터에서 refresh token을 로그인 시 같이 발행시키기 위해 아래와 같이 refresh token관련 코드를 추가함:
      /** 로그인 API **/
      const ACCESS_TOKEN_SECRET_KEY = process.env.ACCESS_TOKEN_SECRET_KEY;
      const REFRESH_TOKEN_SECRET_KEY = process.env.REFRESH_TOKEN_SECRET_KEY;
      
      const tokenStorage = {}; // Refresh Token을 저장할 객체
      
      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 accessToken = jwt.sign(
          { userId: user.userId },
          ACCESS_TOKEN_SECRET_KEY,
          { expiresIn: "3h" },
        );
      
        const refreshToken = jwt.sign(
          { userId: user.userId },
          REFRESH_TOKEN_SECRET_KEY,
          { expiresIn: "7d" },
        );
      
        // Refresh Token을 가지고 해당 유저의 정보를 서버에 저장합니다.
        tokenStorage[refreshToken] = {
          id: user.userId,
          ip: req.ip,
          userAgent: req.headers["user-agent"],
        };
      
        // authotization key값을 가진 쿠키에 Bearer 토큰 형식으로 JWT를 저장합니다.
        res.cookie("authorization", `Bearer ${accessToken}`);
        res.cookie("refreshToken", `Bearer ${refreshToken}`);
      
        return res.status(200).json({ message: "로그인 성공" });
      });
      
      /** Refresh Token으로 Access Token 재발급 **/
      router.post("/token/refresh", refreshMiddleware, async (req, res, next) => {
        const { userId } = req.refreshTokenInfo;
      
        // 새로운 Access Token을 생성합니다.
        const newAccessToken = jwt.sign({ userId }, ACCESS_TOKEN_SECRET_KEY, {
          expiresIn: "3h",
        });
      
        // 새로운 Access Token을 클라이언트에게 전달합니다.
        res.cookie("authorization", `Bearer ${newAccessToken}`);
      
        return res.status(200).json({ message: "새로운 Access Token 발급 성공" });
      });
      
      /** 사용자 조회 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 });
      });
       
    • 코드에서 볼 수 있듯이 Access Token의 경우 3시간, Refresh Token의 경우 7일을 주어 유효기간 내 Refresh Token을 통해  Access Token 재발급이 가능하도록 구현함.
    • 글쓴이 깃헙 발췌:
        • JWT(Json Web Token)을 이용해 인증 기능을 구현함. Access Token이 노출되었을 경우 해당 토큰이 탈취되어 여러 이슈가 생길 수 있음. 예를 들어 해당 사용자의 개인정보 유출이나 사용자의 권한으로 서비스에 접근할 수 있어 금전적 피해도 발생할 수 있음.
        • 이를 방지하기 위해 Access Token의 유효기간을 짧게 가져가고(3시간), Refresh Token의 유효기간을 상대적으로 길게(7일) 하여 Access Token을 재발급하도록 구현함.
    • 참고로, refresh token은 연관된 사용자 정보를 별도의 테이블로 관리하게 되는데, 위에 코드에선 그 역할을 tokenStorage에서 한다고 볼 수 있음. 이번엔 token storage라는 변수를 사용하여 관리하였으나, 해당 방식은 실제 프로덕션 환경에선 부적합하다고 함.
      • 좀 더 자세히 말하자면, 이는 인 메모리(In-memory) 방식을 사용하기 때문에 서버가 재시작 또는 종료될 경우 모든 정보가 사라지게 됨 (흡사 위에 언급한 세션과 같은 느낌). 그래서 실제 서비스에선 방금 언급한 것과 같이 별도의 테이블에서 저장하고 관리함. 이렇게 할 경우, Refresh Token 검증 작업을 MySQL과 같은 데이터베이스를 조회함과 동시에 함께 처리할 수 있게 된다 함!
    • 나아가 refresh token을 검증하는 미들웨어를 추가함 --- 위에 라우터에도 import refreshToken MW해줬음:
      import jwt from "jsonwebtoken";
      
      const REFRESH_TOKEN_SECRET_KEY = process.env.REFRESH_TOKEN_SECRET_KEY;
      
      export default async function (req, res, next) {
        try {
          const { refreshToken } = req.cookies;
          if (!refreshToken) throw new Error("리프레시 토큰이 존재하지 않습니다.");
      
          const [tokenType, token] = refreshToken.split(" ");
      
          if (tokenType !== "Bearer")
            throw new Error("토큰 타입이 일치하지 않습니다.");
      
          const decodedRefreshToken = jwt.verify(token, REFRESH_TOKEN_SECRET_KEY);
          const userId = decodedRefreshToken.userId;
      
          // Refresh Token의 정보를 req 객체에 저장하여 다음 미들웨어에서 사용할 수 있게 합니다.
          req.refreshTokenInfo = decodedRefreshToken;
      
          return next();
        } catch (error) {
          // 토큰이 만료되었거나, 조작되었을 때, 에러 메시지를 다르게 출력합니다.
          switch (error.name) {
            case "TokenExpiredError":
              return res
                .status(401)
                .json({ message: "Refresh Token이 만료되었습니다." });
            case "JsonWebTokenError":
              return res
                .status(401)
                .json({ message: "Refresh Token이 조작되었습니다." });
            default:
              return res.status(401).json({ message: "비정상적인 요청입니다." });
          }
        }
      }
      • Refresh Token 잘 동작함:

AccessToken과 함께 발급된 Refresh Token
Refresh Token을 통해 Access Token 재발급 메세지
Access Token 내역

 

이렇게 마무리 후 배포하고 README 추가한 다음 피드백을 위해 과제 제출하였음.

전반적으로 필수 요구사항을 구현하는데 어려움은 없었지만 깊이 있는 이해를 바탕으로 한 느낌은 없었음. 그럼에도 불구하고 아주 많은, 다양한 컨셉을 배우고 적용하여 그래도 조금은 익숙해진 느낌이 듦.

 

마음 같아선 ejs를 활용하여 이번에도 시각적인 요소를 더하고 싶었으나 이번엔 백엔드 개발 요건을 맞추는데도 어느정도 애를 먹어 그러고 있을 때가 아님을 느끼고 요구조건에 맞추기 위해 부단히 노력했음.

 

특강을 들으며 데이터베이스를 최초에 어떻게 구상 및 모델링 해야 되는지에 대한 고찰이 이번 프로젝트를 진행했을 당시엔 전혀 들어있지 않아 이건 개선해야 할 부분임. 각 데이터 타입별 할당되는 메모리와 join을 할 때 발생하는 부하 등 여러 방면으로 어떻게 구조를 짜는게 좋을 지 고찰을 하는 시간이 꼭 필요할 것으로 보임.

 

다음 글에 튜터님 및 그간 있었던 강의 등에서 배운 개념들에 대해 회고와 함께 남기고자 함 (여기에 다 남기고 싶지만 다음에 찾아 읽기 편하게 하기 위함).

 

Github Repository:

https://github.com/kdevkh/sparamin

관련글 더보기