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

할일목록(TODO) 앱 6 - 데이터 검증하기 (validation)

syleemomo 2023. 8. 11. 11:07
728x90

 

* Mogoose validate 메서드로 DB 저장 이전에 데이터 형식 검증하기 

https://mongoosejs.com/docs/api/schematype.html#SchemaType.prototype.validate()

 

Mongoose v8.3.3: SchemaType

Parameters: SchemaType constructor. Do not instantiate SchemaType directly. Mongoose converts your schema paths into SchemaTypes automatically. Example: const schema = new Schema({ name: String }); schema.path('name') instanceof SchemaType; // true Paramet

mongoosejs.com

 

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

userSchema.path('email').validate(function(value){
    return /^[a-zA-Z0-9]+@{1}[a-z]+(\.[a-z]{2})?(\.[a-z]{2,3})$/.test(value)
}, 'email `{VALUE}` 는 잘못된 이메일 형식입니다.')

// 숫자, 특수문자 최소 1개 포함하기 (7~15자)
userSchema.path('password').validate(function(value){
    return /^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{7,15}$/.test(value)
}, 'password `{VALUE}` 는 잘못된 비밀번호 형식입니다.')

정규표현식을 이용하여 이메일과 비밀번호를 검증하는 코드이다. validate 메서드는 정규표현식을 이용하여 데이터베이스에 저장하려는 값을 저장하기 전에 검증한다. 검증을 통과하지 못하면 미리 설정된 에러 메세지를 출력한다. 

이메일은 at(@)을 무조건 1개만 포함해야 한다. at(2) 앞부분은 영문자(대/소문자)나 아라비아 숫자로 시작하고 영문자나 숫자는 갯수 상관없이 여러개가 나열될 수 있다. at(@) 뒷부분은 영문자(소문자)로 시작하고 갯수 상관없이 여러개가 나열될 수 있다. 그 다음은 점(.)과 영문자(소문자) 2~3개로 끝나야 한다. 다만 옵션으로 영문자(소문자) 2~3개가 점(.) 다음에 추가로 붙을수 있다. 예를 들어 sunrise@gmail.com 이나 sunrise@go.kr 이나 sunrise@gov.go.kr 등은 모두 유효한 이메일 형식이 된다. 

비밀번호는 영문자(대/소문자), 아라비아 숫자, 특수문자 등이 나열될 수 있다. 갯수는 7~15자로 제한한다. 최소 1개의 숫자와 특수문자는 포함해야 한다. 

 

API 테스트 도구에서 데이터 검증을 테스트해볼 수 있다. 

첫번째 이메일은 at(@) 다음에 점(.)이 없기 때문에 에러가 발생한다. 두번째 이메일은 at(@) 앞에 영문자가 포함되어 있지 않기 때문에 에러가 발생한다.

첫번째 비밀번호는 특수문자를 포함하지 않기 때문에 에러가 발생한다. 두번재 비밀번호는 숫자를 포함하고 있지 않기 때문에 에러가 발생한다.

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

todoSchema.path('category').validate(function(value){
    return /오락|공부|음식|자기계발|업무|패션|여행/.test(value)
}, 'category `{VALUE}` 는 유효하지 않은 카테고리입니다.')

정규표현식을 이용하여 카테고리를 검증하는 코드이다. validate 메서드는 정규표현식을 이용하여 데이터베이스에 저장하려는 값을 저장하기 전에 검증한다. 검증을 통과하지 못하면 미리 설정된 에러 메세지를 출력한다.  | 은 OR 와 동일한 의미이다. 설정된 값들 중에 어느 하나가 아니면 에러가 발생한다.

DB 에서 사용자 검색하기
해당 사용자로 로그인하기
로그인한 사용자의 토큰 저장하기
로그인한 사용자의 토큰으로 새로운 할일 생성하기

IT는 정해놓은 카테고리에 포함되어 있지 않기 때문에 에러가 발생한다.

 

https://express-validator.github.io/docs/api/validation-chain/

 

ValidationChain | express-validator

The validation chain contains all of the built-in validators, sanitizers and utility methods to fine

express-validator.github.io

https://developer.mozilla.org/en-US/docs/Learn/Server-side/Express_Nodejs/forms/Create_genre_form

 

Create genre form - Learn web development | MDN

This sub article shows how we define our page to create Genre objects (this is a good place to start because the Genre has only one field, its name, and no dependencies). Like any other pages, we need to set up routes, controllers, and views.

developer.mozilla.org

https://goodmemory.tistory.com/136

 

[NodeJs] Express 서버에서 손쉽게 유효성 검사하기 express-validator

[NodeJs / Express] 서버에서 손쉽게 데이터 유효성 검사하기 express-validator ⭐️ 서버에 전송되는 데이터의 유효성 검사는 빠를 수록 좋다. 왜냐하면 유효하지 않은 데이터를 굳이 가공하는데 비용을

goodmemory.tistory.com

