상세 컨텐츠

본문 제목

[Node.js] sparamin 3 - 보완작업 (QueryString orderKey orderValue / clientId 소셜로그인 / localhost 에러, 127.0.0.1 / admin vs. user / DB ERD)

projects/node.js

by 서울의볼 2024. 2. 5. 17:56

본문

해설강의를 통해 기존 코드에서 추가 보완작업을 진행함.

 

추가 구현사항:

1. 필수 요건 보완

2. 카카오 회원가입/로그인

3. 인사담당자 계정 권한

4. Swagger

 

topic별로 녹색 음영을 해둠

  • 강의를 보아하니 ERD를 괜히 먼저 작성하는 게 아님. ERD 작성 후 SQL table 생성하면 바로 갖다 붙일 수 있음 ㄷㄷ

AQUERY 사용하여 foreign key에 해당하는 걸 끌고와서 관계 맺어줌

  • 참고로 위에 테이블엔 "비밀번호 확인"을 위한 테이블은 당연히 없음. 이건 검증을 위한 부분이라 따로 데이터를 저장할 필요가 없음(후술)!
  • API 명세는 노션으로 작성을 했는데, 이력서 목록조회에서 orderKey와 orderValue를 받는 걸 깜박했음.
  • 본격적으로 코드를 작성함. 기본적으로 express는 body를 받아와서 읽지 못함. 그래서 body-parser를 설치하는 것 (나는 cookie-parser).
    • body parser를 미들웨어처럼 등록하여 사용하게 되는 것 (eg. app.use(bodyParser.json())
  • 비밀번호를 확인하는 부분 역시 기존에 내가 작성한 코드에선 없기에 추가 후 다시 테스트 해봄 
  • router.post("/sign-up", async (req, res, next) => {
      try {
        const {
          email,
          password,
          passwordConfirm,
          name,
          age,
          gender,
          profileImage,
        } = req.body;
        const isExistUser = await prisma.users.findFirst({
          where: { email },
        });

        if (password.length < 6) {
          return res
            .status(400)
            .json({ message: "비밀번호는 최소 6자리 이상이어야 합니다." });
        }

        if (password !== passwordConfirm) {
          return res.status(400).json({ message: "비밀번호가 다릅니다." });
        }

        if (isExistUser) {
          return res.status(409).json({ message: "이미 존재하는 이메일입니다." });
        }
  • Thunder Client에서 다시 돌려봤는데 잘 안되길래 뭔가 하고 보니 패키지를 다시 설치해야 했음 (중간에 맥으로 갈아타서 완성해서 그런가?) 
  • 해설에선 데이터타입을 불리언으로 줘서 success 여부도 함께 반환하도록 구현함 (아래 예시 참조)
  • jwt.io에서 jwt 토큰을 디버그 할 수 있는데, exp가 만료시간임. exp의 이상한 숫자를 unix timestamp라고 함. 적당한 사이트에 들어가서 값을 넣으면 실제 유효기간이 나옴.
  • 튜터님이 피드백 할 때 authMiddleware라고 쓰면 뭘 authenticate하겠다는 건지 모르니 현업에선 이렇게 naming하진 않을 것 같다는 말씀이 있었음. 해설강의에서도 jwt-validate.middleware.js라는 식으로 명명함.
    • 해당 미들웨어의 로직은 다음과 같음:
      1. 헤더에서 accessToken 가져오기
      2. accessToken의 인증방식이 올바른가
      3. 유효기간이 남아있는가
      4. accessToken 안에 userId 데이터가 잘 들어있는가
      5. user 정보 담기
  • 인증미들웨어 모범답안
  • 참고로 commit은 한 기능 끝날 때마다 해주는 게 좋다 함.
  • Bearer token 검사
  • API 명세와 달리 사진과 같이 JOIN으로 인해 다른 테이블의 데이터가 들여쓰여진 채 나오게 되는데, 이건 forEach나 map을 써서 고칠 수 있지만 api 명세를 고치고 frontend 개발자랑 상의하는 것도 방법이래.
  • 요렇게 하면 잘 나옴!
  • 이력서 목록 조회 api 수정이 많았음. QueryString으로 order 데이터를 받아 정렬방식을 결정하게 구현하라 했으나 최초 구현시 이해하지 못해 고냥 넘긴 부분임:
  • /** 이력서 목록 조회 API **/
    router.get("/resumes", async (req, res, next) => {
      const orderKey = req.query.orderKey ?? "resumeId";
      const orderValue = req.query.orderValue ?? "desc";

      if (!["resumeId", "status"].includes(orderKey)) {
        return res.status(400).json({ message: "orderKey가 올바르지 않습니다." });
      }

      if (!["asc", "desc"].includes(orderValue.toLowerCase())) {
        return res.status(400).json({ message: "orderValue가 올바르지 않습니다." });
      }

      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: [
          {
            [orderKey]: orderValue.toLowerCase(), // []로 orderKey를 감싸줘야 resumeId나 status 중 하나가 들어와도 작동 가능 --- 변수를 통해 변수 안의 값이 들어가게 되는 것
          },
        ],
      });

      return res.status(200).json({ data: resumes });
    });
  • 참고로 응답을 반환하지 않을 때 .json() 이렇게 끝내기도 하는데, .end()로 해도 ㄱㅊ. 근데 아무것도 안적으면 계속 응답을 기다리기 때문에 뭐라도 쓰긴 해야함.
  • 테스트는 client 내 query 들어가서 해보면 됨 (흡족함):
  • 또 다른 꿀팁으로, thunder client에서 request folder를 나눴다면 folder 우클릭 후 settings 내 auth 들어가서 cookie를 저장하면 해당 쿠키로 계속 테스트 할 수 있음 (강의에선 기능별로 폴더를 나눴음).
  • 기존의 이력서 수정에서 status를 배열 내 상태값만 올 수 있게 함:
  • /** 이력서 수정 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: "이력서를 찾을 수 없습니다." });
      }

      if (
        ![
          "APPLY",
          "DROP",
          "PASS",
          "INTERVIEW1",
          "INTERVIEW2",
          "FINAL_PASS",
        ].includes(status)
      ) {
        return res.status(400).json({
          message: "올바른 상태값이 아닙니다.",
        });
      }

      const updatedResume = await prisma.resumes.update({
        where: {
          resumeId: +resumeId,
        },
        data: {
          status,
          title,
          intro,
          exp,
          skill,
        },
      });

      return res.status(200).json({ data: updatedResume });
    });
  • 강의에선 crypto-js를 사용하여 단방향 암호화를 진행하였고, 예전엔 md5를 많이 썼다함
  • 카카오로그인 개념도 (다른 서비스도 비슷하다 함):
  • kakao developers 발췌
  • 카카오와 같은 SNS 로그인이 주는 장점은, 클라이언트가 서버에 직접 로그인 정보를 주지 않았음. 내 서버는 카카오가 반환하는 키값만 사용하는 것(ie. access token).
  • 카카오에서 clientId를 넘겨주기에, 테이블부터 clientId column을 추가하였음:
  • ALTER TABLE Users
    ADD column client_id varchar(255) NULL AFTER userId
     
    이었으나... clientId로 column이름을 바꾸고자 아래 명령어를 사용함
     
    ALTER TABLE Users
    CHANGE column client_id clientId varchar(255) NULL AFTER userId
  • ------------------------ 문제 발생
  • 저런식으로 DB에 직접 추가 후 npx prisma db pull을 했으나 schema.prisma에 변경사항이 반영이 안됐음. 그래서 직접 prisma에 clientId column을 추가한 후 push하고 계속 개발을 진행함:
    model Users {
      userId        Int             @id @default(autoincrement()) @map("userId")
      clientId      String?         @unique @map("clientId")
      email         String?         @unique @map("email")
      password      String?         @map("password")
      createdAt     DateTime        @default(now()) @map("createdAt")
      updatedAt     DateTime        @updatedAt @map("updatedAt")
      role          String          @default("user") @map("role")
      comments      Comments[]
      resumes       Resumes[]
      userHistories UserHistories[]
      userInfos     UserInfos?

      @@map("Users")
    }
  • 이후 라우터 역시 아래와 같이 수정함:
    /** 사용자 회원가입 API **/
    router.post("/sign-up", async (req, res, next) => {
      try {
        const {
          email,
          clientId,
          password,
          passwordConfirm,
          name,
          age,
          gender,
          profileImage,
          role,
        } = req.body;

        if (role && !["user", "admin"].includes(role)) {
          return res.status(400).json({ message: "정의되지 않은 권한입니다." });
        }

        // 해설강의에서 유효성검사 실행 내역:
        if (!clientId) {
          if (!email) {
            return res.status(400).json({ message: "이메일은 필수값입니다." });
          }
          if (!password) {
            return res.status(400).json({ message: "비밀번호는 필수값입니다." });
          }
          if (!passwordConfirm) {
            return res
              .status(400)
              .json({ message: "비밀번호 확인은 필수값입니다." });
          }
          if (password.length < 6) {
            return res
              .status(400)
              .json({ message: "비밀번호는 최소 6자리 이상이어야 합니다." });
          }

          if (password !== passwordConfirm) {
            return res
              .status(400)
              .json({ message: "비밀번호가 일치하지 않습니다." });
          }
        }

        if (!name) {
          return res.status(400).json({ message: "이름은 필수값입니다." });
        }

        // 카카오 회원가입
        if (clientId) {
          const isExistUser = await prisma.users.findFirst({
            where: {
              clientId,
            },
          });

          if (isExistUser) {
            return res.status(200).json({ message: "이미 가입된 사용자입니다." });
          }

          const [createdUser, createdUserInfo] = await prisma.$transaction(
            async (tx) => {
              const user = await tx.users.create({
                data: {
                  clientId,
                  role,
                },
                include: {
                  userInfos: true,
                },
              });

              // UserInfos 테이블에 사용자 정보를 추가합니다.
              const userInfo = await tx.userInfos.create({
                data: {
                  userId: user.userId,
                  name,
                },
              });

              return [user, userInfo];
            },
            {
              isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
            },
          );
        } else {
          // email 회원가입
          const isExistUser = await prisma.users.findFirst({
            where: { email },
          });

          if (isExistUser) {
            return res.status(409).json({ message: "이미 존재하는 이메일입니다." });
          }

          // 사용자 비밀번호를 암호화합니다.
          const hashedPassword = await bcrypt.hash(password, 10);

          const [createdUser, createdUserInfo] = await prisma.$transaction(
            async (tx) => {
              const user = await tx.users.create({
                data: {
                  email,
                  password: hashedPassword,
                  role,
                },
                include: {
                  userInfos: true,
                },
              });

              // UserInfos 테이블에 사용자 정보를 추가합니다.
              const userInfo = await tx.userInfos.create({
                data: {
                  userId: user.userId,
                  name,
                  age,
                  gender,
                  profileImage,
                },
              });

              return [user, userInfo];
            },
            {
              isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
            },
          );
        }

        return res.status(201).json({ message: "회원가입이 완료되었습니다." });
      } catch (err) {
        next(err);
      }
    });
  • 향후 SNS 로그인을 추가할 경우 테이블에 type column을 추가하여 네이버, 카카오 등 나눌 수 있음.

 

  • @@@@@@@@@@@@@ 또 대환장할 문제를 마주함!@@@@@@@@@@@@@ (+최소 3시간 소요//스승님 공인 해결하기 어려웠을 문제)
  • 로그인을 하고 access token과 refresh token은 잘 발급 되었으나, 그 토큰을 가지고 회원정보 조회 및 다른 기능들이 아주 갑작스럽게도 인증이 안되어 먹통이 됐음.
  • 트러블 슈팅을 위해 하나씩 console을 찍어보았음:
    import jwt from "jsonwebtoken";
    import { prisma } from "../models/index.js"; // Prisma client 가져오기

    const ACCESS_TOKEN_SECRET_KEY = process.env.ACCESS_TOKEN_SECRET_KEY;

    export default async function (req, res, next) {
      try {
        const { authorization } = req.cookies;
        console.log(authorization); ---> 얘랑 밑에는 잘 나옴
        console.log(req.cookies);
        if (!authorization) throw new Error("토큰이 존재하지 않습니다.");

        const [tokenType, token] = authorization.split(" ");
        // 원래 같았으면 아래,,, 리팩토링한 게 위에     ---> 참고하자
        // const token = authorization.split(" ");
        // const tokenType = token[0];
        // const tokenValue = token[1];

        if (tokenType !== "Bearer")
          throw new Error("토큰 타입이 일치하지 않습니다.");

        const decodedAccessToken = jwt.verify(token, ACCESS_TOKEN_SECRET_KEY);
        const userId = decodedAccessToken.userId;

        const user = await prisma.users.findFirst({
          where: { userId: +userId },
        });
        if (!user) {
          res.clearCookie("authorization");
          throw new Error("토큰 사용자가 존재하지 않습니다.");
        }

        // req.user에 사용자 정보를 저장합니다.
        req.user = user;
        if (user.role === "admin") {
        }

        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 ?? "비정상적인 요청입니다." });
        }
      }
    }
    // routers/users.router.js
    /** 로그인 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 { clientId, email, password } = req.body;
      let user;
      if (clientId) {
        // 카카오 로그인
        user = await prisma.users.findFirst({ where: { clientId } });
        if (!user)
          return res.status(401).json({ message: "존재하지 않는 계정입니다." });
      } else {
        // 이메일 로그인
        user = await prisma.users.findFirst({ where: { email } });
        if (!email)
          return res.status(401).json({ message: "존재하지 않는 이메일입니다." });
        if (!user)
          return res.status(401).json({ message: "존재하지 않는 이메일입니다." });
        // 입력받은 사용자의 비밀번호와 데이터베이스에 저장된 비밀번호를 비교합니다.
        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: "12h" },
      );
      console.log(accessToken);  ---> 여기에서 token을 받아오지 못하고 "undefined"가 찍혔음

      const refreshToken = jwt.sign(
        { userId: user.userId },
        REFRESH_TOKEN_SECRET_KEY,
        { expiresIn: "7d" },
      );
  •  그렇게 access token이 회원정보 조회시 header에 담겨있지 않은 걸 파악하고 여러 트러블슈팅을 해보았으나 사실 모두 tracking하기엔 뇌용량 부족으로 결론만 남김:
    보통 thunder client에서 localhost:port#를 사용해왔는데, 어느 순간 뭐가 잘못된 건진 모르겠으나 domain이 localhost를 인식하지 못해 대신하여 127.0.0.1을 입력하니 작동했음. thunder client의 문제일 수도 있고 다양한 이유가 있으나 현업에선 사실 이런식의 api client를 잘 안쓰게 되어 우선은 자세한 원인 파악은 뒤로 미루기로 함 (사실 영원히 미룰 생각임)...!
  • 대망의 인사담당자 권한 부여 작업을 시작함 (여기서도 엄청나게 많은 난관에 봉착했음 // 세상 쉬운게 없음 -_-):
  •  우선 강의에선 table에 role (혹은 grade라 명명하던데 무튼,,)을 추가했음 (이전 글에서 이미 한 부분). 이후 회원가입에 role을 추가(위에 참고 가능)
  • 요구조건은 admin 권한을 받은 user는 모든 이력서의 status만 수정이 가능하도록 하는 것이었음. 그러기 위해 auth.middleware.js부터 수정하여 req.user에 사용자의 권한 수준을 저장하였음:
    req.user = user;
        if (user.role === "admin") {
          req.isAdmin = true;
        } else {
          req.isUser = true;
        }
  • 그리고 이력서 수정 부분에서 admin과 해당 이력서의 userId를 가지지 않은 타 user의 경우 이력서 수정이 안되고, admin의 경우 status만 수정이 가능토록 구현하였음.
    /** 이력서 수정 API **/
    router.patch("/resumes/:resumeId", authMiddleware, async (req, res, next) => {
      const { resumeId } = req.params;
      const { userId, role } = req.user;
      const { status, title, intro, exp, skill } = req.body;

      const resume = await prisma.resumes.findFirst({
        where: {
          resumeId: +resumeId,
        },
      });

      if (!resume) {
        return res.status(404).json({ error: "이력서를 찾을 수 없습니다." });
      }

      // 권한 부여
      if (!req.isAdmin && resume.userId !== +userId) {
        return res.status(400).json({ message: "권한이 없습니다~~~" });
      }

      const allowedStatusValues = [
        "APPLY",
        "DROP",
        "PASS",
        "INTERVIEW1",
        "INTERVIEW2",
        "FINAL_PASS",
      ];

      if (status && !allowedStatusValues.includes(status)) {
        return res.status(400).json({
          message: "올바른 상태값이 아닙니다.",
        });
      }

      // admin 타 필드 수정 불가
      if (req.isAdmin) {
        const nonStatusFields = Object.keys(req.body).filter(
          (field) => field !== "status",
        );

        if (nonStatusFields.length > 0) {
          return res.status(400).json({
            message: "admin은 status 이외의 필드를 수정할 수 없습니다.",
          });
        }
      }

      const updateData = req.isAdmin
        ? { status }
        : { status, title, intro, exp, skill };

      const updatedResume = await prisma.resumes.update({
        where: {
          resumeId: +resumeId,
        },
        data: updateData,
      });

      return res.status(200).json({ data: updatedResume });
    });
  • Swagger에 대한 추가 강의도 있었으나 실제로 적용하진 않았음. express 환경에선 swagger를 사용하기 불편하고 띄어쓰기 하나에 출력이 안되는 경우도 있기에 Nest.js나 typescript 배울 때 적용하기로 스스로 타협봄... (사실 너무 킹받아서 귀찮음)

 

 

