이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.
이 게시물의 소스코드는 게시판 만들기(2016) / 게시판 - 계정 비밀번호 암호화(bcrypt)에서 이어집니다.
board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.
board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.
- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/3882107a33751afe3dcb46fe18261e7617118ea3
/* * 이 강의는 2020년 버전으로 update되었습니다. -> 2020년 버전 보기 */
이번 포스팅에서는 User 생성/수정에서 일어날 수 있는 error들을 처리해 봅시다.
현재는 username이 중복된다든지, 비밀번호가 일치하지 않는다든지 하는 경우에 에러JSON을 그대로 보여주는데, 에러가 있는 경우 해당 form 으로 돌아가 아래와 같이 에러를 표시하게 해 봅시다.
생성 과정(create)에서 에러가 있는 경우 생성 페이지(new)로, 수정 과정(update)에서 에러가 있는 경우 수정 페이지(edit)로 route간의 이동이 일어나는데, 이때 route간의 정보 전달을 위해 flash를 사용합니다.
flash는 변수처럼 이름을 정하고 값(문자열, 숫자, 배열, 객체 등등 어떠한 형태의 값이라도 사용 가능)을 저장할 수 있는데, 한번 생성 되면 사용될 때까지 서버에 저장이 되어 있다가 한번 사용되면 사라지는 형태의 data입니다.
이 포스팅에서는 connect-flash package를 사용해서 flash를 만듭니다.
또한 User의 username, password, name, email 항목들에 regex를 사용하여 특별한 제약조건을 추가해 봅시다.
(email에는 email형식의 값만 받게 하고, 비밀번호는 영문자와 숫자를 혼용할 것 등등)
주황색은 변경된 파일, 회색은 변화가 없는 파일입니다.
express-session와 connect-flash package를 설치해 줍니다.
express-session은 connect-flash를 실행하기 위해 필요한 package입니다.
$ npm install --save express-session connect-flash
// models/User.js var mongoose = require("mongoose"); var bcrypt = require("bcrypt-nodejs"); // schema //1 var userSchema = mongoose.Schema({ username:{ type:String, required:[true,"Username is required!"], match:[/^.{4,12}$/,"Should be 4-12 characters!"], trim:true, unique:true }, password:{ type:String, required:[true,"Password is required!"], select:false }, name:{ type:String, required:[true,"Name is required!"], match:[/^.{4,12}$/,"Should be 4-12 characters!"], trim:true }, email:{ type:String, match:[/^[a-zA-Z0-9._%+-][email protected][a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/,"Should be a vaild email address!"], trim:true } },{ toObject:{virtuals:true} }); // virtuals ... // password validation // 2 var passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/; var passwordRegexErrorMessage = "Should be minimum 8 characters of alphabet and number combination!"; userSchema.path("password").validate(function(v) { var user = this; // create user if(user.isNew){ if(!user.passwordConfirmation){ user.invalidate("passwordConfirmation", "Password Confirmation is required!"); } if(!passwordRegex.test(user.password)){ user.invalidate("password", passwordRegexErrorMessage); } else if(user.password !== user.passwordConfirmation) { user.invalidate("passwordConfirmation", "Password Confirmation does not matched!"); } } // update user if(!user.isNew){ if(!user.currentPassword){ user.invalidate("currentPassword", "Current Password is required!"); } if(user.currentPassword && !bcrypt.compareSync(user.currentPassword, user.originalPassword)){ user.invalidate("currentPassword", "Current Password is invalid!"); } if(user.newPassword && !passwordRegex.test(user.newPassword)){ user.invalidate("newPassword", passwordRegexErrorMessage); } else if(user.newPassword !== user.passwordConfirmation) { user.invalidate("passwordConfirmation", "Password Confirmation does not matched!"); } } }); // hash password ... // model methods ... // model & export ...
...은 이전과 비교해서 변화가 없는 부분입니다.
User schema와 Password validation 부분이 바뀌었는데, 따로 따로 살펴봅시다.
// schema // 1 var userSchema = mongoose.Schema({ username:{ type:String, required:[true,"Username is required!"], match:[/^.{4,12}$/,"Should be 4-12 characters!"], // 1-2 trim:true, // 1-1 unique:true }, password:{ type:String, required:[true,"Password is required!"], select:false }, name:{ type:String, required:[true,"Name is required!"], match:[/^.{4,12}$/,"Should be 4-12 characters!"], // 1-2 trim:true // 1-1 }, email:{ type:String, match:[/^[a-zA-Z0-9._%+-][email protected][a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/,"Should be a vaild email address!"], // 1-3 trim:true // 1-1 } },{ toObject:{virtuals:true} });
이전에 비해 상당히 달라진 모습이지만 전체적인 구조가 바뀌었을 뿐 실제로 바뀐건 match랑 trim밖에 없습니다.
1-1. trim은 문자열 앞뒤에 빈칸이 있는 경우 빈칸을 제거해 주는 옵션입니다. 어렵지 않죠.
1-2, 1-3. match는 regex(Regular Expression, 정규표현식)를 사용해서 문자열을 검사하는 내용입니다.
정규표현식은 문자열에 특정한 규칙에 맞는 문자열이 있는지 알아보는 표현식인데요, 이거 하나만으로도 책 한권을 쓸 수 있는 내용이니까 따로 공부를 하셔야합니다.
schema에서 match: [/정규표현식/,"에러메세지"]
를 사용하면, 해당 표현식에 맞지 않는 값이 오는 경우 에러메세지를 내보냅니다.
1-2의 regex(/^.{4,12}$/)
를 해석해 보면,/^.{4,12}$/
: regex는 / /
안에 작성합니다./^.{4,12}$/
: ^
는 문자열의 시작을 나타냅니다./^.{4,12}$/
: .
는 어떠한 문자열이라도 상관없음을 나타냅니다./^.{4,12}$/
: {숫자1,숫자2}
는 숫자1 이상, 숫자2 이하의 길이 나타냅니다./^.{4,12}$/
: $
는 문자열의 끝을 나타냅니다.^
과 $
가 regex의 시작과 끝에 동시에 있으면 전체 문자열이 조건에 맞아야 성공입니다..{4,12}
는 어떠한 문자라도 좋지만 4개 이상 12개 이하여야 한다는 뜻입니다.
즉 전체 길이가 4자리 이상 12자리 이하 길이라면 어떠한 문자라도 regex를 통과한다는 의미가 됩니다.
1-3의 /^[a-zA-Z0-9._%+-][email protected][a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/
는 문자열이 이메일의 형식이 맞는지 아닌지를 확인하는 식인데, 인터넷에서 regex를 공부해 보시고 직접 해독해 보시기 바랍니다!
// password validation // 2 var passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/; // 2-1 var passwordRegexErrorMessage = "Should be minimum 8 characters of alphabet and number combination!"; // 2-2 userSchema.path("password").validate(function(v) { var user = this; // create user if(user.isNew){ if(!user.passwordConfirmation){ user.invalidate("passwordConfirmation", "Password Confirmation is required!"); } if(!passwordRegex.test(user.password)){ // 2-3 user.invalidate("password", passwordRegexErrorMessage); // 2-4 } else if(user.password !== user.passwordConfirmation) { user.invalidate("passwordConfirmation", "Password Confirmation does not matched!"); } } // update user if(!user.isNew){ if(!user.currentPassword){ user.invalidate("currentPassword", "Current Password is required!"); } if(user.currentPassword && !bcrypt.compareSync(user.currentPassword, user.originalPassword)){ user.invalidate("currentPassword", "Current Password is invalid!"); } if(user.newPassword && !passwordRegex.test(user.newPassword)){ // 2-3 user.invalidate("newPassword", passwordRegexErrorMessage); // 2-4 } else if(user.newPassword !== user.passwordConfirmation) { user.invalidate("passwordConfirmation", "Password Confirmation does not matched!"); } } });
2-1. 8-16자리 문자열 중에 숫자랑 영문자가 반드시 하나 이상 존재해야 한다는 뜻의 regex입니다.
2-2. 에러메세지가 반복되므로 변수로 선언하였습니다.
2-3. 정규표현식.test(문자열)
함수는 문자열에 정규표현식을 통과하는 부분이 있다면 true
를, 그렇지 않다면 false
를 return합니다.
2-4. 2-3에서 false
가 return되는 경우 2-2에서 선언한 문자열로 model.invalidate
을 호출합니다.
// index.js var express = require("express"); var mongoose = require("mongoose"); var bodyParser = require("body-parser"); var methodOverride = require("method-override"); var flash = require("connect-flash"); // 1 var session = require("express-session"); // 1 var app = express(); // DB setting ... // Other settings app.set("view engine", "ejs"); app.use(express.static(__dirname+"/public")); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended:true})); app.use(methodOverride("_method")); app.use(flash()); // 2 app.use(session({secret:"MySecret", resave:true, saveUninitialized:true})); // 3 // Routes ... // Port setting ...
1. 새로 설치된 package로 부터 flash, session을 선언하였습니다.
2. flash를 초기화 합니다. 이제부터 req.flash라는 함수를 사용할 수 있습니다.req.flash(문자열, 저장할_값)
의 형태로 저장할_값(숫자, 문자열, 오브젝트등 어떠한 값이라도 가능)을 해당 문자열에 저장합니다.
이때 flash는 배열로 저장되기 때문에 같은 문자열을 중복해서 사용하면 순서대로 배열로 저장이 됩니다.req.flash(문자열)
인 경우 해당 문자열에 저장된 값들을 배열로 불러옵니다. 저장된 값이 없다면 빈 배열([]
)을 return합니다.
3. session은 서버에서 접속자를 구분시키는 역할을 합니다. user1과 user2가 웹사이트를 보고 있는 경우 해당 user들을 구분하여 서버에서 필요한 값 들(예를 들어 로그인 상태 정보 등등)을 따로 관리하게 됩니다. flash에 저장되는 값 역시 user1이 생성한 flash는 user1에게, user2가 생성한 flash는 user2에게 보여져야 하기 때문에 session이 필요합니다. {secret:"MySecret", resave:true, saveUninitialized:true}
은 옵션부분으로, secret은 hash를 생성하는데 사용되는 값으로 비밀번호 정도로 생각하면 됩니다. 아무값이나 넣어주고 해커가 알 수 없게 합시다. 환경변수에 저장하면 더욱 안전하겠죠.resave와 saveUninitialized은 현재 강의에서 중요한 내용이 아니므로 설명은 생략합니다. 꼭 알고싶으신 분들은 https://github.com/expressjs/session#options 를 참고합시다.
// routes/users.js var express = require("express"); var router = express.Router(); var User = require("../models/User"); // Index ... // New // 1 router.get("/new", function(req, res){ var user = req.flash("user")[0] || {}; var errors = req.flash("errors")[0] || {}; res.render("users/new", { user:user, errors:errors }); }); // create router.post("/", function(req, res){ User.create(req.body, function(err, user){ if(err){ req.flash("user", req.body); req.flash("errors", parseError(err)); return res.redirect("/users/new"); } res.redirect("/users"); }); }); // show ... // edit router.get("/:username/edit", function(req, res){ var user = req.flash("user")[0]; var errors = req.flash("errors")[0] || {}; if(!user){ User.findOne({username:req.params.username}, function(err, user){ if(err) return res.json(err); res.render("users/edit", { username:req.params.username, user:user, errors:errors }); }); } else { res.render("users/edit", { username:req.params.username, user:user, errors:errors }); } }); // update router.put("/:username",function(req, res, next){ User.findOne({username:req.params.username}) .select({password:1}) .exec(function(err, user){ if(err) return res.json(err); // update user object ... // save updated user user.save(function(err, user){ if(err){ req.flash("user", req.body); req.flash("errors", parseError(err)); return res.redirect("/users/"+req.params.username+"/edit"); } res.redirect("/users/"+user.username); }); }); }); module.exports = router; // Functions function parseError(errors){ var parsed = {}; if(errors.name == 'ValidationError'){ for(var name in errors.errors){ var validationError = errors.errors[name]; parsed[name] = { message:validationError.message }; } } else if(errors.code == "11000" && errors.errmsg.indexOf("username") > 0) { parsed.username = { message:"This username already exists!" }; } else { parsed.unhandled = JSON.stringify(errors); } return parsed; }
route별로 살펴봅시다
// New router.get("/new", function(req, res){ var user = req.flash("user")[0] || {}; var errors = req.flash("errors")[0] || {}; res.render("users/new", { user:user, errors:errors }); });
user 생성시에 에러가 있는 경우 new페이지에 에러와 기존에 입력했던 값들을 보여주게 되는데, 이 값들은 create route에서 생성된 flash로부터 받아옵니다. flash는 array가 오게 되는데 이 프로그램에서는 하나 이상의 값이 저장되는 경우가 없고, 있더라도 오류이므로 무조건 [0]의 값을 읽어 오게 했습니다. 값이 없다면(처음 new페이지에 들어온 경우)에는 || {}
를 사용해서 빈 오브젝트를 넣어 user/new페이지를 생성합니다.
// create router.post("/", function(req, res){ User.create(req.body, function(err, user){ if(err){ req.flash("user", req.body); req.flash("errors", parseError(err)); return res.redirect("/users/new"); } res.redirect("/users"); }); });
user 생성시에 오류가 있다면 user, error flash를 만들고 new페이지로 redirect합니다.
user 생성시에 발생할 수 있는 오류는 2가지로 첫번째는 User model의 userSchema에 설정해둔 validation을 통과하지 못한 경우와, mongoDB에서 오류를 내는 경우입니다. 이때 발생하는 error 객체의 형식이 상이하므로parseError
라는 함수를 따로 만들어서 err을 분석하고 일정한 형식으로 만들게 됩니다.
// edit router.get("/:username/edit", function(req, res){ var user = req.flash("user")[0]; var errors = req.flash("errors")[0] || {}; if(!user){ User.findOne({username:req.params.username}, function(err, user){ if(err) return res.json(err); res.render("users/edit", { username:req.params.username, user:user, errors:errors }); }); } else { res.render("users/edit", { username:req.params.username, user:user, errors:errors }); } });
edit은 처음 접속하는 경우에는 DB에서 값을 찾아 form에 기본값을 생성하고, update에서 오류가 발생해 돌아오는 경우에는 기존에 입력했던 값으로 form에 값들을 생성해야 합니다.
이를 위해 user에는 || {}
를 사용하지 않았으며, user flash값이 있으면 오류가 있는 경우, user flash 값이 없으면 처음 들어온 경우로 가정하고 진행합니다.
이제부터 render시에 username을 따로 보내주는데, 이전에는 user.username이 항상 해당 user의 username이였지만 이젠 user flash에서 값을 받는 경우 username이 달라 질 수도 있기 때문에 주소에서 찾은 username을 따로 보내주게됩니다.
// Functions function parseError(errors){ var parsed = {}; if(errors.name == 'ValidationError'){ for(var name in errors.errors){ var validationError = errors.errors[name]; parsed[name] = { message:validationError.message }; } } else if(errors.code == "11000" && errors.errmsg.indexOf("username") > 0) { parsed.username = { message:"This username already exists!" }; } else { parsed.unhandled = JSON.stringify(errors); } return parsed; }
mongoose에서 내는 에러와 mongoDB에서 내는 에러의 형태가 다르기 때문에 이 함수를 통해 에러의 형태를{ 항목이름: { message: "에러메세지" }
로 통일시켜주는 함수입니다.if
에서 mongoose의 model validation error를, else if
에서 mongoDB에서 username이 중복되는 error를, else 에서 그 외 error들을 처리합니다.
함수 시작부분에 console.log("errors: ", errors")
를 추가해 주면 원래 에러의 형태를 console 에서 볼 수 있습니다.
<!-- views/users/edit.ejs --> <!DOCTYPE html> <html> <head> <% include ../partials/head %> </head> <body> <% include ../partials/nav %> <div class="container user user-edit"> <div class="buttons"> <a class="btn btn-default" href="/users/<%= username %>">Back</a> </div> <form class="user-form form-horizontal" action="/users/<%= username %>?_method=put" method="post"> <div class="contentBox"> <h3 class="contentBoxTop">Edit User</h3> <fieldset> <div class="form-group <%= (errors.currentPassword)?'has-error':'' %>"> <label for="currentPassword" class="col-sm-12 control-label">Current Password*</label> <div class="col-sm-9 col-sm-offset-3"> <input class="form-control" type="password" id="currentPassword" name="currentPassword" value=""> <% if(errors.currentPassword){ %> <span class="help-block"><%= errors.currentPassword.message %></span> <% } %> </div> </div> <hr></hr> <div class="form-group <%= (errors.username)?'has-error':'' %>"> <label for="username" class="col-sm-3 control-label">Username*</label> <div class="col-sm-9"> <input class="form-control" type="text" id="username" name="username" value="<%= user.username %>"> <% if(errors.username){ %> <span class="help-block"><%= errors.username.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.name)?'has-error':'' %>"> <label for="name" class="col-sm-3 control-label">Name*</label> <div class="col-sm-9"> <input class="form-control" type="text" id="name" name="name" value="<%= user.name %>"> <% if(errors.name){ %> <span class="help-block"><%= errors.name.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.email)?'has-error':'' %>"> <label for="email" class="col-sm-3 control-label">Email</label> <div class="col-sm-9"> <input class="form-control" type="text" id="email" name="email" value="<%= user.email %>"> <% if(errors.email){ %> <span class="help-block"><%= errors.email.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.newPassword)?'has-error':'' %>"> <label for="newPassword" class="col-sm-12 control-label">New Password</label> <div class="col-sm-9 col-sm-offset-3"> <input class="form-control" type="password" id="newPassword" name="newPassword" value=""> <% if(errors.newPassword){ %> <span class="help-block"><%= errors.newPassword.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.passwordConfirmation)?'has-error':'' %>"> <label for="passwordConfirmation" class="col-sm-12 control-label">Password Confirmation</label> <div class="col-sm-9 col-sm-offset-3"> <input class="form-control" type="password" id="passwordConfirmation" name="passwordConfirmation" value=""> <% if(errors.passwordConfirmation){ %> <span class="help-block"><%= errors.passwordConfirmation.message %></span> <% } %> </div> </div> <p> <small>*Required</small> </p> </fieldset> </div> <div class="buttons"> <button type="submit" class="btn btn-default">Submit</button> </div> <% if(errors.unhandled){ %> <div class="alert alert-danger"> <%= errors.unhandled %> </div> <% } %> </form> </div> <!-- container end --> </body> </html>
1. user.username 대신 router에서 받은 username을 사용합니다.
2. 각 항목의 form-group class가 있는 div에 <%= (errors.항목이름)?'has-error':'' %>
가 추가되었습니다. 에러가 있다면 bootstrap의 has-error
class를 사용합니다.
3. 각 항목의input
밑에 <% if(errors.항목이름){ %>
부분으로 에러메세지를 보여주는 부분이 추가되었습니다.
4. errors.unhandled
는 따로 표시합니다.
<!-- views/users/new.ejs --> <!DOCTYPE html> <html> <head> <% include ../partials/head %> </head> <body> <% include ../partials/nav %> <div class="container user user-new"> <form class="user-form form-horizontal" action="/users" method="post"> <div class="contentBox"> <h3 class="contentBoxTop">New User</h3> <fieldset> <div class="form-group <%= (errors.username)?'has-error':'' %>"> <label for="username" class="col-sm-3 control-label">Username*</label> <div class="col-sm-9"> <input class="form-control" type="text" id="username" name="username" value="<%= user.username %>"> <% if(errors.username){ %> <span class="help-block"><%= errors.username.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.name)?'has-error':'' %>"> <label for="name" class="col-sm-3 control-label">Name*</label> <div class="col-sm-9"> <input class="form-control" type="text" id="name" name="name" value="<%= user.name %>"> <% if(errors.name){ %> <span class="help-block"><%= errors.name.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.email)?'has-error':'' %>"> <label for="email" class="col-sm-3 control-label">Email</label> <div class="col-sm-9"> <input class="form-control" type="text" id="email" name="email" value="<%= user.email %>"> <% if(errors.email){ %> <span class="help-block"><%= errors.email.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.password)?'has-error':'' %>"> <label for="password" class="col-sm-3 control-label">Password*</label> <div class="col-sm-9"> <input class="form-control" type="password" id="password" name="password" value=""> <% if(errors.password){ %> <span class="help-block"><%= errors.password.message %></span> <% } %> </div> </div> <div class="form-group <%= (errors.passwordConfirmation)?'has-error':'' %>"> <label for="passwordConfirmation" class="col-sm-12 control-label">Password Confirmation*</label> <div class="col-sm-9 col-sm-offset-3"> <input class="form-control" type="password" id="passwordConfirmation" name="passwordConfirmation" value=""> <% if(errors.passwordConfirmation){ %> <span class="help-block"><%= errors.passwordConfirmation.message %></span> <% } %> </div> </div> <p> <small>*Required</small> </p> </fieldset> </div> <div class="buttons"> <button type="submit" class="btn btn-default">Submit</button> </div> <% if(errors.unhandled){ %> <div class="alert alert-danger"> <%= errors.unhandled %> </div> <% } %> </form> </div> <!-- container end --> </body> </html>
1. edit.ejs와 마찬가지로 각 항목의 form-group
class가 있는 div에 <%= (errors.항목이름)?'has-error':'' %>
가 추가되었습니다.
2. 이제 new.ejs에도 value에 user 객체의 값을 기본값으로 들어갑니다.
3. edit.ejs와 마찬가지로 각 항목의input
밑에 <% if(errors.항목이름){ %>
부분으로 에러메세지를 보여주는 부분이 추가되었습니다.
4. errors.unhandled
는 따로 표시합니다.
생각보다 포스팅이 길어졌네요. regex를 처음 보신 분들은 좀 당황스러우셨을 수도 있는데 배우고 나면 상당히 유용하게 쓰이므로 반드시 익히시길 바랍니다.
이 글에서 post model의 에러 처리는 하지 않았는데요, 이건 직접 한번 만들어 보세요.
다음은 드디어 로그인입니다.
댓글
이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기