https://velog.io/@younoah/nodejs-express-validator

https://developer.mozilla.org/en-US/docs/Learn/Server-side/Express_Nodejs/forms/Create_genre_form

 

Create genre form - Learn web development | MDN

This sub article shows how we define our page to create Genre objects (this is a good place to start because the Genre has only one field, its name, and no dependencies). Like any other pages, we need to set up routes, controllers, and views.

developer.mozilla.org

 

 

* Form 데이터 검증하기 - User 모델 

Form 검증을 위한 라이브러리를 설치한다.

npm install express-validator
{
  "name": "todo",
  "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-validator": "^7.0.1",
    "jsonwebtoken": "^9.0.1",
    "moment": "^2.29.4",
    "mongoose": "^7.4.2",
    "morgan": "^1.10.0"
  }
}

package.json 파일이 위치한 곳에 Form 데이터 검증을 위하여 validator.js 라는 유틸리티 파일을 추가한다. 

const { body } = require("express-validator")

const isFieldEmpty = (field) => { // Form 필드가 비어있는지 검사
    return body(field)
    .not()
    .isEmpty()
    .withMessage(`user ${field} is required`)
    .bail() // if email is empty, the following will not be run
    .trim() // 공백제거
}
const validateUserName = () => {
    return isFieldEmpty("name")
    .isLength({ min: 2, max: 20 }) // 2~20자
    .withMessage("user name length must be between 2 ~ 20 characters")
}
const validateUserEmail = () => {
    return isFieldEmpty("email")
    .isEmail() // 이메일 형식에 맞는지 검사
    .withMessage("user email is not valid")
} 

const validateUserPassword = () => {
    return isFieldEmpty("password")
    .isLength({ min: 7 })
    .withMessage("password must be more than 7 characters")
    .bail()
    .isLength({ max: 15 })
    .withMessage("password must be lesser than 15 characters")
    .bail()
    .not()
    .isAlpha()
    .withMessage("password must be at least 1 number")
    .matches(/[!@#$%^&*]/)
    .withMessage("password must be at least 1 special charactor")
    .bail()
    // Form 에서 전달된 password 정보가 일치하는지 검사
    // value : password
    .custom((value, { req }) => req.body.confirmPassword === value)
    .withMessage("Passwords don't match.")
}

module.exports = {
    validateUserName,
    validateUserEmail,
    validateUserPassword
}

요청본문(request body)으로 전송되는 Form 데이터를 검증하는 코드이다.  

express-validator 메서드 검색하기

이해가 잘되지 않는 메서드는 express-validator 문서에서 검색하면 된다. body(field).not().isEmpty() 는 요청본문의 field 가 비어있지 않으면 전체결과가 true 가 되어서 withMessage() 메서드는 실행이 되지 않고 bail() 다음에 오는 trim()으로 넘어간다. 만약 field 가 비어있으면 전체결과가 false 가 되므로 withMessage() 메서드가 실행되고 bail() 다음의 코드는 실행되지 않는다. 

isAlpha() 메서드

not().isAlpha() 는 비밀번호에 알파벳만 있지 않으면 true 가 된다. 즉, 비밀번호에 알파벳 이외의 문자가 섞여 있으면 true 가 되므로 matches() 메서드가 실행되어 특수문자가 존재하는지 검사한다. 만약 not().isAlpha() 가 false 이면 비밀번호에 알파벳만 존재한다. 이때는 바로 아래쪽의 withMessage() 메서드가 실행된다. not().isAlpha() 가 false 이면 알파벳만 존재하므로 matches() 로 특수문자가 존재하는지 검사하면 다시 false 가 된다. 그러므로 matches 바로 아래의 withMessage() 메서드가 실행된다. 알파벳만 있으면 bail()에서 끝나고, custom() 은 실행되지 않는다. 

만약 알파벳만 존재하면 not().isAlpha() 는 false 이므로 바로 아래쪽 withMessage() 가 실행되고, matches() 도 false 이므로 바로 아래쪽 withMessage() 가 실행되고, bail() 에서 끝난다. 

만약 숫자가 포함되어 있으면 not().isAlpha() 는 true 이므로 matches() 가 실행되고 false 이므로 withMessage() 실행후 특특수문자가 존재하지 않는다고 사용자에게 알려주고 bail() 에서 끝난다. 

만약 특수문자가 포함되어 있으면 not().isAlpha() 가 true 이므로, 바로 아래쪽 withMessage() 는 실행되지 않고, matches() 가 실행된다. matches() 도 true 이므로 바로 아래쪽 withMessage() 는 실행되지 않고 bail() 다음의 custom() 이 실행된다. 여기서 문제가 되는 부분은 영문자 + 특수문자가 섞여있는 경우 숫자는 존재하지 않으므로 첫번째 withMessage() 가 실행되어 숫자를 포함해야 한다고 알려줘야 하는데 첫번째 withMessage() 는 실행되지 않고 있다. 또한, 영문자 + 특수문자 조합이면 숫자가 없으므로 더이상 데이터 검증을 진행할 필요가 없는데 custom() 이 실행되어 비밀번호를 검사하고 있다. 결론적으로 현재는 비밀번호에 특수문자만 포함되어 있으면 회원가입이 된다. 추후 코드를 수정할 것이다. 

 

bail 함수의 동작방식

bail() 사이에 존재하는 모든 메서드가 true 인 경우에만 데이터 검증을 계속 진행한다. 만약 bail() 사이에 위치한 메소드 중에서 어느 하나라도 데이터 검증에 실패하면 bail() 사이의 전체 결과는 false 가 되어 더이상 데이터 검증을 진행하지 않는다. 즉, validateUserPassword 함수에서 비밀번호가 알파벳으로만 이루어져 있지 않고, 특수문자를 포함하고 있으면 다음 검증으로 계속 진행된다. 

 

server > src > routes > users.js 파일의 맨 상단에 아래 코드를 추가한다. 

const { validationResult } = require('express-validator')
const {
    validateUserName,
    validateUserEmail,
    validateUserPassword
} = require('../../validator')

파일 상단에 Form 데이터 검증을 위한 함수를 불러온다.

 

server > src > routes > users.js 파일의 회원가입 부분을 아래와 같이 수정한다. 

router.post('/register', [
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], 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 = 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
            })
        }
    }
}))

