\(@^0^@)/

[알리미 2] 끊긴 서버를 위한 대책 본문

프로젝트&웨비나 회고/개인 프로젝트

[알리미 2] 끊긴 서버를 위한 대책

minjuuu 2022. 8. 30. 21:38
728x90

애초에 프로젝트가 과제 제출용이었어서 제출 기간이 지난 현재, 서버가 끊긴 상태이다.
이제까지 백 쪽을 공부한 적이 없는 나로서, 어떻게 해야 서버를 구현할 수 있을까 고민을 하다가 유튜브에 FE뿐 아니라 BE 쪽 Node.js로 TodoList 구현 영상도 있다는 것을 발견하였다.
현재 내 프로젝트가 복잡한 건 아니기에 완벽히 이해할 수는 없지만 어느 정도는 따라 할 수 있을 거라고 생각했다.

근데 내가 만약에 프로젝트를 키워서 다른 기능들을 추가해야 되는 상황이 온다면 서버 쪽을 더 키울 수 있을까...?
그럼 서버 쪽을 또 공부해야 하는데 지금 내 상황에 그게 맞는 걸까..? 어쩌면 그냥 firebase로 구현하는 것이 더 나을 수도 있겠다는 생각이 문득 들었다. 이러한 고민을 안고 Node.js 영상을 보기 시작했다.

https://www.youtube.com/watch?v=f2EqECiTBL8 

DAY-1에서도 똑같은 개발자의 유튜브를 보았는데 이해하기 쉽게 설명해주고, 코드도 기본적이고 날것의? 코드로 시작해서 점점 가독성 좋고 효율적인 코드로 리팩터링 하는 것을 보여준다.
지금까지 해당 강사의 강의를 약 4개 정도 들었는데 공짜로 듣는 게 아까운 정도로 모두 좋은 퀄리티의 강의라고 생각한다.
왜 초반에는 이런 강의를 발견 못했을까ㅠ 하긴 그때는 좋은 강의를 고르는 분별력도 없었을 것 같긴 하다..
아무튼 강의의 댓글을 보면, 영상을 본 사람들 모두 나와 같은 생각을 하는 것 같아서 추천할 수 있는 강의이다 :)


터미널에 node init, install 부터 시작해서 강의를 들었음. JS가 이제 나름 친근한데 프런트랑은 또 다른 코드들을 사용해서 코딩하니까 새롭고 흥미로웠다. 웹브라우저 대신 터미널만 보면서 코딩하려니 어색하기도...
기본적일 수 있지만 영상을 보며 새롭게 알게 된 것들을 한번 정리해보려 한다.

"dependencies": {
    "date-fns": "^2.23.0",
    "uuid": "^8.3.2"
},
"devDependencies": {
    "nodemon": "^2.0.19"
}
  • 특정 버전의 디펜던시를 다운로드하고 싶다면, npm i uuid 후 원하는 버전을 넣어주면 된다.
    ex) npm i uuid@8.3.2
  • 디펜던시들을 보면 숫자들이 있는데, 첫 번째는 메이저 버전, 두 번째는 마이너 버전, 세 번째는 패치 버전이라 함.
    그 숫자들 앞에 ^ 는 마이너 버전 또는 패치 버전의 업데이트에 허용하는 것이고,
    그 숫자들 앞에 ~ 를 붙인다면, 패치 버전의 업데이트에만 허용하는 것이고,
    아무것도 붙이지 않는다면 아무런 업데이트 허용 없이, 해당 버전만을 말하는 것이다.
    또한 숫자들을 다 지우고 "*" 별표만 넣는다면, 모든 업데이트를 허용한다는 뜻

const express = require("express");
const app = express();
const path = require("path");

app.get("/index.html", (req, res) => {
	// res.sendFile("./views/index.html", { root: __dirname });
	res.sendFile(path.join(__dirname, "views", "index.html"));
});
  • 지정한 local port에 "/index.html"를 붙이면, 내 디렉터리의 views 폴더에 있는 index.html를 응답한다.

 

app.get("^/$|/index.html", (req, res) => {
	// res.sendFile("./views/index.html", { root: __dirname });
	res.sendFile(path.join(__dirname, "views", "index.html"));
});
  • 정규식을 활용하여 "/index.html"뿐 아니라, "/" 도 "/index.html"와 똑같은 라우팅 처리를 해준다.

 

app.get("^/$|/index(.html)?", (req, res) => {
	// res.sendFile("./views/index.html", { root: __dirname });
	res.sendFile(path.join(__dirname, "views", "index.html"));
});
  • 또한 옵셔널을 이용하여 "/index"까지도 "/index.html"와 똑같은 루트도 들어가지도록 라우팅 처리를 해준다.

Route Handlers

app.get("/*", (req, res) => {
	res.status(404).sendFile(path.join(__dirname, "views", "404.html"));
});
  • react-router@v6 처럼 설정하지 않은 루트를 입력할 경로 처리를 "/*"로 해준다.
    status가 404일 경우에 views/404.html를 응답한다.

Chaining Route Handlers

app.get(
	"/hello(.html)?",
	(req, res, next) => {
		console.log("attempted to load hello.html");
		// 다음 핸들러 또는 다음 express로 넘어간다.
		next();
	},
	(req, res) => {
		res.send("Hello World");
	},
);
  • 잘은 모르겠지만, 내 생각에 next함수가 promise의 then과 비슷한 역할을 해주는 것 같음.
const one = (req, res, next) => {
	console.log("one");
	next();
};
const two = (req, res, next) => {
	console.log("two");
	next();
};
const three = (req, res) => {
	console.log("three");
	res.send("DONE");
};

app.get("/chain(.html)?", [one, two, three]);
  • 강사가 미들웨어랑 비슷한 역할을 한다고 함.

const logger = (req, res, next) => {
	logEvents(`${req.method}\t${req.headers.origin}\t${req.url}`, "reqLog.txt");
	console.log(`${req.method} ${req.path}`);
	next();
};

app.use(logger);
const errHandler = (err, req, res, next) => {
	logEvents(`${err.name}: ${err.message}`, "errLog.txt");
	console.error(err.stack);
	res.status(500).send(err.message);
};

app.use(errHandler);
  •  app.use는 middleware이며, regex을 허용하지 않는다.
    최신 버전의 express에서는 regex(정규식) 지원함

 

app.all("*", (req, res) => {
	res.status(404);
	if (req.accepts("html")) {
		res.sendFile(path.join(__dirname, "views", "404.html"));
	} else if (req.accepts("json")) {
		res.json({ error: "404 Not Found" });
	} else {
		res.type("txt").send("404 Not Found");
	}
});
  • app.all은 routing 역할을 하고, 모든 http methods에 한번에 응답한다.

VSC Extension - ThunderClient

  • API 연결을 하여 postman을 활용하지 않고, VSC Extension 중 하나인 Thunder Client를 다운로드하여서 사용하였는데 최근에 그래도 postman을 사용해봤어서 그런지 새로운 툴도 어렵지 않게 적용할 수 있었던 것 같다.
    VSC 내부에서 서버가 제대로 작동하는지 테스트를 할 수 있어서, 훨씬 간편했음.
  • CRUD 로직을 작성하면서 node.js도 react와 같은 언어를 쓰고 구현해내야 하는 것은 동일하기에,
    (처음이라 강사의 로직을 보고 따라 하긴 했지만) 작성한 코드들이 다 이해가 되어서 예전에 처음 js, react 배울 때 보단 수월하게 학습하고 있는 것 같다.
  • 물론 한 번에 모든 것들을 전부 이해할 수는 없지만, 그래도 개인적으로는 아예 모르는 것보다 어떤 식으로 흘러가는지 대충이라도 아는 것이 훨씬 낫다고 생각해서... 들을지 말지 고민하고 최근에 집중이 잘 안 돼서 영상을 보는데 시간이 꽤 들었지만 이번 기회에 Node.js를 찍먹 하길 잘한 것 같다.

