NodeJS+Express+Typescript

Haizel·2024년 2월 18일
1
post-thumbnail

NodeJS + Express + Typescript 환경의 개발 서버 만들어보자!

❗Mini Tip

❶ API Type 부여하는 두가지 방법

  1. RequestHandler
import { RequestHandler } from "express";

const getPosts: RequestHandler = (req, res) => {
	...
};
  1. parameter
import { Request, Response, NextFunction } from "express";

const getPosts = ( req : Request, res : Response, next : NextFunction ) => {
	...
};

❷ file(ROM) vs Memory(RAM) 차이

  1. 휘방성 👉 RAM은 프로그램이 종료되면 삭제(초기화)된다.
  2. 속도 👉 RAM이 훨씬 빠르다.

01. MVC 패턴

MVC 패턴은 웹 프레임워크에서 많이 쓰이는 대표적인 디자인 패턴으로, 각각 Model, View, Controller의 약자이다.

MVC 패턴의 주 목적은 애플리케이션을 구성할 때 각자 맡은 역할대로 소스코드를 구분하여 유지보수 혹은 기능 추가 / 삭제를 보다 쉽게 하기 위해서이다.

  • Model : 데이터에 관련된 행위만 담당
  • View : 화면에 관련된 행위만 담당
  • Controller : 사용자 요청에 대한 행위만 담당

그럼 MVC패턴을 NodeJS + Express + Typescript 환경에서 구현한다고 생각해보자.

  • Express에서 제공하는 Router는 일명 miniApp으로 화면과 가장 가깝게 상호작용하며 요청을 처리한다. 👉 View 역할
  • Router를 통해 받은 요청(CRUD 등)을 적절히 응답할 수 있도록 👉 Controller를 구현한다.
  • 그리고 다른 서버나 폴더 내 파일, 실제 DB에 존재하는 데이터를 핸들링하는 행위를 할 코드들은 👉 Model로 관리한다.

02. Product Set-up

  1. git 세팅
git init

touch .gitignore
  • .gitignore 작성
node_modules 
dist
  1. npm pakage 설치
npm init -y

npm i -D typescript ts-node @types/node

npx tsc --init
  1. tsconfing.json 파일 세팅
/* Emit */
"declaration": true 
"declarationMap": true
"sourceMap": true
"outDir": "./dist"
  1. package.json 세팅
  • concurrently 설치
npm i  concurrently
  • script 작성
"scripts": {
	"dev": "concurrently \"tsc --watch --project tsconfig.json\" \"node --watch dist/app.js\"",

	"build": "tsc --project tsconfig.json",

	"start": "node dist/app.js"
},

03. express Set-up


  1. npm package 설치
npm i express
npm i --save-dev @types/express

npm i axios

npm i body-parser
npm i --save-dev @types/body-parser
  1. app.ts
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("**----------------------------------**");
})
  1. express.type 설정
  • 경로
📦src  
 ┣ 📂types  
 ┃ ┗ 📜express.type.ts  
 ┗ 📜app.ts
  • express.type.ts 작성
declare namespace Express {
	interface Request {
		user?: {
		id: string;
		encryptedPassword: string;
		};
	}
}

  1. 개발 서버 실행
npm run dev

04. API CRUD 실습


① auth

  • 경로
	📦 src  
	 ┣ 📂 domain
	 ┃ ┣ 📂 auth  
	 ┃ ┃ ┣ 📜 auth.controller.ts  
	 ┃ ┃ ┗ 📜 auth.service.ts  
	 ┗ 📜 index.ts (controllers.ts)

  1. npm pakage 설치
  • npm bcrypt : 비밀번호를 해시하는 데 도움이 되는 라이브러리
npm i bcrypt  
npm i --save-dev @types/bcrypt  
npm i  jsonwebtoken
npm i --save-dev @types/jsonwebtoken

  1. nanoid를 통해 임시 JWT_SECRET_KEY 생성
npx nanoid --size 48 // 사이즈는 적절히 조절 가능 !

// config.ts

// 🔑 nanoid를 통해 생성한 임시 시크릿 키 👉 연습이라 해당 페이지에 작성했지만, 통상적으론 보안을 위해 ❗️.env 파일 ❗️안에 작성해주는 것이 좋다.

export const JWT_SECRET_KEY =
"_5w7G-sZKDyj81T8BtxD4iPfR3waKax4F9HxKS05XK3ShfBh";

❶ auth.service.ts

1️⃣ case1) userInfo를 메모리에서 관리하는 경우

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;

2️⃣ case2) 폴더 내 파일(json 형태)에서 관리하는 경우

// ./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;

❷ auth.controller.ts

  • Router(라우터) 에 첫 번째 인자로는 엔드포인트(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;

② brand

  • 경로
📦 src  
 ┣ 📂 domain  
 ┃ ┣ 📂 brands  
 ┃ ┃ ┣ 📜brands.controller.ts  
 ┃ ┃ ┣ 📜brands.model.ts  
 ┃ ┃ ┣ 📜brands.service.ts  
 ┃ ┃ ┗ 📜brands.type.ts  

💡 brands.type

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 };
	};

❶ brands.model.ts

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;

❷ brands.service.ts

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;

❸ brands.controller

import { Router } from "express";
import brandsService from "./brands.service";

const brandsController = Router();

brandsController.get("/", brandsService.getBrands);
brandsController.get("/:brandId", brandsService.getBrand);

export default brandsController;

③ Todos

  • 경로
📦 src  
 ┣ 📂 domain  
 ┃ ┣ 📂 todos  
 ┃ ┃ ┣ 📜 todos.service.ts
 ┃ ┃ ┣ 📜 todos.controller.ts
  • npm pakage 설치
npm i uuid
npm i --save-dev @types/uuid
  • import 및 Type 지정
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;
};

❶ todos.service.ts

📌 GET (READ)

  • case1) 다른 server에서 get해오는 경우
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);
};

  • case2) 폴더 내 파일(json 형태)에서 get해오는 경우
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);
};

📌 POST (CREATE)

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가 추가되었습니다.");
};

📌 responseJwtVerify.ts

👉 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;

📌 DELETE

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("투두가 삭제되었습니다.");
};

📌 PUT (UPDATE)

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;

❷ todos.controller.ts

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;

④ controller 관리

❶ index.ts (controllers.ts)

컨트롤러가 많으면 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;

05. middlewares


  • 경로
📦src   
 ┣ 📂middlewares  
 ┃ ┗ 📜authenticator.middleware.ts  
 ┗ 📜app.ts

① authenticator.middleware.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

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("**----------------------------------**");
})

📂 참고자료

profile
한입 크기로 베어먹는 개발지식 🍰

0개의 댓글

관련 채용 정보