프로젝트/할일목록(TODO) 앱

할일목록(TODO) 앱 8 - 사용량(트래픽) 제한 구현하기

syleemomo 2023. 8. 12. 16:39
728x90

* 주의할점 : 앞서 virtual 필드 추가한 코드가 반영이 안되어 있을수 있음 

 

토큰을 발급받은 사용자라고 하더라도 API서버에 무리를 줄만큼 과도한 트래픽을 발생하도록 허용하는 것은 좋지 않다. 그렇기 때문에 일정시간동안 API 사용횟수를 제한하여 서버의 트래픽을 제한하는 것이 좋다. 예를 들어 무료로 서비스를 이용하는 사용자는 하루에 50번으로 제한하고 유료 사용자는 하루에 500번으로 제한하는 것이다. 

npm install express-rate-limit

사용량(트래픽) 제한에 대한 구현을 위하여 위 패키지를 설치한다.

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^1.4.0",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "express-async-handler": "^1.2.0",
    "express-rate-limit": "^6.9.0",
    "express-validator": "^7.0.1",
    "jsonwebtoken": "^9.0.1",
    "moment": "^2.29.4",
    "mongoose": "^7.4.2",
    "morgan": "^1.10.0"
  }
}

server > index.js 파일에 아래 코드를 추가한다.

const expressRateLimit = require('express-rate-limit')

const limitUsage = expressRateLimit({
  windowMs: 60 * 1000, // 1분 (ms)
  max: 1, // 분당 최대사용 횟수
  handler(req, res){
    res.status(429).json({
      code: 429,
      message: 'You can use this service 1 times per minute'
    })
  }
})

module.exports = {
  limitUsage
}

package.json 파일이 위치한 곳에 limiter.js 파일을 생성하고 위와 같이 작성한다. 

server > src > routes > users.js 파일에 아래 코드를 수정한다.

const { limitUsage } = require('../../limiter')
const express = require('express')
const User = require('../models/User') 
const expressAsyncHandler = require('express-async-handler') 
const { generateToken, isAuth } = require('../../auth')
const { limitUsage } = require('../../limiter')
const { validationResult } = require('express-validator')
const {
    validateUserName,
    validateUserEmail,
    validateUserPassword
} = require('../../validator')

const router = express.Router()

router.post('/register', limitUsage, [
  validateUserName(),
  validateUserEmail(),
  validateUserPassword()
], expressAsyncHandler(async (req, res, next) => {
  const errors = validationResult(req)
  console.log(req.body)
  if(!errors.isEmpty()){
      console.log(errors.array())
      res.status(400).json({ 
          code: 400, 
          message: 'Invalid Form data for user',
          error: errors.array()
      })
  }else{
      const user = new User({
          name: req.body.name,
          email: req.body.email,
          userId: req.body.userId,
          password: req.body.password
      })
      const newUser = await user.save() // DB에 User 생성
      if(!newUser){
          res.status(401).json({ code: 401, message: 'Invalid User Data'})
      }else{
          const { name, email, userId, isAdmin, createdAt } = newUser 
          res.json({
              code: 200,
              token: generateToken(newUser),
              name, email, userId, isAdmin, createdAt
          })
      }
  }
}))

router.post('/login', limitUsage, [
  validateUserEmail(),
  validateUserPassword()
] , expressAsyncHandler(async (req, res, next) => {
  const errors = validationResult(req)
  console.log(req.body)
  if(!errors.isEmpty()){
    console.log(errors.array())
    res.status(400).json({ 
        code: 400, 
        message: 'Invalid Form data for user',
        error: errors.array()
    })
  }else{
    const loginUser = await User.findOne({
      email: req.body.email, 
      password: req.body.password,
    })
    if(!loginUser){
      res.status(401).json({ code: 401, message: 'Invalid Email or Password' })
    }else{
      const { name, email, userId, isAdmin, createdAt } = loginUser 
      res.json({ 
        code: 200, 
        token: generateToken(loginUser), 
        name, email, userId, isAdmin, createdAt
      })
    }
  }
}))

router.post('/logout', (req, res, next) => {
  res.json("로그아웃")
})

// isAuth : 사용자를 수정할 권한이 있는지 검사하는 미들웨어 
router.put('/', limitUsage, [
  validateUserName(),
  validateUserEmail(),
  validateUserPassword()
], isAuth, expressAsyncHandler(async (req, res, next) => {
  const errors = validationResult(req)
  if(!errors.isEmpty()){
    console.log(errors.array())
    res.status(400).json({ 
        code: 400, 
        message: 'Invalid Form data for user',
        error: errors.array()
    })
  }else{
    const user = await User.findById(req.user._id)
    if(!user){
      res.status(404).json({ code: 404, message: 'User Not Founded'})
    }else{
      user.name = req.body.name || user.name 
      user.email = req.body.email || user.email
      user.password = req.body.password || user.password
      user.isAdmin = req.body.isAdmin || user.isAdmin
      user.lastModifiedAt = new Date() // 수정시각 업데이트
      
      const updatedUser = await user.save()
      const { name, email, userId, isAdmin, createdAt } = updatedUser
      res.json({
        code: 200,
        token: generateToken(updatedUser),
        name, email, userId, isAdmin, createdAt
      })
    }
  }
}))