Register

const fsPromises = require("fs").promises;
const path = require("path");
const bcrypt = require("bcrypt");

const handleNewUser = async (req, res) => {
	const { user, pwd } = req.body;
	if (!user || !pwd)
		return res
			.status(400)
			.json({ message: "Username and password are required." });
	// check for duplicate usernames in the db
	const duplicate = usersDB.users.find((person) => person.username === user);
	if (duplicate) return res.sendStatus(409); // Conflict
	try {
		// encrypt the password
		const hashedPwd = await bcrypt.hash(pwd, 10);
		// store the new user
		const newUser = { username: user, password: hashedPwd };
		usersDB.setUsers([...usersDB.users, newUser]);
		await fsPromises.writeFile(
			path.join(__dirname, "..", "model", "users.json"),
			JSON.stringify(usersDB.users),
		);
		console.log(usersDB.users);
		res.status(201).json({ success: `New user ${user} created!` });
	} catch (err) {
		res.status(500).json({ message: err.message });
	}
};
  • user가 회원가입을 할 때 user 또는 pwd를 입력하지 않았다면 400 error와 error 메시지를 보여준다.
  • user들의 데이터를 저장하는 usersDB에서 해당 user의 username을 찾는데,
    만약 중복이 발생한다면 409 error를 보여준다.
  • req가 성공적으로 받아와 졌다면, 디펜던시 bcypt의 hash를 통해 암호화에 사용할 솔트를 생성해서 보안을 위해 password에 hash값을 추가하여 usersDB에 username과 password 데이터들을 넣어주고,
    성공적으로 받아와 졌다는 201 status와 success 메시지를 res로 보낸다.
  • 만약 username과 password를 usersDB에 넣는 작업을 실패했다면, 500 error와 메시지를 res로 보낸다.

 

Auth

const bcrypt = require("bcrypt");

const handleLogin = async (req, res) => {
	const { user, pwd } = req.body;
	if (!user || !pwd)
		return res
			.status(400)
			.json({ message: "Username and password are required." });
	const foundUser = usersDB.users.find((person) => person.username === user);
	if (!foundUser) return res.sendStatus(401); // Unauthorized
	// evaluate password
	const match = await bcrypt.compare(pwd, foundUser.password);
	if (match) {
		// create JWTs
		res.json({ success: `User ${user} is logged in!` });
	} else {
		res.sendStatus(401);
	}
};
  • register와 비슷한 형식으로 진행한다.
  • user가 회원가입을 할 때 user 또는 pwd를 입력하지 않았다면 400 error와 error 메시지를 보여준다.
  • user들의 데이터를 저장하는 usersDB에서 로그인을 시도하려는 사용자의 username이 없다면? 401 error
  • 해당 유저가 입력한 password와 usersDB의 password가 같지 않다면? 401 error
    같다면? success 메시지를 보내주며, 토큰을 생성하여 발급해준다. 

ACCESS_TOKEN 생성

require('crypto').randomBytes(64).toString('hex')
  • 터미널에 위의 코드를 치면, 긴 소스코드가 나오는데 그것이 토큰!

 

ACCESS_TOKEN_SECRET=f9d0108e3624ae3a950b1329de06134
  • .env 파일에 넣어서 보관한다.

Token을 보관하기 위한 httpOnly

res.cookie("jwt", refreshToken, {
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000,
});
  • httpOnly Cookie는 JavaScript가 이용할 수 없다. 또한 100% 보안이 되는 것은 아니다.
    하지만 다른 Cookie 또는 LocalStorage는 JavaScript를 이용할 수 있기에 httpOnly Cookie가 이들보단 안전하다.

Token 적용

  • 사용자 login시 res로 accessToken을 받아오고, res Headers의 set-cookie(httpOnly)에는 refreshToken이 생성된다.

 

const jwt = require("jsonwebtoken");
require("dotenv").config();