추가로,,, 어제 스승님이 라이브로 코드리뷰를 해주셨음. 메모를 좀 남기고자 함:

  • 현업에선 테이블 관계가 1:1은 잘 안쓴다 함 --> join을 최대한 줄이는 게 핵심이고, 1:1의 관계라면 그냥 같이 쓰는 게 나음.
  • 함수와 변수명은 소문자(카멜케이스)가 국룰임. Class를 쓸 때만 대문자 allowed.
  • 로그 미들웨어 내 logger도 따로 분리하여 관리 추천
    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();
    }
  • UX를 생각해보니, default값은 user로 주는게 맞겠더라. 좀 나중에 깨달아서 적어둠 (원랜 안그랬다는 뜻).
  • transaction을 쓸 때 async await을 주로 썼는데, 순서가 상관 없기에 promise all을 쓰면 좋다고 함. 속도의 차이가 데이터가 많을 때 확실히 나기 때문. async await의 경우 for문을 쓰며 다 펼쳐놓고 기다렸다(await) 하나씩 내려가기 때문임.
  • DB 테이블에서 @db.text와 그냥 varchar의 차이점:
    이미지는 base64 encoding해서 저장하는데, varchar의 제한을 넘기 쉬움. 이때 LOB(Large OBject)의 개념이 등장하는데 varchar와 달리 LOB메모리에 해당 데이터의 주소를 할당해주고 실제 raw data의 경우 별도의 테이블에서 관리하게 됨.
    참고 이미지

    varchar는 최대 65535 사이즈까지 가능한데, 보통 space를 좀 take up 하더라도 벽돌쌓기를 생각 했을 때, 일정한 벽돌들이 할당되어 나열되어야 추후 들어오는 데이터들 size의 adjustment로 인한 부하가 줄게 되는 것. 그래서 일찌감치 좀 여유롭게 사이즈를 할당해둠.
    참고로 varchar의 default값이 255라 varchar(255)라 자주 표기되는 것.

 

 

회고:

 

일단 해설강의를 그대로 적용하기엔 내 코드가 너무 달라 결국 알아서 구현한 수준으로 감. 해설강의의 그것은 매우 클린한 코딩이라 따라하고 싶은 마음이 굴뚝 같았지만,,, 걍 그림의 떡 보듯 다음 기회에 복기할 생각임.

 

진도가 안나가니 너무 답답함이 많은 프로젝트였음. 머리에 남는 것도 선명하지 않고, 계속 복습이 필요한 부분들인 거 같은데 어느수준으로 타협을 해야 추진력을 잃지 않을까 고민이 많이 됨.

 

가장 기억에 남는 수많은 조언들 중 하나는, DB의 구조와 어떤 함수를 썼을 때 가장 메모리를 덜 잡아먹을 지에 대한 고찰이었음. 뭔가 이런 부분에 있어 고찰의 의지가 생긴 것에 큰 의의를 두지만 다음에 비슷한 프로젝트나, 실무를 할 때 지금 레벨에서 적용 및 활용이 가능할 지는 의문임.

 

아직 모든게 서툴고 방향성이 맞는지도 모르겠고, 지금 필자는 매우 혼란스러운 조타수인 것.

 

리팩토링한 코드는 github 참고: https://github.com/kdevkh/sparamin

관련글 더보기