// isAuth : 사용자를 삭제할 권한이 있는지 검사하는 미들웨어 
router.delete('/', limitUsage, isAuth, expressAsyncHandler(async (req, res, next) => {
  const user = await User.findByIdAndDelete(req.user._id)
  if (!user) {
    res.status(404).json({ code: 404, message: 'User Not Founded'})
  }else{
    res.status(204).json({ code: 204, message: 'User deleted successfully !' })
  } 
}))

module.exports = router

회원가입, 로그인, 사용자정보 변경, 사용자정보 삭제 라우터에 사용량 제한을 걸어서 과도한 사용을 제한한다. 사용을 제한하는 것이므로 limitUsage 미들웨어는 모든 미들웨어들보다 우선적으로 적용되어야 한다. 

 

server > src > routes > todos.js 파일에 아래 코드를 수정한다. 

const { limitUsage } = require('../../limiter')
const express = require('express')
const Todo = require('../models/Todo') 
const expressAsyncHandler = require('express-async-handler') 
const { limitUsage } = require('../../limiter')
const { isAuth, isAdmin } = require('../../auth')
const mongoose = require('mongoose')
const { Types: { ObjectId } } = mongoose
const { validationResult } = require('express-validator')
const {
  validateTodoTitle,
  validateTodoDescription,
  validateTodoCategory
} = require('../../validator')

const router = express.Router()

// isAuth : 전체 할일목록을 조회할 권한이 있는지 검사하는 미들웨어 
router.get('/', limitUsage, isAuth, expressAsyncHandler(async (req, res, next) => {
  const todos = await Todo.find({ author: req.user._id }).populate('author') // req.user 는 isAuth 에서 전달된 값
  if(todos.length === 0){
    res.status(404).json({ code: 404, message: 'Fail to find todos !'})
  }else{
    res.json({ code: 200, todos })
  }
}))

// isAuth : 특정 할일을 조회할 권한이 있는지 검사하는 미들웨어 
router.get('/:id', limitUsage, isAuth, expressAsyncHandler(async (req, res, next) => {
  const todo = await Todo.findOne({ 
    author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
    _id: req.params.id // TODO id 
  })
  if(!todo){
    res.status(404).json({ code: 404, message: 'Todo Not Found '})
  }else{
    res.json({ code: 200, todo })
  }
}))

// isAuth : 새로운 할일을 생성할 권한이 있는지 검사하는 미들웨어 
router.post('/', limitUsage, [
  validateTodoTitle(),
  validateTodoDescription(),
  validateTodoCategory()
], isAuth, expressAsyncHandler(async (req, res, next) => {
  const errors = validationResult(req)
  if(!errors.isEmpty()){
    console.log(errors.array())
    res.status(400).json({ 
        code: 400, 
        message: 'Invalid Form data for todo',
        error: errors.array()
    })
  }else{
    const searchedTodo = await Todo.findOne({
      author: req.user._id, 
      title: req.body.title,
    })
    if(searchedTodo){
      res.status(204).json({ code: 204, message: 'Todo you want to create already exists in DB !'})
    }else{
      const todo = new Todo({
        author: req.user._id, // 사용자 id
        title: req.body.title,
        description: req.body.description,
        category: req.body.category,
        imgUrl: req.body.imgUrl
      })
      const newTodo = await todo.save()
      if(!newTodo){
        res.status(401).json({ code: 401, message: 'Failed to save todo'})
      }else{
        res.status(201).json({ 
          code: 201, 
          message: 'New Todo Created',
          newTodo // DB에 저장된 할일
        })
      }
    }
  }
}))

// isAuth : 특정 할일을 변경할 권한이 있는지 검사하는 미들웨어 
router.put('/:id', limitUsage, [
  validateTodoTitle(),
  validateTodoDescription(),
  validateTodoCategory()
], isAuth, expressAsyncHandler(async (req, res, next) => {
  const errors = validationResult(req)
  if(!errors.isEmpty()){
    console.log(errors.array())
    res.status(400).json({ 
        code: 400, 
        message: 'Invalid Form data for todo',
        error: errors.array()
    })
  }else{
    const todo = await Todo.findOne({ 
      author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
      _id: req.params.id // TODO id 
    })
    if(!todo){
      res.status(404).json({ code: 404, message: 'Todo Not Found '})
    }else{
      todo.title = req.body.title || todo.title
      todo.description = req.body.description || todo.description
      todo.isDone = req.body.isDone || todo.isDone
      todo.category = req.body.category || todo.category
      todo.imgUrl = req.body.imgUrl || todo.imgUrl
      todo.lastModifiedAt = new Date() // 수정시각 업데이트
      todo.finishedAt = todo.isDone ? todo.lastModifiedAt : todo.finishedAt
      
      const updatedTodo = await todo.save()
      res.json({
        code: 200,
        message: 'TODO Updated',
        updatedTodo
      })
    } 
  }
}))

