Post 등록하기

7

번역 완료율

이 장에서는:

  • 클라이언트에서 Post를 등록하는 방법을 배운다.
  • 간단한 보안성 검사를 구현한다.
  • Post 등록폼에 대한 접근을 제한한다.
  • 추가보안을 위해 서버쪽 메서드 사용법을 배운다.
  • Posts.insert 데이터베이스 호출을 사용하여 콘솔에서 post를 등록하는 것이 매우 쉽다는 것은 알지만, 사용자가 post를 등록하기 위해 콘솔을 열도록 할 수는 없다!

    결국, 우리는 사용자가 앱에 새 글을 등록하는 사용자 인터페이스를 구현해야 한다.

    Post 등록 페이지 만들기

    먼저 새 페이지에 대한 route를 정의한다:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() { return Meteor.subscribe('posts'); }
    });
    
    Router.route('/', {name: 'postsList'});
    
    Router.route('/posts/:_id', {
      name: 'postPage',
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/submit', {name: 'postSubmit'});
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    
    lib/router.js

    헤더에 링크 추가하기

    Route를 정의했으니, 이제 헤더에 등록 페이지에 대한 링크를 추가할 수 있다:

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
          <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
              <span class="sr-only">Toggle navigation</span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
          </div>
          <div class="collapse navbar-collapse" id="navigation">
            <ul class="nav navbar-nav">
              <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
              {{> loginButtons}}
            </ul>
          </div>
        </div>
      </nav>
    </template>
    
    client/templates/includes/header.html

    Route를 설정했다는 것은 사용자가 브라우저에서 /submit URL을 요청하면, 미티어가 postSubmit 템플릿을 화면에 보여준다는 의미이다. 그러므로 아래와 같이 템플릿을 작성한다:

    <template name="postSubmit">
      <form class="main form">
        <div class="form-group">
          <label class="control-label" for="url">URL</label>
          <div class="controls">
              <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="title">Title</label>
          <div class="controls">
              <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
          </div>
        </div>
        <input type="submit" value="Submit" class="btn btn-primary"/>
      </form>
    </template>
    
    client/templates/posts/post_submit.html

    주: 많은 마크업이 있지만, 이들은 단순히 Twitter Bootstrap에서 온 것이다. form 엘리먼트들만이 필수적이고, 나머지 마크업은 앱을 다소 보기좋게 해 줄 뿐이다. 이것은 아래와 같이 보일 것이다:

    Post 등록폼
    Post 등록폼

    이것은 단순한 폼이다. 이 폼의 작동여부에 대하여는 걱정할 필요없다. 우리는 폼의 submit 이벤트를 가로채어 Javascript를 통해서 데이터를 갱신할 것이다(Javascript가 비활성화된 경우, 미티어 앱이 전혀 동작하지 않는 경우를 고려하여, JS가 없을 때의 대체 페이지를 제공하는 것은 별 의미는 없다.).

    Post 등록하기

    폼의 submit 이벤트를 이벤트 핸들러에 묶는다. 이 때, submit 이벤트를 사용하는 것이 최선(버튼의 click 이벤트를 사용하는 것에 비하여)인데, 이는 가능한 모든 submit 요청(예를 들면, URL 필드에서 엔터 키를 눌렀을 때와 같이)를 모두 커버하여 처리하기 때문이다.

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        post._id = Posts.insert(post);
        Router.go('postPage', post);
      }
    });
    
    client/templates/posts/post_submit.js

    Commit 7-1

    Post 등록 페이지를 추가하고 헤더에 이 페이지로의 링크를 추가했다.

    이 함수는 jQuery를 사용하여 폼 필드의 값들을 추출하고, 그 결과로부터 새로운 post 객체를 구성한다. 여기서 핸들러의 매개변수 eventpreventDefault 메서드를 호출하여 브러우저가 폼의 submit을 그대로 진행하지 않도록 차단해야 한다.

    마침내, 우리는 새로운 post 페이지로 route를 설정할 수 있다. 컬렉션의 insert() 함수는 데이터베이스에 저장되는 객체의 id를 리턴한다. 이 과정에서 라우터의 go() 함수를 통해서 이동할 URL로 간다.

    결론은 사용자가 submit버튼을 누르면, post가 생성, 등록되고, 사용자는 이 등록된 post에 대한 토론 페이지로 바로 가는 것이다.

    추가 보안

    Post를 등록하는 과정은 잘 되지만, 모든 방문자들이 다 등록하게 하려는 것은 아니다: 우리는 방문자들이 등록하려면 로그인하게 하려고 한다. 물론, 로그아웃 상태의 사용자에게는 post 등록 폼을 숨기게 할 수도 있다. 하지만 아직은, 사용자는 로그인하지 않고도 브라우저 콘솔에서 post를 등록할 수 있으니, 이대로 둘 수는 없다.

    감사하게도 데이터 보안 기능이 미티어 컬렉션에 잘 구현되어 있다; 이 기능은 새 프로젝트를 만들때, 기본적으로 꺼진 상태로 되어 있다. 그래서 쉽게 시작할 수 있었고, 지루한 작업은 나중으로 미루고 앱을 구축할 수 있었다.

    우리 앱은 이제 더 이상 이런 보호막이 없어도 되는 상태가 되었으니, 이를 벗어 버리도록 하자! 이제 insecure 패키지를 제거한다:

    $ meteor remove insecure
    
    터미널

    이렇게 하면, post 등록 폼이 더 이상 동작하지 않는 것을 볼 수 있다. 이것은 insecure 패키지가 없으면, 클라이언트 쪽에서 post 컬렉션에 등록하는 것이 더 이상 허용되지 않기 때문이다. 클라이언트 쪽에서 post 등록을 가능하게 하려면 미티어에게 명시적인 규정을 부여하거나, 그렇지 않으면 서버쪽에서 post를 등록해야 한다.

    Post 등록을 허용하기

    등록폼이 다시 동작하도록 클라이언트 쪽에서 post 등록을 허용하는 방법을 알아보자. 나중에 알게되지만, 우리는 결국에는 다른 기술을 사용할 것이다. 하지만, 지금은 아래 방법으로 하면 쉽게 동작하게 할 수 있다:

    Posts = new Mongo.Collection('posts');
    
    Posts.allow({
      insert: function(userId, doc) {
        // only allow posting if you are logged in
        return !! userId;
      }
    });
    
    lib/collections/posts.js

    Commit 7-2

    패키지 insecure를 삭제하고 post에 쓰기를 허용했다.

    우리는 Posts.allow를 호출한다. 이것은 미티어에게 “다음 조건하에서 클라이언트의 Posts 컬렉션 조작을 허용한다."라고 지시하는 것이다. 위의 경우, 우리는 "클라이언트가 userId를 가지고 있으면 post를 등록하도록 허용한다” 라고 지시하는 것이다.

    수정 작업을 하려는 사용자의 userIdallowdeny 요청에 전달된다(혹은 로그인 사용자가 없으면 null을 리턴한다). 그리고 사용자 계정이 미티어의 핵심 부분과 묶여있으니 항상 정확한 userId에 의존할 수 있다.

    우리는 post를 등록하려면 로그인하도록 해왔다. 로그아웃한 다음 post를 등록하려고 시도해보기 바란다; 그러면 콘솔에서 이런 메시지를 볼 것이다:

    등록 실패: Access denied
    등록 실패: Access denied

    그런데, 우리는 여전히 다음의 이슈들을 처리해야 한다:

    • 로그아웃 상태의 사용자가 여전히 post 등록 폼 페이지에 접근할 수 있다.
    • Post가 그 사용자와 어떤 방식으로든 묶여있지 않다(그리고, 이를 강제할 코드가 서버에는 없다).
    • 동일한 URL을 가리키는 복수의 post가 등록될 수 있다.

    이런 문제들의 해법을 찾아보자.

    새 Post 등록폼에 접근 제한하기

    로그아웃 상태의 사용자가 post 등록폼을 열람하는 것을 막는 것으로 시작하자. 이 작업은 route hook을 정의하는 방식으로 라우터 레벨에서 할 것이다.

    Hook이란 라우팅 과정을 가로채서 라우터가 수행하는 작업을 바꾸어 버린다. 이것은 경호원이 당신의 입장을 허용(또는 돌려보내는 것)하기 전에 당신의 신원을 확인하는 과정과 대비하여 생각할 수 있다.

    우리가 해야 할 일은 사용자가 로그인 했는지를 검사하는 것이다. 로그인하지 않았다면, postSubmit 템플릿 대신에 accessDenied 템플릿을 그린다(그리고는 라우터의 동작을 중지한다). 그러므로, router.js 파일을 아래와 같이 수정한다:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() { return Meteor.subscribe('posts'); }
    });
    
    Router.route('/', {name: 'postsList'});
    
    Router.route('/posts/:_id', {
      name: 'postPage',
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/submit', {name: 'postSubmit'});
    
    var requireLogin = function() {
      if (! Meteor.user()) {
        this.render('accessDenied');
      } else {
        this.next();
      }
    }
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
    
    lib/router.js

    접근 거부 페이지를 위한 템플릿도 만든다:

    <template name="accessDenied">
      <div class="access-denied jumbotron">
        <h2>Access Denied</h2>
        <p>You can't get here! Please log in.</p>
      </div>
    </template>
    
    client/templates/includes/access_denied.html

    Commit 7-3

    로그인 상태가 아닐 때 post 등록 페이지로의 접근을 거절했다.

    이제 로그인하지 않은 상태로 http://localhost:3000/submit/ URL에 접근 요청을 하면 다음과 같은 장면을 보게 된다:

    Access denied 템플릿
    Access denied 템플릿

    이러한 라우팅 hook의 좋은 점은 이것도 반응형이라는 점이다. 이 의미는 사용자가 로그인할 때, 콜백이나 그 비슷한 것을 생각할 필요가 없다는 것이다. 사용자의 로그인 상태가 변할 때, 라우터의 페이지 템플릿은 accessDenied에서 postSubmit로 순간적으로 변하는 데, 이 과정에 이를 다루는 어떤 명시적 코드도 작성할 필요가 없다 (그리고, 이것은 다른 브라우저 탭에서도 일어난다).

    로그인한 다음, 해당 페이지를 새로고침해보자. 접근거절 템플릿이 짧은 순간에 새 글쓰기 페이지로 바뀌어 나타나는 것을 보게 될 것이다. 이렇게 되는 이유는 미티어가 템플릿 렌더링을 가능한 신속하게 수행하기 때문인데, 이 시점은 서버와 통신하기 전이며, 해당 사용자가 현재(브라우저의 로컬 저장소에 저장되어) 존재하는 지를 체크하기도 전이다.

    이 문제(이것은 클라이언트와 서버 사이의 대기 시간에 대한 복잡한 문제를 처리할 때면 자주 겪는 일상적인 종류의 문제이기도 하다)를 해결하기 위해서, 우리는 사용자가 접속한 상태인지를 알아보기 위해 기다리는 짧은 시간 동안 구동화면을 보여줄 것이다.

    결국 이 시점에서 우리는 사용자가 정확한 로그인 인증 정보를 가지고 있는지를 모르며, 우리가 이를 확인할 때까지 우리는 accessDeniedpostSubmit 템플릿 중 어느 것을 보여줄 것인지를 결정할 수 없다.

    그래서 우리는 hook 처리 부분을 변경하여, Meteor.loggingIn()true인 동안 로딩중 템플릿을 사용한다:

    //...
    
    var requireLogin = function() {
      if (! Meteor.user()) {
        if (Meteor.loggingIn()) {
          this.render(this.loadingTemplate);
        } else {
          this.render('accessDenied');
        }
      } else {
        this.next();
      }
    }
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
    
    lib/router.js

    Commit 7-4

    로그인을 기다리는 동안 로딩중 화면을 보인다.

    링크 숨기기

    사용자가 로그아웃 상태에서 이 페이지에 실수로 접근하는 것을 방지하는 가장 쉬운 방법은 해당 링크를 숨기는 것이다. 이것은 매우 쉽다:

    //...
    
    <ul class="nav navbar-nav">
      {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
    </ul>
    
    //...
    
    client/templates/includes/header.html

    Commit 7-5

    로그인 상태인 경우만 post 등록 링크를 보인다.

    currentUser 헬퍼는 accounts 패키지에서 제공하며 Meteor.user()와 동등한 handlebars이다. 이것은 반응형이므로 이 링크는 로그인 여부에 따라서 보여지거나 숨겨질 것이다.

    미티어 메서드(Method): 더 나은 추상화와 보안

    이제 로그아웃 상태의 사용자의 새 post 등록에 대한 접근을 막았다. 그리고 그들이 속임수를 쓰거나 콘솔을 통해서 글쓰기를 시도하는 것까지 차단했다. 하지만, 아직도 우리가 다루어야 할 몇 가지가 있다:

    • Post에 등록일시를 기록하기.
    • 동일한 URL로 동시에 두 번 이상 글쓰기 시도를 차단할 것.
    • Post의 저자에 대한 상세정보(ID, username, 등)를 추가하는 것.

    이 모든 작업을 submit 이벤트 핸들러에서 처리할 수 있다고 생각할 지 모르겠다. 그런데, 실제로 이를 해보면 여러 가지 문제가 발생하는 것을 겪게 된다.

    • 등록일시의 경우, 사용자 컴퓨터의 시간이 정확하다는 것을 전제로 해야 하지만, 이것이 항상 그렇다고 보장할 수 없다.
    • 클라이언트들은 그 사이트로 등록되는 모든 URL에 대하여 알지 못한다. 그들은 그들이 현재 볼 수 있는(이 부분이 얼마나 정확하게 작동하는 지는 나중에 볼 것이다) post들에 대하여만 알기 때문에, 클라이언트 쪽에서 URL의 유일성을 강제할 방법은 없다.
    • 마지막으로, 우리가 클라이언트 쪽에서 사용자 정보를 추가할 수는 있지만, 그 정확성을 강제할 수는 없다. 사용자 중에는 브라우저 콘솔을 사용하는 사람들도 있기 때문이다.

    이런 이유로, 이벤트 핸들러를 단순하게 하는 것이 바람직하며, 컬렉션에 가장 기본적인 삽입이나 수정하는 것 이상의 무엇을 한다면 메서드(Method)를 사용하도록 한다.

    미티어 메서드는 클라이언트에서 호출하는 서버쪽 함수이다. 이것은 매우 낯설다 - 사실은, 보이지는 않았지만, Collectioninsert, update 그리고 remove 함수는 모두 메서드이다. 이들을 생성하는 방법을 알아보자.

    post_submit.js로 돌아가보자. Posts 컬렉션에 직접 삽입하지 않고, post라는 이름의 메서드를 호출한다:

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        Meteor.call('postInsert', post, function(error, result) {
          // display the error to the user and abort
          if (error)
            return alert(error.reason);
    
          Router.go('postPage', {_id: result._id});  
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Meteor.call 함수의 첫 매개변수에는 메서드 이름을 넣는다. 이 함수를 호출할 때, 여러 매개변수를 전달(이 경우, 입력 폼에서 구성한 post객체)할 수 있다. 그리고 마지막에 콜백함수를 추가하는데 이 함수는 서버쪽의 메서드가 수행된 다음에 실행된다.

    Meteor method 콜백은 항상 2개의 매개변수 errorresult를 가진다. 어떤 이유로든 error 매개변수가 있으면, 사용자에게 (콜백을 취소하기 위하여 return을 통해서) 경고를 보낸다. 만약 모든 것이 잘 돌아가면, 사용자를 새로 만든 post 토론 페이지로 redirect한다.

    보안 검사

    이 기회에 우리는 audit-argument-checks 패키지를 이용하여 method에 보안을 추가하려고 한다.

    이 패키지는 미리 정의된 패턴에 따라서 JavaScript 객체를 검사한다. 예제에서 우리는 이를 이용하여 method를 호출하는 사용자가 제대로 로그인된 상태인지를 (Meteor.userId()String인지를 확인하여) 검사한다. 그리고 매개변수로 전달되는 postAttributes 객체의 titleurl이 문자열인지도 검사한다. 그래서 데이터베이스에 아무 데이터나 들어가지 않도록 한다.

    이제 lib/collections/posts.js 파일에 postInsert 메서드를 정의하자. posts.js에서 allow() 블럭은 삭제하는데, 이것은 Meteor Method는 이것들을 건너뛰기 때문이다.

    그 다음엔 postAttributes 객체를 확장하여 3개의 속성값을 추가한다: 사용자의 _id, username, 그리고 post가 등록된 시간이다. 이것을 데이터베이스이 입력하여 리턴되는 _id값을 JavaScript 객체 형태로 리턴하여 클라이언트( 이 method를 호출한 사용자)에 전달한다.

    Posts = new Mongo.Collection('posts');
    
    Meteor.methods({
      postInsert: function(postAttributes) {
        check(Meteor.userId(), String);
        check(postAttributes, {
          title: String,
          url: String
        });
    
        var user = Meteor.user();
        var post = _.extend(postAttributes, {
          userId: user._id, 
          author: user.username, 
          submitted: new Date()
        });
    
        var postId = Posts.insert(post);
    
        return {
          _id: postId
        };
      }
    });
    
    lib/collections/posts.js

    _.extend()Underscore 라이브러리의 method로서, 단순히 하나의 객체에 속성을 추가하여 “확장”하는 기능을 한다.

    Commit 7-6

    메서드를 사용하여 post를 등록한다.

    Allow/Deny여 바이 바이

    Meteor Method는 서버에서 실행된다. 그러므로 Meteor는 이것을 신뢰할 수 있다고 본다. 그래서 Meteor method는 allow/deny 콜백을 건너뛴다.

    서버에서도 모든 insert, update, 또는 remove 앞에 임의의 코드를 실행시키고 싶다면, collection-hooks 패키지를 검토해보길 권한다.

    이중 등록 방지

    Method를 마무리하기 전에 한 가지만 더 지적하고자 한다. 어떤 post가 이미 등록된 것과 같은 URL을 가지는 경우, 우리는 이를 추가하지 않고 대신에 기존의 post로 사용자를 redirect할 것이다.

    Meteor.methods({
      postInsert: function(postAttributes) {
        check(this.userId, String);
        check(postAttributes, {
          title: String,
          url: String
        });
    
        var postWithSameLink = Posts.findOne({url: postAttributes.url});
        if (postWithSameLink) {
          return {
            postExists: true,
            _id: postWithSameLink._id
          }
        }
    
        var user = Meteor.user();
        var post = _.extend(postAttributes, {
          userId: user._id, 
          author: user.username, 
          submitted: new Date()
        });
    
        var postId = Posts.insert(post);
    
        return {
          _id: postId
        };
      }
    });
    
    lib/collections/posts.js

    데이터베이스에서 동일한 URL을 가지는 post를 검색한다. 하나라도 발견되면, 해당 post의 _id와 함께 postExists: true 플래그를 추가하여 리턴함으로써 클라이언트가 이 특별한 상황을 알도록 한다.

    그리고 return을 호출하였으므로, method는 insert 문을 실행하지 않고 우아하게 이중 등록을 방지하면서 실행을 마치게 된다.

    이제 남은 할 일은 postExists 정보를 사용하여 클라이언트 쪽에서 이벤트 핸들러를 통해서 경고 메시지를 보여주는 것이다:

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        Meteor.call('postInsert', post, function(error, result) {
          // display the error to the user and abort
          if (error)
            return alert(error.reason);
    
          // show this result but route anyway
          if (result.postExists)
            alert('This link has already been posted');
    
          Router.go('postPage', {_id: result._id});  
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Commit 7-7

    post URL의 유일함을 구현한다.

    Post 정렬

    Post의 등록 일시가 있으므로, 이 속성을 아용하여 정렬을 구현할 수 있다. 이를 위해서, 우리는 Mongo의 sort 연산자를 사용하는데, 이것은 해당 키와 오름, 내림 차순 지정 기호로 정렬기능을 수행한다.

    Template.postsList.helpers({
      posts: function() {
        return Posts.find({}, {sort: {submitted: -1}});
      }
    });
    
    client/templates/posts/posts_list.js

    Commit 7-8

    등록일시로 post목록을 정렬한다.

    약간의 작업이 들었지만, 마침내 우리는 앱에 사용자들이 콘텐츠를 안전하게 등록시키는 사용자 인터페이스를 구현했다!

    하지만, 사용자가 콘텐츠를 등록하게 하는 어떤 앱이든 편집하고 삭제하는 기능도 구현해주어야 한다. 이것은 다음 장에서 다룰 것이다.