게시판 - 회원가입

소스코드

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

이 게시물의 소스코드는 게시판 만들기 / 게시판 - Front End 개발에서 이어집니다.

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

git reset --hard
git pull
git reset --hard 4adcb08
git reset --soft 9d1396d
npm install
atom .

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

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 4adcb08
git reset --soft 9d1396d
npm install
atom .

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


사이트에 회원가입 기능을 만들어 봅시다!

회원가입 기능은 왠지 복잡할 것 같지만, 사실 post model이나 주소록 만들기 예제의 contact model과 같이 회원(user) model을 만들고, CRUD 중 create, 즉 회원 데이터를 생성해 DB에 저장하는 것이 회원가입 기능입니다. 다만 user model에는 특별히 비밀번호가 추가되는데, 이를 통해 로그인시에 username과 password가 일치하는 user가 DB에 있는지를 확인하게 됩니다. 로그인 기능은 이후의 강의에서 다루도록 하고, 이번 강의에서는 user CRUD를 만들어 봅시다.

contact, post에 이어 이번이 3번째로 CRUD 기능을 만드는데요, 이번엔 back end(model, route) 코드와 front end(ejs) 코드를 한 강의에 진행하겠습니다.

폴더구조

코드 - js

user model부터 살펴봅시다.

// models/User.js

var mongoose = require('mongoose');

// schema // 1
var userSchema = mongoose.Schema({
  username:{type:String, required:[true,'Username is required!'], unique:true},
  password:{type:String, required:[true,'Password is required!'], select:false},
  name:{type:String, required:[true,'Name is required!']},
  email:{type:String}
},{
  toObject:{virtuals:true}
});

// virtuals // 2
userSchema.virtual('passwordConfirmation')
  .get(function(){ return this._passwordConfirmation; })
  .set(function(value){ this._passwordConfirmation=value; });

userSchema.virtual('originalPassword')
  .get(function(){ return this._originalPassword; })
  .set(function(value){ this._originalPassword=value; });

userSchema.virtual('currentPassword')
  .get(function(){ return this._currentPassword; })
  .set(function(value){ this._currentPassword=value; });

userSchema.virtual('newPassword')
  .get(function(){ return this._newPassword; })
  .set(function(value){ this._newPassword=value; });

// password validation // 3
userSchema.path('password').validate(function(v) {
  var user = this; // 3-1

  // create user // 3-3
  if(user.isNew){ // 3-2
    if(!user.passwordConfirmation){
      user.invalidate('passwordConfirmation', 'Password Confirmation is required.');
    }

    if(user.password !== user.passwordConfirmation) {
      user.invalidate('passwordConfirmation', 'Password Confirmation does not matched!');
    }
  }

  // update user // 3-4
  if(!user.isNew){
    if(!user.currentPassword){
      user.invalidate('currentPassword', 'Current Password is required!');
    }
    else if(user.currentPassword != user.originalPassword){
      user.invalidate('currentPassword', 'Current Password is invalid!');
    }

    if(user.newPassword !== user.passwordConfirmation) {
      user.invalidate('passwordConfirmation', 'Password Confirmation does not matched!');
    }
  }
});

// model & export
var User = mongoose.model('user',userSchema);
module.exports = User;

1. schema : requiretrue 대신 배열([ ... ])이 들어갔습니다. 첫번째는 true/false 값이고, 두번째는 에러메세지입니다. 그냥 true/false을 넣을 경우 기본 에러메세지가 나오고, 위와 같이 배열을 사용해서 에러메세지 내용을 원하는 대로 변경할 수 있습니다.

password에는 select:false가 추가되었습니다. select:false로 설정하면 DB에서 해당 모델을 읽어 올때 해당 항목값을 읽어오지 않습니다. 비밀번호는 중요하기 때문에 DB에서 값을 읽어오지 않게 설정했습니다. 물론 이 값이 필요한 경우도 있는데, 이럴 때는 해당 값을 읽어오도록 특별히 설정을 해주어야 합니다. 이 설정은 아래 route코드에서 설명합니다.