// isAuth : 특정 할일을 삭제할 권한이 있는지 검사하는 미들웨어 
router.delete('/:id', limitUsage, isAuth, expressAsyncHandler(async (req, res, next) => {
  const todo = await Todo.findOne({ 
    author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
    _id: req.params.id // TODO id 
  })
  if(!todo){
    res.status(404).json({ code: 404, message: 'Todo Not Found '})
  }else{
    await Todo.deleteOne({ 
      author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
      _id: req.params.id // TODO id 
    })
    res.status(204).json({ code: 204, message: 'TODO deleted successfully !' })
  }
}))

router.get('/group/:field', limitUsage, isAuth, isAdmin, expressAsyncHandler(async (req, res, next) => { // 어드민 페이지
  const docs = await Todo.aggregate([
    {
      $group: {
        _id: `$${req.params.field}`,
        count: { $sum: 1 }
      }
    }
  ])
  
  console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
  docs.sort((d1, d2) => d1._id - d2._id)
  res.json({ code: 200, docs})
}))

router.get('/group/date/:field', limitUsage, isAuth, isAdmin, expressAsyncHandler(async (req, res, next) => { // 어드민 페이지
  if(req.params.field === 'createdAt' || req.params.field === 'lastModifiedAt' || req.params.field === 'finishedAt'){
    const docs = await Todo.aggregate([
      {
        $group: {
          _id: { year: { $year: `$${req.params.field}` }, month: { $month: `$${req.params.field}` } },
          count: { $sum: 1 }
        }
      },
      { $sort : { _id : 1 } } // 날짜 오름차순 정렬
    ])
    
    console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
    docs.sort((d1, d2) => d1._id - d2._id)
    res.json({ code: 200, docs})
  }else{
    res.status(204).json({ code: 204, message: 'No Content'})
  }
}))

router.get('/group/mine/:field', limitUsage, isAuth, expressAsyncHandler(async (req, res, next) => { // 대쉬보드
  const docs = await Todo.aggregate([
    {
      $match: { author: new ObjectId(req.user._id) }
    },
    {
      $group: {
        _id: `$${req.params.field}`,
        count: { $sum: 1 }
      }
    }
  ])
  
  console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
  docs.sort((d1, d2) => d1._id - d2._id)
  res.json({ code: 200, docs})
}))

router.get('/group/mine/date/:field', limitUsage, isAuth, expressAsyncHandler(async (req, res, next) => { // 어드민 페이지
  if(req.params.field === 'createdAt' || req.params.field === 'lastModifiedAt' || req.params.field === 'finishedAt'){
    const docs = await Todo.aggregate([
      {
        $match: { author: new ObjectId(req.user._id) }
      },
      {
        $group: {
          _id: { year: { $year: `$${req.params.field}` }, month: { $month: `$${req.params.field}` } },
          count: { $sum: 1 }
        }
      },
      { $sort : { _id : 1 } } // 날짜 오름차순 정렬
    ])
    
    console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
    docs.sort((d1, d2) => d1._id - d2._id)
    res.json({ code: 200, docs})
  }else{
    res.status(204).json({ code: 204, message: 'No Content'})
  }
}))


module.exports = router

할일 생성, 조회, 변경, 삭제, 그룹핑 라우터에 사용량 제한을 걸어서 과도한 사용을 제한한다. 사용을 제한하는 것이므로 limitUsage 미들웨어는 모든 미들웨어들보다 우선적으로 적용되어야 한다. 

 

* 사용량(트래픽) 제한 테스트하기

 

 

 

 

* 사용자에 따라 사용량을 제한할 수 있는 이유

https://stackoverflow.com/questions/74387049/how-can-rate-limiter-be-used-per-userid

 

How can rate limiter be used per userId?

We are currently using rate limiter which from my understanding limits requests per user IP. Example code below import rateLimit from 'express-rate-limit' const apiLimiter = rateLimit({ window...

stackoverflow.com

https://stackoverflow.com/questions/75727806/express-rate-limit-blocks-request-by-my-client-app-ip-and-not-by-users-ip

 

express-rate-limit blocks request by my client app IP and not by users IP

I have the following problem. I have two apps deployed on DigitalOcean, API (using Nodejs and express) and CLIENT (create-react-app). I want to add an api call rate limiter. I've tried using expres...

stackoverflow.com

해당 사용자를 제한하기 위해 express-rate-limit 라이브러리는 클라이언트의 IP주소를 검사한다. 해당 IP 주소로부터의 요청이 시간당 몇번 전달되는지에 따라 사용량을 제한한다. 만약 특정 사용자가 다른 IP 주소에서 요청한 경우 이를 제한하기 위해서는 참고자료와 같이 해당 사용자의 아이디나 이메일과 같은 값으로 사용량을 제한하기 위하여 KeyGenerator 속성을 설정하면 된다. 또한, 전체 라우터에 대하여 공통적으로 사용량 제한을 설정하기 위해서는 app.use()에 미들웨어를 설정하면 된다. 

728x90