const verifyJWT = (req, res, next) => {
	const authHeader = req.headers["authorization"];
	if (!authHeader) return res.sendStatus(401);
	const token = authHeader.split(" ")[1];
	jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded) => {
		if (err) return res.sendStatus(403); // invalid token
		req.user = decoded.username;
		next();
	});
};

module.exports = verifyJWT;
  • 확실히 front 쪽을 배운 상태로 강의를 보니깐 각 코드들이 어떤 상황인지 쉽게 이해할 수 있었다.
  • req.headers["authorization"]은 Bearer + token을 받아오기에 해당 코드의 뒷부분만 split 하여 token에 할당해준다.
  • jwt.verify를 활용해서 해당 token과 .env에 있는 access_TOKEN_SECRET의 코드가 동일하다면 decode 된 username을 req로 보내주고, 그렇지 않다면 403 error를 표시

 

const authHeader = req.headers.authorization || req.headers.Authorization;
if (!authHeader?.startsWith("Bearer ")) return res.sendStatus(401);
  • 위의 코드에서 해당 코드 두줄을 조금 더 구체적으로 해주는 것이 좋다.
  • 물론 req.headers에서 소문자 a로 해서 authorization으로 들어오는 것이 일반적이지만, 혹시 모르니 Authorization도 추가.
  • 또한 token은 무조건 앞에 Bearer가 붙어서 들어오기 마련이기에,
    authHeader의 Bearer로 시작하는 string타입의 소스가 들어오지 않는다면 401 error를 응답해준다.

 

const verifyJWT = require("./middleware/verifyJWT");

// routes
app.use("/", require("./routes/root"));
app.use("/register", require("./routes/register"));
app.use("/auth", require("./routes/auth"));

app.use(verifyJWT);
app.use("/employees", require("./routes/api/employees"));
  • 전체적으로 관리하는 main file인 server.js에서 verify를 import 하고, routes를 설정해주는 섹션에서 root, register, auth 파트에는 verifyJWT를 인증할 필요 없고, 일단은 employees에서만 인증하면 되므로 위와 같이 코드를 적어주면 employees에만 verifyJWT이 적용된다.

Authorization VS. Authentication

  • Authentication : the process of verifying who someone is
  • Authorization : the process of verifying what resources a user has access to.
  • 아이디와 비번으로 로그인을 하는 경우 Authentication을 통해 우리가 누군지 알아낸다.
  • 로그인 후 JWT 토큰을 발급받고, 어떤 API 요청을 해야 할 경우 Authorization를 통해 해당 API에 access 유무를 확인한다.

User Roles Vs. Permissions

  • Provide different levels of access
  • Sent in access token payload
  • Verified with middleware

MongoDB의 장점

  • performance : 컬렉션이 쿼리 되는 속도가 매우 빠르다.
  • flexibility : 전체 구조를 손상시키지 않고 새 파일을 추가하는 것 또는 객체에 새 속성을 추가하는 등의 구조적 변경을 쉽게 할 수 있다.
  • scalability : nosql은 매우 짧은 대기 시간에 높은 요청률로 대규모 데이터베이스를 지원할 수 있다.
  • usability : 매우 빠르게 mongodb를 시작하고 실행할 수 있다.

Mongoose로 MongoDB연결

  • MongoDB에서 database를 생성 후 내 app과 connect 하기 위해 해당 string을 복사 후
    .env에서 해당 코드 안에 비밀번호와 아이디를 적어 DATABASE_URI의 변수명에 할당했다.
const mongoose = require("mongoose");

const connectDB = async () => {
	try {
		await mongoose.connect(process.env.DATABASE_URI, {
			useUnifiedTopology: true,
			useNewUrlParser: true,
		});
	} catch (err) {
		console.error(err);
	}
};

module.exports = connectDB;
  • .env로부터 DATABASE_URI를 가져와서, mongoose로 DB를 연결해준다.
const mongoose = require("mongoose");
const connectDB = require("./config/dbConn");
const PORT = process.env.PORT || 3500;

// Connect to MongoDB
connectDB();