회원가입 로직을 처리하기 전에 앞서 Form 데이터 검증을 위한 함수를 배열 형태의 미들웨어로 추가한다. Form 데이터 검증을 위한 함수를 실행하면 검증을 위해 정의한 코드가 반환되면서 요청이 들어올때마다 Form 의 name, email, password 필드를 검증한다. validationResult 는 Form 데이터 검증 함수(미들웨어)를 이용하여 실제로 요청객체(req)로 전달된 Form 데이터를 검증한다. 검증결과로 에러가 발생하면 400 (Bad Request) 코드를 에러와 함께 전달한다. 

요약하면 validationResult 함수는 req (요청객체)를 인자로 받아서 req.body (요청본문의 데이터) 를 이용하여 validateUserName, validateUserEmail, validateUserPassword 함수로 Form 데이터를 검증한다. 

 

* Form 데이터 검증 테스트하기 - 회원가입 Form

유효하지 않은 사용자정보로 회원가입을 진행한다. name, email, password 필드가 모두 비어있다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. email, password 필드가 비어있다.

 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. email은 비어있지 않지만, 이메일 형식에 맞지 않다. 또한, password 필드가 비어있다.

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 필드가 비어있다.

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 에 숫자와 특수문자가 포함되어 있지 않다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 에 특수문자가 포함되어 있지 않다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 에 숫자가 포함되어 있지 않다. 여기서 코드에 오류가 있다는 점을 알 수 있다. 현재 숫자가 포함되어 있지 않으므로 "password must be at least 1 number" 라는 경고문구가 떠야 하지만 그렇지 않고 있다. 추후 코드 리팩토링으로 에러부분을 수정할 예정이다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 에 알파벳이 존재하지 않는다. 여기서 코드에 오류가 있다는 점을 알 수 있다. 숫자와 특수문자는 있으므로 해당하는 경고문구 뜨지 않는 것은 맞다. 하지만 알파벳이 없음에도 불구하고 다음 데이터 검증을 진행하고 있다. 정상대로 동작한다면 알파벳이 없는 경우 데이터 검증을 더이상 진행하지 않고 에러를 발생시키고 멈춰야 한다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. 모든 필드가 조건을 다 만족하지만 confirm password 필드가 존재하지 않는다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. 모든 필드가 조건을 다 만족하지만 password 필드와 confirm password 필드가 일치하지 않는다. 

 

유효한 사용자정보로 회원가입을 진행한다. 모든 필드가 조건을 다 만족하므로 회원가입에 성공한다.

 

 

* 코드 리팩토링하기

현재 문제는 비밀번호에 알파벳을 포함하지 않아도 회원가입이 된다.

 

validator.js 파일을 아래와 같이 수정한다. 

const validateUserPassword = () => {
    return isFieldEmpty("password")
    .isLength({ min: 7 })
    .withMessage("password must be more than 7 characters")
    .bail()
    .isLength({ max: 15 })
    .withMessage("password must be lesser than 15 characters")
    .bail()
    .matches(/[A-Za-z]/)
    .withMessage("password must be at least 1 alphabet")
    .matches(/[0-9]/)
    .withMessage("password must be at least 1 number")
    .matches(/[!@#$%^&*]/)
    .withMessage("password must be at least 1 special charactor")
    .bail()
    // Form 에서 전달된 password 정보가 일치하는지 검사
    // value : password
    .custom((value, { req }) => req.body.confirmPassword === value)
    .withMessage("Passwords don't match.")
}

정규표현식을 사용하여 알파벳, 숫자, 특수문자 모두 최소 1개 이상은 포함하도록 한다. 이렇게 하면 알파벳, 숫자, 특수문자 중 어느 하나라도 포함하지 않으면 에러가 발생한다. bail() 은 이전 유효성 검증에서 하나라도 실패한 경우 더이상 그 다음의 유효성 검증을 진행하지 않는다. 즉, AND 로 동작한다. 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 필드에 숫자와 특수문자가 없다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 필드에 알파벳과 특수문자가 없다. 

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 필드에 알파벳과 숫자가 없다.

 

유효하지 않은 사용자정보로 회원가입을 진행한다. password 에 알파벳, 숫자, 특수문자 모두 포함하지만 confirm password 가 없다. 

 

유효한 사용자정보로 회원가입을 진행한다. 모든 필드가 조건을 다 만족하므로 회원가입에 성공한다.

 

* 로그인 Form 검증하기

server > src > routes > users.js 파일의 로그인 부분을 아래와 같이 수정한다. 

router.post('/login', [
  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
      })
    }
  }
}))

로그인 로직을 처리하기 전에 앞서 Form 데이터 검증을 위한 함수를 배열 형태의 미들웨어로 추가한다. Form 데이터 검증을 위한 함수를 실행하면 검증을 위해 정의한 코드가 반환되면서 요청이 들어올때마다 Form 의 email, password 필드를 검증한다. validationResult 는 Form 데이터 검증 함수(미들웨어)를 이용하여 실제로 요청객체(req)로 전달된 Form 데이터를 검증한다. 검증결과로 에러가 발생하면 400 (Bad Request) 코드를 에러와 함께 전달한다. 

 

* Form 데이터 검증 테스트하기 - 로그인 Form

유효하지 않은 사용자정보로 로그인을 진행한다. email, password 필드가 비어있다.

 

유효하지 않은 사용자정보로 로그인을 진행한다. email은 비어있지 않지만, 이메일 형식에 맞지 않다. 또한, password 필드가 비어있다.

 

유효하지 않은 사용자정보로 로그인을 진행한다. password 필드가 비어있다.

 

유효하지 않은 사용자정보로 로그인을 진행한다. password 에 숫자와 특수문자가 포함되어 있지 않다. 

 

유효하지 않은 사용자정보로 로그인을 진행한다. password 에 특수문자가 포함되어 있지 않다. 

 

유효하지 않은 사용자정보로 로그인을 진행한다. password 에 숫자가 포함되어 있지 않다. 

 

유효하지 않은 사용자정보로 로그인을 진행한다. password 가 최대글자수 조건을 만족하지 못한다. 

 

유효하지 않은 사용자정보로 로그인을 진행한다. 모든 필드가 조건을 다 만족하지만 confirm password 필드가 존재하지 않는다. 

 

Form 데이터 검증을 통과하였지만, 회원목록에서 일치하는 사용자를 찾지 못하였기 때문에 권한에러가 발생한다. 

 

DB 에 존재하는 회원정보로 로그인하면 로그인에 성공한다. 이후 사용자정보 변경 Form 검증을 테스트하기 위하여 로그인후 토큰을 메모장에 저장해두도록 한다. 

 

* 사용자정보 변경 Form 검증하기

server > src > routes > users.js 파일의 사용자정보 변경 부분을 아래와 같이 수정한다. 

// isAuth : 사용자를 수정할 권한이 있는지 검사하는 미들웨어 
router.put('/', [
  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
      })
    }
  }
}))

사용자정보 변경 로직을 처리하기 전에 앞서 Form 데이터 검증을 위한 함수를 배열 형태의 미들웨어로 추가한다. Form 데이터 검증을 위한 함수를 실행하면 검증을 위해 정의한 코드가 반환되면서 요청이 들어올때마다 Form 의 name, email, password 필드를 검증한다. validationResult 는 Form 데이터 검증 함수(미들웨어)를 이용하여 실제로 요청객체(req)로 전달된 Form 데이터를 검증한다. 검증결과로 에러가 발생하면 400 (Bad Request) 코드를 에러와 함께 전달한다. isAuth 함수는 Form 검증을 통과하지 못하면 실행되지 않는다. 

 

코드 리팩토링 : 사용자 정보 변경할때는 이메일이나 비밀번호가 비어있어도 되는데, 현재는 해당 필드들이 비어있으면 사용자 정보를 수정하지 못한다. 이 부분은 추후 코드 업데이트가 필요하다. 아니면 사용자 정보 중 브라우저에서 전송되지 않는 필드는 어차피 기존의 값을 유지하기 때문에 사용자 정보 변경시에는 Form 검증을 하지 않는것도 하나의 방법이다. 

 

* 사용자정보 변경 form 검증 테스트하기

