1. 과제 이름 : 아이템 시뮬레이터
주소 : https://github.com/YongHyeon1231/item_simulator
2. 과제 목표
- Express.js의 기본적인 사용법 및 CRUD 기반의 REST API 설계 방법 숙지
- NoSQL의 한 종류인 MongoDB를 mongoose를 통해 사용하는 법에 대해서 학습
- RDBMS의 한 종류인 MySQL를 prisma를 통해 사용하는 법에 대해서 학습
- Node.js를 이용해서 게임 아이템 제작 시뮬레이션 서비스 백엔드 서버를 구현
- AWS EC2에 Express.js를 이용한 웹 서비스를 배포
- 즉, 프로젝트에 요구 사항을 토대로 API 리스트를 작성하고, 백엔드 서버를 설계할 수 있다.
3. API 명세서
API 목록
users | characters | items | equipments | inventories | getMoney | shops |
API 상세 정보 (수정 중)
https://www.notion.so/API-90fb99e500e344858dbd0648b0c217bb
4. ERD
5. 프로젝트 초기화
# 프로젝트를 초기화합니다.
yarn init -y
# 라이브러리를 설치합니다.
yarn add express prisma @prisma/client cookie-parser jsonwebtoken
# nodemon 라이브러리를 DevDependency로 설치합니다.
yarn add -D nodemon
# 설치한 Prisma를 초기화 하여, Prisma를 사용할 수 있는 구조를 생성합니다.
npx prisma init
yarn add prettier
// mysql 드라이버 설치
yarn add mysql2
# dotenv 모듈을 설치합니다
yarn add -D dotenv
# yarn을 이용해 Joi를 설치합니다.
yarn add joi
yarn add bcrypt
yarn add @types/bcrypt -D
6. 유저 인증 미들웨어 구현
1. Request의 Authorization 헤더에서 JWT를 가져와서 인증 된 사용자인지 확인하는 Middleware를 구현합니다.
-> 클라이언트에서는 쿠키로 JWT를 전달하지 않습니다.
-> 오로지 Authorization 헤더로만 JWT를 전달합니다.
-> src/middlewares/auths/user-authenticator.middleware.js를 만들어줍니다.
import jwt from "jsonwebtoken";
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, process.env.JWT_SECRET_KEY);
const userId = decodedToken.userId;
const user = await prisma.users.findFirst({
where: {userId: +userId},
});
if (!user) {
throw new Error("토큰 사용자가 존재하지 않습니다.");
}
req.user = user;
next();
} catch(error) {
res.clearCookie("authorization");
switch(error.name) {
case "TokenExpriredError":
return res.status(401).json({message: "토큰이 만료되었습니다."});
case "JsonWebTokenError":
return res.status(401).json({message: "토큰이 조작되었습니다."});
default:
return res.status(401).json({message: error.message ?? "비정상적인 요청입니다."});
}
}
}
7. 데이터베이스 모델링
-> Users, Characters, Inventories, Equipments, Items
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// 데이터베이스 모델링
model Users {
userId Int @id @default(autoincrement()) @map("userId")
username String @unique @map("username")
password Striung @map("password")
characters Characters[] // 1:N
@@map("Users")
}
model Characters {
characterId Int @id @default(autoincrement()) @map("characterId");
characterName String @unique @map("characterName")
health Int @default(500) @map("health")
power Int @default(100) @map("power")
money Int @default(10000) @map("money")
userId Int @map("userId")
users Users @relation(fields: [userId], references: [userId], onDelete: Cascade)
inventories Inventories[] // 1:N
equipments Equipments[] // 1:N
@@map("Characters")
}
model Inventories {
inventoryId String @id @default(uuid()) @map("inventoryId")
characterId Int @map("characterId")
itemCode Int @map("itemCode")
count Int @default(0) @map("count")
characters Characters @relation(fields: [characterId], references: [characterId], onDelete: Cascade)
items ItemCode @relation(fields:[itemCode], references: [itemCode], onUpdate: Cascade)
@@map("Inventories")
}
model Items {
itemCode Int @id @map("itemCode")
itemName String @unique @map("itemName")
itemStat Json @map("itemStat")
itemPrice Int @map("itemPrice")
inventories Inventories[] // 1:N
@@map("Items")
}
model Equipments {
equipmentId String @id @default(uuId()) @map("equipmentId")
characterId Int @map("characterId")
itemCode Int @map("itemCode")
characters Characters @relation(fields: [characterId], references: [characterId], onDelete: Cascade)
@@map("Equipments")
}
// 현재 모델링 상태
// Users : Characters = 1: N
// Characters : Inventories = 1: N <- 1:1인줄 알았음 설계를 다시 생각해보는중
// Characters : Equipments = 1: N
// Items : Iventories = 1: N
// Equipments의 itemCode는 단순 장착한 아이템이 어떤것인지 알기 위해서 Stat을 추가해줘야 하기 때문에 넣었습니다.
// Inventories의 itemCode는 내 item이 몇개 있는지 확인 Count는 그 갯수를 빼거나 하기 위해서 필요
8. Joi 유효성 검사 (Item 부분)
가장 애먹었던 부분인 unknown(true)의 부분 저 부분이 없으면 내가 req.body에서 받아온 부분이 itemCode itemName, itemPrice라면 itemCodeSchema의 부분에서 자동으로 저것만 체크하고 넘어갈 줄 알았는데 마음대로 되지 않는게 코드라는 것을 한번 더 알게 되었습니다.
import Joi from 'joi';
// 한유정 튜터님께서 추천해주신 방법
const itemSchema = Joi.object({
itemCode: Joi.number().integer().min(1000).required(),
itemName: Joi.string().min(1).max(30).required(),
itemStat: {
health: Joi.number().integer().optional(),
power: Joi.number().integer().optional(),
},
itemPrice: Joi.number().integer().required(),
});
// 제가 고안한 방법 <- 너무 관리가 불편하다.... (힝...)
const itemCodeSchema = Joi.object({
itemCode: Joi.number().integer().min(1000).required(),
}).unknown(true);
const itemNameSchema = Joi.object({
itemName: Joi.string().min(1).max(30).required(),
}).unknown(true);
// optional: 객체의 해당 필드가 선택 사항이라는 것을 명확하게 나타내는 방법
const itemStatSchema = Joi.object({
itemStat: {
health: Joi.number().integer().optional(),
power: Joi.number().integer().optional(),
},
}).unknown(true);
const itemCountSchema = Joi.object({
count: Joi.number().integer().required(),
}).unknown(true);
const itemPriceSchema = Joi.object({
itemPrice: Joi.number().integer().required(),
}).unknown(true);
// strict: 현재 키와 모든 자식 키에 대한 형 변환을 방지합니다.
// boolean() 과 number() 는 자동으로 strict() 모드 에서 실행됩니다 .
const itemEquipSchema = Joi.object({
equip: Joi.boolean().strict().required(),
}).unknown(true);
const itemValidationErrorHandler = function (res, err, itemName) {
console.log(`아이템 ${itemName} 유효 실패: `, err.message);
let msg = `아이템 ${itemName} 유효 실패`;
return res.status(400).json({ message: msg });
};
const itemValidatorJoi = {
itemValidation: async function (req, res, next) {
const validation = await itemSchema.validateAsync(req.body);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - itemValidation');
}
next();
},
itemCodeParamsValidation: async function (req, res, next) {
console.log("에러위치 => ");
const validation = await itemCodeSchema.validateAsync(req.params);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.params - Params');
}
req.params.itemCode = parseInt(req.params.itemCode);
next();
},
itemCodeBodyValidation: async function (req, res, next) {
try {
// object니까 객체로 바꿔서 넣는법 {itemCode: req.body.itemCode}
const validation = await itemCodeSchema.validateAsync(req.body);
next();
} catch (error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - Code Body');
}
},
itemNameValidation: async function (req, res, next) {
console.log('itemNameValidation => ', req.body);
const validation = await itemNameSchema.validateAsync(req.body);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - name');
}
next();
},
itemStatValidation: async function (req, res, next) {
const validation = await itemStatSchema.validateAsync(req.body);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - stat');
}
const { itemStat } = req.body;
if (!itemStat) itemStat = { health: 0, power: 0 };
if (!itemStat.health) itemStat.health = 0;
if (!itemStat.power) itemStat.power = 0;
req.body.itemStat = itemStat;
next();
},
itemCountValidation: async function (req, res, next) {
const validation = await itemCountSchema.validateAsync(req.body);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - Count');
}
next();
},
itemPriceValidation: async function (req, res, next) {
const validation = await itemPriceSchema.validateAsync(req.body);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - price');
}
next();
},
itemEquipValidation: async function (req, res, next) {
const validation = await itemEquipSchema.validateAsync(req.body);
if (validation.error) {
return itemValidationErrorHandler(res, validation.error, 'req.body - Equip boolean');
}
next();
},
};
export default itemValidatorJoi;
9. 코드를 작성하면서 가장 아쉬웠던 부분
아래의 코드에 주석으로 적어 넣은 것처럼 Inventory라고 구현을 했지만 사실 Slot을 추가하는 방식이 되었고 다음에 하게 되면 Inventory안에 Slot이 여러개 들어가 있게한다음 Inventory가 구현되면 Slot을 미리 특정 사이즈만큼 구현을 해두고 비어있는 곳을 찾거나 아이템이 들어있는 곳을 Slot 번호로 찾는 식으로 구현을 할 것 입니다.
// 인벤토리 아이템 추가 <- 다음에 할 때는 inventory 번호를 지정해서 그 번호 칸이 비워지면 순서대로 들어가게 만들어야겠다.
router.post(
'/characters/:characterId/inventories',
userAuthMiddleware,
characterValidatorJoi.characterIdValidation,
async (req, res, next) => {
try {
const characterId = req.params.characterId;
const user = req.user;
const { itemCode } = req.body;
let msg;
const character = await prisma.characters.findFirst({
where: { characterId: characterId, userId: +user.userId },
});
if (!character) return res.status(404).json({ message: '캐릭터가 존재하지 않습니다.' });
const slot = await prisma.inventories.findFirst({
where: { characterId: characterId, itemCode: +itemCode },
});
if (!slot) {
const inventoryItem = await prisma.inventories.create({
data: {
characterId: characterId,
itemCode: +itemCode,
count: +1,
},
});
msg = { message: '인벤토리 새로운 슬롯이 생성되었습니다.', data: inventoryItem };
} else {
const inventoryItem = await prisma.inventories.update({
where: { inventoryId: slot.inventoryId, characterId: characterId, itemCode: +itemCode },
data: {
//count: +slot.count + 1,
count: {
increment: +1
},
},
});
msg = { message: '기존에 있던 인벤토리 슬롯이 업데이트 되었습니다.', data: inventoryItem };
}
return res.status(200).json(msg);
} catch (error) {
next(error);
}
},
);
10. 질문과 답변
- 암호화 방식
- 비밀번호를 DB에 저장할 때 Hash를 이용했는데, Hash는 단방향 암호화와 양방향 암호화 중 어떤 암호화 방식에 해당할까요?
- (Hash는 임의의 길이의 데이터를 고정된 길이의 데이터로 반환 시켜주는 함수이고 입력값의 길이가 달라도 출력값은 언제나 고정된 길이로 반환합니다. 또한, 동일한 값이 입력되면 언제나 동일한 출력값을 보장해줍니다. Hash의 주된 용도로는 해시 테이블 자료구조에 사용되어, 빠른 데이터 검색을 위해 사용되고 암호학에서 사용되고 데이터의 무결성을 확인해 주는데 사용됩니다. 여기서 암호학 해시 함수가 가져야 하는 특성 중 단방향 암호화가 있습니다. 단방향 암호화라는 것은 Hash하는 것의 원본 데이터를 뭉갠다 라는 표현을 쓰고 절대 복호화가 불가능하여야 하며 Hash값을 가지고 원본값으로 연산할 수 없어야합니다. 따라서 Hash는 단방향 암호화입니다.)
- 비밀번호를 그냥 저장하지 않고 Hash 한 값을 저장 했을 때의 좋은 점은 무엇인가요?
- (Hash함수의 특성인 단방향 암호화로 인해 데이터를 해싱했을 때 암호화된 값을 다시 복호화 할 수 없어 보안에 뛰어납니다. (하지만 똑같은 값을 주어졌을 때 똑같은 해싱 값으로 나오기 때문에 주의해야한다.))
- 비밀번호를 DB에 저장할 때 Hash를 이용했는데, Hash는 단방향 암호화와 양방향 암호화 중 어떤 암호화 방식에 해당할까요?
- 인증 방식
- JWT(Json Web Token)을 이용해 인증 기능을 했는데, 만약 Access Token이 노출되었을 경우 발생할 수 있는 문제점은 무엇일까요?
- (Access Token이 노출되면 실제로는 원래 유저가 아니지만 해당 유저의 권한을 가지고 할 수 있는 모든 것을 마음대로 사용할 수 있어 위험하다.)
- 해당 문제점을 보완하기 위한 방법으로는 어떤 것이 있을까요?
- (이러한 보안에 대한 문제를 위해 Access Token의 유효기간을 짧게 설정하여 사용자가 자주 인증하게해주거나, Refresh Token을 만들어 DB에 저장하고, Access Token이 만료되면 Refresh Token을 통해 새로운 Access Token을 생성해주는 방법이 있다.)
- JWT(Json Web Token)을 이용해 인증 기능을 했는데, 만약 Access Token이 노출되었을 경우 발생할 수 있는 문제점은 무엇일까요?
- 인증과 인가
- 인증과 인가가 무엇인지 각각 설명해 주세요.
- (인증은 사용자의 신원을 검증하는 것이고, 인가는 인증된 사용자가 어떠한 자원에 접근할 수 있는지 확인하는 것이다.)
- 위 API 구현 명세에서 인증을 필요로 하는 API와 그렇지 않은 API의 차이가 뭐라고 생각하시나요?
- (인증이 필요없는 회원가입은 아직 사용자의 신원을 검증할 필요가 없기 때문에 인증이 필요하지 않고 과제에서 구현한 UserMiddleware는 사용자의 쿠키를 검증하는 단계이기 때문에 인증에 해당합니다.)
- 아이템 생성, 수정 API는 인증을 필요로 하지 않는다고 했지만 사실은 어느 API보다도 인증이 필요한 API입니다. 왜 그럴까요?
- (관리자가 아닌 누군가 사기 아이템을 생성하고 초보자의 아이템이 최상위 아이템이 되면 게임 경제가 망가지기 때문입니다.)
- 인증과 인가가 무엇인지 각각 설명해 주세요.
- Http Status Code
- 과제를 진행하면서 사용한 Http Status Code를 모두 나열하고, 각각이 의미하는 것과 어떤 상황에 사용했는지 작성해 주세요.
- 200: 요청이 성공했다는 의미입니다. (GET, HEAD, PUT, TRACE)
- 201: 요청이 성공했고, 그 결과 새로운 리소스가 생성되었습니다. (POST, PUT)
- 400: Bad Request 서버가 클라이언트 오류로 인식되는 상황(ex: 잘못된 요청 구문, 잘못된 요청 메시지 프레이밍 또는 사기성 요청 라우팅)으로 인해 요청을 처리할 수 없거나 처리하지 않습니다.
- 401: Unauthorized HTTP 표준은 "인증되지 않음"을 명시하지만, 의미적으로 이 응답은 "인증되지 않음"을 의미합니다. 즉, 클라이언트는 요청된 응답을 받으려면 자신을 인증해야합니다.
- 404: Not Fount 서버가 요청한 리소스를 찾을 수 없습니다. 브라우저에서 이는 URL이 인식되지 않음을 의미합니다.
- 409: Conflict 이 응답은 요청이 서버의 현재 상태와 충돌할 때 전송됩니다.
- 500: Internal Server Error 서버에서 어떻게 처리해야 할지 모르는 상황이 발생했습니다.
- 과제를 진행하면서 사용한 Http Status Code를 모두 나열하고, 각각이 의미하는 것과 어떤 상황에 사용했는지 작성해 주세요.
- 게임 경제
- 현재는 간편한 구현을 위해 캐릭터 테이블에 money라는 게임 머니 컬럼만 추가하였습니다.
- 이렇게 되었을 때 어떠한 단점이 있을 수 있을까요?
- (항상 문제점은 보안에 있습니다. 캐릭터의 보안이 뚫려 내 캐릭터를 마음대로 조종이 가능 할 때 money는 결국 재화에 대한 데이터이기에 게임에서 굉장히 민감한 자원입니다. 해당 자원이 캐릭터 정보와 함께 있으면 캐릭터 정보가 뚫릴 댸 재화의 양을 마음대로 늘리거나 줄일 수 있습니다.)
- 이렇게 하지 않고 다르게 구현할 수 있는 방법은 어떤 것이 있을까요?
- (즉, 보안성을 늘리는게 핵심이고 당장에 생각할 수 있는 것은 서버에서 클라이언트에 직접 전송하게 될 캐릭터 정보에 넣는 것 보다는 데이터베이스에 직접 들어갈 재화 리소스를 따로 관리를 하여 보안성을 늘려줘야 할 것 같습니다. 다른 DB에 넣는 방식으로 하여 캐릭터 정보 와 재화 관리는 따로 해주면 더욱 좋을 것 같습니다.)
- 이렇게 되었을 때 어떠한 단점이 있을 수 있을까요?
- 아이템 구입 시에 가격을 클라이언트에서 입력하게 하면 어떠한 문제점이 있을 수 있을까요?
- (아이템 가격을 자기 마음대로 구매할 수 있기 때문에 예를 들어 아이템 상점에서 0원에 산 무기가 판매할 때 10000이라고 하면 돈 복사가 되기 떄문에 게임 경제가 망가집니다. 또한, 가격을 구매할때 1000원 짜리를 10000원으로 구매하게 된다면 원래 10개를 구매할 생각이었지만 1개를 구매하게 되어 서버 롤백을 원하는 경우가 많아질 것 같습니다. 경매장에서는 문제가 없지만 인간이라면 언제든 실수를 할 수 있기 때문에 1000만원짜리를 100만원에 올리는 실수를 방지하기 위해 경매장에 다른 유저들에게 실제로 보이는 시간 까지 시간 차이를 두어 아이템 가격을 다시 체크하고 잘못 입력한 경우 다시 수거할 수 있는 물리적인 시간을 주는 것도 괜찮은 것 같습니다. <- 와우가 그렇습니다.)
- 현재는 간편한 구현을 위해 캐릭터 테이블에 money라는 게임 머니 컬럼만 추가하였습니다.
11. 어려웠던 점
어려웠던 점 Joi를 쓰는법이 가장 어려웠습니다. Joi가 사용자가 입력한 데이터가 유효한지 검사하는 유효성 검사 라이브러리인 것을 알고 이때 주어진 규칙 (아이디는 몇자 이상, 글자는 몇자 이상, 이건 숫자다 등등)을 통과하는 값들만 입력되도록 제한 할 수 있습니다. 여기서 왜 어려웠나? 저는 이 Joi를 이용한 규칙을 각각 다 나눴습니다. 제가 한 프로젝트를 예시로 itemCode는 숫자고 최소 1000이상의 수 이고 itemName은 문자열이면서 1-30자이다. 이렇게 모든 request body에서 받아올 수 있는 가능성이 한 번이라도 있다면 다 따로따로 적어줬습니다. 에러가 처음 생기기 시작한 부분인 아이템 생성 부분에서 이 유효성 검사를 4번을 진행하게 되었습니다. 이때 규칙 뒤에다가 unknown(true) 옵션을 입력하지 않으면 여러개의 request가 왔을 때 그다음 검사로 넘어가지지 않았고 한번에 관리를 할 수 있게 적고 뒤에다가 unknown(true)옵션을 적어서 여기에 request되지 않은 부분을 따로 관리했으면 더욱 하드 코딩하는 양이 줄어들 수 있었는데 또한, 다른 곳에서 유효성 검사를 할때 3-4개 이렇게 적어야하는걸 1개로 줄일 수도 있기 때문에 관리적인 측면에다가 여러번 컴퓨터가 코드를 읽을 때 시간 단축또한 할 수 있을 것 같다는 생각이 생겨 고치려고 하였지만 시간상의 문제로 일단 진행을 계속하게 되었습니다. 원래 처음에 schema를 여러개를 작성해서 db를 여러개로 관리를 하여 user데이터베이스 shop데이터베이스 재화데이터베이스 등등으로 나누려고 하였는데 관리하는 법을 모르고 한번 작성한 schema모델을 db push를 했을 경우 데이터를 온전히 보존하면서 모델에 새로운 모델을 추가하는 법(없다고 합니다.)을 몰랐기 때문에 원래 처음 생각한 구조를 다시 간단하게 바꿔야해 프로젝트를 갈아 엎었습니다. 인벤토리에 아이템을 추가할 때 개념을 인벤토리가 아닌 Slot으로 잡았다는 것을 제가 작성한 Schema를 볼 때는 몰랐고 db push를 하고 작성을 하던 도중 깨달았습니다. 만약 inventory는 1개 그리고 그 하위에 slot의 숫자를 정해서 그 slot을 미리 만들어 놓고 비어있느 슬롯에 아이템을 추가 또는 슬롯 검색을 통해서 해당 아이템이 있는지 없는지를 할 수 있었다면 더욱 게임같았을 탠데 이부분을 못한것에 대한 아쉬움이 있습니다. 마지막으로, 캐릭터, 아이템코드로 아이템 조회 등등 조회하는 부분이 엄청 많은데 이걸 따로 빼서 에러처리까지 해결하면 코드가 깔끔해질 것 같은데 시간상의 이유로 하지 못해서 너무 아쉽습니다. 코드를 다 작성한 후 Transaction의 데이터 일관성을 필요로 하는 부분이 보였기 때문에 마지막으로 작성을 하여 제출을 마무리할 것 같습니다.
'Node.js' 카테고리의 다른 글
데이터 링크 계층에 관하여... (1) | 2024.09.03 |
---|---|
물리 계층이란? (1) | 2024.09.03 |
Node.js 용어 정리 - 2주차 (0) | 2024.08.30 |
Node.js 용어 정리 - 1주차 (0) | 2024.08.29 |
TextRPG 만들기 - 1탄 (0) | 2024.08.22 |