mongoose.connection.once("open", () => {
	console.log("Connected to MongoDB");
	app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
});
  • DB 연결 후 port가 연결되도록 코드 수정.

Mongoose Schema

기존의 users.json

const mongoose = require("mongoose");
const Schema = mongoose.Schema;

const userSchema = new Schema({
	username: {
		type: String,
		required: true,
	},
	roles: {
		User: {
			type: Number,
			default: 2001,
		},
		Editor: Number,
		Admin: Number,
	},
	password: {
		type: String,
		required: true,
	},
	refreshToken: String,
});

module.exports = mongoose.model("User", userSchema);
  • 기존의 json파일과 거의 비슷하다. 공식문서 가이드에 따라 스키마를 구현 후, controller에 연결.
const User = require("../model/User");

// check for duplicate usernames in the db
const duplicate = await User.findOne({ username: user }).exec();
if (duplicate) return res.sendStatus(409); // Conflict

try {
// encrypt the password
const hashedPwd = await bcrypt.hash(pwd, 10);

// create and store the new user
const result = await User.create({
    username: user,
    password: hashedPwd,
});
  • mongoDB에 연결해서 관리하기에, 기존의 코드보다 훨씬 간결해짐.
  • Schema의 findOne함수를 통해 username을 찾아서 duplicate에 할당해준다.
    만약 중복이라면 conflict error인 409 코드를 반환.
  • result부분도 간결하다. create함수를 통해서 user의 username과 hash 처리된 password를 대입해준다.

UnhandledPromiseRejectionWarning: MongoServerError

  • 발단
    • Thunder Client로 Schema 적용한 부분이 제대로 작동하는지 테스트 도중 MongoServerError 에러 발견.
  • 원인
    • user is not allowed to do action [find] on [test.users]
  • 첫 번째 문제 해결 : database uri에 Collection 추가
    • 우선 왜 test.users가 나왔는지부터 생각했음. 나는 분명 CompanyDB라는 Collection을 만들었는데,
      왜 test라는 만들지도 않은 Collection안에서 users를 찾는 것일까?

connect 할 때 분명 password 부분만 replace하래서 그렇게 하였는데, 그게 아니었다.
검색해보니, mongodb.net 뒤에 Collection를 추가해야 했던 것.

DATABASE_URI=mongodb+srv://<<username>>:/<<password>>@cluster0.vqenh0n.mongodb.net/<<collectionName>>?retryWrites=true&w=majority

 

.env에 username, password, collectionName을 확인하고 넣어줘야 한다.

  • 그 후, Thunder Client를 다시 작동시켜보니 MongoServerError는 계속 작동했지만,
    •  user is not allowed to do action [find] on [CompanyDB.users]로 test부분이 바뀌었다.
  • 두 번째 해결 : DataBase Access의 MongoDB Roles에 설정 추가.

MongoDB Roles를 설정하지 않아서 생기는 error였음.

일단은 해당 옵션으로 설정해주었음.

  • 에러 해결 후 다시  Thunder Client로 회원가입 테스트를 해보니 제대로 연결된 것을 볼 수 있다.

 


이렇게 해서 node.js express mongoDB를 사용하여 rest api 서버를 구현하였고, 이제 프런트와 연결하는 일만 남았다.
한번 들은 것으로 100프로 이해하는 것은 바라지도 않았고, 물론 모두 이해하지도 못했다.
그래도 서버 쪽은 아예 무지했던 내가, 코드를 보며 어느 정도 흐름을 이해하고 어떤 역할을 하는지 파악할 수 있게 된 것 같아서 나름 만족스러운 결과라고 생각한다.

원래 내 프로젝트는 정말 단순한 todo-list였는데 강의를 들으며 user-roles로 user 외에도 더 많은 roles를 추가했다.
그래서 조금 더 복잡한 프로젝트로 admin, editor가 있는 사이트를 만들고 싶다고 생각돼서, 레퍼런스들을 찾아보면서 조금 더 구체적으로 구상할 예정이다.

728x90