사용자정보를 변경하려고 하면 위와 같은 에러가 발생한다. 사용자 정보를 변경하려면 권한이 필요한데, 권한여부를 판별하려면 요청시 토큰을 함께 서버로 전달해야 하기 때문이다. 그래서 로그인후 토큰을 발급받고 테스트를 진행하도록 한다. 

 

사용자정보 중에서 isAdmin 필드만 변경하려고 하면 아래와 같이 에러가 발생한다.

 

사용자정보 중에서 isAdmin 필드를 변경하려고 name 필드를 추가하면 아래와 같이 에러가 발생한다.

 

사용자정보 중에서 isAdmin 필드를 변경하려고 name, email 필드를 추가하면 아래와 같이 에러가 발생한다. 이메일 형식이 잘못되었다. 

 

사용자정보 중에서 isAdmin 필드를 변경하려고 name, email 필드를 추가하면 아래와 같이 에러가 발생한다. 이번에는 제대로된 이메일 형식으로 전송하였다.

 

사용자정보 중에서 isAdmin 필드를 변경하려고 name, email, password 필드를 추가하면 아래와 같이 에러가 발생한다. 비밀번호에 숫자, 특수문자가 빠져있다.

 

사용자정보 중에서 isAdmin 필드를 변경하려고 name, email, password 필드를 추가하면 아래와 같이 에러가 발생한다. 이번에는 제대로된 비밀번호를 전송하였지만, conform password 가 빠져있다.

 

사용자정보 중에서 isAdmin 필드를 변경하려고 name, email, password, confirmPassword 필드를 추가하면 아래와 같이 성공적으로 사용자정보가 업데이트된다. 이제 독수리 사용자에게 관리자 권한이 부여되었다. 

관리자 권한 부여

 

* Form 데이터 검증하기 - Todo 모델

validator.js 파일에 아래 코드를 추가한다. 

const validateTodoTitle = () => {
  return isFieldEmpty("title")
  .isLength({ min: 2, max: 20 }) // 2~20자
  .withMessage("todo title length must be between 2 ~ 20 characters")
}
const validateTodoDescription = () => {
  return isFieldEmpty("description")
  .isLength({ min: 5, max: 100 }) // 5 ~100자
  .withMessage("todo description length must be between 5 ~ 100 characters")
}
const validateTodoCategory = () => {
  return isFieldEmpty("category")
  .isIn(['오락', '공부', '음식', '자기계발', '업무', '패션', '여행'])
  .withMessage('todo category must be one of 오락 | 공부 | 음식 | 자기계발 | 업무 | 패션 | 여행')
}

요청본문(request body)으로 전송되는 Form 데이터를 검증하는 코드이다.  

module.exports = {
    validateUserName,
    validateUserEmail,
    validateUserPassword,
    validateTodoTitle,
    validateTodoDescription,
    validateTodoCategory
}

Todo 모델의 Form 검증을 위한 함수를 외부에서 사용하기 위하여 내보낸다. 

server > src > routes > todos.js 파일의 맨 상단에 아래 코드를 추가한다. 

const { validationResult } = require('express-validator')
const {
  validateTodoTitle,
  validateTodoDescription,
  validateTodoCategory
} = require('../../validator')

파일 상단에 Form 데이터 검증을 위한 함수를 불러온다.

server > src > routes > todos.js 파일에서 새로운 할일을 생성하는 POST 요청로직을 아래와 같이 수정한다.

// isAuth : 새로운 할일을 생성할 권한이 있는지 검사하는 미들웨어 
router.post('/', [
  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에 저장된 할일
        })
      }
    }
  }
}))

새로운 할일을 추가하는 로직을 처리하기 전에 앞서 Form 데이터 검증을 위한 함수를 배열 형태의 미들웨어로 추가한다. Form 데이터 검증을 위한 함수를 실행하면 검증을 위해 정의한 코드가 반환되면서 요청이 들어올때마다 Form 의 title, description, category 필드를 검증한다. validationResult 는 Form 데이터 검증 함수(미들웨어)를 이용하여 실제로 요청객체(req)로 전달된 Form 데이터를 검증한다. 검증결과로 에러가 발생하면 400 (Bad Request) 코드를 에러와 함께 전달한다. isAuth 함수는 Form 검증을 통과하지 못하면 실행되지 않는다. 

 

* 새로운 할일 생성 form 검증 테스트하기

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 title, description, category 필드는 빈 문자열로 전송한다. 이렇게 하면 아래와 같은 에러가 발생한다.

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 title은 비어있지는 않지만, 글자수를 만족하지 못한다. description, category 필드는 빈 문자열로 전송한다. 이렇게 하면 아래와 같은 에러가 발생한다.

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 description, category 필드만 빈 문자열로 전송한다. 이렇게 하면 아래와 같은 에러가 발생한다.

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 description은 비어있지는 않지만, 글자수를 만족하지 못한다. 그리고 category 필드만 빈 문자열로 전송한다. 이렇게 하면 아래와 같은 에러가 발생한다.

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 category 필드만 빈 문자열로 전송한다. 이렇게 하면 아래와 같은 에러가 발생한다.

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 category 필드에서 주어진 카테고리에 포함되지 않는 값을 전송한다. 이렇게 하면 아래와 같은 에러가 발생한다.

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 title, description, category 필드의 폼 검증은 모두 성공한다. 하지만 이렇게 하면 아래와 같은 에러가 발생한다. 