2. DB에 저장되는 값 이외의 항목이 필요할 땐 virtual 항목으로 만듭니다. 즉 passwordConfirmation, originalPassword, currentPassword, newPassword는 회원가입, 회원정보 수정을 위해 필요한 항목이지만, DB에 저장할 필요는 없는 값들입니다. 이처럼 DB에 저장될 필요는 없지만, model에서 사용하고 싶은 항목들은 virtual로 만듭니다.

3. password를 DB에 생성, 수정하기 전에 값이 유효(valid)한지 확인(validate)을 하는 코드를 작성합니다. 

3-1. validation callback 함수 속에서 this는 user model입니다. 헷갈리지 않도록 user 변수에 넣었습니다.

3-2. model.isNew 항목은 해당 모델이 생성되는 경우에는 true, 아니면 false의 값을 가집니다. 이 항목을 이용해서 현재 password validation이 '회원가입' 단계인지, 아니면 '회원 정보 수정'단계인지를 알 수 있습니다.

3-3. 회원가입의 경우 password confirmation값이 없는 경우와, password값이 password confirmation값과 다른 경우에 유효하지않음처리(invalidate)를 하게 됩니다. model.invalidate함수를 사용하며, 첫번째는 인자로 항목이름, 두번째 인자로 에러메세지를 받습니다.

3-4. 회원 정보 수정의 경우 current password값이 없는 경우와, current password값이 original password값과 다른 경우, new password값과 password confirmation값이 다른 경우 invalidate합시다. 회원정보 수정시에는 항상 비밀번호를 수정하는 것은 아니기 때문에 new password와 password confirmation값이 없어도 에러는 아닙니다.

다음으로 route을 살펴봅시다.

// routes/users.js

var express = require('express');
var router = express.Router();
var User = require('../models/User');

// Index // 1
router.get('/', function(req, res){
  User.find({})
    .sort({username:1})
    .exec(function(err, users){
      if(err) return res.json(err);
      res.render('users/index', {users:users});
    });
});

// New
router.get('/new', function(req, res){
  res.render('users/new');
});

// create
router.post('/', function(req, res){
  User.create(req.body, function(err, user){
    if(err) return res.json(err);
    res.redirect('/users');
  });
});

// show
router.get('/:username', function(req, res){
  User.findOne({username:req.params.username}, function(err, user){
    if(err) return res.json(err);
    res.render('users/show', {user:user});
  });
});

// edit
router.get('/:username/edit', function(req, res){
  User.findOne({username:req.params.username}, function(err, user){
    if(err) return res.json(err);
    res.render('users/edit', {user:user});
  });
});

// update // 2
router.put('/:username', function(req, res, next){
  User.findOne({username:req.params.username}) // 2-1
    .select('password') // 2-2
    .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; // 2-3
      for(var p in req.body){ // 2-4
        user[p] = req.body[p];
      }

      // save updated user
      user.save(function(err, user){
        if(err) return res.json(err);
        res.redirect('/users/'+user.username);
      });
  });
});

// destroy
router.delete('/:username', function(req, res){
  User.deleteOne({username:req.params.username}, function(err){
    if(err) return res.json(err);
    res.redirect('/users');
  });
});

module.exports = router;

나머지는 지금까지 만들어 왔던 route들과 기본 형태는 같습니다. 다만 id대신에 username을 route에 사용하였는데요, 그냥 id말고 다른 항목도 route에 사용할 수도 있다는 것을 보여주기 위함입니다.

index와 update만 자세히 살펴봅시다. 나머지는 username을 id대신 사용하는 것 말고는 특별한 것이 없습니다.

1. 지금까지의 index route과는 다르게, 찾은 값을 정렬하는 기능이 추가되었습니다. sort함수가 추가되었는데요, 이 함수에는 {username:1} 이 들어가서 username을 기준으로 오름차순(asc)으로 정렬하고 있습니다.(-1을 넣으면 내림차순(desc)이 됩니다.)

sort 함수를 사용하기 위해 구조가 좀 바뀌었는데요, sort함수가 있을 때와 없을 때를 비교해서 살펴봅시다.

sort가 없을때 (callback 함수가 find 함수에 인자로 들어감)

User.find({}, function(err, users){
  ...
});

sort가 있을때 (callback 함수가 exec함수에 인자로 들어감)

User.find({})
  .sort({username:1})
  .exec(function(err, users){
    ...
  });

sort말고도 다양한 함수들이 끼어들 수 있는데요, 이러한 경우에 위와 같이 exec 함수를 사용합니다.

2-1. 이번에는 findOneAndUpdate함수대신에 findOne함수로 값을 찾은 후에 값을 수정하고 user.save함수로 값을 저장합니다. 단순히 값을 바꾸는 것이 아니라 user.password를 조건에 맞게 바꿔주어야 하기 때문이죠.

2-2. select함수를 이용하면 DB에서 어떤 항목을 선택할지, 안할지를 정할 수 있습니다. user schema에서 password의 select을 false로 설정했으니 DB에 password가 있더라도 기본적으로 password를 읽어오지 않게 되는데, select('password')를 통해서 password를 읽어오게 했습니다. 참고로 select함수로 기본적으로 읽어오게 되어 있는 항목을 안 읽어오게 할 수도 있는데 이때는 항목이름 앞에 -를 붙이면 됩니다. 또한 하나의 select함수로 여러 항목을 동시에 정할 수도 있는데, 예를 들어 password를 읽어오고, name을 안 읽어오게 하고 싶다면 .select('password -name')를 입력하면 되겠습니다.

2-3. user의 update(회원 정보 수정)은 password를 업데이트 하는 경우와, password를 업데이트 하지 않는 경우로 나눌 수 있는데, 이에 따라 user.password의 값이 바뀝니다.

2-4. user는 DB에서 읽어온 data이고, req.body가 실제 form으로 입력된 값이므로 각 항목을 덮어 쓰는 부분입니다.

// index.js

...

// Routes
app.use('/', require('./routes/home'));
app.use('/posts', require('./routes/posts'));
app.use('/users', require('./routes/users')); // 1

...

1. 마지막으로 users route를 추가해 줍니다.

코드 - ejs

view들은 post와 비교해서 특별한 것이 전혀 없습니다.

<!-- views/partials/nav.ejs -->

...
    <div class="collapse navbar-collapse" id="navbarSupportedContent">
      ...
      <ul class="navbar-nav ml-auto"> <!-- 1 -->
        <li class="nav-item"><a href="/users" class="nav-link">Users</a></li>
        <li class="nav-item"><a href="/users/new" class="nav-link">Sign Up</a></li>
      </ul>
    </div>
...

ml-auto class를 이용하면 우측정렬된 메뉴를 만들 수 있습니다.

나머지 ejs파일들은 특별한 내용은 없으므고 그냥 쭉 살펴봅시다.

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

<!DOCTYPE html>
<html>
  <head>
    <%- include('../partials/head') %>
  </head>
  <body>
    <%- include('../partials/nav') %>

    <div class="container mb-3">

      <h3 class="mb-3">Users</h3>

      <ul class="list-group">
        <% if(users == null || users.length == 0){ %>
          <li class="list-group-item"> There is no user yet.</div>
        <% } %>
        <% users.forEach(function(user) { %>
          <li class="list-group-item">
            <a href="/users/<%= user.username %>"><%= user.username %></a>
          </li>
        <% }) %>
      </ul>

    </div>
  </body>
</html>

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

