게시판 - User Error 처리

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 게시판 만들기 / 게시판 - 계정 비밀번호 암호화(bcrypt)에서 이어집니다.

board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard 5ce1cfa
git reset --soft 2af8082
npm install
atom .

board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 5ce1cfa
git reset --soft 2af8082
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/5ce1cfae79394467ea32693884d9eb69fdc4a1b8


이번 포스팅에서는 User 생성/수정에서 일어날 수 있는 error들을 처리해 봅시다.

현재는 username이 중복된다든지, 비밀번호가 일치하지 않는다든지 하는 경우에 웹사이트가 에러을 JSON 그대로 보여주고 사이트가 뻗는데, 좋은 사용자 경험(User Experience)가 아닙니다. 에러가 있는 경우 아래와 같이 사이트에 에러들을 보기 좋게 표시해 줍시다.

생성 과정(create)에서 에러가 있는 경우 생성 페이지(new)로, 수정 과정(update)에서 에러가 있는 경우 수정 페이지(edit)로 route간의 이동이 일어나는데, 이때 route간의 정보 전달을 위해 flash라는 것을 사용합니다.

flash는 변수처럼 이름과 값(문자열, 숫자, 배열, 객체 등등 어떠한 형태의 값이라도 사용 가능)을 저장할 수 있는데, 한번 생성 되면 사용될 때까지 서버 메모리상에 저장이 되어 있다가 한번 사용되면 사라지는 형태의 data입니다.

이 강의에서는 connect-flash package를 사용해서 flash를 만듭니다.

추가로 regex(Regular Expression, 정규표현식)를 사용하여 User의 username, password, name, email 항목들에게 특정한 형식의 값만 저장 할 수 있게 해봅시다(email에는 email형식의 값만 받게 하고, 비밀번호는 영문자와 숫자를 혼용할 것 등등). regex는 문자열이 특정한 형식을 가지고 있는지 아닌지를 판단하기 위해 사용됩니다.

폴더구조

Package 설치

express-session와 connect-flash package를 설치해 줍니다.
express-session은 connect-flash를 실행하기 위해 필요한 package입니다.

$ npm install --save express-session connect-flash

코드 - js

// models/User.js

...

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

...

// 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!');
    }
    else if(!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!');
    }
  }
});

...

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, 정규표현식)이 들어가서 값이 regex에 부합하는지 않으면 에러메세지를 내게 됩니다.
정규표현식은 문자열에 특정한 규칙에 맞는 문자열이 있는지 알아보는 표현식인데요, 이거 하나만으로도 책 한권을 쓸 수 있는 내용이니까 따로 공부를 하셔야합니다.

1-2는 username을 위한 regex인데요, regex(/^.{4,12}$/)를 해석해 보면,
/^.{4,12}$/ : regex는 / /안에 작성합니다. 즉 / /를 통해 이게 regex임을 알수 있습니다.
/^.{4,12}$/ : ^문자열의 시작 위치를 나타냅니다.
/^.{4,12}$/ : .어떠한 문자열이라도 상관없음을 나타냅니다.
/^.{4,12}$/ : {숫자1,숫자2}숫자1 이상, 숫자2 이하의 길이 나타냅니다.
/^.{4,12}$/ : $문자열의 끝 위치를 나타냅니다.
해석하면  "문자열의 시작 위치에 길이 4 이상 12 이하인 문자열이 있고, 바로 다음이 문자열의 끝이여야 함". 즉 전체 길이가 4이상 12자리 이하의 문자열이라면 이 regex를 통과할 수 있습니다.