imgUrl 필드가 빠져있어서 MongoDB 에서 에러가 발생한다. 

코드 리팩토링: 추후 코드 리팩토링시 imgUrl 필드도 폼 검증에 포함할 필요가 있을것 같다. 

 

새로운 할일을 생성하기 위한 POST 요청을 보낸다. 이때 요청본문(request body)의 title, description, category 필드의 폼 검증은 모두 성공한다. 그리고 imgUrl 도 추가하면 아래와 같이 새로운 할일이 생성되고 DB에 추가된다.

 

* 할일정보 변경 Form 검증하기 

server > src > routes > todos.js 파일에서 할일을 업데이트하는 PUT 요청로직을 아래와 같이 수정한다.

// isAuth : 특정 할일을 변경할 권한이 있는지 검사하는 미들웨어 
router.put('/:id', [
  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
      })
    } 
  }
}))

할일을 업데이트하는 로직을 처리하기 전에 앞서 Form 데이터 검증을 위한 함수를 배열 형태의 미들웨어로 추가한다. Form 데이터 검증을 위한 함수를 실행하면 검증을 위해 정의한 코드가 반환되면서 요청이 들어올때마다 Form 의 title, description, category 필드를 검증한다. validationResult 는 Form 데이터 검증 함수(미들웨어)를 이용하여 실제로 요청객체(req)로 전달된 Form 데이터를 검증한다. 검증결과로 에러가 발생하면 400 (Bad Request) 코드를 에러와 함께 전달한다. isAuth 함수는 Form 검증을 통과하지 못하면 실행되지 않는다. 

 

코드 리팩토링 : 특정 할일을 변경할때는  title, description, category 필드가 비어있어도 되는데, 현재는 해당 필드들이 비어있으면 사용자 정보를 수정하지 못한다. 이 부분은 추후 코드 업데이트가 필요하다. 아니면 특정 할일 정보중 브라우저에서 전송되지 않는 필드는 어차피 기존의 값을 유지하기 때문에 특정 할일 변경시에는 Form 검증을 하지 않는것도 하나의 방법이다. 

 

* 할일정보 변경 Form 검증 테스트하기

TODO 중에서 isDone 필드를 변경하려고 하면 아래와 같이 에러가 발생한다. title, description, category 필드가 빠져있다.

 

TODO 중에서 isDone 필드를 변경하려고 하면 아래와 같이 에러가 발생한다. 모든 필드가 작성되었지만 카테고리가 미리 정해놓은 범주에 속하지 않는다.

 

TODO 중에서 isDone 필드 변경에 성공하였다. 

 

* 몽구스 save() 에러처리하기

회원가입시 동일한 이메일 주소로 가입하려고 시도하면 아래와 같이 Internal Server Error(500)가 발생한다.

기존에 존재하는 이메일 주소인 경우
Internal Server Error (500)

 

router.post('/register', [ // 폼검증을 위한 미들웨어 
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], expressAsyncHandler(async (req, res, next) => { // /api/users/register
    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{
        console.log(req.body)
        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 저장 
        if(!newUser){
            res.status(400).json({ code: 400, message: 'Invalid User Data'})
        }else{
            const {name, email, userId, isAdmin, createdAt } = newUser
            res.json({
                code: 200,
                token: generateToken(newUser), // 사용자 식별 + 권한검사를 위한 용도 
                name, email, userId, isAdmin, createdAt // 사용자에게 보여주기 위한 용도
            })
        }
    }
}))

폼검증에 성공하면 사용자정보를 DB에 저장하기 위하여 save() 메서드를 실행한다. 이때 save() 메서드에서 resolve() 가 되면 newUser 값이 반환되어 해당값에 따라 조건문으로 처리하면 된다. 그러나 save() 메서드에서 reject() 를 호출하면 프로미스가 에러를 전달하는데 async, await 으로 처리한 경우 도중에 에러가 나면 처리할 방법이 없다. 도중에 에러가 나면 Internal server error (500)가 된다. 

save() 에러처리하기

이때는 위와 같이 async, await 대신에 then, catch 메서드로 에러를 처리할 수 있다. 또는 try, catch 로 save() 메서드를 감싸줘서 에러를 처리할 수 있다. 

