[Node.js] sparamin 2 - Draft (AWS RDS / Express / MySQL / Prisma / yarn / JWT / winston / bcrypt / Thunder Client / ERD Cloud)
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
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 });
});
/** 이력서 수정 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;
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);
}
});
app.use(
expressSession({
secret: "customized_secret_key", // 세션을 암호화하는 비밀 키를 설정
resave: false, // 클라이언트의 요청이 올 때마다 세션을 새롭게 저장할 지 설정, 변경사항이 없어도 다시 저장
saveUninitialized: false, // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
cookie: {
// 세션 쿠키 설정
maxAge: 1000 * 60 * 60 * 24, // 쿠키의 만료 기간을 1일로 설정합니다.
},
}),
);
.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;
/** 로그인 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 });
});
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: "비정상적인 요청입니다." });
}
}
}
이렇게 마무리 후 배포하고 README 추가한 다음 피드백을 위해 과제 제출하였음.
전반적으로 필수 요구사항을 구현하는데 어려움은 없었지만 깊이 있는 이해를 바탕으로 한 느낌은 없었음. 그럼에도 불구하고 아주 많은, 다양한 컨셉을 배우고 적용하여 그래도 조금은 익숙해진 느낌이 듦.
마음 같아선 ejs를 활용하여 이번에도 시각적인 요소를 더하고 싶었으나 이번엔 백엔드 개발 요건을 맞추는데도 어느정도 애를 먹어 그러고 있을 때가 아님을 느끼고 요구조건에 맞추기 위해 부단히 노력했음.
특강을 들으며 데이터베이스를 최초에 어떻게 구상 및 모델링 해야 되는지에 대한 고찰이 이번 프로젝트를 진행했을 당시엔 전혀 들어있지 않아 이건 개선해야 할 부분임. 각 데이터 타입별 할당되는 메모리와 join을 할 때 발생하는 부하 등 여러 방면으로 어떻게 구조를 짜는게 좋을 지 고찰을 하는 시간이 꼭 필요할 것으로 보임.
다음 글에 튜터님 및 그간 있었던 강의 등에서 배운 개념들에 대해 회고와 함께 남기고자 함 (여기에 다 남기고 싶지만 다음에 찾아 읽기 편하게 하기 위함).
Github Repository:
[Node.js] sparamin 3 - 보완작업 (QueryString orderKey orderValue / clientId 소셜로그인 / localhost 에러, 127.0.0.1 / admin vs. user / DB ERD) (1) | 2024.02.05 |
---|---|
[Node.js] sparamin 노트 (세션 vs 토큰 / Scale-out Scale-in / 토큰의 역사) (0) | 2024.02.02 |
[Node.js] sparamin 1 - 공사중 (2) | 2024.01.31 |
[Node.js] Spart Store 2 (4) | 2024.01.23 |
[Node.js] Spart Store 1 (1) | 2024.01.23 |