<!DOCTYPE html>
<html>
  <head>
    <%- include('../partials/head') %>
  </head>
  <body>
    <%- include('../partials/nav') %>

    <div class="container mb-3">

      <h3 class="contentBoxTop mb-3">New User</h3>

      <form action="/users" method="post">

        <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="" class="form-control">
          </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="" class="form-control">
          </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="" class="form-control">
          </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">
          </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">
          </div>
        </div>
        <p>
          <small>*Required</small>
        </p>

        <div class="form-group">
          <button type="submit" class="btn btn-primary">Submit</button>
        </div>
      </form>

    </div>
  </body>
</html>

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

<!DOCTYPE html>
<html>
  <head>
    <%- include('../partials/head') %>
  </head>
  <body>
    <%- include('../partials/nav') %>

    <div class="container mb-3">

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

      <form action="/users/<%= user.username %>?_method=put" method="post">

        <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">
          </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">
          </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">
          </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">
          </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">
          </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">
          </div>
        </div>

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

        <div class="buttons">
          <a class="btn btn-primary" href="/users/<%= user.username %>">Back</a>
          <button type="submit" class="btn btn-primary">Submit</button>
        </div>

      </form>

    </div>
  </body>
</html>

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

<!DOCTYPE html>
<html>
  <head>
    <%- include('../partials/head') %>
  </head>
  <body>
    <%- include('../partials/nav') %>

    <div class="container mb-3">

      <h3 class="contentBoxTop"><%= user.username %></h3>

      <form class="user-form" action="/users" method="post">
        <fieldset disabled>
          <div class="form-group row">
            <label for="name" class="col-sm-3 col-form-label">Name</label>
            <div class="col-sm-9">
              <input class="form-control" type="text" id="name" name="name" value="<%= user.name %>">
            </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 class="form-control" type="text" id="email" name="email" value="<%= user.email %>">
            </div>
          </div>
        </fieldset>
      </form>

      <div>
        <a class="btn btn-primary" href="/users">Back</a>
        <a class="btn btn-primary" href="/users/<%= user.username %>/edit">Edit</a>
        <form action="/users/<%= user.username %>?_method=delete" method="post" class="d-inline">
          <a class="btn btn-primary" href="javascript:void(0)" onclick="confirm('Do you want to delete this?')?this.parentElement.submit():null;">Delete</a>
        </form>
      </div>

    </div>
  </body>
</html>

코드 - css

/* public/css/master.css */

...

.user-form {
  width: 400px;
}

실행결과

Sign Up 메뉴를 누르면 회원가입 form이 뜹니다.

회원가입이 끝나거나, 메뉴의 Users를 누르면 index가 나옵니다.

show입니다.

edit입니다. 회원가입할때는 password가 필수지만, 정보 수정시의 new password는 필수가 아닙니다. 다만 current password가 DB에 저장된 값과 일치해야합니다.

마치며..

긴 강의 읽으시느라 수고하셨습니다. 이번 포스팅은 코드가 좀 많긴한데, 비슷한 내용을 자꾸 반복하면서 계속 글을 나눌 수 없어서 한번에 해봤습니다.

이번 강의를 보면서 비밀번호를 그대로 DB에 저장하는 보안상의 엄청나게 중요한 문제를 발견하신 분들이 있으실텐데, 다음 포스팅에서는 이에 대해 알아보겠습니다.

댓글

김도윤 2020.04.13
신규 User 생성(POST) 시  invalidate 함수가 없다고 나오기에 디버깅 했더니
req.body로 User 생성 후 save 혹은 create 시  validate 함수의  var user = this;에서 (3-1 부분) this 가 document 전체를 가지고 있지 않고 _passwordConfirmation 만 가진 Object로 나옵니다.  어찌 해결해볼 수 있을까요?
I
Ian H 2020.04.14
@김도윤,
이상하네요. 가지고 계신 소스코드를 github에 올려주시면 한번 체크해보겠습니다!
W
Woojin Kim 2020.05.22
안녕하세요 선생님 혹시    toObject:{virtuals:true} 요 부분은 왜 사용하는 걸까요 ? 아무리 찾아봐도 명쾌한 답을 못찾아서 댓글 남겨요 ! this._passwordConfirmation;에서 underscore가 붙는 mongoose의 규칙이 있는지도 궁금합니다..! 
I
Ian H 2020.05.22
@Woojin Kim,
안녕하세요^^ 
mongoose의 model은 toObject함수를 사용해서 plain javascrirpt object로 변경될 수 있습니다. 직접 toObject 함수를 사용하지 않더라도 console.log에 model을 넣어서 사용하면 toObject함수가 자동으로 먼저 호출됩니다. (https://mongoosejs.com/docs/guide.html#toObject )
질문주신 toObject 부분은 이 toObject함수에 대한 설정을 하는 부분입니다.
virtuals:true는 virtual로 설정된 항목들을 toObject함수에서 표시하게 하는 설정으로 기본적으로 virtual들은 console.log에서 표시되지 않습니다.(다른 모든 가능한 설정들: https://mongoosejs.com/docs/api.html#document_Document-toObject)
위 코드의 // update user object 부분에서 console.log(user)를 사용하시면, toObject:{virtuals:true}가 있을 때랑 없을 때 차이를 볼 수 있습니다.
사실  이 게시판 프로젝트에서는 toObject함수를 호출하지 않으므로 웹사이트 자체의 변화는 전혀 없고 단지 // update user object 부분에서 debugging을 위해서 사용되었습니다. 
해당 내용을 나중에 본문에 추가하겠습니다!
I
Ian H 2020.05.22
this._passwordConfirmation 는 virtual property인 passwordConfirmation의 실제 value를 저장하는 곳입니다. 이 값은 set함수(setter)와 get함수(getter)에서 사용되어 있구요. 
C계열의 언어에서 getter와 setter를 사용할때 통상적으로 저렇게 underscore(_)를 앞에 붙여주기에 사용했습니다. 
즉 통상적으로 passwordConfirmation는 public 으로 access가능한 값이고, _passwordConfirmation는 class내에서 passwordConfirmation를 만들 때 사용하는 값입니다.
그럼 어차피 passwordConfirmation가 _passwordConfirmation인데 왜 두개가 따로 있느냐면, passwordConfirmation가 virtual이기 때문입니다(virtual value는 값을 무조건 다른 곳에서 가져와야 합니다). passwordConfirmation가 virtual이여야 하는 이유는? passwordConfirmation를 DB에 저장하고 싶지 않지만, user모델에서 사용하고 싶기 때문입니다.
혹시나 더 궁금한점있으면 댓글남겨주세요!
W
Woojin Kim 2020.05.23
@Ian H,
와.... 이해가 쏙쏙 됐습니다... 빠르고 자세하고 정확한 답변 너무 감사드립니다 !!
nodeJS에서 기본적인 CRUD와 간단한 API만을 구현하고 있었는데 많이 배웠습니다. 너무 감사드립니다 선생님 !!
u
utoru80 2020.06.26
// models/User.js  에서 userSchema.path('password').validate(function(v) {  이부분의 'password' 이거요  모델.path 함수 는   사용자에게 인풋 값으로 받은 password 값 을 가져온건가요??  어디에서 password 값이 뚝딱 떨어져 나왔네요.
I
Ian H 2020.06.29
@utoru80,
// routes/users.js의 // create 에서는 views/users/new.ejs에서 입력받은 값이 사용되고, 그 외의 경우에는 DB의 값이 사용됩니다.   // routes/users.js의 코드를 보시면 User 모델이 form값으로 만들어지는지, 아니면 DB의 data로 불러지는지를 알 수 있습니다.
u
utoru80 2020.06.29
@Ian H,
답변 감사합니다. 도움이 정말 많이 됩니다.
I
Ian H 2020.06.30
@utoru80,
👍
댓글쓰기

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

UP