NodeJS + Express + Typescript 환경의 개발 서버 만들어보자!
import { RequestHandler } from "express";
const getPosts: RequestHandler = (req, res) => {
...
};
import { Request, Response, NextFunction } from "express";
const getPosts = ( req : Request, res : Response, next : NextFunction ) => {
...
};
MVC 패턴은 웹 프레임워크에서 많이 쓰이는 대표적인 디자인 패턴으로, 각각 Model, View, Controller의 약자이다.
MVC 패턴의 주 목적은 애플리케이션을 구성할 때 각자 맡은 역할대로 소스코드를 구분하여 유지보수 혹은 기능 추가 / 삭제를 보다 쉽게 하기 위해서이다.
- Model : 데이터에 관련된 행위만 담당
- View : 화면에 관련된 행위만 담당
- Controller : 사용자 요청에 대한 행위만 담당
그럼 MVC패턴을 NodeJS + Express + Typescript
환경에서 구현한다고 생각해보자.
Router
는 일명 miniApp
으로 화면과 가장 가깝게 상호작용하며 요청을 처리한다. 👉 View 역할Router
를 통해 받은 요청(CRUD 등)을 적절히 응답할 수 있도록 👉 Controller를 구현한다.git init
touch .gitignore
.gitignore
작성node_modules
dist
npm init -y
npm i -D typescript ts-node @types/node
npx tsc --init
/* Emit */
"declaration": true
"declarationMap": true
"sourceMap": true
"outDir": "./dist"
concurrently
설치npm i concurrently
"scripts": {
"dev": "concurrently \"tsc --watch --project tsconfig.json\" \"node --watch dist/app.js\"",
"build": "tsc --project tsconfig.json",
"start": "node dist/app.js"
},
npm i express
npm i --save-dev @types/express
npm i axios
npm i body-parser
npm i --save-dev @types/body-parser
import express from "express";
import bodyParser from 'body-parser';
const app = express();
const port = 5555; // 연습용 port로, 최대한 유니크한 포트로 연결한다.
const jsonParser = bodyParser.json();
app.use(jsonParser);
app.listen(port, () => {
console.log("**----------------------------------**");
console.log("========== Server is On!!! ========== ");
console.log("**----------------------------------**");
})
📦src
┣ 📂types
┃ ┗ 📜express.type.ts
┗ 📜app.ts
declare namespace Express {
interface Request {
user?: {
id: string;
encryptedPassword: string;
};
}
}
npm run dev
📦 src
┣ 📂 domain
┃ ┣ 📂 auth
┃ ┃ ┣ 📜 auth.controller.ts
┃ ┃ ┗ 📜 auth.service.ts
┗ 📜 index.ts (controllers.ts)
npm i bcrypt
npm i --save-dev @types/bcrypt
npm i jsonwebtoken
npm i --save-dev @types/jsonwebtoken
JWT_SECRET_KEY
생성npx nanoid --size 48 // 사이즈는 적절히 조절 가능 !
// config.ts
// 🔑 nanoid를 통해 생성한 임시 시크릿 키 👉 연습이라 해당 페이지에 작성했지만, 통상적으론 보안을 위해 ❗️.env 파일 ❗️안에 작성해주는 것이 좋다.
export const JWT_SECRET_KEY =
"_5w7G-sZKDyj81T8BtxD4iPfR3waKax4F9HxKS05XK3ShfBh";
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
// 가입한 회원정보를 담을 배열 :
const usersInfo: Array<{ id: string; encryptedPassword: string }> = [];
class AuthService {
async SignUp( req: Request, res : Response ) {
const { id, password } = req.body;
// bcrypt 라이브러리로 비밀번호 해싱 (saltOrRounds는 아무 숫자, 문자 상관 없음)
const encryptedPassword = await bcrypt.hash(password, 12);
usersInfo.push({ id, encryptedPassword });
res.json(encryptedPassword);
}
async logIn( req: Request, res : Response ) {
const { id, password } = req.body;
const user = usersInfo.find((user) => user.id === id);
if (!user) return res.sendStatus(404);
// bcrypt를 통해 비밀번호가 일치하는지 확인
const isVerified = await bcrypt.compare(password, user.encryptedPassword);
if (!isVerified) return res.sendStatus(400);
//* jwt 라이브러리를 사용해 시크릿 키로 해싱된 accessToken을 만든다. 👉 나중에 해당 accessToken이 유효한 유저인지 확인하기 위해 'subject'의 값으로 'id'를 담아준다 ❗️
const accessToken = jwt.sign({ id }, JWT_SECRET_KEY, { subject: id });
res.json(accessToken);
}
}
const authService = new AuthService();
export default authService;
// ./src/data/user.data.json
[]
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import fs from "fs/promises";
import jwt from "jsonwebtoken";
import { JWT_SECRET_KEY } from "../../../config/secretKey";
const signUp = async (req: Request, res: Response) => {
const { id, password } = req.body;
const encryptedPassword = await bcrypt.hash(password, 15);
const usersInfo = await fs
.readFile("./src/data/user.data.json", {
encoding: "utf-8",
})
.then((text) => JSON.parse(text));
usersInfo.push({ id, encryptedPassword });
const stringfiedNewUsersInfo = JSON.stringify(usersInfo);
const newUsersInfo = await fs.writeFile(
"./src/data/user.data.json",
stringfiedNewUsersInfo,
{
encoding: "utf-8",
}
);
res.send("회원가입 되었습니다.");
};
const logIn = async (req: Request, res: Response) => {
const { id, password } = req.body;
const usersInfo = await fs
.readFile("./src/data/user.data.json", {
encoding: "utf-8",
})
.then((text) => JSON.parse(text));
const loginUser = usersInfo.find((user: ) => user.id === id);
if (!loginUser) return res.sendStatus(404);
const isVerified = await bcrypt.compare(
password,
loginUser.encryptedPassword
);
if (!isVerified) return res.sendStatus(400);
const accessToken = jwt.sign({ id }, JWT_SECRET_KEY, { subject: id });
res.json(accessToken);
};
const authService = {
signUp,
logIn,
};
export default authService;
엔드포인트(URI)
를, 두 번째 인자로는 HTTP 메서드에 대한 요청을 수신할 때 호출되는 콜백 함수(핸들러 함수, 또는 라우터 함수)를 지정한다.import { Router } from "express";
import authService from "./auth.service";
// 👉 Router는 App의 엔드포인트(URI)가 클라이언트 요청에 응답하는 방식을 말한다.
const authController = Router(); // Router === miniApp
authController.post("/sign-up", authService.signUp);
authController.post("/log-in", authService.logIn);
export default authController;
📦 src
┣ 📂 domain
┃ ┣ 📂 brands
┃ ┃ ┣ 📜brands.controller.ts
┃ ┃ ┣ 📜brands.model.ts
┃ ┃ ┣ 📜brands.service.ts
┃ ┃ ┗ 📜brands.type.ts
export interface Brand {
id: number;
nameKr: string;
nameEn: string;
}
export type Response<D = null> =
| {
success: true;
result: D;
error: null;
}
| {
success: false;
result: null;
error: { message: string };
};
import axios from "axios";
import { Brand, Response } from "./brands.type";
const client = axios.create({
baseURL: "base 서버 주소", // 👉 외부 서버가 있다면 baseURL 연결
});
class BrandsModel {
async findAll() {
const { data } = await client.get<Response<Brand[]>>("/brands");
if (!data.success) throw new Error(data.error.message);
return data.result;
}
async findUnique(brandId: number) {
const { data } = await client.get<Response<Brand[]>>(`/brands/${brandId}`);
if (!data.success) throw new Error(data.error.message);
return data.result;
}
}
const brandsModel = new BrandsModel();
export default brandsModel;
import { Request, Response } from "express";
import brandsModel from "./brands.model";
class BrandsService {
async getBrands(req: Request, res: Response) {
const brands = await brandsModel.findAll();
res.json(brands);
}
async getBrand(req: Request, res: Response) {
const brandId = Number(req.params.brandId);
const brand = await brandsModel.findUnique(brandId);
res.json(brands);
}
}
const brandsService = new BrandsService();
export default brandsService;
import { Router } from "express";
import brandsService from "./brands.service";
const brandsController = Router();
brandsController.get("/", brandsService.getBrands);
brandsController.get("/:brandId", brandsService.getBrand);
export default brandsController;
📦 src
┣ 📂 domain
┃ ┣ 📂 todos
┃ ┃ ┣ 📜 todos.service.ts
┃ ┃ ┣ 📜 todos.controller.ts
npm i uuid
npm i --save-dev @types/uuid
import { Request, Response } from "express";
import fs from "fs/promises";
import { v4 as uuid } from "uuid";
export type TodoType = {
id: number | string;
userId: number;
title: string;
completed: boolean;
};
import axios from "axios";
const client = axios.create({
baseURL: "url 주소"",
});
const getTodos = async (req: Request, res: Response) => {
const { data } = await client.get("/");
res.json(data);
};
const getTodos = async (req: Request, res: Response) => {
const todos = await fs
.readFile("./src/data/todos.data.json", {
encoding: "utf-8",
}).then((text) => JSON.parse(text));
res.json(todos);
};
import responseJwtVerify from "../../config/jwtVerify";
const postTodo = async (req: Request, res: Response) => {
const { title } = req.body;
const id = responseJwtVerify(req);
const todos = await fs.readFile("./src/data/todos.data.json", {
encoding: "utf-8",
}).then((text) => JSON.parse(text));
const newTodo = {
userId: id,
id: uuid(),
title,
completed: false,
};
todos.push(newTodo);
const stringifiedNewTodos = JSON.stringify(todos);
const result = await fs.writeFile("./src/data/todos.data.json",
stringifiedNewTodos, {
encoding: "utf-8",
}
);
res.json("todo가 추가되었습니다.");
};
👉 accessToken을 가진 유저의 id decoding
import jwt from "jsonwebtoken";
import { JWT_SECRET_KEY } from "./secretKey";
import { Request } from "express";
function responseJwtVerify(req: Request) {
const accessToken = req.headers.authorization.split("Bearer ")[1];
const { sub: id } = jwt.verify(accessToken, JWT_SECRET_KEY);
return id;
}
export default responseJwtVerify;
const deleteTodo = async (req: Request, res: Response) => {
let todoId: string | number = req.params.todoId;
todoId = isNaN(Number(todoId)) ? todoId : Number(todoId);
const todos = await fs.readFile("./src/data/todos.data.json",
{
encoding: "utf-8",
}).then((text) => JSON.parse(text) as TodoType[]);
const newTodos = todos.filter((todo) => todo.id !== todoId);
const stringifiedNewTodos = JSON.stringify(newTodos);
await fs.writeFile("./src/data/todos.data.json", stringifiedNewTodos,
{
encoding: "utf-8",
});
res.json("투두가 삭제되었습니다.");
};
const updateTodo = async (req: Request, res: Response) => {
let todoId: string | number = req.params.todoId;
todoId = isNaN(Number(todoId)) ? todoId : Number(todoId);
const { title } = req.body;
const todos = await fs
.readFile("./src/data/todos.data.json", {
encoding: "utf-8",
})
.then((text) => JSON.parse(text) as TodoType[]);
todos.forEach((todo) => {
if (todo.id === todoId) {
todo.title = title;
}
});
const stringifiedNewTodos = JSON.stringify(todos);
await fs.writeFile("./src/data/todos.data.json", stringifiedNewTodos, {
encoding: "utf-8",
});
res.send("투두가 수정되었습니다.");
};
그리고 이를 하나로 모아 export
한다.
const todosService = {
getTodos,
deleteTodo,
postTodo,
updateTodo,
};
export default todosService;
import { Router } from "express";
import todosService from "./todos.service";
const todosController = Router();
todosController.get("/", todosService.getTodos);
todosController.post("/", todosService.postTodo);
todosController.delete("/:todoId", todosService.deleteTodo);
todosController.put("/:todoId", todosService.updateTodo);
export default todosController;
컨트롤러가 많으면 app.ts
가 복잡해지고, 특히 서버는 실행 순서가 중요하기 때문에
컨트롤러들을 한데 모아 하나의 라우터로 관리하자!
import { Router } from "express";
import authController from "./auth/auth.controller";
import brandsController from "./brands/brands.controller";
import todosController from "./domain/todos/todos.controller";
const controllers = Router();
controllers.use("/auth", authController);
controllers.use("/brands", brandsController);
controllers.use("/todos", todosController);
export default controllers;
📦src
┣ 📂middlewares
┃ ┗ 📜authenticator.middleware.ts
┗ 📜app.ts
import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken"; // 👉 JWT Web Token을 만들어줄 라이브러리
import { JWT_SECRET_KEY } from "../config"; // 👉 JWT Web Token 만들 때 사용할 시크릿 키
import { usersInfo } from "../contexts/auth/auth.service"; // 👉 가입한 회원정보 Info
// 👉 accessToken 제약 없이 방문할 수 있는 페이지 목록
const publicRoutes = ["/auth/sign-up", "/auth/log-in"];
export default function authenticator( req: Request, res: Response, next: NextFunction) {
// ❶ accessToken이 필요없는 페이지라면 👉 지나가 ~
if (publicRoutes.includes(req.url)) return next();
// ❷ 아니라면, accessToken 있는지 확인 👉 req.headers.authorization 에서 !
const accessToken = req.headers.authorization?.split("Bearer ")[1];
// 👉 없다면 돌려보낸다 (401 에러)
if (!accessToken) return res.sendStatus(401);
// ❸ accessToken이 있다면 👉 accessToken이 유효한지 확인
try {
// 1. 시크릿 키(JWT_SECRET_KEY)로 디코딩해 id(subject) 획득
const { sub : id } = jwt.verify(accessToken, JWT_SECRET_KEY);
// 2. 해당 id가 가입한 회원 목록(userInfo)에 있는지 확인
const user = userInfo.find((user) => user.id === id);
// 3. accessToken은 유효하나 현 시점 DB에 존재하지 않은 회원인 경우 (회원 탈퇴 등) 👉 404 에러
if (!user) return res.sendStatus(404);
// 모든 단계를 통과하면 인증된 유저이다 !
req.user = user;
} catch (e) {
return res.sendStatus(401);
}
// authenticator 미들웨어 종료
next();
}
app.ts
에 모든 미들웨어를 연결(use
)해준다.
import express from "express";
import bodyParser from 'body-parser';
import controllers from "./contexts";
import authenticator from "./middlewares/authenticator.middleware";
const app = express();
const port = 5555; // 연습용 port로, 최대한 유니크한 포트로 연결한다.
const jsonParser = bodyParser.json();
app.use(authenticator);
app.use(jsonParser);
app.use(controllers);
app.listen(port, () => {
console.log("**----------------------------------**");
console.log("======== Server is On!!! ======== ");
console.log("**----------------------------------**");
})
📂 참고자료