1-3의 이메일의 형식을 위한 regex인데요, /^[a-zA-Z0-9._%+-][email protected][a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/는 이 regex는 인터넷에서 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!');
    }
    else if(!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를 반환합니다.

2-4. 2-3에서 false가 반환되는 경우 2-2에서 선언한 문자열로 model.invalidate함수를 호출합니다.

// index.js

...
var methodOverride = require('method-override');
var flash = require('connect-flash'); // 1
var session = require('express-session'); // 1
var app = express();

...

// Other settings
...
app.use(methodOverride('_method'));
app.use(flash()); // 2
app.use(session({secret:'MySecret', resave:true, saveUninitialized:true})); //3

...

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은session을 hash화하는데 사용되는 값으로 비밀번호 정도로 생각하면 됩니다. 아무값이나 넣어주고 해커가 알 수 없게 합시다. 환경변수에 저장하면 더욱 안전하겠죠. resave와 saveUninitialized은 현재 강의에서 중요한 내용이 아니므로 설명은 생략합니다. 꼭 알고싶으신 분들은 https://github.com/expressjs/session#options 를 참고합시다.

// routes/users.js

...

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

// 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');
  });
});

...

// 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')
    .exec(function(err, user){
      if(err) return res.json(err);

      // update user object
      user.originalPassword = user.password;
      user.password = req.body.newPassword? req.body.newPassword : user.password;
      for(var p in req.body){
        user[p] = req.body[p];
      }

      // 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 에서 볼 수 있습니다.

코드 - ejs

<!-- views/users/edit.ejs -->

...

      <h3 class="mb-3">Edit User</h3>

      <form action="/users/<%= username %>?_method=put" method="post"> <!-- 1 -->

        <div class="form-group row">
          <label for="currentPassword" class="col-sm-3 col-form-label">Current Password*</label>
          <div class="col-sm-9 col-sm-offset-3">
            <input type="password" id="currentPassword" name="currentPassword" value="" class="form-control <%= (errors.currentPassword)?'is-invalid':'' %>"> <!-- 2 -->
            <% if(errors.currentPassword){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.currentPassword.message %></span>
            <% } %>
          </div>
        </div>

        <hr></hr>

        <div class="form-group row">
          <label for="username" class="col-sm-3 col-form-label">Username*</label>
          <div class="col-sm-9">
            <input type="text" id="username" name="username" value="<%= user.username %>" class="form-control <%= (errors.username)?'is-invalid':'' %>"> <!-- 2 -->
            <% if(errors.username){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.username.message %></span>
            <% } %>
          </div>
        </div>

        <div class="form-group row">
          <label for="name" class="col-sm-3 col-form-label">Name*</label>
          <div class="col-sm-9">
            <input type="text" id="name" name="name" value="<%= user.name %>" class="form-control <%= (errors.name)?'is-invalid':'' %>"> <!-- 2 -->
            <% if(errors.name){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.name.message %></span>
            <% } %>
          </div>
        </div>

        <div class="form-group row">
          <label for="email" class="col-sm-3 col-form-label">Email</label>
          <div class="col-sm-9">
            <input type="text" id="email" name="email" value="<%= user.email %>" class="form-control <%= (errors.email)?'is-invalid':'' %>"> <!-- 2 -->
            <% if(errors.email){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.email.message %></span>
            <% } %>
          </div>
        </div>

        <div class="form-group row">
          <label for="newPassword" class="col-sm-3 col-form-label">New Password</label>
          <div class="col-sm-9 col-sm-offset-3">
            <input type="password" id="newPassword" name="newPassword" value="" class="form-control <%= (errors.newPassword)?'is-invalid':'' %>"> <!-- 2 -->
            <% if(errors.newPassword){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.newPassword.message %></span>
            <% } %>
          </div>
        </div>

        <div class="form-group row">
          <label for="passwordConfirmation" class="col-sm-3 col-form-label">Password Confirmation</label>
          <div class="col-sm-9 col-sm-offset-3">
            <input type="password" id="passwordConfirmation" name="passwordConfirmation" value="" class="form-control <%= (errors.passwordConfirmation)?'is-invalid':'' %>"> <!-- 2 -->
            <% if(errors.passwordConfirmation){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.passwordConfirmation.message %></span>
            <% } %>
          </div>
        </div>

        <p>
          <small>*Required</small>
        </p>

        <% if(errors.unhandled){ %> <!-- 4 -->
          <div class="alert alert-danger">
            <%= errors.unhandled %>
          </div>
        <% } %>

...

1. user.username 대신 router에서 받은 username을 사용합니다.

2. 각 항목의 form-group class가 있는 div에 <%= (errors.항목이름)?'is-invalid':'' %> 가 추가되었습니다. 에러가 있다면 bootstrapinvalid-feedback class를 사용합니다.

3. 각 항목의 input밑에 <% if(errors.항목이름){ %> 부분으로 에러메세지를 보여주는 부분이 추가되었습니다.

4. errors.unhandled는 따로 표시합니다.

<!-- views/users/new.ejs -->

...

        <div class="form-group row">
          <label for="username" class="col-sm-3 col-form-label">Username*</label>
          <div class="col-sm-9">
            <input type="text" id="username" name="username" value="<%= user.username %>" class="form-control <%= (errors.username)?'is-invalid':'' %>"> <!-- 1, 2 -->
            <% if(errors.username){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.username.message %></span>
            <% } %>
          </div>
        </div>
        <div class="form-group row">
          <label for="name" class="col-sm-3 col-form-label">Name*</label>
          <div class="col-sm-9">
            <input type="text" id="name" name="name" value="<%= user.name %>" class="form-control <%= (errors.name)?'is-invalid':'' %>"> <!-- 1, 2 -->
            <% if(errors.name){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.name.message %></span>
            <% } %>
          </div>
        </div>
        <div class="form-group row">
          <label for="email" class="col-sm-3 col-form-label">Email</label>
          <div class="col-sm-9">
            <input type="text" id="email" name="email" value="<%= user.email %>" class="form-control <%= (errors.email)?'is-invalid':'' %>"> <!-- 1, 2 -->
            <% if(errors.email){ %>
              <span class="invalid-feedback"><%= errors.email.message %></span>
            <% } %>
          </div>
        </div>
        <div class="form-group row">
          <label for="password" class="col-sm-3 col-form-label">Password*</label>
          <div class="col-sm-9">
            <input type="password" id="password" name="password" value="" class="form-control <%= (errors.password)?'is-invalid':'' %>"> <!-- 1, 2 -->
            <% if(errors.password){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.password.message %></span>
            <% } %>
          </div>
        </div>
        <div class="form-group row">
          <label for="passwordConfirmation" class="col-sm-3 col-form-label">Password Confirmation*</label>
          <div class="col-sm-9 col-sm-offset-3">
            <input type="password" id="passwordConfirmation" name="passwordConfirmation" value="" class="form-control <%= (errors.passwordConfirmation)?'is-invalid':'' %>"> <!-- 1, 2 -->
            <% if(errors.passwordConfirmation){ %> <!-- 3 -->
              <span class="invalid-feedback"><%= errors.passwordConfirmation.message %></span>
            <% } %>
          </div>
        </div>
        <p>
          <small>*Required</small>
        </p>

        <% if(errors.unhandled){ %> <!-- 4 -->
          <div class="alert alert-danger">
            <%= errors.unhandled %>
          </div>
        <% } %>

...

1. edit.ejs와 마찬가지로 각 항목의 form-group class가 있는 div에 <%= (errors.항목이름)?'is-invalid':'' %> 가 추가되었습니다.

2. 이제 new.ejs에도 value에 user 객체의 값을 기본값으로 들어갑니다.

3. edit.ejs와 마찬가지로 각 항목의input밑에 <% if(errors.항목이름){ %> 부분으로 에러메세지를 보여주는 부분이 추가되었습니다. 

4. errors.unhandled는 따로 표시합니다.

마치며

생각보다 포스팅이 길어졌네요. regex를 처음 보신 분들은 좀 당황스러우셨을 수도 있는데 배우고 나면 상당히 유용하게 쓰이므로 반드시 익히시길 바랍니다.

다음은 드디어 로그인입니다.

댓글

댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP