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으로 개발을 진행하며 개발 순서와 발생한 문제점을 좀 더 체계적이고 자세하게 기록해보고자 함:
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")
}
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, '포트로 서버가 열렸어요!');
});
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
// Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
log: ['query', 'info', 'warn', 'error'],
// 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
errorFormat: 'pretty',
}); // PrismaClient 인스턴스를 생성합니다.
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;
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 ?? "비정상적인 요청입니다." });
}
}
}
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 });
});
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();
}
export default function (err, req, res, next) {
// 에러를 출력합니다.
console.error(err);
// 클라이언트에게 에러 메시지를 전달합니다.
res.status(500).json({ errorMessage: "서버 내부 에러가 발생했습니다." });
}
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;
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;
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,
},
);
// 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 **/
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);
}
});
[Node.js] sparamin 노트 (세션 vs 토큰 / Scale-out Scale-in / 토큰의 역사) (0) | 2024.02.02 |
---|---|
[Node.js] sparamin 2 - Draft (AWS RDS / Express / MySQL / Prisma / yarn / JWT / winston / bcrypt / Thunder Client / ERD Cloud) (0) | 2024.02.02 |
[Node.js] Spart Store 2 (4) | 2024.01.23 |
[Node.js] Spart Store 1 (1) | 2024.01.23 |
[Node.js] NBC Movie Review 2 (3) | 2024.01.16 |