JavaScript & TypeScript

[express.js] 아주 간단한 express.js, singleton, typescript, prisma+mysql, mongoose+mongodb, oop

꼰딩 2023. 6. 18. 14:10

최근 엘리스 SW 5기 수강생 두분께 무료 수업을 진행해드리고 있는데, 블로그에도 글을 올리면 좋겠다 싶어서 포스팅하게 됐습니다.

 

정말 기초적인 수준의 singleton과 oop를 typescript와 express를 이용하여 구현해보았습니다.

singleton 아이디어는 회사 선임분께서 작성하신 코드에서 영감을 받았습니다.

 

또한, typescript를 사용하여 간단하게 oop를 구현하였으며, 3 layered architecture임에도 불구하고 추상화를 통해 각 계층에 대한 정보 외에는 모르게 설계하였습니다. (시간 관계상 service에만 적용)

그래서 repository layer에서 prisma와 mongoose 어떤것을 사용하여도 service layer 에서는 영향이 없습니다.

 

우선 mysql, mongodb docker-compose 파일입니다.

mysql

services:
db1:
image: mysql:8.0
ports:
- '3307:3306'
restart: always
container_name: basic-db
stdin_open: true
tty: true
environment:
TZ: Asia/Seoul
MYSQL_USER: test
MYSQL_PASSWORD: test
MYSQL_DATABASE: basic
MYSQL_ROOT_PASSWORD: root
MYSQL_INITDB_CHARSET: utf8mb4
MYSQL_INITDB_COLLATION: utf8mb4_unicode_ci
volumes:
- ./mysql:/var/lib/mysql:rw
# tmpfs:
# - /var/lib/mysql:rw

tmpfs 옵션은 데이터를 메모리에 적재하겠다는 의미인데, 나중에 테스트 할 일이 있을때 사용하면 됩니다.

 

mongodb

# compose 파일 버전
version: '3'
services:
# 서비스 명
mongodb:
# 사용할 이미지
image: mongo
# 컨테이너 실행 시 재시작
restart: always
# 컨테이너명 설정
container_name: mongodb
# 접근 포트 설정 (컨테이너 외부:컨테이너 내부)
ports:
- '27018:27017'
# 환경 변수 설정
environment:
# MongoDB 계정 및 패스워드 설정 옵션
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: root
# 볼륨 설정
volumes:
- ./data/mongodb:/data/db

 

 

DI 의존성 주입을 사용하여 controller는 다음과 같이 작성했습니다.

import { NextFunction, Request, Response, Router } from 'express'
import { OrderService } from '../services/order.service'

export class OrderController {
router: Router
test: number
constructor(private readonly orderService: OrderService) {
this.orderService = orderService
this.test = 0
this.router = Router()
this.router.get('/test', (req, res) => {
console.log(this.test)
this.test++
res.send({ result: this.test })
})
this.router.get('/:id', this.getOrder)
this.router.post('/', this.postOrder)
this.router.patch('/:id', this.patchOrder)
this.router.delete('/:id', this.deleteOrder)
}

getOrder = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params
if (!id) {
return next()
}

const result = await this.orderService.getOrder(id)
res.status(200).json(result)
} catch (error) {
next(error)
}
}
postOrder = async (req: Request, res: Response, next: NextFunction) => {
try {
const { totalQty, orderItems } = req.body
await this.orderService.postOrder(totalQty, orderItems)

res.status(201).json({ result: 'success' })
} catch (error) {
next(error)
}
}
patchOrder = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const { totalQty, orderItems } = req.body
await this.orderService.patchOrder(id, totalQty, orderItems)
res.status(200).json({ result: 'success' })
} catch (error) {
next(error)
}
}
deleteOrder = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params

await this.orderService.deleteOrder(id)
res.status(200).json({ result: 'success' })
} catch (error) {
next()
}
}
}

 

test api는 추후 singleton인지 확인하기 위해서 작성한 것입니다.

여기서 주의해야할 것은, 내부 함수를 화살표함수로 작성하지 않으면 this가 변하게 되어 orderService를 가지고 있지 않게 됩니다.

controller가 가지고 있는 router로 나중에 app.use에 등록해줄것입니다.

 