router.post('/register', [ // 폼검증을 위한 미들웨어 
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], expressAsyncHandler(async (req, res, next) => { // /api/users/register
    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{
        console.log(req.body)
        const user = new User({
            name: req.body.name, 
            email: req.body.email, 
            userId: req.body.userId, 
            password: req.body.password
        })
        user.save() // 사용자정보 DB 저장 
        .then(() => {
            const {name, email, userId, isAdmin, createdAt } = newUser
            res.json({
                code: 200,
                token: generateToken(newUser), // 사용자 식별 + 권한검사를 위한 용도 
                name, email, userId, isAdmin, createdAt // 사용자에게 보여주기 위한 용도
            })
        }).catch(e => {
            res.status(400).json({ code: 400, message: 'Invalid User Data'})
        })
    }
}))

회원가입 API 를 위와 같이 수정하면 아래와 같이 동일한 이메일 주소인 경우에 Bad Request (400) 오류를 발생시킨다. 

동일한 이메일 주소로 가입하려는 경우

router.post('/register', [ // 폼검증을 위한 미들웨어 
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], expressAsyncHandler(async (req, res, next) => { // /api/users/register
    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{
        console.log(req.body)
        const user = new User({
            name: req.body.name, 
            email: req.body.email, 
            userId: req.body.userId, 
            password: req.body.password
        })
        try{
            const newUser = await user.save() // 사용자정보 DB 저장 
            const {name, email, userId, isAdmin, createdAt } = newUser
            res.json({
                code: 200,
                token: generateToken(newUser), // 사용자 식별 + 권한검사를 위한 용도 
                name, email, userId, isAdmin, createdAt // 사용자에게 보여주기 위한 용도
            })
        }catch(e){
            res.status(400).json({ code: 400, message: 'Invalid User Data'})
        }
    }
}))

회원가입 API 를 위와 같이 수정하면 아래와 같이 동일한 이메일 주소인 경우에 Bad Request (400) 오류를 발생시킨다. 

동일한 이메일 주소로 가입하려는 경우

 

코드 리팩토링 : try, catch 나 then, catch 를 사용할때 에러가 나면 해당 에러를 구분해서 브라우저에게 전송하는 것이 좋을것 같다. 에러객체의 name, code 속성을 활용하면 에러종류에 따라 다르게 처리할 수 있다. 

https://stackoverflow.com/questions/75400755/how-do-i-write-truly-custom-error-messages-and-codes-for-mongoose-validation

 

How do I write truly custom error messages and codes for Mongoose validation?

I'm trying to follow the MVC architectural pattern and do all of my validation in my Mongoose model, rather than my controller. I'm wondering how I can set error codes and truly custom error messag...

stackoverflow.com

https://mongoosejs.com/docs/api/error.html

 

Mongoose v8.3.4: Error

Parameters: msg «String» Error message Type: Inherits: MongooseError constructor. MongooseError is the base class for all Mongoose-specific errors. Example: const Model = mongoose.model('Test', new mongoose.Schema({ answer: Number })); const doc = new Mo

mongoosejs.com

https://tesseractjh.tistory.com/178

 

[mongoose] 에러 핸들링

Built-in Validator export const UserSchema = new Schema( { userId: { type: String, unique: true, required: true, match: /[a-z0-9_]{4,16}/, }, ... } ); mongoose Schema를 작성할 때, 각각의 field에 대해 validator를 지정할 수 있으며, 각각

tesseractjh.tistory.com

https://stackoverflow.com/questions/63402084/how-can-i-get-specific-error-messages-from-a-mongoose-schema

 

How can i get specific error messages from a Mongoose Schema?

I am trying to set up user validation in Mongoose and struggling to get the specific messages to appear. Here is my model const userSchema = new Schema({ name: { type: String,

stackoverflow.com

https://stackoverflow.com/questions/14407007/best-way-to-check-for-mongoose-validation-error

 

Best way to check for mongoose validation error

I've got two validation functions for my usermodel User.schema.path('email').validate(function(value, respond) { User.findOne({email: value}, function(err, user) { if(err) throw err; if(...

stackoverflow.com

 

* 사용자정보 변경시 특정 필드만 폼검증하기

https://express-validator.github.io/docs/api/one-of/

 

oneOf | express-validator

oneOf()

express-validator.github.io

현재 사용자정보를 변경할때 name, email, password 필드를 모두 전달해야 폼검증을 수행한다. 하지만 사용자 정보를 변경할때는 특정 필드만 요청본문(request body)로 전달해서 수정하고 싶을수도 있다. 이때 요청본문으로 전달한 특정 필드만 폼검증을 수행하려면 어떻게 하면 될까?

const { validationResult, oneOf } = require('express-validator')

express-validator 라이브러리에 oneOf 미들웨어 함수를 추가한다. 

router.put('/', oneOf([
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], {
    message: 'At least one field of user must be provided'
}), isAuth, expressAsyncHandler( async (req, res, next) => {
    const errors = validationResult(req)
    if(!errors.isEmpty()){
        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,
            status: updatedUser.status,
                createdAgo: updatedUser.createdAgo,
                lastModifiedAgo: updatedUser.lastModifiedAgo
            })
        }
    }
}))

server > src > routes > users.js 파일에서 사용자정보 변경 API를 위와 같이 수정한다. 

oneOf([
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], {
    message: 'At least one field of user must be provided'
})

해당부분이 변경된 부분이다. oneOf 미들웨어 함수는 배열로 주어진 폼검증 미들웨어들 중에 어느 하나의 검증만 성공하면 에러가 발생하지 않는다. 그러므로 요청본문으로 특정필드만 전달해도 해당 필드의 폼검증이 성공하면 사용자정보는 업데이트된다. 

잘못된 name 를 전송한 경우
name 필드와 나머지 필드의 폼검증이 모두 실패한 경우
올바른 name 필드값을 전송한 경우
name 필드의 폼검증만 성공한 경우

이제 특정 필드만 전송해도, 해당 필드가 폼검증에 성공하면 사용자정보는 업데이트된다. 

 

코드 리팩토링 : 이렇게 특정필드만 폼검증을 수행하도록 하더라도 만약 폼검증이 필요하지 않은 필드만 변경하려고 하면 폼검증에 실패하여 업데이트가 불가능하다. 이러한 경우에는 폼검증이 필요하지 않은 필드만 따로 수정할 수 있도록 라우터를 분리하는 것이 좋을것 같다. 

 

* 회원가입과 로그인시 폼검증 다르게 하기 

회원가입시에는 confirmPassword 필드가 필요하지만 로그인시에는 필요없을수도 있다. 이러한 경우 폼검증을 아래와 같이 다르게 할 수 있다. 

router.post('/register', (req, res, next) => {
    req.type = "register"
    next()
}, [ // 폼검증을 위한 미들웨어 
    validateUserName(),
    validateUserEmail(),
    validateUserPassword()
], expressAsyncHandler(async (req, res, next) => { // /api/users/register
    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{
        console.log(req.body)
        const user = new User({
            name: req.body.name, 
            email: req.body.email, 
            userId: req.body.userId, 
            password: req.body.password
        })
        user.save() // 사용자정보 DB 저장 
        .then(() => {
            const {name, email, userId, isAdmin, createdAt } = user
            res.json({
                code: 200,
                token: generateToken(user), // 사용자 식별 + 권한검사를 위한 용도 
                name, email, userId, isAdmin, createdAt, // 사용자에게 보여주기 위한 용도
                status: user.status, 
                createdAgo: user.createdAgo,
                lastModifedAgo: user.lastModifiedAgo 
            })
        })
        .catch(e => {
            console.log(e)
            res.status(400).json({ code: 400, message: 'Invalid User Data'})
        })
    }
}))

폼 검증하기 전에 회원가입과 로그인을 구분하기 위하여 req.type 을 미들웨어 함수에서 다르게 설정한다. 

router.post('/login', (req, res, next) => {
    req.type = null 
    next()
}, [
    validateUserEmail(),
    validateUserPassword()
], expressAsyncHandler(async (req, res, next) => { // /api/users/login
    req.type = null 
    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{
        console.log(req.body)
        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 Invalid Password '})
        }else{
            const { name, email, userId, isAdmin, createdAt, createdAgo, lastModifiedAgo, status } = loginUser 
            res.json({
                code: 200, 
                token: generateToken(loginUser),
                name, email, userId, isAdmin, createdAt,
                createdAgo, lastModifiedAgo, status
            })
        }
    }
}))

폼 검증하기 전에 회원가입과 로그인을 구분하기 위하여 req.type 을 미들웨어 함수에서 다르게 설정한다. 

const validateUserPassword = () => {
    return isFieldEmpty("password")
           .isLength({ min: 7 })
           .withMessage("password must be more than 7 characters")
           .bail()
           .isLength({ max: 15 }) 
           .withMessage("password must be lesser than 15 characters")
           .bail()
           .matches(/[A-Za-z]/)
           .withMessage('password must be at least 1 alphabet')
           .matches(/[0-9]/)
           .withMessage("password must be at least 1 number")
           .matches(/[!@#$%^&*]/)
           .withMessage("password must be at least 1 special character")
           .bail() // value: 요청본문에서 전달된 비밀번호 
           .custom((value, { req }) => {
            console.log(req.type)
            if(req.type === 'register') 
                return req.body.confirmPassword === value
            else 
                return true  
           }) // filter 메서드처럼 동작
           .withMessage("Password don't match")
}

validator.js 파일에서 비밀번호 일치여부를 검사할때 req.type 이 "register"이면 비밀번호 일치여부를 검사하고, null 이면 로그인하는 경우이므로 이때는 비밀번호 일치여부를 검사하지 않도록 한다. 또는 회원가입과 로그인시 URL 주소가 다르므로 요청한 URL 주소를 조회하기 위하여 req.path 을 req.type 대신에 사용해도 된다. 이렇게 하면 req.path 는 "/register" 나 "/login" 이 되기 때문에 req.type 없이 구분이 된다. 

728x90