오류

9

번역 완료율

이 장에서는:

  • 오류와 메시지를 보여주는 더 나은 구조를 만든다.
  • 사용자가 오류를 보았는 지를 알기 위한 `Template.rendered` 사용법을 배운다.
  • 오류가 한 번만 보여지게 라우터 필터를 사용한다.
  • 사용자의 폼 입력 처리과정에서 문제가 발생했을 때 사용자에게 경고하기 위해 그저 브라우저의 표준 alert() 대화상자를 사용하는 것은 좀 불만스럽다. 이것은 확실히 좋은 UX는 아니다. 우리는 더 잘 할 수 있다.

    대신, 사용자에게 그 흐름을 깨지 않으면서 진행 내용을 알려주는 보다 발전된 형태를 구현하는 융통성있는 오류 보고 체계를 구현해보자.

    우리는 브라우저 화면의 우상단에 오류를 보여주는 간단한 시스템을 구축하려고 한다. 이것은 인기있는 Mac 앱인 Growl과 유사하다.

    로컬 컬렉션 소개

    시작하기에 앞서 오류를 저장할 컬렉션을 만든다. 오류는 현재 세션에서만 적용되고 어쨋든 저장할 필요가 없으므로, 새로운 형태로 구현하는데, 그것은 로컬 컬렉션을 만드는 것이다. 이 의미는 Errors 컬렉션은 브라우저에만 존재하며, 서버와 동기화하지 않을 것이라는 점이다.

    이를 완성하기 위해, 우리는 (클라이언트에서만 존재하는 컬렉션을 만들기 위해서) client 디렉토리 내부에 오류 컬렉션을 만드는 데, 그 MongoDB 컬렉션 이름을 null로 지정한다 (따라서 이 컬렉션의 데이터는 서버의 데이터베이스에는 절대로 저장되지 않을 것이다):

    // Local (client-only) collection
    Errors = new Mongo.Collection(null);
    
    client/helpers/errors.js

    컬렉션이 만들어졌으니, 우리는 여기에 오류를 추가할 때 호출하는 throwError 함수를 추가할 수 있다. 이 컬렉션은 현 사용자에 “국한"되므로, allowdeny, 또는 그 밖의 보안 관련사항에 대해서 걱정할 필요없다.

    throwError = function(message) {
      Errors.insert({message: message});
    };
    
    client/helpers/errors.js

    오류를 저장하는 데에 로컬 컬렉션을 이용하는 장점은 모든 컬렉션처럼 이것이 반응형이라는 것이다 – 이 의미는 다른 컬렉션 데이터를 보여주는 것과 마찬가지로 오류를 보여주는 것을 선언적 방법으로 구현할 수 있다는 것이다.

    오류 보여주기

    우리는 메인 레이아웃의 상단에 오류를 보여주려고 한다:

    <template name="layout">
      <div class="container">
        {{> header}}
        {{> errors}}
        <div id="main" class="row-fluid">
          {{> yield}}
        </div>
      </div>
    </template>
    
    client/templates/application/layout.html

    이제 errors.htmlerrorserror 템플릿을 만든다:

    <template name="errors">
      <div class="errors">
        {{#each errors}}
          {{> error}}
        {{/each}}
      </div>
    </template>
    
    <template name="error">
      <div class="alert alert-danger" role="alert">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{message}}
      </div>
    </template>
    
    client/templates/includes/errors.html

    두 개의 템플릿

    하나의 파일에 두 개의 템플릿이 들어있다. 지금까지 우리는 "파일 하나에, 템플릿 하나"를 고수하여 왔다. 그런데 미티어에서는 하나의 파일에 모든 템플릿을 다 넣어도 잘 작동한다 (비록 이렇게 하면 main.html이 무척 혼란스럽긴 하겠지만 말이다!).

    이 때, 두 템플릿 모두 짧기 때문에 예외적으로 그들을 한 파일에 다 넣어서 관리가 용이하게 한다.

    이제 템플릿 헬퍼를 만들고 계속 진행한다!

    Template.errors.helpers({
      errors: function() {
        return Errors.find();
      }
    });
    
    client/templates/includes/errors.js

    이제 우리는 수작업으로 오류 메시지를 테스트해 볼 수 있다. 브라우저 콘솔을 열고 다음과 같이 입력해보자:

    throwError("I'm an error!");
    
    오류 메시지 테스트하기
    오류 메시지 테스트하기

    Commit 9-1

    기본적인 오류 보고.

    두 종류의 오류

    이 시점에서 "앱 수준"의 오류와 "코드 수준"의 오류 사이의 차이점을 알아두는 것이 중요하다.

    앱 수준(app-level) 의 오류는 일반적으로 이용자가 일으킨다. 그리고 이용자가 그것에 순서대로 대응할 수 있다. 여기에는 유효성 오류, 접근 제한 오류, "not found” 오류, 등이 포함된다. 이들은 이용자에게 무슨 문제이든 그들에게 닥친 문제점을 해결하는 데 도움을 주기 위하여 보여주는 오류를 의미한다.

    한 편, 코드 수준(code-level)의 오류는 코드상의 실제 버그에 의해서 의도하지 않게 발생하는 것으로서 이용자에게 직접 보여주기를 원하지 않으며, 대신 (Kadira와 같은) 서드파티 오류 추적 서비스 등으로 관리되기를 원한다.

    이 장에서는, 우리는 버그를 잡기 위해서가 아니라, 전자에 초점을 맞추어 다룰 것이다.

    오류 만들기

    이제 오류를 화면에 보여주는 방법은 알게 되었다, 그러나 여전히 뭔가 더 만들어야 할 것들이 있다. 실제로 우리는 이미 훌륭한 오류 시나리오 – 중복 post 경고 –를 구현한 바 있다. 우리는 단순하게 postSubmit 이벤트 핸들러에 있는 alert 호출을 우리가 방금 구성한 새로운 throwError 함수로 바꾸어 볼 것이다.

    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 throwError(error.reason);
    
          // show this result but route anyway
          if (result.postExists)
            throwError('This link has already been posted');
    
          Router.go('postPage', {_id: result._id});  
        });
      }
    });
    
    client/templates/posts/post_submit.js

    똑같은 작업을 postEdit 이벤트 헬퍼에서도 구현한다:

    Template.postEdit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var currentPostId = this._id;
    
        var postProperties = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        }
    
        Posts.update(currentPostId, {$set: postProperties}, function(error) {
          if (error) {
            // display the error to the user
            throwError(error.reason);
          } else {
            Router.go('postPage', {_id: currentPostId});
          }
        });
      },
      //...
    });
    
    client/templates/posts/post_edit.js

    Commit 9-2

    오류 보고를 실제로 사용한다.

    시도해보자: URL http://meteor.com를 입력하여 post를 등록해보자. 이 URL이 이미 존재하는 post에 등록되었기 때문에, 다음과 같은 화면을 볼 것이다:

    오류화면 보이기
    오류화면 보이기

    오류 지우기

    여기서 오류 메시지가 몇 초 후에 저절로 사라지는 것을 볼 수 있을 것이다. 이것은 순전히 이 책의 초반부에 우리가 추가했던 스타일시트에 포함된 약간의 CSS 마술 덕분이다:

    @keyframes fadeOut {
      0% {opacity: 0;}
      10% {opacity: 1;}
      90% {opacity: 1;}
      100% {opacity: 0;}
    }
    
    //...
    
    .alert {
      animation: fadeOut 2700ms ease-in 0s 1 forwards;
      //...
    }
    
    client/stylesheets/style.css

    우리는 (전체 애니메이션에서 0%, 10%, 90%, 그리고 100%의 값을 가지는) 투명도 속성에 대한 4개의 키프레임을 지정하여 fadeOut CSS 애니메이션을 정의하고 있다. 그리고 이 애니메이션을 .alert 클래스에 적용하고 있다.

    이 애니메이션은 2700 밀리초동안을 실행하며, ease-in 방식을 적용하여, 0초의 지연시간으로 실행되고, 한 번만 실행하며, 마지막에 실행이 완료된 다음에는 마지막 키프레임 상태에 있게 된다.

    애니메이션 대 애니메이션

    우리가 왜 미티어 자체로 제어되는 애니메이션 대신에 (미리 결정되고 앱의 통제 밖에 있는) CSS기반의 애니메이션을 사용하는 지에 대하여 궁금해 할 지 모르겠다.

    미티어는 애니메이션을 삽입하는 기능을 제공하지만, 우리는 이 챕터에서 오류에 초점을 맞추려고 하였다. 그래서 여기서는 “멍청한” CSS 애니메이션을 사용하고 나중에 나오는 애니메이션 챕터에서 그 멋진 물건을 다루도록 할 것이다.

    이것은 잘 작동하지만, (이를테면, 동일한 링크를 세 번 클릭하여) 복수의 오류를 구동하면 이들이 상단에 차례로 쌓이는 것을 볼 수 있다:

    Stack overflow.
    Stack overflow.

    이것은 .alert 엘리먼트가 보이기에는 사라지고 있지만, DOM에서는 여전히 존재하고 있기 때문이다. 이 문제를 해결하여야 한다.

    이것이 바로 미티어가 반짝이는 바로 그런 상황이다. Errors 컬렉션은 반응형이기 때문에, 이 지나간 오류들을 제거하기 위해서 우리가 할 일은 컬렉션에서 제거하기만 하면 된다!

    우리는 Meteor.setTimeout을 사용하여 지정한 시간 후에 (이 경우는 3000밀리초) 구동되도록 할 것이다.

    Template.errors.helpers({
      errors: function() {
        return Errors.find();
      }
    });
    
    Template.error.rendered = function() {
      var error = this.data;
      Meteor.setTimeout(function () {
        Errors.remove(error._id);
      }, 3000);
    };
    
    client/templates/includes/errors.js

    Commit 9-3

    3초 후에 오류가 모두 사라진다.

    rendered 콜백은 템플릿이 브라우저에 렌더링된 후에 한 번 구동된다. 콜백 내부에서 this는 현 템플릿 인스턴스를 가리킨다. 그리고 this.data를 통해서 우리는 현재 렌더링되는 객체의 데이터에 (이 경우, 오류 객체) 접근할 수 있다.

    유효화 구현

    지금까지 우리는 폼에서 어떤 유효화도 적용하지 않았다. 최소한의 수준에서, 우리는 이용자들이 post의 URL과 제목을 입력하기를 바란다. 그래서 이용자들이 그렇게 하도록 해보자.

    우리는 입력이 누락된 필드를 찾아내기 위해서 두 가지를 할 것이다: 첫째, 모든 문제가 있는 폼 필드의 부모 div 엘리먼트에 has-error CSS 클래스를 지정한다. 둘째, 해당 필드 아랫쪽에 도움말 오류 메시지를 보여준다.

    이를 위해서, postSubmit 템플릿에 새로운 헬퍼를 적용한다:

    <template name="postSubmit">
      <form class="main form">
        <div class="form-group {{errorClass 'url'}}">
          <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"/>
              <span class="help-block">{{errorMessage 'url'}}</span>
          </div>
        </div>
        <div class="form-group {{errorClass 'title'}}">
          <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"/>
              <span class="help-block">{{errorMessage 'title'}}</span>
          </div>
        </div>
        <input type="submit" value="Submit" class="btn btn-primary"/>
      </form>
    </template>
    
    client/templates/posts/post_submit.html

    여기서 각 헬퍼마다 (urltitle) 매개변수를 전달하는 것이 유의하기 바란다. 이것은 동시에 동일한 헬퍼를 재사용하는 데, 매개변수에 따라서 그 작동이 다르게 적용된다.

    이제부터 재미있는 부분이다: 이 헬퍼들을 실제로 작동시켜보자.

    어떠한 잠재적인 오류 메시지이든 담는 postSubmitErrors 객체를 Session에 저장한다. 이용자가 폼을 입력하면, 이 객체가 변경되면서, 차례로 폼의 마크업과 콘텐츠를 반응형으로 수정한다.

    우선, postSubmit 템플릿이 생성되는 시점에 객체를 초기화한다. 이로써 이용자는 이 페이지에 이전 방문했을 때 남아있던 예전 오류 메시지를 보이지 않게 한다.

    그리고는 두 템플릿 헬퍼를 정의한다. 이 둘은 모두 Session.get('postSubmitErrors') 객체의 field 속성을 바라본다 (여기서 field는 우리가 헬퍼를 호출하는 위치에 따라서 url이거나 title이다).

    errorMessage가 단순히 메시지 자체를 리턴하는 반면, errorClass는 메시지의 존재를 검사하여 만약 메시지가 있다면 has-error를 리턴한다.

    Template.postSubmit.created = function() {
      Session.set('postSubmitErrors', {});
    }
    
    Template.postSubmit.helpers({
      errorMessage: function(field) {
        return Session.get('postSubmitErrors')[field];
      },
      errorClass: function (field) {
        return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
      }
    });
    
    client/templates/posts/post_submit.js

    브라우저 콘솔을 열고 다음 코드를 입력하면 헬퍼가 제대로 작동하는 것을 테스트해 볼 수 있다:

    Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
    
    Browser console
    빨간 경고! 빨간 경고!
    빨간 경고! 빨간 경고!

    다음 단계는 postSubmitErrors Session 객체를 폼에 연동하는 작업이다.

    이 작업을 하기 전에, post 객체를 바라보는 posts.jsvalidatePost 함수를 새로 만든다. 그리고 (말하자면, title이나 url 필드가 누락되었는 지를 담는) 적절한 오류를 담는 errors 객체를 리턴한다:

    //...
    
    validatePost = function (post) {
      var errors = {};
    
      if (!post.title)
        errors.title = "Please fill in a headline";
    
      if (!post.url)
        errors.url =  "Please fill in a URL";
    
      return errors;
    }
    
    //...
    
    lib/collections/posts.js

    이 함수를 postSubmit 이벤트 핸들러에서 호출한다:

    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()
        };
    
        var errors = validatePost(post);
        if (errors.title || errors.url)
          return Session.set('postSubmitErrors', errors);
    
        Meteor.call('postInsert', post, function(error, result) {
          // display the error to the user and abort
          if (error)
            return throwError(error.reason);
    
          // show this result but route anyway
          if (result.postExists)
            throwError('This link has already been posted');
    
          Router.go('postPage', {_id: result._id});  
        });
      }
    });
    
    client/templates/posts/post_submit.js

    유의할 것은 오류가 존재하는 경우 헬퍼의 실행을 취소하기 위해서 return을 사용하는 것이지, 실제로 이 값을 다른 어디로 리턴하려고 그러는 것이 아니라는 점이다.

    Caught red-handed.
    Caught red-handed.

    서버에서의 유효성 검사

    다 된 것은 아직 아니다. 우리는 클라이언트에서 URL과 title의 존재에 대하여 유효성 검사를 하고 있다. 하지만 서버에서는 어떻게 될까? 결국, 누군가는 여전히 브라우저 콘솔에서 postInsert 메서드를 수작업으로 호출하여 빈 post를 입력하려고 시도할 수 있다.

    이 경우에 서버에서 어떤 오류 메시지도 보여줄 필요는 없겠지만, 우리는 동일한 validatePost 함수를 이용할 수 있다. 이번의 경우만 제외하고, 우리는 이것을 이벤트 헬퍼에서 뿐만아니라, postInsert method에서도 호출할 것이다.

    Meteor.methods({
      postInsert: function(postAttributes) {
        check(this.userId, String);
        check(postAttributes, {
          title: String,
          url: String
        });
    
        var errors = validatePost(postAttributes);
        if (errors.title || errors.url)
          throw new Meteor.Error('invalid-post', "You must set a title and URL for your post");
    
        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

    또 다시, 이용자들이 “You must set a title and URL for your post” 메시지를 보아야 하는 것은 절대 아니다. 이것은 우리가 공들여 구현한 사용자 인터페이스를 우회하여, 직접 콘솔을 통해서 접근하려는 그 누군가에게만 보여질 것이다.

    이를 테스트하기 위해서 브라우저 콘솔을 열고 URL이 없는 post를 등록해보자:

    Meteor.call('postInsert', {url: '', title: 'No URL here!'});
    

    작업이 제대로 되었다면, “You must set a title and URL for your post” 메시지와 함께 무서운 코드 덩이가 되돌아 올 것이다.

    Commit 9-4

    등록시에 post 콘텐츠의 유효성 검사.

    유효성 편집

    이 작업을 post 수정 폼에도 동일한 유효성 검사과정을 적용한다. 코드는 매우 비숫하다: 먼저 템플릿:

    <template name="postEdit">
      <form class="main form">
        <div class="form-group {{errorClass 'url'}}">
          <label class="control-label" for="url">URL</label>
          <div class="controls">
              <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
              <span class="help-block">{{errorMessage 'url'}}</span>
          </div>
        </div>
        <div class="form-group {{errorClass 'title'}}">
          <label class="control-label" for="title">Title</label>
          <div class="controls">
              <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
              <span class="help-block">{{errorMessage 'title'}}</span>
          </div>
        </div>
        <input type="submit" value="Submit" class="btn btn-primary submit"/>
        <hr/>
        <a class="btn btn-danger delete" href="#">Delete post</a>
      </form>
    </template>
    
    client/templates/posts/post_edit.html

    그리고 템플릿 헬퍼:

    Template.postEdit.created = function() {
      Session.set('postEditErrors', {});
    }
    
    Template.postEdit.helpers({
      errorMessage: function(field) {
        return Session.get('postEditErrors')[field];
      },
      errorClass: function (field) {
        return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
      }
    });
    
    Template.postEdit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var currentPostId = this._id;
    
        var postProperties = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        }
    
        var errors = validatePost(postProperties);
        if (errors.title || errors.url)
          return Session.set('postEditErrors', errors);
    
        Posts.update(currentPostId, {$set: postProperties}, function(error) {
          if (error) {
            // display the error to the user
            throwError(error.reason);
          } else {
            Router.go('postPage', {_id: currentPostId});
          }
        });
      },
    
      'click .delete': function(e) {
        e.preventDefault();
    
        if (confirm("Delete this post?")) {
          var currentPostId = this._id;
          Posts.remove(currentPostId);
          Router.go('postsList');
        }
      }
    });
    
    client/templates/posts/post_edit.js

    Post 등록 폼에서 우리가 했던 것처럼, 서버에서도 post에 대한 유효성 검사를 하려고 한다. 명심할 것은 post 수정은 method를 사용하지 않고, 클라이언트에서 직접 update를 호출한다는 점이다.

    이것은 대신에 새로운 deny 콜백을 추가해야 한다는 사실을 의미한다:

    //...
    
    Posts.deny({
      update: function(userId, post, fieldNames, modifier) {
        var errors = validatePost(modifier.$set);
        return errors.title || errors.url;
      }
    });
    
    //...
    
    lib/collections/posts.js

    매개변수 post는 기존의 post 값을 가리킨다. 이 경우, 우리는 수정본에 대한 유효성을 검사하고자 한다. 그래서 (Posts.update({$set: {title: ..., url: ...}})에서와 마찬가지로) modifier$set 속성의 콘텐츠에 대하여 validatePost를 호출한다

    이것이 동작하는 이유는 modifier.$set가 전체 post 객체가 그러하듯이 동일한 두 개의 속성 titleurl을 담고 있기 때문이다. 물론, title 만 또는 url만을 수정하는 부분 수정은 실패하지만, 사실 이것은 별 이슈는 아니다.

    여기서 deny 콜백이 두 번째라는 점을 유의할 필요가 있다. 복수의 deny 콜백을 추가하는 경우, 그들중의 어느 하나만 true를 리턴해도 실패한다. 이 경우, updatetitleurl 필드 모두 empty값이 아니고, 이 둘만을 수정하려고 할 때에만 성공할 것이라는 것을 의미한다.

    Commit 9-5

    수정할 때 post contents의 유효성을 검사한다.