service에서는 추상화를 사용했습니다

 

import { OrderItem } from '@prisma/client'
import { IRepository } from '../interfaces/database.interface'

export interface UpdateOrderItemDto {
orderItemId: string
qty: number
}

export class OrderService {
constructor(private readonly orderRepository: IRepository) {
this.orderRepository = orderRepository
}

async getOrder(id: string) {
const order = await this.orderRepository.findOneById(id)
if (order?._id) {
const { _id, ...restOrder } = order
return {
id: _id,
...restOrder
}
}

return order
}

async postOrder(totalQty: number, orderItems: OrderItem[]) {
await this.orderRepository.createOne(totalQty, orderItems)
}

async patchOrder(id: string, totalQty: number, orderItems: UpdateOrderItemDto[]) {
await this.orderRepository.updateOne(id, totalQty, orderItems[0])
}

async deleteOrder(id: string) {
await this.orderRepository.deleteOne(id)
}
}

 

 

import { OrderDto } from '../dto/order.dto'
import { OrderItemDto } from '../dto/orderItem.dto'
import { UpdateOrderItemDto } from '../services/order.service'

export interface IRepository {
findOneById(id: string): Promise<OrderDto | null>
createOne(totalQty: number, orderItems?: OrderItemDto[]): Promise<void>
updateOne(id: string, totalQty: number, orderItems: UpdateOrderItemDto): Promise<void>
deleteOne(id: string): Promise<void>
}

 

추상화로 인해 db가 변경되어도 service 코드에서는 신경쓰지 않아도 됩니다.

 

이름과 dto의 위치는 엄격하게 설정하지 않았습니다.

 

prisma mysql repository

 

import { Order, OrderItem, PrismaClient, Prisma } from '@prisma/client'
import { UpdateOrderItemDto } from '../services/order.service'
import { OrderDto } from '../dto/order.dto'
import { OrderItemDto } from '../dto/orderItem.dto'
import { IRepository } from '../interfaces/database.interface'

export class OrderRepository implements IRepository {
constructor(private readonly prismaClient: PrismaClient) {}

async findOneById(id: string): Promise<OrderDto | null> {
return await this.prismaClient.order.findUnique({ where: { id } })
}

async createOne(totalQty: number, orderItems?: OrderItemDto[]): Promise<void> {
await this.prismaClient.order.create({
data: { totalQty, orderItems: { create: orderItems } }
})
}

async updateOne(id: string, totalQty: number, orderItems: UpdateOrderItemDto) {
await this.prismaClient.order.update({
where: { id },
data: {
totalQty,
orderItems: {
update: {
where: { id: orderItems.orderItemId },
data: { qty: orderItems.qty }
}
}
}
})
}

async deleteOne(id: string) {
await this.prismaClient.order.update({
where: { id },
data: {
deletedAt: new Date(),
orderItems: {
updateMany: {
where: {},
data: {
deletedAt: new Date()
}
}
}
}
})
}
}

 

 

mongoose mongodb schema

import { Schema } from 'mongoose'

export interface IOrder extends Document {
id: string
totalQty: Number
orderItems: IOrderItem[]
deletedAt: Date
}

export interface IOrderItem extends Document {
id: string
name: String
company: String
qty: Number
deletedAt: Date
orderId: String
}

export const OrderItemSchema = new Schema({
name: String,
company: String,
qty: Number,
deletedAt: Date,
orderId: String
})

export const OrderSchema = new Schema(
{
totalQty: Number,
orderItems: {
type: [OrderItemSchema]
},
deletedAt: Date
},
{ versionKey: false, collection: 'order' }
)

 

mongoose mongodb model

import mongoose, { Connection, Model, Schema, Types } from 'mongoose'
import { IRepository } from '../interfaces/database.interface'
import { OrderDto } from '../dto/order.dto'
import { OrderItemDto } from '../dto/orderItem.dto'
import { UpdateOrderItemDto } from '../services/order.service'

export class OrderModel<T extends Document> implements IRepository {
private orderModel: Model<T>
constructor(mongodbConnection: Connection, orderSchema: Schema<T>) {
this.orderModel = mongodbConnection.model<T & Document>('order', orderSchema)
}

async findOneById(id: string): Promise<OrderDto | null> {
return await this.orderModel
.findOne({ _id: new Types.ObjectId(id) }, { totalQty: 1, id: 1, deletedAt: 1 })
.lean()
}

async createOne(totalQty: number, orderItems?: OrderItemDto[]): Promise<void> {
await this.orderModel.create({ totalQty, orderItems })
}

async updateOne(id: string, totalQty: number, orderItems: UpdateOrderItemDto): Promise<void> {
const exist_order = await this.orderModel.findOne({ _id: new Types.ObjectId(id) }).lean()

await this.orderModel.updateOne(
{ _id: new Types.ObjectId(id) },
{
$set: {
totalQty,
orderItems: {
qty: orderItems.qty
}
}
}
)
}
async deleteOne(id: string): Promise<void> {
await this.orderModel.updateOne(
{ _id: new Types.ObjectId(id) },
{
$set: {
deletedAt: new Date()
}
}
)
}
}

mongoDB 에서는 mysql과는 다르게 ObjectId (_id) 를 사용하기 때문에 우선 id로 받고 db 접근할때 _id 로 변경해주었습니다.

기본적으로 application에서는 id를 사용한다는것을 전제로 사용했습니다.

mongoDB 에서 _id를 id로 변경할 수도 있지만, 지금은 mongoDB 보다는 application에 대한 기본 코드이므로 이렇게 설정했습니다.

 

서버를 작동하는 main.ts 입니다.

app도 class로 만들어서 완벽한 signleton 으로 하면 좋겠지만, 각 layer에서 생성자를 통해 singleton 을 만든것과 원리는 같기때문에 생략했습니다.

 

import express, { Express, Router } from 'express'
import dotenv from 'dotenv'
import { OrderController } from './controllers/order.controller'
import { OrderService } from './services/order.service'
import { OrderRepository } from './repositories/order.repository'
import { PrismaClient } from '@prisma/client'
import { errorHandler } from './error-handler'
import mongoose from 'mongoose'
import { OrderModel } from './models/order.model'
import { IOrder, OrderSchema } from './models/order.schema'

dotenv.config()

const app: Express = express()
const port = process.env.PORT || 3000

app.use(express.json())

if (!process.env.MONGODB_URL) {
throw new Error('MONGODB_URL is not defined')
}

const mongodbConnection = mongoose.createConnection(process.env.MONGODB_URL)
const orderModel = new OrderModel<IOrder>(mongodbConnection, OrderSchema)

const prismaClient = new PrismaClient()
const orderRepository = new OrderRepository(prismaClient)
const orderService = new OrderService(orderModel)
const orderController = new OrderController(orderService)

const router = Router()
router.use('/orders', orderController.router)

app.use('/', router)
app.use(errorHandler)

app.listen(port, () => {
console.log(`listening on port ${port}`)
})

서버가 구동될때 각 class의 인스턴스를 생성하여 상위 계층에 의존성 주입을 해줍니다.

기본적인 방식대로 router.use에 router를 등록하면, api에 요청을 보낼때마다 함수가 초기화, 실행이 됩니다.

하지만 이렇게 인스턴스 객체를 등록하면 한 번 등록되어 api 요청이 여러번 와도 동일한 객체의 메소드를 실행합니다.

그것을 확인하기 위해 test api 를 만들어놓았고, 실제로 api 를 반복요청시 test 변수의 수가 계속 증가하는 것을 확인할 수 있습니다.

this.router.get('/test', (req, res) => {
console.log(this.test)
this.test++
res.send({ result: this.test })
})

router를 등록한다면 함수가 매 실행시 초기화되기때문에 상태를 유지하고 있지 않아 매번 0으로 나타납니다.

 

.env

PORT=3000

DATABASE_URL="mysql://test:test@localhost:3307/study"
MONGODB_URL="mongodb://root:root@localhost:27018/admin"

 

간단한 crud 코드이며, OOP, DI, Singleton의 튜토리얼이라고 보면 될거같습니다.

이 원리를 알면 NestJS의 작동 원리를 이해하는데에 도움이 될 것입니다.

 

코드

https://github.com/MCprotein/ts-express