Discover Meteor

Building Real-Time JavaScript Web Apps

서론

1

간단한 사고 실험을 해보자. 컴퓨터에서 두 개의 윈도우로 동일한 폴더를 연다.

이제 한 쪽의 윈도우에서 파일을 하나 삭제한다. 다른 창에서도 그 파일이 삭제되었을까?

실제로 어떻게 되는지 해 볼 필요도 없다. 로컬 파일 시스템에서 무언가를 수정하면, 그 변화는 새로 고침을 하거나 콜백을 호출하거나 하지 않아도 어디서나 적용된다. 이것은 그냥 일어난다.

그런데, 동일한 시나리오가 웹에서는 어떻게 되는지 생각해보자. 예를 들어, 두 개의 브라우저 윈도우를 열고 동일한 워드프레스 관리자 사이트를 열어, 그 중 하나의 브라우저에서 새 글쓰기를 한다. 데스크탑에서와 달리, 다른 브라우저 창에서는 아무리 오래 기다려도 직접 새로고침을 하기 전에는 그 변경 내용이 반영되지 않는다.

수 년 동안, 우리는 웹사이트란 간단히 말하면 사용자가 개별 화면들과 통신하는 형태라는 생각에 익숙해져있다.

그러나 미티어는 웹을 실시간이며 반응형으로 만들어 기존의 웹에 도전하는 새로운 흐름의 프레임워크이자 기술이다.

미티어(Meteor)란 무엇인가?

미티어는 실시간 웹 앱을 구축할 목적으로 Node.js 기반 위에 구축된 플랫폼이다. 이것은 데이터베이스와 사용자 인터페이스 사이에 자리하여 양쪽이 서로 동기화 상태를 유지하도록 한다.

미티어는 Node.js 기반 위에 구축되어, JavaScript를 클라이언트와 서버 양쪽에서 사용한다. 더욱이, 미티어는 양쪽의 환경사이에서 코드를 공유할 수도 있다.

이런 결과로 미티어는 웹 개발 과정의 많은 일상적인 귀찮고 어려운 일들을 추상화함으로써 매우 강력하면서도 간결한 플랫폼이 되었다.

왜 미티어인가?

그렇다면 왜 다른 웹 프레임워크가 아닌 미티어를 배워야 하는가? 미티어의 다양한 기능들은 논외로 하더라도, 결국 이 하나에 이른다고 믿는다: 미티어는 배우기 쉽다.

다른 어떤 프레임워크보다 더욱 그렇듯이, 미티어는 수 시간정도면 실시간 웹 앱을 개발하고 실행하는 것이 가능하다. 그리고 프론트엔드 개발 경험이 있다면, 이미 Javascript에 익숙할 것이므로 새로운 언어를 배울 필요도 없다.

미티어가 독자의 요구에 이상적인 프레임워크일 수도 있고, 아닐 수도 있다. 하지만 며칠 밤의 또는 주말 기간 정도의 코스로 시작할 수 있으니, 스스로 해보고 답을 구해보는 것이 어떨까?

왜 이 책인가?

지난 몇 년간, 우리는 웹과 모바일에서 상업적이거나 오픈소스프로젝트에 이르기까지 수많은 미티어 프로젝트를 수행해왔다.

우리는 수많은 것을 배웠다. 그러나 우리의 궁금증에 해답을 찾는 것이 항상 쉬운 것은 아니었다. 우리는 많은 다른 소스를 가져와서 조합해야 했고, 많은 경우에는 직접 개발해야 했다. 그래서 이 책을 통해서 우리는 그 경험을 공유하기를 원했다. 그리고 단계별 안내서를 만들어 독자가 완성된 Meteor 앱을 처음부터 끝까지 따라가면서 구축하도록 하였다.

우리가 구축하려는 앱은 Hacker NewsReddit과 같은 소셜 뉴스 사이트의 단순화 버전으로, (이 앱의 빅브라더인 미티어 오픈소스 앱 Telescope에서 유추하여) Microscope라고 이름을 지었다. 이를 구축하는 동안, 우리는 미티어 앱의 구축에 투입되는 사용자 계정, 미티어 컬렉션, 라우팅 등과 같은 모든 요소들을 다룰 것이다.

이 책은 누구를 위한 책인가?

이 책을 저술하는 동안 우리 목표의 하나는 접하기 쉽고 이해하기 쉽게 하는 것이었다. 그래서 여러분이 미티어, Node.js, MVC 프레임워크, 심지어는 일반적인 서버 사이트 코딩에 대한 경험이 없어도 따라갈 수 있을 것이다.

한 편, 우리는 여러분이 기본적인 JavaScript 문법과 개념에 대하여는 잘 알고 있다고 가정한다. 하지만, 여러분이 일부 jQuery 코드를 살펴보았거나 브라우저의 개발자 콘솔로 작업을 해 보는 정도만 되어도 충분하다.

이 책은 누구를 위한 것인가?

우리는 이 책을 가까이하기 쉽고 이해하기 쉽게 저술하는 것이 목표였다. 그러니, 독자가 Meteor, Node.js, MVC 프레임워크, 또는 일반적인 서버 쪽의 코딩에 경험이 없어도 그냥 따라갈 수 있을 것이다.

한 편, 우리는 독자가 기본적인 JavaScript의 문법과 개념에는 친숙하다고 가정한다. 만약 독자가 jQuery 코드를 파헤쳐보았거나 또는 브라우저 콘솔에서 놀아보았다면, 이 정도면 되었다.

저자 소개

우리가 누군지, 신뢰할만한 지 궁금한 분들을 위해서, 우리에 대한 약간의 배경 설명을 하겠다.

Tom Coleman은 품질과 사용자 체험에 초점을 두는 웹 개발 샵인 Percolate Studio의 일원이다. 그는 Atmosphere 패키지 저장소의 운영그룹의 일원이며, 또한 많은 다른 미티어 오픈 소스 프로젝트(Iron Router와 같은)를 지원하고 있다.

Sacha GreifHipmunkRubyMotion와 같은 스타트업에서 제품과 웹 디자이너로 일했다. 그는 TelescopeSidebar (Telescope에 기반한)의 제작자이며 Folyo의 설립자이다.

장과 사이드바

우리는 이 책이 미티어 입문자와 고급 프로그래머 모두에게 유용하도록, 각 장을 두 범주로 구분하였다: 정규 장(1부터 14까지)과 사이드바(.5 숫자).

정규 장은 앱의 구축과정을 독자들이 따라가도록 한다. 그리고 독자들이 너무 상세한 부분에 깊이 빠져들지 않도록 가장 중요한 단계들을 설명하여 가능한 빨리 독자가 조작하도록 할 것이다.

한 편, 사이드바는 미티어의 복잡한 내부를 깊이있게 파고 들어가서 이면의 실제 동작하는 부분에 대한 이해를 높이도록 도울 것이다.

그러니 독자가 입문자라면, 처음 읽을 때에는 사이드바는 건너뛰고, 미티어 전체를 읽어본 후에 나중에 돌아와서 읽어보기 바란다.

커밋과 시점별 소스

프로그래밍 책을 따라 읽어가다가 어느 순간 독자가 작성한 코드가 예제와 맞지 않음을 깨닫게 되고 더 이상 제대로 작동하지 않는 것보다 나쁜 경우는 없다.

이렇게 되지 않도록, 우리는 Microscope를 위한 Github 저장소를 설치했다. 그리고 모든 코드 변경시마다 이를 커밋한 git에 대한 링크를 제공할 것이다. 또한, 각 커밋은 그 커밋 시점의 앱의 실제 인스턴스에 대한 링크를 제공하여 독자가 자신의 코드와 비교할 수 있게 하였다. 아래는 실제 예제이다:

Commit 11-2

Display notifications in the header.

하지만 우리가 이 커밋 버전들을 제공한다고 해서 하나 하나 git checkout을 해야 한다는 의미는 아니다. 시간을 들여서라도 직접 코드를 수동으로 타이핑한다면 훨씬 잘 배울 것이다!

다른 리소스들

미티어의 특정 부분에 대하여 더 배우길 원하면, 공식 미티어 문서가 최고의 출발점이다.

또한 문제해결과 질문에 대하여는 Stack Overflow를 추천한다. 그리고 실시간 도움이 필요하다면 #meteor IRC channel 채널을 추천한다.

Git이 필요한가?

Git 버전 컨트롤에 익숙해지는 것이 이 책을 따라가는데 반드시 필요한 것은 아니지만, 강력하게 권고한다.

빨리 배우고 싶다면, Nick Farina의 Git Is Simpler Than You Think를 추천한다.

독자가 git 입문자라면, GitHub for Mac을 추천하는데, 이것을 사용하면 command line을 사용하지 않고 저장소를 복제하거나 관리할 수 있다.

접촉하기

시작하기

2

첫 인상은 중요하다. 미티어의 설치과정은 비교적 힘들지 않을 것이다. 대부분의 경우는 5분이내에 설치하고 실행할 수 있다.

먼저, 터미널 윈도우를 열고 다음과 같이 입력하여 미티어를 설치한다:

curl https://install.meteor.com | sh

이것으로 meteor 실행파일이 시스템에 설치되고, 미티어를 사용할 준비가 된 것이다.

미티어를 설치하지 않기

미티어를 독자의 컴퓨터에 설치할 수 없다면(혹은 설치를 원하지 않으면), Nitrous.io를 방문하여 살펴보기를 권한다.

Nitrous.io는 앱을 실행하거나 브라우저에서 바로 코드를 편집하게 하는 서비스이다. 우리는 독자가 설치하는데 도움이 되는 간단한 안내서를 작성하여 제공한다.

이 안내서의 “Installing Meteor” 섹션까지 읽은 다음, 이 장의 “간단한 앱 만들기” 섹션에서 시작하여 이 책을 다시 따라가면 된다.

간단한 앱 만들기

이제 Meteor를 설치하였으니, 앱을 만들어보자. Meteor의 명령어 도구인 meteor를 사용한다:

meteor create microscope

이 명령어는 미티어를 다운로드하고, 기본적인 설정을 수행한 다음, 미티어 프로젝트를 사용할 수 있는 상태로 만든다. 실행이 완료되면, microscope/ 디렉토리에서 아래 파일들을 볼 수 있다:

microscope.css  
microscope.html 
microscope.js   

미티어가 만든 이 앱은 몇 가지 단순한 패턴을 보여주는 간단한 보일러플레이트 애플리케이션이다.

이 앱이 기능은 별로 없어도, 실행할 수는 있다. 이 앱을 실행하려면 터미널에서 다음을 입력하면 된다:

cd microscope
meteor

이제 브라우저에서 http://localhost:3000/ (또는 http://0.0.0.0:3000/)을 입력하면 다음과 같은 화면을 볼 수 있다:

미티어에서의 Hello World.
미티어에서의 Hello World.

Commit 2-1

기본적인 microscope 프로젝트를 생성했다.

축하한다! 처음으로 미티어 앱을 실행하였다. 그리고, 이 앱을 중단하려면, 앱이 실행 중인 터미널에서 ctrl+c를 누르면 된다.

또한 Git을 사용한다면, git init으로 이 저장소를 초기화할 적절한 시점이다.

Meteorite여 안녕

한 때, 미티어가 Meteorite라는 이름을 가지는 외부 패키지 관리자에 의존하던 때가 있었다. Meteor 버전 0.9.0 이후로 Meteorite는 Meteor 본체로 흡수되어 더 이상 존속하지 않게 되었다.

그러므로 이 책에서나 또는 미티어 관련 자료를 찾아보다가 Meteorite의 mrt 명령어 도구에 대한 언급이 있다면, 이를 meteor로 바꾸면 된다.

패키지 추가

이번에는 미티어의 패키지 시스템을 이용하여 프로젝트에 Bootstrap 프레임워크를 추가할 것이다.

이것은 직접 CSS와 JavaScript 파일을 직접 추가하는 보통의 방법과 다를 것이 없지만, 미티어 커뮤니티의 한 사람인 Andrew Mao의 도움을 받아 항상 최신 상태로 유지한다 (mizzao:bootstrap-3의 “mizzao”는 패키지 제작자의 username이다).

또한 Underscore 패키지도 추가한다. Underscore는 JavaScript 유틸리티 라이브러리로 JavaScript 데이터 구조를 다룰 때 매우 유용하다.

이 글을 쓸 때까지는, underscore 패키지는 여전히 “공식” 미티어 패키지의 일부이어서, 제작자가 없다.

meteor add mizzao:bootstrap-3
meteor add underscore

Bootstrap 3를 추가하는 것에 유의하기 바란다. 이 책의 일부 화면은 Bootstrap 2를 적용했던 Microscope의 이전 버전에서 가져온 것으로 다소 다르게 보일 수 있다.

Commit 2-2

Bootstrap과 underscore 패키지를 추가했다.

Bootstrap 패키지를 추가하자마자 앱의 외형이 바뀌는 것을 볼 수 있을 것이다:

Bootstrap 적용 후
Bootstrap 적용 후

외부 자원을 포함하는 “전통적인” 방식과는 달리, 어떤 CSS 파일이나 JavaScript 파일을 링크시킬 필요는 없다. 왜냐면 미티어는 모든 것을 알아서 해주기 때문이다. 이것은 미티어 패키지의 많은 잇점 중의 하나일 뿐이다.

패키지에 대하여

미티어에서 패키지에 대하여 언급할 때는, 보다 구체적으로 지칭한다. 미티어에서는 다섯가지 형식의 패키지를 사용한다:

  • 미티어 core는 여러 개의 코어 패키지들로 나누어진다. 이들은 모든 미티어 앱에 포함된다. 여기는 신경쓸 것 없다.
  • 정규 미티어 패키지는 (이들이 클라이언트와 서버 양쪽에서 동작한다는 의미로) “isopacks” 또는 동형 패키지(isomorphic packages)라 부른다. accounts-uiappcache와 같은 퍼스트파티 패키지는 미티어 코어팀에서 담당하며 미티어에 포함되어 있다.
  • 써드파티 패키지는 다른 사용자들이 개발한 isopack으로서 미티어 패키지 서버에 업로드되어 있는 것을 가리킨다. 이들은 Atmosphere나 또는 meteor search 명령어로 찾아볼 수 있다.
  • 로컬 패키지는 여러분이 직접 작성한 패키지로 /packages 디렉토리에 넣는다.
  • NPM 패키지(Node Packaged Modules)는 Node.js 패키지이다. 이들은 미티어에서 단독으로 작동하지는 않지만, 위의 네 가지 형식의 패키지 내부에서 사용될 수 있다.

미티어 앱의 파일구조

코딩을 시작하기에 앞서, 프로젝트를 적절하게 구성해야 한다. 깔끔한 빌드를 위해 microscope 디렉토리에서 microscope.html, microscope.js, 그리고 microscope.css를 삭제하라.

다음, /microscope 디렉토리의 하위에 4개의 서브 디렉토리를 생성하라: /client, /server, /public, 그리고 /lib.

그 다음엔, 빈 파일 main.htmlmain.js파일을 /client 디렉토리에 만든다. 지금 앱이 동작하지 않는 것은 걱정할 것 없다. 다음 장에서 이 파일들의 내부를 채울 것이다.

이 디렉토리 중의 몇 개는 특별하다. 코드의 실행에 대하여 이야기하자면, 미티어에는 다음과 같은 규칙이 있다:

  • 서버에서만 실행되는 코드는 /server 디렉토리에 넣는다.
  • 클라이언트에서만 실행되는 코드는 /client 디렉토리에 넣는다.
  • 그 밖의 모든 것은 클라이언트와 서버 양쪽 모두에서 실행된다.
  • 정적 자원들(fonts, images, 등)은 /public 디렉토리에 넣는다.

그리고 미티어가 파일을 로드하는 순서를 알아두면 또한 유용할 것이다:

  • /lib 디렉토리에 있는 파일들은 그 밖의 다른 모든 파일들 보다 먼저 로드된다.
  • main.*의 이름을 가진 파일들은 그 밖의 다른 모든 파일들 보다 나중에 로드된다.
  • 그 밖의 모든 파일들은 파일명의 알파벳 순으로 로드된다.

미티어에 위와 같은 규칙이 있지만, 원하지 않으면 앱의 구성에서 미리 정해진 파일구조를 사용하도록 강제하는 것은 아니다. 우리가 제안하는 구조는 우리의 방식일 뿐, 공식적으로 채택된 규정은 아니다.

이에 대하여 더 상세한 정보를 원하면 공식 Meteor 문서를 살펴보기를 권한다.

Meteor는 MVC인가?

Ruby on Rails 같은 프레임워크에서 미티어로 옮겨왔다면, 미티어 앱이 MVC (Model View Controller) 패턴을 채택하는 지 궁금해 할 것이다.

짧게 답변한다면 “아니오"이다. Rails와는 달리, 미티어는 앱에 미리 정해진 구조를 강제하지 않는다. 그러므로 이 책에서는 우리가 가장 이해하기 쉬운 방식으로 코드를 배치할 것이며, (MVC 같은) 두문자어들에 대하여는 너무 걱정할 것 없다.

Public은 없다?

그렇다. 우리는 거짓말을 했다. Microscope 앱은 정적자원을 사용하지 않으니 public/ 디렉토리가 필요없다! 하지만, 대부분의 다른 앱들은 최소한 몇 개의 이미지는 포함할테니, 이것을 다룰 필요는 있다고 생각했다.

그런데, 숨은 .meteor 디렉토리도 보았는지 모르겠다. 이곳은 미티어가 자체 코드를 저장하는 곳인데, 여기에 있는 코드를 수정하는 것은 보통은 매우 나쁜 발상이다. 이에 대한 유일한 예외는 .meteor/packages.meteor/release 파일들인데, 이들은 각각 스마트 패키지와 사용할 미티어 버전을 기술하는데 사용된다. 패키지를 추가하거나 미티어 릴리즈를 변경할 때, 이 파일들을 검사한다면 도움이 될 것이다.

언더스코어 대 카멜표기

오래된 언더스코어(my_variable) 대 카멜케이스(myVariable) 논쟁에 대하여 할 수 있는 말은, 어떤 것을 선택하든 계속 고수한다면 별 상관이 없다는 것이다.

이 책에서 카멜케이스 방식을 사용하는 이유는 보편적으로 JavaScript에서 사용되는 방식(따지고 보면, JavaScript이지 java_script가 아니다!)이기 때문이다.

이 규칙의 유일한 예외는 파일명인데, 여기서는 언더스코어 방식(my_file.js)을 사용하며, CSS 클래스의 경우는 하이픈 방식(.my-class)을 사용한다. 이 이유는 파일시스템의 경우는, 언더스코어가 가장 일반적이기 때문이며, 반면에 CSS 문법에서는 이미 하이픈(font-family, text-align, 등)을 사용하기 때문이다.

CSS 다루기

이 책은 CSS에 관한 것이 아니다. 그러므로, 스타일링을 상세하게 다루어 진도가 느려지지 않도록, 처음부터 전체 스타일시트를 제공하기로 했다. 그러니 이것에 대하여 다시 걱정할 일은 없다.

미티어가 CSS를 자동으로 로드하고 최적화하므로, 다른 정적 자원들과는 다르게 /public 디렉토리가 아닌 /client 디렉토리에 넣는다. 바로 client/stylesheets/ 디렉토리를 만들고, 이 style.css 파일을 넣어라:

.grid-block, .main, .post, .comments li, .comment-form {
  background: #fff;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  padding: 10px;
  margin-bottom: 10px;
  -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); }

body {
  background: #eee;
  color: #666666; }

.navbar {
  margin-bottom: 10px; }
  /* line 32, ../sass/style.scss */
  .navbar .navbar-inner {
    -webkit-border-radius: 0px 0px 3px 3px;
    -moz-border-radius: 0px 0px 3px 3px;
    -ms-border-radius: 0px 0px 3px 3px;
    -o-border-radius: 0px 0px 3px 3px;
    border-radius: 0px 0px 3px 3px; }

#spinner {
  height: 300px; }

.post {
  /* For modern browsers */
  /* For IE 6/7 (trigger hasLayout) */
  *zoom: 1;
  position: relative;
  opacity: 1; }
  .post:before, .post:after {
    content: "";
    display: table; }
  .post:after {
    clear: both; }
  .post.invisible {
    opacity: 0; }
  .post.instant {
    -webkit-transition: none;
    -moz-transition: none;
    -o-transition: none;
    transition: none; }
  .post.animate{
    -webkit-transition: all 300ms 0ms;
    -webkit-transition-delay: ease-in;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in; }
  .post .upvote {
    display: block;
    margin: 7px 12px 0 0;
    float: left; }
  .post .post-content {
    float: left; }
    .post .post-content h3 {
      margin: 0;
      line-height: 1.4;
      font-size: 18px; }
      .post .post-content h3 a {
        display: inline-block;
        margin-right: 5px; }
      .post .post-content h3 span {
        font-weight: normal;
        font-size: 14px;
        display: inline-block;
        color: #aaaaaa; }
    .post .post-content p {
      margin: 0; }
  .post .discuss {
    display: block;
    float: right;
    margin-top: 7px; }

.comments {
  list-style-type: none;
  margin: 0; }
  .comments li h4 {
    font-size: 16px;
    margin: 0; }
    .comments li h4 .date {
      font-size: 12px;
      font-weight: normal; }
    .comments li h4 a {
      font-size: 12px; }
  .comments li p:last-child {
    margin-bottom: 0; }

.dropdown-menu span {
  display: block;
  padding: 3px 20px;
  clear: both;
  line-height: 20px;
  color: #bbb;
  white-space: nowrap; }

.load-more {
  display: block;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.05);
  text-align: center;
  height: 60px;
  line-height: 60px;
  margin-bottom: 10px; }
  .load-more:hover {
    text-decoration: none;
    background: rgba(0, 0, 0, 0.1); }

.posts .spinner-container{
  position: relative;
  height: 100px;
}

.jumbotron{
  text-align: center;
}
.jumbotron h2{
  font-size: 60px;
  font-weight: 100;
}

@-webkit-keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

.errors{
  position: fixed;
  z-index: 10000;
  padding: 10px;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  pointer-events: none;
}
.alert {
          animation: fadeOut 2700ms ease-in 0s 1 forwards;
  -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards;
     -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards;
  width: 250px;
  float: right;
  clear: both;
  margin-bottom: 5px;
  pointer-events: auto;
}
.posts .spinner-container{
    position: relative;
    height: 100px;
}
client/stylesheets/style.css

Commit 2-3

재구성된 파일 구조.

CoffeeScript에 대하여

이 책은 순수 JavaScript로 작성될 것이다. 하지만, CoffeeScript를 선호한다면, 미티어가 지원하는 기능을 이용하기 바란다. 그저 CoffeeScript 패키지를 추가하고 진행하면 된다:

meteor add coffeescript

배포(Deployment)

Sidebar 2.5

어떤 사람들은 프로젝트를 완벽해질 때까지 조용히 작업하기를 좋아하지만, 또 다른 사람들은 가능한 빨리 세상을 보여주고 싶어한다.

독자가 첫 번째 부류의 사람이고 당장 컴퓨터에서 개발하고 싶다면 이 장을 건너뛰고 진행해도 좋다. 그렇지 않고 시간을 들여서라도 미티어 앱의 온라인 배포 방법을 배우길 원한다면, 우리가 그렇게 해 줄 것이다.

우리는 미티어 앱을 배포하는 방법에 대한 몇 가지 다른 방법들을 배울 것이다. 편안하게 이들 각각을 개발 과정의 어느 단계에서든 사용해 보기 바란다, Microscope이나 다른 미티어 앱이든지 상관없이 말이다. 자 시작하자!

사이드바 소개

이 장은 사이드바 장이다. 사이드바 장은 좀 더 일반적인 미티어 주제를 이 책의 나머지와는 독립적으로 보다 깊이있게 다룬다.

따라서, Microscope의 구축을 계속하고 싶다면, 이 장을 건너뛰고 나중에 돌아와서 읽어도 된다.

Meteor에 배포하기

미티어 서브도메인(즉, http://myapp.meteor.com)에 배포하는 것이 가장 쉬운 선택이며, 처음 시도해 볼 것이다. 이곳은 초기에 앱을 시연하거나, 또는 스테이징 서버를 신속히 설치하기에 유용하다.

미티어에 배포하는 것은 매우 간단하다. 터미널을 열고, 앱 디렉토리로 이동하여, 다음을 입력하면 된다:

meteor deploy myapp.meteor.com

물론, “myapp” 은 독자가 정한 이름으로 바꾸되, 이미 사용되지 않은 이름이어야 할 것이다.

처음 앱을 배포하는 경우에는 Metero 계정을 만들라는 메시지가 나타날 것이다. 그리고 모든 것이 잘 진행되면, 잠시 후면 http://myapp.meteor.com 에서 앱에 접속할 수 있다.

이 호스트 인스턴스의 데이터베이스에 직접 접속하거나 앱의 도메인을 커스텀 도메인으로 설정하는 것 같은 일에, 더 자세히 알고 싶다면 미티어 공식 문서를 참조하기 바란다.

Modulus에 배포

Modulus는 Node.js 앱을 배포하는 데 훌륭한 선택이다. 이곳은 미티어를 공식 지원하는 몇 안되는 PaaS(Platform-as-a-service)중의 하나이다. 그리고 이미 상당수의 사람들이 여기에서 상품화된 미티어 앱을 구동하고 있다.

Demeteorizer

Modulus는 미티어 앱을 표준 Node.js 앱으로 변환시켜주는 도구 demeteorizer를 오픈소스로 공개했다.

먼저 계정을 생성하자. Modulus에 앱을 배포하려면 Modulus 명령어 도구를 설치하여야 한다:

npm install -g modulus

그리고 인증을 통과한다:

modulus login

이제 Modulus 프로젝트를 생성한다 (이 작업은 Modulus의 웹 대시보드에서도 할 수 있다):

modulus project create

그 다음 단계는 앱에서 접속할 MongoDB 데이터베이스를 생성하는 것이다. Modulus 자체MongoHQ를 이용하거나 다른 클라우드 MongoDB 프로바이더의 것을 이용하여 MongoDB 데이터베이스를 만든다.

MongoDB 데이터베이스를 만들었으면, Modulus의 웹 인터페이스(Dashboard > Databases > Select your database > Administration)에서 데이터베이스에 대한 MONGO_URL을 얻는다. 그리고 이를 사용하여 앱 설정을 다음과 같이 한다:

modulus env set MONGO_URL "mongodb://<user>:<pass>@mongo.onmodulus.net:27017/<database_name>"

이제 앱을 배포하면 된다. 다음과 같이 입력한다:

modulus deploy

이제 Modulus에 앱을 성공적으로 배포하였다. 로그 접속, 커스텀 도메인 설정, 그리고 SSL에 대한 더 많은 정보가 필요하면 the Modulus documentation을 참조하기 바란다.

Meteor Up

매일 새로운 클라우드 솔루션이 등장하고 있지만, 이들은 종종 다양한 문제나 한계에 직면한다. 그래서 현 시점에서 볼 때, 독자가 소유한 서버에 배포하는 것이 미티어 앱을 상품화하는 최선의 방법이다. 남은 문제는 직접 배포하는 것이 그리 간단하지 않고, 특히 상품화 수준의 배포를 원할 때 더욱 그렇다.

Meteor Up(또는 줄여서 mup)이 이 이슈를 해결하는 또 다른 시도인데, 이는 명령어 유틸리티로서 설치와 배포를 다룬다. 그래서 Meteor Up을 사용하여 Microscope를 배포하는 방법을 알아보기로 하자.

무엇보다도 먼저, 서버가 필요할 것이다. 우리가 추천하는 서비스는 월 $5부터 시작하는 Digital Ocean이나 Micro 인스턴스를 무료로 제공(바로 확장성 문제에 이르겠지만, Meteor Up으로 이것 저것 해보기는 충분하다)하는 AWS이다.

어떤 서비스를 선택하든, 세 가지를 알아야 한다: 서버의 IP 주소, 로그인(보통 root 또는 ubuntu), 그리고 비밀번호. 이들은 안전한 곳에 보관하라, 바로 필요할테니까!

Meteor Up 초기화

시작하려면, Meteor Up을 npm을 통해서 다음과 같이 설치해야 한다:

npm install -g mup

그리고 특별한 별도의 디렉토리를 생성하여 특정한 배포에 대한 Meteor Up 설정을 유지한다. 별도의 디렉토리를 사용하는 이유는 두 가지이다: 첫째, Git 저장소에 비밀번호 같은 것을 포함하는 것을 피하는 방법으로 가장 좋은데, 특히 저장소가 공개저장소일 때 그렇다.

둘째, 복수의 별도의 디렉토리를 사용함으로써, 다수의 Meteor Up 설정을 관리할 수 있다. 예를 들면, 이렇게 하여 제품화 단계, 스테이징 단계별로 편리하게 배포할 수 있다.

그러므로, 새로운 디렉토리를 생성하고, 이를 사용하여 새로운 Meteor Up 프로젝트를 초기화한다:

mkdir ~/microscope-deploy
cd ~/microscope-deploy
mup init

Dropbox로 공유하기

독자와 팀원 모두가 동일한 배포 설정을 사용하는 훌륭한 방법은 Meteor Up 설정 폴더를 Dropbox, 또는 이와 유사한 서비스 내부에 만드는 것이다.

Meteor Up 설정

새 프로젝트를 초기화하는 과정에서 Meteor Up은 두 가지 파일을 생성한다: mup.jsonsettings.json.

mup.json은 모든 배포관련 설정을 보관하는 반면, settings.json은 모든 앱 관련 설정(OAuth token, analytics token 등)을 담는다.

다음 단계는 mup.json 파일을 설정하는 것이다. 아래는 mup init 명령으로 생성되는 초기설정 상태의 mup.json 파일이다. 독자가 할 일은 빈 칸을 채우는 것이다:

{
  //server authentication info
  "servers": [{
    "host": "hostname",
    "username": "root",
    "password": "password"
    //or pem file (ssh based authentication)
    //"pem": "~/.ssh/id_rsa"
  }],

  //install MongoDB in the server
  "setupMongo": true,

  //location of app (local directory)
  "app": "/path/to/the/app",

  //configure environmental
  "env": {
    "ROOT_URL": "http://supersite.com"
  }
}
mup.json

이 설정을 하나 하나 따져보자.

서버 인증

Meteor Up은 비밀번호 기반과 사설키(PEM) 기반의 인증을 지원하므로, 거의 모든 클라우드 서비스를 이용할 수 있다.

중요 노트: 비밀번호 기반의 인증을 선택한다면 sshpass를 먼저 설치하여야 한다(이 안내를 참조하라).

MongoDB 설정

다음 단계는 앱에서 사용하는 MongoDB 데이터베이스를 설정하는 것이다. 우리는 MongoHQ나 기타 클라우드 MongoDB 서비스를 이용하기를 권하는 데, 이들이 전문적인 지원과 더 나은 관리도구를 제공하기 때문이다.

MongoHQ를 사용하기로 결정하면, setupMongo 값을 false로 설정하고 mup.jsonenv 블록에 MONGO_URL 환경 변수를 추가한다. Meteor Up으로 MongoDB를 호스트한다면, setupMongo값을 true로 설정하기만 하면 Meteor Up이 나머지를 처리할 것이다.

미티어 앱 경로

Meteor Up 설정이 다른 디렉토리에 존재하므로, app 속성으로 앱의 정보를 Meteor Up에게 알려주어야 한다. 그저 앱의 전체경로를 입력하면 된다. 이 때 전체경로는 터미널에서 앱의 디렉토리로 이동하여 pwd 명령어를 실행하면 얻을 수 있다.

환경변수

앱의 환경변수들(ROOT_URL, MAIL_URL, MONGO_URL, 등과 같은)을 env 블록에 지정한다.

설정과 배포

배포하기 전에 서버를 설정하여 미티어 앱을 호스트할 수 있는 상태로 준비해야 한다. Meteor Up은 이 복잡한 과정을 단일 명령어로 해결하는 마술을 부린다!

mup setup

이 명령어는 서버의 성능과 네트워크의 연결 상태에 따라 수 분정도가 걸릴 것이다. 설정이 성공하면, 마침내 앱을 배포할 수 있다:

mup deploy

이 명령어는 앱을 bundle로 만들고, 방금 설정한 서버에 배포를 수행한다.

로그 보기

로그는 매우 중요하다. Meteor Up은 로그를 tail -f 명령어를 모방하여 처리하는 매우 쉬운 방법을 제공한다. 다음을 입력하라:

mup logs -f

이것으로 Meteor Up의 기능의 개요를 둘러보았다. 더 많은 정보를 원하면 Meteor Up의 GitHub repository를 방문하기를 권한다.

이들 미티어 앱을 배포하는 세 가지 방법이면 대부분의 경우에 충분히 대처할 수 있을 것이다. 물론, 독자들 중의 일부는 Meteor 서버를 처음부터 끝까지 완벽하게 통제하기를 선호할 수도 있다. 하지만, 이것은 나중의 주제이거나 또는 다른 책에서 다루게 될 것이다!

템플릿(Template)

3

미티어 개발에 편하게 진입할 수 있도록 우리는 바깥에서 안으로 접근하는 방식을 취할 것이다. 바꾸어 말하면, 우리는 “아무 기능도 없는” HTML/JavaScript 파일의 외형을 먼저 구축하고, 그 다음에 이를 앱의 내부 동작과 연결한다.

이 장에서는 /client 디렉토리 내부에서 일어나는 것들에 대해서만 다룬다는 의미이다.

새로운 파일 main.html/client 디렉토리에 생성한 다음, 아래 코드를 채워 넣는다:

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar navbar-default" role="navigation">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main" class="row-fluid">
      {{> postsList}}
    </div>
  </div>
</body>
client/main.html

이것이 주 템플릿이 될 것이다. 보는 바와 같이, 이것은 {{> postsList}} 태그를 제외하면 모두 HTML이다. 이 때, {{> postsList}} 태그는 곧 보게 될 postsList 템플릿을 삽입할 자리이다. 이제 몇 개의 템플릿을 더 만들어보자.

미티어 템플릿

핵심은, 소셜 뉴스 사이트는 목록 구조의 포스트들로 이루어지는데, 템플릿을 구성하는 방식도 정확하게 일치한다.

/client 디렉토리 하위에 /templates 디렉토리를 만들자. 이곳에 모든 템플릿을 두는데, 깔끔하게 /templates 디렉토리 하위에 /posts 디렉토리를 만들고 post 관련 템플릿들을 넣을 것이다.

파일 찾기

미티어는 파일을 잘 찾는다. /client 디렉토리의 어디에 넣든지 미티어는 이를 찾아서 컴파일한다. 이것은 Javascript나 CSS 파일 경로를 직접 입력할 필요가 없다는 것을 의미한다.

이것은 동일한 디렉토리에 모든 파일을 넣을 수도 있고, 하나의 파일에 모든 코드를 넣을 수도 있다는 의미이기도 하다. 그러나 미티어가 모든 것을 컴파일하여 하나의 최적화된 파일로 만드니, 전체를 잘 구조화하고 보기 좋은 파일 구조를 사용하기 바란다.

마침내 두 번째 템플릿을 만들 준비가 되었다. client/templates/posts 디렉토리에 posts_list.html 파일을 만든다:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

그리고 post_item.html도 만든다:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
  </div>
</template>
client/templates/posts/post_item.html

템플릿 엘리먼트의 속성인 name="postsList"에 주목하라. 이것은 미티어가 템플릿의 위치를 추적하는 데 사용된다 (실제 파일의 이름은 상관없다).

이제 미티어의 템플릿 시스템인 Spacebars를 소개할 차례다. Spacebars는 단순 HTML에 세 가지가 추가된 형태이다: (“partials”이라고 부르기도 하는) inclusions, expressions, 그리고 block helpers가 있다.

Inclusions{{> templateName}}의 문법을 사용하는 데, 미티어에게 해당 위치에 같은 이름의 템플릿으로 대체하라는 의미이다(예제의 경우, postItem).

Expressions{{title}}와 같이 현재 객체의 속성값이나, 또는 현재 템플릿 매니저(나중에 더 자세하게 다룬다)에 정의된 템플릿 헬퍼의 리턴 값을 지정한다.

마지막으로, block helpers는 템플릿의 흐름을 제어하는 특별한 태그로, {{#each}}…{{/each}} 또는 {{#if}}…{{/if}} 같은 것들이 있다.

더 깊이있게

Spacebars에 대하여 더 상세한 공부를 원하면 Spacebars 문서를 참조하기 바란다.

이 정도만 알면, 여기서 다루는 내용은 이해할 수 있다.

먼저, postsList 템플릿에서는 posts 객체를 {{#each}}…{{/each}} 블록 헬퍼로 반복하고 있다. 그리고 반복할 때마다 postItem 템플릿을 삽입한다.

posts 객체는 어디서 왔을까? 좋은 질문이다. 이것은 실제로는 템플릿 헬퍼로서, 동적으로 값을 지정하는 수단으로 생각하면 된다.

postItem 템플릿은 바로 알 수 있다. 이것은 세 가지 expression만을 사용한다: {{url}}{{title}}은 둘 다 도큐먼트의 속성값을 리턴한다. 그리고 {{domain}}은 템플릿 헬퍼를 호출한다.

템플릿 헬퍼

지금까지 우리는 Spacebars에 대하여 알아보았는데, 이것은 이것은 몇 개의 태그를 가진 HTML과 다를 게 없다. PHP(또는 Javascript를 포함하는 정규 HTML페이지)와 같은 다른 언어와는 달리, 미티어에서는 템플릿과 그 로직이 분리되어 있으며, 템플릿 자체는 아무 일도 하지 않는다.

템플릿이 제 기능을 하려면 헬퍼가 있어야 한다. 헬퍼는 요리사에 비유할 수 있는데 요리사는 원재료(데이터)를 가져와서, 이를 가공하고, 완성된 요리가 담긴 접시를 웨이터(템플릿)에게 전달하고 웨이터는 그것을 고객에게 제공한다.

지금까지 Spacebars로 작업을 해 왔는데, 이것은 몇 개의 태그를 가진 HTML과 다를 게 없다. PHP(또는 Javascript를 포함하는 정규 HTML페이지)와 같은 다른 언어와는 달리, 미티어에서는 템플릿과 그 로직이 분리되어 있으며, 템플릿 자체는 아무 일도 하지 않는다.

바꾸어 말하면, 템플릿의 역할이 변수의 값을 보여주거나, 루프를 도는 것이라면, 헬퍼는 실제로 각 변수에 값을 들고 날라 지정하는 일을 한다.

콘트롤러?

모든 템플릿 헬퍼들을 포함하는 파일을 일종의 콘트롤러라고 생각할 수도 있다. 하지만, 이것은 혼동을 줄 수도 있다. 왜냐면 콘트롤러란 (적어도 MVC 관점에서는) 일반적으로는 다소 다른 역할을 하기 때문이다.

그러므로 우리는 용어 정의를 유보하고, 템플릿의 JavaScript 코드에 대하여 이야기할 때, 단순하게 “그 템플릿의 헬퍼“ 또는 “그 템플릿의 로직”이라 부르기로 했다.

일을 쉽게 하기위해, 우리는 템플릿 이름을 따라서 헬퍼 이름을 짓고 확장자를 .js로 하는 관례를 채택할 것이다. 이에 따라 /client/templates/posts 디렉토리에 posts_list.js를 만들어 첫 헬퍼를 작성해보자:

var postsData = [
  {
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  },
  {
    title: 'Meteor',
    url: 'http://meteor.com'
  },
  {
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  }
];
Template.postsList.helpers({
  posts: postsData
});
client/templates/posts/posts_list.js

코드가 올바로 작성되었다면 다음과 같는 모습을 브라우저에서 볼 수 있을 것이다:

정적 데이터로 작성한 첫 템플릿
정적 데이터로 작성한 첫 템플릿

Commit 3-1

기본적인 posts 목록 템플릿과 정적 데이터를 추가했다.

우리는 여기서 두 가지 작업을 한다. 첫째, postsData 배열에 초기 데이터를 지정했다. 이 데이터는 보통은 데이터베이스에서 오지만, 그 방법을 아직 다루지 않았기 때문에(다음 장까지 기다려라) 정적 데이터로 “임시 처리"하고 있다.

둘째, 우리는 Template.postsList.helpers() 함수를 사용하여 posts라는 이름의 템플릿 헬퍼를 정의한다. 이 헬퍼는 단순히 postsData 배열을 리턴한다.

기억하겠지만, 우리는 postsList 템플릿에서 posts라는 이름의 헬퍼를 사용하고 있다:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

posts 헬퍼를 정의함으로써 템플릿을 사용할 수 있다. 따라서 템플릿은 postsData 배열을 반복처리하고, 그 안에 들어있는 각 배열 객체를 postItem 템플릿으로 보낸다.

Commit 3-1

기본적인 posts 목록 템플릿과 정적 데이터를 추가했다.

domain 헬퍼

유사한 방법으로, 이제 우리는 post_item.js를 만들어 postItem 템플릿의 로직을 처리한다:

Template.postItem.helpers({
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js

이 경우 domain 헬퍼의 값은 배열이 아니고, 익명 함수이다. 이러한 패턴은 매우 흔히 나타난다. 그리고 이전의 단순화된 더미 데이터 예제에 비교해서 훨씬 일반적이다 (그리고 더 유용하다).

Displaying domains for each links.
Displaying domains for each links.

domain 헬퍼는 URL을 가져와서 약간의 JavaScript 마술을 통해서 그 URL의 도메인 값을 리턴한다. 그런데, 이 URL은 처음에 어디서 왔을까?

이 질문의 답을 알려면 posts_list.html 템플릿으로 돌아가야 한다. {{#each}} 블록 헬퍼는 그 배열을 반복할 뿐 아니라 이 블럭 내부의 this의 값을 그 반복되는 객체에 지정한다.

이것은 {{#each}} 태그 내부에서 각 post는 순차적으로 this로 지정되며, 그 안에 포함된 템플릿의 매니저인 (post_item.js) 내부에서 내내 사용할 수 있다는 의미이다.

이제 this.url이 어떻게 현재 post의 URL을 리턴하는 지 알았다. 또한, post_item.html 템플릿 내부에서 {{title}}{{url}}을 사용하면, 미티어가 이것이 this.titlethis.url을 의미한다는 것을 인지하여 그 올바른 값을 리턴한다.

Commit 3-2

`postItem`의 `domain` 헬퍼 설정.

JavaScript 마술

이것이 미티어에 특정된 것은 아니지만, 여기서 위의 "JavaScript 마술"에 대하여 설명하고 넘어간다. 먼저 빈 anchor (a) HTML 엘리먼트를 생성하고 이를 메모리에 저장한다.

그리고 그 href 속성을 현재 post의 URL로 지정한다(우리가 본 바와 같이, 헬퍼에서 this는 현재 작동중인 객체이다).

마지막으로, a 엘리먼트의 hostname 속성을 사용하여 URL에서 도메인 이름만을 추출한다.

올바르게 따라왔다면, 브라우저에서 목록을 볼 수 있을 것이다. 이 목록은 정적인 데이터일뿐, 아직은 미티어의 실시간 기능을 이용한 것은 아니다. 다음 장에서 이를 바꾸는 방법을 보게 될 것이다!

Hot Code Reload

독자는 파일이 변경될 때마다 브라우저 창을 수동으로 새로고침할 필요가 없었다는 사실을 눈치챘을 지 모르겠다.

이것은 미티어가 프로젝트 디렉토리에 있는 모든 파일을 추적하기 때문이다. 그리고 그 파일들 중에 하나라도 변경이 감지되면 브라우저를 자동으로 새로고침한다.

미티어의 hot code reload는 매우 똑똑해서 두 새로고침 사이의 애플리케이션의 상태도 유지한다!

Git과 GitHub 사용하기

Sidebar 3.5

GitHubGit 버전 컨트롤 시스템에 기반한 오픈소스 프로젝트를 위한 소셜 저장소이다. 이것의 주요 기능은 프로젝트에서 코드의 공유와 협업을 쉽게 하는 것이다. 하지만 또한 훌륭한 학습도구이기도 하다. 이 사이드바 장에서는 독자가 Discover Meteor를 따라하는 과정에서 GitHub를 사용하는 몇 가지 방법을 빠르게 훑어볼 것이다.

이 사이드바 장은 독자가 Git과 GitHub에 익숙하지 않다고 가정한다. 만약 독자가 이들에 익숙하다면 다음장으로 바로 넘어가도 된다!

커밋(Commit)하기

Git 저장소의 기본 작업단위는 커밋(commit)이다. 커밋은 주어진 시점에서의 코드 상태에 대한 스냅사진이라고 생각하면 된다.

우리는 단순하게 Microscope에 대한 완성된 코드를 제공하는 대신에, 각 단계별 스냅사진을 만들고 GitHub에서 이 모두를 온라인으로 볼 수 있도록 하였다.

예를 들면, 이것은 이전 장의 마지막 커밋의 모습이다:

GitHub에서의 Git 커밋 화면.
GitHub에서의 Git 커밋 화면.

아래에 보이는 것은 post_item.js 파일의 “diff” (“difference”를 의미)로서, 다른 표현으로는 이 커밋에 따른 변경 사항을 의미한다. 이 경우, post_item.js 파일은 처음부터 작성되었으므로, 그 내용 전체가 녹색으로 나타난다.

이 책의 나중 부분의 예제와 비교해 보기 바란다:

코드 수정.
코드 수정.

이번에는, 수정된 라인들만 녹색으로 나타난다.

물론, 때로는 코드 라인을 추가하거나 수정하는 것이 아니라 삭제하기도 한다:

코드 삭제.
코드 삭제.

이와같이 우리는 GitHub를 처음 사용해보았다: 변경 사항을 한 눈에 보기

커밋한 코드 살펴보기

Git의 커밋 뷰는 이 커밋에 반영된 변경사항들을 보여주지만, 때때로 변경되지 않은 파일들을 보고 싶은 경우도 있는데, 이는 그 진행 단계에서 코드가 어떻게 보이는 지를 확인하기 위함이다.

다시 GitHub로 돌아가자. 커밋 페이지에 있을 때, Browse code 버튼을 눌러보라:

Browse code 버튼.
Browse code 버튼.

이제 특정한 커밋상태의 저장소에 접근할 수 있을 것이다.

commit 3-2의 저장소.
commit 3-2의 저장소.

GitHub에서 우리가 해당 커밋에서 찾는 시각적 단서를 많이 주지는 않지만, “normal” master view와 비교하여 그 파일 구조가 달라진 것을 한 눈에 볼 수 있다.

commit 14-2의 저장소.
commit 14-2의 저장소.

로컬로 커밋에 접근하기

지금까지 우리는 GitHub에서 커밋한 전체 코드를 온라인으로 살펴보는 방법을 알아보았다. 그러나 같은 일을 로컬에서 하고 싶다면 어떻게 해야 할까? 예를 들면, 특정한 커밋 상태에서 이것이 어떻게 작동하는 지를 보기 위하여 로컬에서 앱을 실행하고자 하는 경우가 있다.

이를 위해, 우리는 첫 단계(최소한 이 책에서는 그렇다)를 git 커맨드 라인 유틸리티로 시작할 것이다. 우선, Git이 설치된 것을 확인하라. 그리고 Microscope repository를 복제하라(바꾸어 말하면, 복사본을 로컬에 다운로드하라).

$ git clone git@github.com:DiscoverMeteor/Microscope.git github_microscope

끝부분의 github_microscope는 앱을 복제하여 저장할 로컬 디렉토리의 이름이다. 이미 microscope라는 이름의 디렉토리가 존재한다면, 그저 다른 이름으로 바꾸면 된다(GitHub repo에 있는 이름과 똑같을 필요는 없다).

git 커맨드 라인 유틸리티를 사용할 수 있도록 cd 명령으로 저장소 디렉토리로 이동한다:

$ cd github_microscope

이제 GitHub로부터 저장소를 복제하여 그 앱의 모든 코드를 다운로드하였다. 이는 커밋한 마지막 코드를 보고 있다는 의미이다.

고맙게도, 다른 것들에는 영향을 주지 않으면서 시간을 되돌려 특정한 커밋을 “check out” 하는 방법이 있다. 한 번 시도해보자:

$ git checkout chapter3-1
Note: checking out 'chapter3-1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at a004b56... Added basic posts list template and static data.

Git은 우리가 “detached HEAD” 상태에 있다는 것을 알려주고 있는데, 이는 Git에 관한 한, 지나간 커밋을 볼 수는 있지만 수정할 수는 없다는 것을 의미한다. 마치 이것은 유리구슬을 통해서 과거를 보는 마법사에 비유할 수 있다.

(Git에는 과거의 커밋을 변경할 수 있는 명령어들도 있다는 점에 유의하기 바란다. 이는 마치 시간 여행자가 과거로 돌아가서 나비를 밟는 것과 같은 일이 일어날 수 있다는 의미이나 이것은 여기서의 간단한 소개의 범위를 넘어선다.)

우리가 단순하게 chapter3-1이라고 입력할 수 있었던 이유는 모든 Microscope의 커밋마다 각 장에 대한 표식으로 미리 꼬리표를 달아 놓았기 때문이다. 이렇게 하지 않았다면, 먼저 해당 커밋의 해시(hash)나 또는 unique identifier를 찾아야 했을 것이다.

다시 한 번, GitHub는 우리의 삶을 보다 편안하게 해준다. 커밋의 해시는 파란 커밋 헤더 박스의 오른쪽 하단에서 찾을 수 있다:

Commit hash 찾기.
Commit hash 찾기.

그러면 태그 대신 해시로 시도해보자:

$ git checkout c7af59e425cd4e17c20cf99e51c8cd78f82c9932
Previous HEAD position was a004b56... Added basic posts list template and static data.
HEAD is now at c7af59e... Augmented the postsList route to take a limit

그리고 마지막으로, 우리가 마술 유리구슬을 보는 것을 멈추고 현재로 돌아오려면 어떻게 해야 할까? Git에게 master branch를 check out하고 싶다고 말하면 된다:

$ git checkout master

이 과정의 어느 시점에서나, “detached HEAD” 상태에서도, 앱을 meteor 명령어로 실행시킬 수도 있다. 만약 Meteor가 패키지가 누락되었다는 메시지를 보이면 meteor update 명령을 바로 실행시키면 되는데, 이것은 Microscope의 Git 저장소에는 패키지가 포함되어 있지 않기 때문이다.

History 관점

또 다른 일반적인 시나리오가 있다: 독자가 어떤 파일을 보고 있는데 독자가 전에 보지 못했던 변경 사항이 있는 것을 발견했다. 문제는 언제 파일이 변경되었는지 기억을 못한다는 것이다. 그러면 올바른 버전을 찾을 때까지 각 커밋을 하나씩 찾아보려고 할 것이다. 하지만, GitHub의 History 기능 덕분에 쉽게 할 수 있다.

우선, GitHub에 있는 저장소의 파일중의 하나에 접근하여, “History” 버튼을 누른다:

GitHub의 History 버튼.
GitHub의 History 버튼.

이제 이 특정한 파일에 영향을 준 모든 커밋 목록이 정돈되어 나타난다.

파일의 history 보기.
파일의 history 보기.

책임 공방(Blame Game)

마무리 단계로 Blame을 살펴보자:

GitHub의 Blame 버튼.
GitHub의 Blame 버튼.

이 깔끔한 뷰는 어느 커밋에서, 어떤 파일의 라인마다 누가 수정하였는 지를(바꾸어 말하면, 누구의 잘못으로 작동하지 않는 지를) 보여준다:

GitHub의 Blame 열람.
GitHub의 Blame 열람.

Git은 매우 복잡한 도구이다 - GitHub도 그렇다 -, 그러므로 이 단일 장에서 모두 다루기를 기대할 수는 없다. 사실, 이들 도구로 가능한 것들에 대한 겉핥기만 한 것이다. 하지만, 바라건데, 이 정도만으로도 이 책의 나머지를 따라 가는데에 도움이 될 것이다.

컬렉션(Collection)

4

1장에서, 우리는 미티어의 핵심 기능인 클라이언트와 서버 사이의 데이터의 자동 동기화에 대하여 언급한 바 있다.

이 장에서는, 그 작동 과정에 대하여 세밀하게 알아보고 이를 가능하게 하는 핵심 기술인 미티어 컬렉션(Collection)의 동작을 살펴본다.

컬렉션(collection)은 특별한 데이터 구조체로서 데이터를 영구 저장할 수 있는 서버의 MongoDB 데이터베이스에 저장한다. 그리고 이를 각 연결된 이용자 브라우저와 실시간으로 동기화한다.

우리는 post를 영구적으로 저장하고 이들을 사용자들 사이에서 공유하도록 하려고 한다. 그래서 우리는 Posts라는 이름의 컬렉션을 만들어 저장한다.

Collection은 어떤 앱에서나 중심적인 역할을 한다. 그러므로 가장 먼저 정의하여 lib 디렉토리에 넣는다. 우선 lib 디렉토리 내부에 collections/ 디렉토리를 만들고 여기에 여기에 posts.js 파일을 만든다. 그리고 아래 내용을 넣는다:

Posts = new Mongo.Collection('posts');
lib/collections/posts.js

Commit 4-1

Posts 컬렉션을 추가했다.

Var을 적용할까 말까?

미티어에서, var 키워드는 해당 객체의 영역(scope)을 현재의 파일로 제한한다. 여기서, 우리는 Posts 컬렉션을 앱 전체에서 이용하기를 원한다. 이것이 우리가 var 키워드를 사용하지 않는 이유이다.

데이터 저장

웹 앱은 데이터의 처리에 있어 3가지 기본적인 방식을 가지고 있다. 그리고 그 각각은 다른 역할을 가진다:

  • 브라우저 메모리: JavaScript 변수같은 것들은 브라우저 메모리에 저장된다. 이것은 영구적이지 않다는 의미이다: 이것은 현재 브라우저 탭에 한정된다. 그리고 그 탭을 닫는 순간 사라진다.
  • 브라우저 저장소(storage) 브라우저는 데이터를 쿠키나 로컬 스토리지에 보다 영구적으로 저장할 수 있다. 여기 저장된 데이터는 세션 한계를 넘어서 저장할 수 있지만, 현재 이용자에 한정되며 (하지만 브라우저 탭의 한계는 벗어났다) 다른 사용자들과 쉽게 공유하지 못한다.
  • 서버 데이터베이스 영구적으로 데이터를 저장하는 최상의 장소로서 한 사용자에만 한정되지 않고 이용할 수 있는 곳은 전통적인 데이터베이스이다 (MongoDB는 미티어 앱에서의 기본 솔루션이다).

미티어는 이 모두를 사용하며, (곧 보겠지만) 때로는 한 장소에서 다른 곳으로 데이터를 동기화한다. 말하자면, 데이터베이스는 데이터의 원본을 저장하는 “정통(canonical)” 데이터 소스로 존재한다.

클라이언트와 서버

client/server/가 아닌 폴더에 존재하는 코드는 양쪽에서 실행된다. 그러므로 Posts 컬렉션은 클라이언트와 서버 양쪽에서 이용할 수 있다. 그런데, 각 환경에서 컬렉션이 동작하는 방식은 완전히 다르다.

서버에서, 컬렉션은 MongoDB 데이터베이스와 연결되어 데이터 입출력을 처리한다. 이런 측면에서 이것은 표준 데이터베이스 라이브러리와 비교할 수 있다.

그런데 클라이언트에서는, 컬렉션은 실제 정통 컬렉션의 부분집합의 복제본이다. 클라이언트 쪽의 컬렉션은 그 부분집합을 지속적으로 그리고 (대개는) 명백하게 최신의 상태로 실시간으로 유지한다.

콘솔 대 콘솔 대 콘솔

이 장에서는, 브라우저 콘솔을 사용하여 시작하는 데, 이것을 터미널이나 Mongo 쉘(shell)과 혼동하면 안된다. 아래는 각각에 대한 속성 입문서이다.

터미널

터미널
터미널
  • 운영체제에서 구동된다.
  • 서버에서의 console.log()를 호출하면 여기로 출력된다.
  • 프롬프트: $.
  • 다른 이름: Shell, Bash

브라우저 콘솔

브라우저 콘솔
브라우저 콘솔
  • 브라우저 내부에서 구동되어, JavaScript 코드를 실행한다.
  • 클라이어트에서의 console.log()를 호출하면 여기로 출력된다.
  • 프롬프트: .
  • 다른 이름: JavaScript Console, DevTools Console

Mongo 쉘

Mongo 쉘
Mongo 쉘
  • 터미널에서 meteor mongo 명령어를 실행하여 구동된다.
  • 앱의 데이터베이스에 직접 접속한다.
  • 프롬프트: >.
  • 다른 이름: Mongo Console

각각의 경우에 프롬프트 문자 ($, , or >)를 명령어의 일부로 입력해야 하는 것은 아니다. 그리고 프롬프트로 시작하지 않는 라인은 이전 명령의 출력 결과라고 보면 된다.

서버에서의 컬렉션

서버로 돌아가서, 컬렉션은 Mongo 데이터베이스로의 API로 기능한다. 서버쪽 코드를 작성할 때, Posts.insert() 또는 Posts.update()와 같은 Mongo 명령어를 쓸 수 있고, 이들은 Mongo에 저장된 posts 컬렉션을 변경한다.

Mongo 데이터베이스의 내부를 보려면, 새로운 터미널 창을 열고 (현재 meteor가 구동 중인 터미널 창은 그대로 둔 채로), 앱이 있는 디렉토리로 이동한다. 그리고, 명령어 meteor mongo를 실행하여 Mongo shell을 구동하라. 여기에서 표준 Mongo 명령어를 입력할 수 있다(그리고, ctrl+c 단축키로 빠져 나온다). 예를 들어 아래와 같이 입력해보자:

meteor mongo

> db.posts.insert({title: "A new post"});

> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};
Mongo 쉘

Meteor.com에서의 Mongo

*.meteor.com에서 앱을 호스팅하면, 배포된 앱의 Mongo 콘솔은 명령어 meteor mongo myApp으로 접속할 수 있다.

그리고 거기에 있는 동안에는, 앱의 로그파일도 meteor logs myApp명령으로 볼 수 있다.

Mongo의 문법도 Javascript 인터페이스를 사용하므로 익숙하다. 우리가 Mongo 콘솔에서 더 이상의 데이터 작업을 하지는 않겠지만, 때때로 무슨 일이 일어나는지 엿볼 수는 있다.

클라이언트에서의 컬렉션

클라이언트에서 컬렉션은 좀 더 흥미롭다. 클라이언트에서 Posts = new Mongo.Collection('posts'); 라고 선언하는 것은, 실제 Mongo 컬렉션의 로컬, 인-브라우저 캐시를 생성하는 것이다. 우리가 클라이언트 쪽의 컬렉션을 “캐시"라고 말하는 것은, 데이터의 부분 집합을 가지며, 데이터에 빠르게 접근할 수 있다는 것을 의미한다.

이 부분을 이해하는 것이 이것이 미티어가 작동하는 방식의 기본이라는 점에서 중요하다. 일반적으로, 클라이언트 쪽 컬렉션은 Mongo 컬렉션에 저장된 전체 도큐먼트의 부분집합이다.(결국, 우리는 전체 데이터베이스를 클라이언트로 보내는 것을 원하지 않는다).

두 번째, 이 도큐먼트들은 브라우저 메모리에 저장되는 데, 이는 여기에 접근하는 것이 기본적으로 순간적이라는 것을 의미한다. 그러므로 클라이언트에서 Posts.find()를 호출할 때, 데이터를 가져오려고 서버나 데이터베이스에 느리게 갔다오는 일은 없다. 데이터는 이미 로드되어 있기 때문이다.

MiniMongo 소개

미티어의 클라이언트쪽 Mongo 구현체를 MiniMongo라 부른다. 이는 완벽한 구현체는 아니어서, 때로 MiniMongo에서 구현되지 않는 Mongo 기능을 접할 수도 있다. 그래도, 이 책에서 다루는 모든 기능은 Mongo와 MiniMongo 양쪽 모두에서 유사하게 동작한다.

클라이언트-서버 통신

여기서 핵심부분은 클라이언트의 컬렉션이 같은 이름(여기서는 'posts')의 서버 컬렉션과 동기화하는 방법이다.

이에 대한 상세한 설명보다는, 그저 무슨 일이 일어나는지 지켜보기 바란다.

두 개의 브라우저 윈도우를 열고, 각각에서 Javascript 콘솔을 연다. 그리고, 터미널에서 Mongo 콘솔을 연다.

이 때, 이 세 개의 창에서 이전에 우리가 만든 단일 도큐먼트를 볼 수 있다 (앱의 UI 에서는 여전히 이전의 3개의 더미 post를 볼 수 있지만 이것은 무시하자).

> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
Mongo 쉘
 Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
브라우저 콘솔 1

이제, 새로운 post를 등록한다. 브라우저 창의 하나에서 아래 명령을 실행한다:

 Posts.find().count();
1
 Posts.insert({title: "A second post"});
'xxx'
 Posts.find().count();
2
브라우저 콘솔 1

예상한대로, post는 로컬 컬렉션에 저장된다. 이제 Mongo에서 확인해보자:

❯ db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
Mongo 쉘

보는 바와 같이, 클라이언트에서 서버로 보내는 한 줄의 코드 작성없이도 post를 Mongo 데이터베이스로 삽입했다(뭐, 엄격하게 말하면, 딱 줄의 코드를 작성하긴 했다: new Meteor.Collection('posts')). 하지만 이게 다는 아니다!

두 번째 브라우저를 열고, 브라우저 콘솔에서 아래 명령을 실행하여 보자:

 Posts.find().count();
2
브라우저 콘솔 2

여기에도 post가 있다! 이 두 번째 브라우저를 새로고침하거나 여기서 무슨 작업을 하지 않았어도, 어떤 코드를 작성하지 않았어도 그렇다. 이것은 마술처럼 그리고 순간적으로 일어났다. 나중에 이에 대하여 보다 명백하게 알게 될 것이다.

무슨 일이 일어났는고 하니, 새로운 post가 등록된 클라이언트쪽의 컬렉션이 서버쪽 컬렉션에게 알린 것이다. 그리고, 그 post를 Mongo 데이터베이스에 삽입하고, 연결된 모든 다른 post 컬렉션들에게도 알려준 것이다.

브라우저 콘솔에서 Posts를 가져오는 것은 실용적이지는 않다. 우리는 이 데이터를 템플릿으로 보내는 방법을 배울 것이고, 이 과정에서 단순한 HTML 프로토타입을 작동하는 실시간 웹 애플리케이션으로 바꿀 것이다.

데이터베이스 활용

브라우저 콘솔에서 컬렉션의 내용을 열람하는 것은 그저 한 기능일 뿐이다. 우리가 진정 원하는 것은 화면에서 데이터를 보여주고, 그 데이터가 변경되는 모습을 보여주는 것이다. 그렇게 함으로써, 우리 앱을 정적인 데이터를 보여주는 단순 웹 페이지에서 동적으로 변경되는 데이터를 보여주는 실시간 웹 애플리케이션으로 바꾸게 될 것이다.

우리의 첫 과제는 데이터베이스에 데이터를 넣는 것이다. 우리는 서버가 처음 기동할 때 구조화된 데이터를 Posts 컬렉션에 넣는 초기 데이터 파일(fixture file)을 사용하여 데이터를 넣을 것이다.

우선, 데이터베이스를 비운다. meteor reset을 이용하여, 데이터베이스를 지우고 프로젝트를 리셋한다. 물론 이 명령어를 실행하는 것은 실제 프로젝트에서 작업을 진행할 때에는 매우 조심스럽게 해야 한다.

미티어 서버를 중지시킨(ctrl-c를 누른다) 다음 커맨드 라인에서 다음을 실행한다:

meteor reset

이 reset 명령어는 Mongo 데이터베이스를 완전하게 비운다. 이것은 개발단계에서는 유용한 명령어이지만, 데이터베이스가 불일치 상태에 빠지게 될 가능성이 높다.

이제 다시 미티어 앱을 구동시킨다:

meteor

데이터베이스가 비었으니 이제 아래 코드를 추가하여 서버가 구동되고 Posts 컬렉션이 빈 상태일 때마다, 3개의 post가 데이터베이스에 저장되도록 한다:

if (Posts.find().count() === 0) {
  Posts.insert({
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  });

  Posts.insert({
    title: 'Meteor',
    url: 'http://meteor.com'
  });

  Posts.insert({
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  });
}
server/fixtures.js

Commit 4-2

Posts 컬렉션에 데이터를 추가했다.

우리는 이 파일을 server/ 디렉토리에 넣었으므로, 이는 사용자 브라우저에는 로드되지 않을 것이다. 이 코드는 서버가 구동될 때 바로 실행되어 Posts 컬렉션에 3개의 post를 추가하도록 데이터베이스에 insert 요청을 한다. 아직은 어떠한 데이터 보안 처리를 하지 않았으므로, 이 파일이 서버에서 구동되나, 브라우저에서 구동되나 차이는 없다.

이제 meteor 명령으로 서버를 다시 구동한다. 그리고 이 3개의 post는 데이터베이스에 삽입될 것이다.

동적 데이터

이제 브라우저 콘솔을 열어, MiniMongo에 3개의 post가 로드된 것을 볼 수 있다:

 Posts.find().fetch();
브라우저 콘솔

이 post들을 HTML에 보이기 위해서, 템플릿 헬퍼를 사용한다.

3장에서 우리는 미티어에서 단순 데이터 구조의 HTML 뷰를 구축하기 위하여 데이터 컨텍스트를 Spacebars 템플릿에 엮는 방법을 적용해 보았다. 컬렉션 데이터도 같은 방법으로 엮을 수 있다. 단지, 정적인 postsData Javascript 데이터 객체를 동적인 컬렉션으로 바꾸기만 하면 된다.

안그래도, postsData 코드는 편하게 삭제한다. 이제 posts_list.js 파일의 코드는 다음과 같을 것이다:

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

Commit 4-3

컬렉션을 `postsList` 템플릿과 연동했다.

찾기(Find)와 가져오기(Fetch)

미티어에서, find()커서를 리턴하는데, 이는 반응형 데이터 소스이다. 우리가 그 데이터의 내용을 얻으려고 할 때, 현재 커서 위치에서 데이터를 배열로 변환하는 fetch()를 사용한다.

앱 내부에서, 미티어는 똑똑하게도 이를 배열로 변환하지 않고도 커서를 따라서 반복하는 방법을 안다. 이런 이유로 실제 미티어 코드에서 fetch()는 잘 보이지 않는다(그리고 위의 예에서 사용하지 않은 이유이기도 하다).

변수에 지정된 정적 배열로부터 post의 목록을 가져오는 대신, 이제 posts 헬퍼에 대한 커서를 리턴한다 (같은 데이터를 사용하므로 별로 달라지는 것은 없을 것이다):

라이브 데이터 사용하기
라이브 데이터 사용하기

{{#each}} 헬퍼가 Posts 전체를 반복하여, 이들을 화면에 뿌려주었다. 서버쪽의 컬렉션은 Mongo로부터 post 목록을 추출하여, 이들을 클라이언트쪽의 컬렉션에 넘기고, Spacebars 헬퍼가 이들을 템플릿으로 전달한 것이다.

이제, 한 단계를 더 나가자; 브라우저 콘솔에서 새로운 post를 추가해보자:

 Posts.insert({
  title: 'Meteor Docs', 
  url: 'http://docs.meteor.com'
});
브라우저 콘솔

다시 브라우저를 보면 다음과 같이 나타난다:

콘솔에서 post 추가하기
콘솔에서 post 추가하기

독자는 처음으로 반응형으로 동작하는 것을 본 것이다. 우리가 Spacebars에게 Posts.find() 커서를 통해서 반복하도록 지시했을 때, 이것은 지속적으로 변화를 관찰하면서, 그 HTML을 가장 간단한 방식으로 수정하여 화면에 올바른 데이터를 보여준다.

DOM의 변화를 살펴보기

이 경우에, 가능한 가장 간단한 변경은 새로운 <div class="post">...</div>를 추가하는 것이다. 실제로 이렇게 되는 것을 보고 싶다면, DOM inspector를 열고 기존의 post들중의 하나에 대응하는 <div>를 선택하면 된다.

이제, JavaScript 콘솔에서 또 다른 post를 삽입한다. 그리고 inspector를 다시 보면, 새 post에 대응하는 추가된 <div>를 볼 수 있지만, 여전히 동일한 기존의 <div>가 선택된 상태로 있는 것을 볼 수 있다. 이것이 언제 엘리먼트들이 화면에 다시 그려지고 언제 그대로 있는지를 알 수 있는 유용한 방법이다.

컬렉션에 접속하기: 발행(Publication)과 구독(Subscription)

지금까지는 autopublish 패키지를 활성화시킨 상태였는데, 이 패키지는 실서비스용이 아니다. 그 이름에서 알 수 있듯이, 이 패키지는 각 컬렉션마다 그 전체를 연결된 각 클라이언트와 공유하게 한다. 이는 우리가 진정 원하는 바가 아니므로, 이 기능을 끄도록 한다.

터미널 창을 열고 아래와 같이 입력한다:

meteor remove autopublish

이렇게 하면 바로 효과가 나타난다. 브라우저를 보면, post가 모두 사라졌을 것이다! 이것은 우리가 post에 대한 클라이언트 쪽의 컬렉션이 데이터베이스에 있는 모든 post의 미러가 되도록 autopublish에 의존해왔기 때문이다.

결국, 우리는 사용자가 실제로 (페이징같은 것을 고려하여) 보고 싶어하는 post만을 전달하면 된다는 점을 확실하게 해두자. 하지만 당장은 Posts 전체를 발행(publish)하도록 설정할 것이다.

이렇게 하기 위해서, 우리는 publish() 함수를 만들어 모든 post를 참조하는 커서를 리턴하게 한다:

Meteor.publish('posts', function() {
  return Posts.find();
});
server/publications.js

클라이언트에서, 이 발행(publication)을 구독(subscribe)해야 한다. 아래 라인을 main.js에 추가하기만 하면 된다:

Meteor.subscribe('posts');
client/main.js

Commit 4-4

패키지 `autopublish`를 삭제하고 기본적인 발행을 작성했다.

다시 브라우저를 보면, post 목록이 모두 돌아왔다. 휴!

결론

그래서 우리는 무엇을 이루었을까? 아직 사용자 인터페이스를 다루지는 않았지만 이제 정상적으로 동작하는 웹 애플리케이션을 얻었다. 이 애플리케이션을 인터넷에 배포할 수도 있다. 그리고 (브라우저 콘솔을 이용하여) 새로운 이야기를 올리고 이것이 전 세계 다른 사람들의 브라우저에 나타나는 것을 볼 수 있게 되었다.

발행(Publication)과 구독(Subscription)

Sidebar 4.5

발행(publication)과 구독(subscription)은 미티어에서 가장 기본적이고 중요한 개념 중의 하나이지만, 막상 시작해보면 이해하기 어렵다.

이로 인해 미티어가 보안상 불안하다거나 미티어 앱은 대용량 데이터를 처리하지 못한다는 등의 많은 오해가 있었다.

사람들이 이 개념에 대하여 초기에 다소 혼란을 느끼는 큰 이유는 미티어가 부리는 “마술"때문이다. 이 마술이 궁극적으로는 매우 유용할지라도, 보이지 않는 내부에서 실제로 일어나는 것을 (마술이란 게 그렇듯이) 이해하기 어렵게 할 수 있다. 그러므로, 이 마술 계층을 벗겨내고 그 내부에서 일어나는 것을 이해하도록 해보자.

옛날에는

우선 미티어가 나오기 전인 2011년의 좋았던 시절로 돌아가보자. 독자는 간단한 Rails 앱을 개발하고 있다고 하자. 어떤 사용자가 독자의 사이트를 방문하면 클라이언트(즉, 브라우저)는 서버에서 구동되는 앱에 요청(request)을 보낸다.

앱의 첫 번째 일은 그 사용자가 원하는 데이터를 알아내는 것이다. 이것은 12페이지의 검색결과, Mary의 사용자 프로필, Bob의 최근 20개 트윗 등일 수 있다. 이것은 독자가 찾는 책을 찾아서 매장을 돌아다니는 서점 점원에 비유할 수 있다.

올바른 데이터를 구했다면, 앱의 두 번째 일은 이 데이터를 멋진, 사람이 읽을 수 있는 HTML(또는 API라면 JSON)로 번역하는 것이다.

서점에 비유하자면, 독자가 구매한 책을 포장하고, 멋진 백에 넣는 일일 것이다. 이것이 유명한 Model-View-Controller 모델의 "View” 영역이다.

마침내, 앱은 HTML 코드를 브라우저로 전송한다. 앱이 할 일은 다했다. 이제 그저 맥주나 마시며 다음 요청이 올 때를 기다리는 것이다.

미티어 방식

이에 비하여 무엇이 미티어를 그렇게 특별하게 만드는 지를 살펴보도록 하자. 우리가 본 바와 같이, 미티어의 혁신의 핵심은 Rails 앱이 서버에서만 동작하는 반면에, 미티어 앱은 클라이언트(브라우저)에서 동작하는 클라이언트쪽 컴포넌트도 포함한다는 것이다.

클라이언트에 데이터베이스의 부분집합을 보내기.
클라이언트에 데이터베이스의 부분집합을 보내기.

이것은 마치 서점 점원이 책을 찾아줄 뿐 아니라, 독자를 집에까지 따라와서 밤에 그 책을 읽어주는 것(약간 소름끼치는 소리로 받아들일 수 있지만)과 같다.

이 아키텍처로 인하여 미티어는 많은 멋진 기능을 제공하는데, 그 중에서도 으뜸은 미티어에서 데이터베이스는 어디에서나라고 부르는 것이다. 단지 데이터베이스에 넣기만 하면 미티어가 그 부분집합을 가져와서 클라이언트에 복사하여 둘 것이다.

이것은 두 가지 큰 의미를 가진다: 첫째, HTML 코드를 클라이언트로 보내는 대신, 미티어 앱은 실제 생 데이터를 보내고 클라이언트가 그것을 처리하게 한다(데이터만 전송). 둘째, 서버에 갔다오는 시간을 기다려야 하는 일없이 즉시 데이터에 접속할 수 있다(대기시간 보정(latency compensation)).

발행(Publishing)

앱의 데이터베이스는 수 십만개의 도큐먼트를 담을 수 있는데, 이들중 일부는 지극히 개인적이거나 민감할 수도 있다. 따라서 클라이언트에 이들 전체를 미러링하는 것은 보안상, 확장성 측면에서 명백히 해서는 안되는 일이다.

따라서 우리는 미티어에게 데이터의 어떤 부분집합을 클라이언트로 보낼 지를 지정하는 방법이 필요하며, 이를 발행(publication)을 통해서 구현할 것이다.

Microscope로 돌아가보자. 아래는 데이터베이스에 있는 앱의 post 목록 전체이다:

데이터베이스에 들어있는 post 목록 전체.
데이터베이스에 들어있는 post 목록 전체.

Microscope에 이 기능은 확실히 실제로 존재하지는 않지만, 이 post들의 일부가 욕설이 담긴 내용으로 표시가 되어 있다고 상상하자. 우리는 이들을 데이터베이스에 담아두기는 하지만, 사용자들이 이용할 수(즉, 클라이언트로 보내지는 경우)는 없어야 한다.

우리의 첫 과제는 미티어에게 클라이언트쪽에 전송한 데이터를 알려주는 일이 될 것이다. 우리는 미티어에게 표시가 없는 post들만을 발행(publish)하도록 할 것이다.

표시된 post를 제외함.
표시된 post를 제외함.

아래가 그 대응하는 코드로 서버에 위치한다:

// on the server
Meteor.publish('posts', function() {
  return Posts.find({flagged: false}); 
});

이렇게 하면 클라이언트가 표시된 post에 접근하는 가능한 방법은 없다는 것이 확실하다. 이것이 바로 미티어 앱을 안전하게 만드는 방법이다: 현재 클라이언트가 접근하기를 바라는 데이터만을 발행하라.

DDP

기본적으로, 발행/구독 시스템은 서버쪽(소스)의 컬렉션에서 클라이언트쪽(타겟)의 컬렉션으로 데이터를 전달하는 깔때기로 비유할 수 있다.

깔때기로 표현된 프로토콜을 DDP(Distributed Data Protocol)라 부른다. DDP에 대하여 더 자세하게 알고 싶으면, Matt DeBergalis(Meteor 설립자의 한 사람)의 The Real-time Conference에서의 강연이나 또는 독자를 이 개념으로 좀 더 상세하게 안내할 Chris Mather의 screencast를 시청하기 바란다.

구독하기

우리가 표시되지 않은 post들을 클라이언트에 보내기를 원한다 해도, 한 번에 수십만 post를 보낼 수는 없다. 우리는 클라이언트가 특정한 시점에 그들이 원하는 데이터 세트를 지정하도록 하는 방법이 필요했고, 바로 그것이 구독(subscription)이 도입된 계기이다.

구독하는 데이터는 미티어의 MongoDB에 대한 클라이언트 구현체인 Minimongo 덕분에 클라이언트에 미러된다.

예를 들면, 우리가 현재 Bob Smith의 프로필 페이지를 열람중이고 그가 등록한 post만을 보기를 원한다고 해보자.

Bob의 post를 구독하면 클라이언트에 미러링된다.
Bob의 post를 구독하면 클라이언트에 미러링된다.

우선, 우리는 발행을 매개변수를 받도록 수정한다:

// on the server
Meteor.publish('posts', function(author) {
  return Posts.find({flagged: false, author: author});
});

그리고 앱의 클라이언트 쪽 코드에서 그 발행을 구독할 때 매개변수를 정의한다:

// on the client
Meteor.subscribe('posts', 'bob-smith');

이것이 미티어 앱의 클라이언트 부분을 확장성있게 구현하는 방법이다: 이용가능한 모든 데이터를 구독하는 대신, 현재 필요한 부분만을 선택하여 뽑아오는 것이다. 이 방식으로 서버쪽의 데이터베이스가 아무리 크다 해도 브라우저 메모리의 과부하를 피할 수 있다.

찾기

이제 Bob의 post들이 다양한 카테고리에 걸쳐서 있다고 하자(예를 들면, “JavaScript”, “Ruby”, 그리고 “Python”). 우리가 여전히 Bob의 모든 post를 메모리에 로드하기를 원하지만, 이제 그 중에서 “JavaScript” 카테고리에 있는 것들만을 보여주기를 원한다고 하자. 여기서 “찾기”가 도입된다.

클라이언트에서 도큐먼트의 일부만 선택하기.
클라이언트에서 도큐먼트의 일부만 선택하기.

서버에서 한 것과 마찬가지로, 우리는 Posts.find() 함수를 사용하여 데이터의 부분집합을 선택한다:

// on the client
Template.posts.helpers({
  posts: function(){
    return Posts.find({author: 'bob-smith', category: 'JavaScript'});
  }
});

이제 발행과 구독의 역할이 무엇인지를 잘 이해하게 되었으므로 몇 가지 자주 사용되는 구현 패턴을 깊이있게 살펴보기로 하자.

Autopublish

미티어 프로젝트를 처음부터 (즉, meteor create 명령어를 사용하여) 생성하면 자동적으로 autopublish 패키지가 활성화된다. 이것이 정확하게 무엇을 하는지 알아보는 것으로 시작하자.

autopublish의 목표는 미티어 앱의 코딩을 매우 쉽게 시작하도록 하는 것이며, 이를 위해 서버에서 클라이언트로 오는 모든 데이터를 자동으로 미러링하도록 하여, 발행과 구독을 처리한다.

Autopublish
Autopublish

이것은 어떻게 작동할까? 서버에 'posts'라는 컬렉션이 있다고 가정하자. 그러면 autopublish는 Mongo posts 컬렉션에서 검색되는 모든 post를 클라이언트(하나만 있다고 가정한다)에 ’posts'라 불리는 컬렉션으로 보낸다.

그러므로 autopublish를 사용하면 발행에 대하여는 생각할 필요가 없다. 데이터는 어디에나 있고 할 일은 단순하다. 물론, 애플리케이션의 데이터베이스에 완전한 복제품이 모든 사용자의 시스템에 캐시되어 있다는 것은 명백하게 문제가 될 수는 있다.

이런 이유로, autopublish는 처음 시작할 때에, 그리고 발행에 대하여 생각해 본 적이 없을 때까지만 적절하다.

컬렉션 전체를 발행

autopublish를 제거하면, 클라이언트에서 모든 데이터가 사라지는 것을 바로 알 수 있다. 이를 복원하는 쉬운 방법은 단순히 autopublish가 하는 일을 복제하여 컬렉션 전체를 발행하는 것이다. 예를 들면:

Meteor.publish('allPosts', function(){
  return Posts.find();
});
컬렉션 전체를 발행하기
컬렉션 전체를 발행하기

여전히 컬렉션 전체를 발행하지만, 이제는 최소한 어떤 컬렉션을 발행할 것인지 아닌지를 통제할 수는 있게 되었다. 이 경우에 우리는 Posts 컬렉션은 발행하지만 Comments는 하지 않는다.

컬렉션 일부만 발행

다음 단계는 컬렉션의 일부만을 발행하는 것이다. 예를 들어 특정 저자가 쓴 post만을 발행해보자:

Meteor.publish('somePosts', function(){
  return Posts.find({'author':'Tom'});
});
컬렉션 일부를 발행하기
컬렉션 일부를 발행하기

실제로 내부에서 이루어지는 일은

Meteor publication documentation을 읽었다면, 아마도 클라이언트에서 레코드의 속성을 지정하기 위해 added()ready()를 사용하는 글에 압박을 받을 것이다. 그리고 그 글과 그런 메서드를 전혀 사용하지 않는 미티어 앱과 맞추어보느라 고생할 것이다.

그 이유는 미티어가 매우 중요한 편의를 제공하기 때문이다: 바로 _publishCursor() 메서드이다. 이것이 사용된 것을 본 적은 없을 것이다. 아마도 직접은 아니겠지만, publish 함수에서 커서를 리턴한다면(즉, Posts.find({'author':'Tom'})), 바로 여기서 미티어가 이 함수를 사용한다.

미티어에서 somePosts 라는 발행 함수가 커서를 리턴할 때, 미티어는 커서를 자동적으로 발행하기 위해서 _publishCursor()를 - 예상한 대로 - 호출한다.

_publishCursor()가 하는 일은 다음과 같다:

  • 서버쪽 컬렉션의 이름을 확인한다.
  • 커서로부터 일치하는 모든 도큐먼트들을 가져와서 이름이 같은 클라이언트 쪽의 컬렉션으로 보낸다(이 작업에 .added()를 사용한다).
  • 도큐먼트가 추가, 삭제, 변경될 때, 이 변경 내용을 클라이언트 쪽의 컬렉션으로 보낸다(커서에서 .observe()를 사용하다가 .added(), .updated() 그리고 removed()를 사용한다).

그러므로 위의 예에서, 사용자는 자신의 클라이언트 쪽의 캐시에 관심있는 post 목록(Tom이 작성한 post들)만을 받게 되는 것을 알 수 있다.

일부 속성만 발행

Post 목록의 일부만을 발행하는 방법을 보았다. 하지만, 목록의 내용을 더 얇게 할 수도 있다! 특정한 속성들만을 발행하는 방법을 알아보자.

이전과 같이, find()를 사용하여 커서를 리턴하지만, 이번에는 다른 필드를 배제할 것이다:

Meteor.publish('allPosts', function(){
  return Posts.find({}, {fields: {
    date: false
  }});
});
일부 속성만 발행하기
일부 속성만 발행하기

물론 이 두 가지를 결합할 수도 있다. 예를 들면, Tom이 작성한 모든 글에서 그 날짜는 제외하고 리턴하고 싶다면 다음과 같이 작성한다:

Meteor.publish('allPosts', function(){
  return Posts.find({'author':'Tom'}, {fields: {
    date: false
  }});
});

요약

지금까지 우리는 모든 컬렉션의 모든 도큐먼트들의 모든 속성을 발행하는 방법에서 일부 컬렉션의 일부 도큐먼트들의 일부 속성만을 발행하는 방법까지 알아보았다.

이 정도면 미티어 발행으로 할 수 있는 기본적인 것들은 모두 다루었다. 그리고 이 정도의 간단한 기법들로도 용례의 대부분을 커버할 것이다.

때로는, 발행들을 결합하고, 연결하고, 통합하여 더 나아갈 필요가 있을 수 있는데, 이런 것들은 나중에 다룰 것이다!

라우팅(Routing)

5

이제 (결국엔 사용자가 등록할) post 목록 페이지가 있으니, 사용자들이 각 post마다 토론을 할 수 있는 개별적인 post 페이지가 필요하다.

우리는 이 페이지들을 퍼머링크(permalink)를 통해서 접근할 수 있게 하려고 한다. 퍼머링크란 각 post마다 유일한 http://myapp.com/posts/xyz(여기서 xyz는 MongoDB의 _id)라는 형태의 URL을 가리킨다.

이것은 브라우저의 URL바에 존재하는 것을 보고 이에 대응하는 올바른 콘텐츠를 화면에 보여주는 일종의 라우팅(routing)이 필요하다는 것을 의미한다.

Iron Router 패키지 추가하기

Iron Router는 미티어 앱에 특별하게 맞춰진 라우팅 패키지이다.

이것은 라우팅(경로를 설정)을 도울 뿐 아니라, 필터링(이 경로의 일부에 동작을 지정)도 처리하고 구독(무슨 데이터에 접근하는 지를 제어)을 관리하기까지 한다. (주: Iron Router는 Discover Meteor의 공동저자인 Tom Coleman이 개발에 일부 참여했다.)

먼저, 이 패키지를 Atmosphere로부터 설치한다:

$ meteor add iron:router
터미널

이 명령어는 Iron Router 패키지를 다운로드하여 앱에 설치하고, 사용할 수 있는 상태로 만든다. 때로 이 패키지를 사용하기 전에 앱을 재구동(`ctrl+c`로 프로세스를 삭제한 다음 `meteor`로 다시 구동한다)해야 할 수도 있다는 점에 유의하라.

Router 용어

이 장에서 우리는 라우터의 많은 다양한 기능을 다룰 것이다. Rails와 같은 프레임워크에 대한 경험이 있다면, 이 개념의 대부분에 이미 익숙할 것이다. 하지만, 그렇지 않다면 좀 더 빠르게 배울 수 있도록 용어를 제공한다:

  • 루트(Routes): 루트는 라우팅의 기본 구축 블록이다. 이것은 앱에게 어디로 갈지 그리고 URL을 만나면 무엇을 할 지를 지시하는 지침의 집합이다.
  • 경로(Paths): 경로는 앱에 있는 URL이다. 이것은 정적(`/termsofservice`)일 수도 있고, 동적(`/posts/xyz`)일 수도 있으며, 쿼리 매개변수(`/search?keyword=meteor`)를 담을 수도 있다.
  • 세그먼트(Segments): 슬래시 문자(`/`)로 구분되는 경로의 일부를 의미한다.
  • 후크(Hooks): 후크는 라우팅 프로세스의 전, 후 또는 그 프로세스 중간에 실행될 수 있는 동작이다. 전형적인 예제로는 페이지를 보여주기 전에 사용자가 적절한 권한을 가지고 있는지 확인하는 것이 있다.
  • 필터(Filters): 필터는 하나 이상의 루트에서 전역적으로 정의하는 단순한 hook이다.
  • 루트 템플릿(Route Templates): 각 루트는 템플릿을 지정해야 한다. 만약 템플릿을 지정하지 않으면, 라우터는 루트와 이름이 같은 템플릿을 찾는다.
  • 레이아웃(Layouts): 레이아웃은 디지털 사진 프레임의 하나로 생각할 수 있다. 레이아웃은 현재의 템플릿을 감싸는 모든 HTML 코드를 포함하며, 템플릿이 변경되어도 현재 상태를 유지한다.
  • 컨트롤러(Controllers): 때때로, 많은 템플릿이 동일한 매개변수를 재사용하는 것을 볼 수 있다. 이 경우에 코드를 반복하는 대신에, 이런 루트들마다 라우팅 로직 전체를 담고 있는 하나의 routing controller에서 상속받아 구현하게 할 수 있다.

Iron Router에 대하여 더 자세히 알고 싶다면, GitHub에 있는 문서를 참조하기 바란다.

라우팅: URL을 템플릿에 매핑하기

지금까지, {{>postsList}}와 같은 것을 포함하는 하드 코딩된 템플릿을 사용하여 레이아웃을 작성하였다. 그래서 앱의 콘텐츠가 변경되어도, 해당 페이지의 기본 구조는 항상 동일하였다: 헤더가 있고 그 아래에 post 목록이 있는 형태의 구조를 말한다.

Iron Router를 사용하면 HTML의 <body> 태그 내부에서 그려지는 것을 다른 파일로 넘길 수 있다. 그래서 정규 HTML페이지에서 작업하는 것과 같은 형태로 태그의 콘텐츠를 정의하지는 않을 것이다. 대신, 라우터가 {{> yield}} 템플릿 헬퍼를 담고 있는 특별한 레이아웃 템플릿을 가리키도록 할 것이다.

{{> yield}} 헬퍼는 특별한 동적 영역을 정의하여 어떤 템플릿이 현재 경로(관습상, 이 특별한 템플릿을 지금부터 “route 템플릿”이라 부른다.)에 대응하던간에 자동으로 그려주도록 할 것이다:

레이아웃과 템플릿.
레이아웃과 템플릿.

먼저, 레이아웃을 만들고 여기에 {{> yield}} 헬퍼를 추가한다. main.html 파일에서 HTML <body> 태그를 삭제하고 레이아웃 코드를 자체 템플릿인 layout.html 파일로 옮긴다 (이 파일은 새로 client/templates/application 디렉토리를 만들고 이 내부에 둔다).

Iron Router는 이 레이아웃을 확 줄어든 main.html 템플릿에 넣는다. 그 형태는 다음과 같다:

<head>
  <title>Microscope</title>
</head>
client/main.html

반면에 새로 만들어지는 layout.html 파일은 앱의 외부 레이아웃을 담는다:

<template name="layout">
  <div class="container">
    <header class="navbar navbar-default" role="navigation"> 
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

여러분은 postsList 템플릿을 삽입하는 영역을 yield 헬퍼를 호출하는 것으로 대체한 것을 볼 수 있을 것이다.

이렇게 바꾸면, 화면에는 아무것도 보이지 않는다. 그리고 브라우저 콘솔에 오류가 뜰 것이다. 이것은 라우터에게 / URL이 무엇을 할 지 아직 지정하지 않았기 때문에 단순히 빈 템플릿을 보여주는 것이다.

이제, 해 왔던 대로 root / URL을 postsList 템플릿에 매핑해보자. 프로젝트의 루트 디렉토리에 /lib 디렉토리를 만들고 여기에 router.js 파일을 만든다:

Router.configure({
  layoutTemplate: 'layout'
});

Router.map(function() {
  this.route('postsList', {path: '/'});
});
lib/router.js

우리는 두 가지 중요한 작업을 했다. 첫째, 우리는 라우터에게 모든 route에 대한 기본 레이아웃으로 방금 만든 레이아웃을 사용하도록 했다.

둘째, postsList라 불리는 새로운 route를 정의하고 이것을 / 경로로 매핑했다.

/lib 폴더

/lib 폴더 내부에 있는 코드는 앱에서 가장 먼저 로드(스마트 패키지에서는 예외가 있을 수 있다)된다. 그러므로 이곳은 항상 이용가능 상태로 있어야 할 헬퍼 코드를 넣어 두기에는 최적의 장소이다.

한 가지 경고: /lib 폴더가 /client/server 어느 쪽에도 속해있지 않다는 것은, 여기에 있는 것은 양쪽 환경 모두에서 이용가능하다는 것을 의미한다는 점에 유의해야 한다.

이름이 있는 Route

여기서 모호한 부분을 명확하게 해두자. 우리는 루트를 postsList라고 이름지었는데, 또한 템플릿에도 postsList라는 이름이 있다. 그럼, 여기에 무슨 일이 있을까?

초기 설정 상태의 Iron Router는 루트 이름과 동일한 이름을 가진 템플릿을 찾는다. 사실은 루트 이름을 경로(path)로부터 추론하기도 한다. 특정한 경우(경로가 /인 경우)에 이것이 동작하지 않기도 하지만, Iron Router는 우리가 경로를 http://localhost:3000/postsList로 사용했다면, 올바른 템플릿을 찾았을 것이다.

맨 먼저 route의 이름부터 지어야 하는 지에 대하여 궁금해 할 지 모르겠다. Route의 이름을 지음으로써 앱 내부에서의 링크 지정을 보다 쉽게 하는 Iron Router의 주요 기능을 사용할 수 있다. 가장 유용한 것이 {{pathFor}} Spacebars 헬퍼인데, 이는 route의 URL 경로를 리턴한다.

메인 홈 링크가 post 목록 페이지를 가리키도록 하려면, 정적인 / URL을 지정하는 대신에 Spacebars 헬퍼를 사용할 수 있다. 결국 그 결과는 같지만, 이렇게 하는 것이 나중에 라우터가 그 루트 경로를 변경하더라도 헬퍼가 항상 올바른 URL 경로를 리턴하게 하는 유연성을 준다.

<header class="navbar navbar-default" role="navigation"> 
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
  </div>
</header>

//...
client/templates/application/layout.html

Commit 5-1

매우 기본적인 라우팅.

데이터 기다리기(Wait on)

만약 앱의 현재 버전을 배포한다면(또는 위 링크를 사용하여 웹 인스턴스를 구동한다면), post 목록이 나타나기 전에 잠깐 동안 목록이 빈 상태로 보여지는 것을 볼 수 있다. 이것은 페이지가 처음 로드될 때, posts 구독(subscription)이 서버로부터 데이터를 가져오는 작업을 완료할 때까지는 보여줄 목록이 없기 때문이다.

무슨 일이 진행되는 동안, 그리고 사용자가 잠깐 기다려야 하는 동안 시각적인 피드백을 제공하는 것은 매우 좋은 사용자 경험이 될 것이다.

다행히 Iron Router는 이를 처리하는 편리한 방법을 제공한다: 우리는 구독을 기다리도록 할 수 있다:

우리는 posts 구독을 main.js에서 라우터로 옮긴다:

Router.configure({
  layoutTemplate: 'layout',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
lib/router.js

여기서 우리가 말하고자 하는 것은 사이트의 모든 루트에 대하여 (지금은 하나 뿐이지만, 곧 더 늘어날 것이다!), posts 구독하기를 원한다는 점이다.

이것과 이전에 (구독이 main.js 내부에 있었지만 현재는 비어있고 삭제할 수 있다) 우리가 해왔던 것의 핵심 차이는, 이제 Iron Router는 루트가 언제 “ready” 상태인 지를 – 즉, 루트가 렌더링에 필요한 데이터를 가지는 시점을 – 알고 있다.

로드된 상태를 얻기

postsList 루트가 언제 준비(ready)상태인지를 아는 것은 우리가 빈 템플릿을 보여줄 것이라면 대단한 일은 아니다. 다행히, Iron Router는 준비가 될 때까지 템플릿을 보여주는 것을 연기하고, 대신 loading 템플릿을 보여주는 빌트인 방식을 제공한다.

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
lib/router.js

유의할 점은 우리가 waitOn함수를 라우터 레벨에서 전역적으로 정의하고 있기 때문에, 이 과정은 사용자가 처음 앱에 접속할 때, 한 번만 구동된다는 사실이다. 그 이후, 그 데이터는 브라우저 메모리에 이미 로드되어 있게 된다. 그리고 라우터는 다시 기다리지 않아도 된다.

퍼즐의 마지막 조각은 실제 로딩 템플릿이다. 우리는 spin 패키지를 이용하여 멋지게 애니메이션이 되는 로딩 spinner를 만들 것이다. 이것은 meteor add sacha:spin 명령어로 추가한다. 그리고 loading 템플릿을 client/templates/includes 디렉토리에 다음과 같이 만든다:

<template name="loading">
  {{>spinner}}
</template>
client/templates/includes/loading.html

알아둘 사항은 {{>spinner}}spin 패키지에 포함된 일부라는 점이다. 이 부분이 앱의 “외부”에서 왔어도, 다른 템플릿과 마찬가지로 사용할 수 있다.

구독을 기다리도록 하는 것은 일반적으로 좋은 아이디어이다. 사용자 체험 측면에서 뿐 아니라, 템플릿 내부에서 데이터를 항상 이용할 수 있는 것으로 가정할 수 있게 한다는 점에서도 그렇다. 이것은 템플릿이 화면을 그릴 때, 필요한 데이터가 이미 이용가능한 상태가 되게 해야 하는 필요성 – 이렇게 하려면 종종 기교를 부려서 우회해야 한다 – 이 없게 된다.

Commit 5-2

Post 구독을 기다리다(waitOn).

반응성에 대하여 훑어보기

반응성(Reactivity)은 미티어의 핵심 부분이다. 우리가 아직은 이를 제대로 다루지는 않았지만, loading 템플릿이 이 개념에 대한 맛보기는 된다.

데이터가 아직 로드된 상태가 아닐 때 loading 템플릿으로 리다이렉트하는 것은 좋은데, 데이터 로드가 완료되었을 때 사용자를 본래 페이지로 되돌리는 시점을 라우터는 어떻게 알까?

현 시점에서는, 이 부분이 반응성이 등장하는 바로 그 자리라고 말하는 선에서 정리하자. 하지만 걱정마시라. 이에 대하여 곧 더 배우게 될 테니까!

특정 Post로 라우팅하기

postsList 템플릿으로 route를 설정하는 방법을 보았으므로, 이번에는 단일 post의 상세 내용을 보여주기 위한 route를 설정해보자.

한 가지 깨달은 것이 있다: 우리는 post마다 route를 하나씩 정의할 수는 없다, 이렇게 하면 수 백개의 route가 필요할 수 도 있다. 그래서, 우리는 하나의 동적인 route를 설정하고, 이 루트로 모든 post를 보여주게 할 것이다.

우선, 예전의 post 목록에서 사용했던 것과 동일한 템플릿을 렌더링하는 새로운 템플릿을 만든다.

<template name="postPage">
  {{> postItem}}
</template>
client/templates/posts/post_page.html

우리는 나중에 이 템플릿에 더 많은 (comments 같은) 엘리먼트를 추가할 것이다. 하지만, 현재는 {{> postItem}}를 포함하는 간단한 수준으로 구현할 것이다.

우리는 또 다른 루트를 만들어, /posts/<ID>의 형태를 가지는 URL 경로를 postPage 템플릿에 매핑한다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage'
});
lib/router.js

이 특이한 :_id 문법은 라우터에게 두 가지를 지시한다: 첫째, /posts/xyz/ (여기서 “xyz”는 무엇이든 가능하다) 형태의 어떤 route라도 일치하는 것으로 간주한다. 둘째, “xyz” 자리에 어떤 값이 오더라도 이것을 라우터의 params 배열에 있는 _id 속성에 넣는다.

유의할 점은 우리는 여기서 _id을 편의상 사용하고 있다는 것이다. 라우터는 이 값에 실제 _id값이 전달되는지 그저 랜덤 문자열이 전달되는지 알 방법이 없다는 것이다.

우리는 이제 올바른 템플릿에 라우팅을 하고 있지만, 여전히 무언가 빠져있다: 라우터는 우리가 보여주려는 post의 _id값을 알고 있지만, 템플릿은 여전히 실마리를 풀지 못하고 있다. 그렇다면 우리는 이 차이를 어떻게 좁힐 것인가?

감사하게도, 라우터에는 영리하게 구축된 해법이 있다: 바로 템플릿의 데이터 컨텍스트(data context)를 지정하는 것이다. 데이터 컨텍스트(data context)란 템플릿과 레이아웃으로 만들어진 맛있는 케이크의 내부를 채우는 것과 비슷하다. 단순히 템플릿을 아래 내용으로 채우기만 하면 된다:

데이터 컨텍스트.
데이터 컨텍스트.

위 예제에서, 우리는 URL에서 추출한 _id를 기반으로 post를 찾아서 적절한 데이터 컨텍스트를 얻는다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});
lib/router.js

그래서 사용자가 이 route로 접속할 때마다, 그에 대응하는 post를 찾아 이를 템플릿에 전달한다. 기억해 둘 내용은 findOne은 이 쿼리에 대응하는 단일 post를 리턴한다는 것과, 매개변수로 id만을 제공하는 것은 {_id: id}에 대한 단축 표현이라는 점이다.

Route에서의 data 함수 내부에서, this는 현재 일치한 route에 대응한다. 그리고 this.params을 사용하여 route의 이름 부분(path내에서 접두어 :를 붙여 표현하는 부분)에 접근한다.

데이터 컨텍스트에 대한 추가 정보

템플릿의 데이터 컨텍스트(data context)를 설정함으로써, 템플릿 헬퍼 내부의 this의 값을 제어할 수 있다.

이것은 보통 {{#each}} 반복자(iterator)에서 은연중에 처리되는데, 이 반복자는 각 반복 할 때의 데이터 컨텍스트를 자동적으로 해당 아이템에 지정한다:

{{#each widgets}}
  {{> widgetItem}}
{{/each}}

그러나 우리는 이것을 {{#with}}을 사용하여 명시적으로 수행할 수도 있는데, 이것은 단순히 “이 객체를 가져와서 다음 템플릿에 이를 적용시켜라"고 하는 의미이다. 예를 들면, 다음과 같이 쓸 수 있다:

{{#with myWidget}}
  {{> widgetPage}}
{{/with}}

또한 컨텍스트를 템플릿 호출에 대한 매개변수로 전달하여 동일한 결과를 얻을 수 있다. 그러므로 위의 코드는 아래와 같이 다시 쓸 수 있다:

{{> widgetPage myWidget}}

데이터 컨텍스트에 대한 보다 깊은 이해를 위하여 이 주제에 대한 우리 블로그 를 추천한다.

동적으로 명명되는 route 헬퍼 사용하기

마지막으로, 우리는 개별 post에 링크를 할 때마다 올바른 위치를 가리키고 있는 것을 확실하게 해 둘 필요가 있다. 다시, <a href="/posts/{{_id}}">과 같은 작업을 한다면, route 헬퍼를 사용하여 보다 유연하게 처리할 수 있다.

우리는 post route를 postPage라 이름을 지었고, {{pathFor 'postPage'}} 헬퍼를 사용할 수 있다:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

Commit 5-3

단일 post 페이지로 라우팅하기.

그런데 잠깐, 라우터는 /posts/xyz에서 xyz부분을 구하는 방법을 어떻게 정확하게 알까? 어찌되었건, 우리는 어떤 _id도 전달하지 않는다.

Iron Router가 참으로 똑똑해서 스스로 그것을 알아낸다는 것이 밝혀졌다. 우리는 라우터에게 postPage 루트를 사용하도록 지시했다. 그리고 라우터는 이 루트가 일종의 _id를 요구한다는 것을 안다(이것이 path를 정의하는 방법이기 때문이다).

그래서 라우터는 이 _id를 논리적으로 가장 적합한 위치 - {{pathFor 'postPage'}} 헬퍼의 데이터 컨텍스트, 다시말하면 this - 에서 찾는다. 그리고 this는 해당 post에 대응하는데, 이 post는 (놀랍게도!) _id속성을 가진다.

다른 방법으로, 라우터에게 _id 속성값을 찾고 있다는 것을 알릴 수 있다. 그 방법은 헬퍼에게 두 번째 매개변수를 전달(즉,{{pathFor 'postPage' someOtherPost}})하는 것이다. 이 패턴의 실제 적용사례는 예를 들면 목록에서 이전, 다음 post에 대한 링크를 얻을 때이다.

이것이 제대로 동작하는 지를 보려면, post 목록 페이지로 이동하여 ‘Discuss’ 링크 중의 하나를 클릭하여, 그 결과가 아래와 같은 지를 보는 것이다:

단일 post 페이지.
단일 post 페이지.

HTML5 pushState

알아두어야 할 한 가지는 이 URL들의 변경은 HTML5 pushState를 이용하여 일어난다는 것이다.

라우터는 사이트 내부 URL에 대한 클릭을 인지하면, 브라우저가 현재 앱에서 다른 곳으로 나가는 것을 방지하면서, 대신에 앱의 상태에 필요한 수정을 한다.

모든 것이 잘 동작하면 해당 페이지는 즉시 변경된다. 사실 때때로 이 변화가 너무 빨라서 일종의 페이지 변이가 요구될 수도 있다. 이것은 이 장의 범위를 넘는 것이지만, 그래도 흥미있는 주제이기는 하다.

Post Not Found

라우팅이 다음과 같이 양쪽으로 동작한다는 것을 잊지 않도록 하자: 라우팅은 임의의 페이지를 방문할 때 URL을 변경할 수 있는 반면에, URL을 변경할 때 새로운 페이지를 보여줄 수도 있다. 그래서, 이용자가 잘못된 URL을 입력하면 어떻게 처리할 지를 지정하여야 한다.

고맙게도, Iron Router는 notFoundTemplate 기능을 제공한다.

먼저, 단순하게 404 오류 메시지를 보여주는 템플릿을 구성한다:

<template name="notFound">
  <div class="not-found jumbotron">
    <h2>404</h2>
    <p>Sorry, we couldn't find a page at this address.</p>
  </div>
</template>
client/templates/application/not_found.html

그리고, Iron Router에게 이 템플릿을 가리킨다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

//...
lib/router.js

이 오류 페이지를 테스트하기 위해서, http://localhost:3000/nothing-here와 같이 임의의 URL에 접속을 시도해보자.

그런데 잠깐, 만약 http://localhost:3000/posts/xyz와 같은 형태의 URL, 여기서 xyz가 유효한 post _id아닌 경우에는 어떻게 될까? 이것은 유효하지만, 데이터가 존재하지 않는 경로이다.

다행히, Iron Router는 똑똑하게도 우리가 router.js의 끝에 특별한 dataNotFound hook를 추가하면 모든 것을 처리해 준다:

//...

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

이렇게 하면 Iron Router는 “not found” 페이지를 잘못된 경로에 대해서 뿐만 아니라 postPage 경로에서 data 함수의 리턴값이 "잘못된” (즉, null, false, undefined, 또는 empty) 객체를 리턴하는 경우에도 보여준다.

Commit 5-4

not found 템플릿 추가.

왜 “Iron”인가?

혹시 “Iron Router”라는 이름의 뒷이야기가 궁금하신가? Iron Router의 저자인 Chris Mather에 따르면 운석(meteor)이 주로 철(iron)로 이루어졌다는 사실로부터 유래한다고 한다.

세션(Session)

Sidebar 5.5

미티어는 반응형 프레임워크이다. 이 의미는 데이터가 변경되면, 명시적으로 무슨 일을 하지 않고도 앱이 변경된다는 것이다.

우리는 데이터와 route가 변경될 때, 템플릿이 이에 따라서 변경되는 과정을 이미 본 적이 있다.

우리는 이것이 어떻게 작동하는지 나중에 더 깊이있게 살펴보기로 하고, 여기서는 일반적인 앱에서 특히 유용한 몇 가지 기본적인 반응형 특성에 대하여 소개하고자 한다.

미티어 세션

현재 Microscope에서는, 사용자 앱의 현재 상태는 URL(그리고 데이터베이스)에 모두 들어있다.

하지만 많은 경우에, 앱의 현재 사용자에게만 해당하는 일시적인 상태 정보(예를 들면, 어떤 엘리먼트가 보여지는 지, 숨겨지는 지)를 저장할 필요가 있다. 세션은 이런 것을 처리하는 데 편리한 방법이다.

세션은 전역 반응형 데이터 소스이다. 이것은 전역 싱글톤 객체라는 의미에서 전역적이다: 세션은 하나만 있고 어디서나 접근할 수 있다. 전역 변수는 일반적으로 나쁘게 보는 경향이 있지만, 이 경우에 세션은 앱의 여러 다른 파트와 소통하는 중심 통신 수단으로서 사용된다.

세션의 변경

세션은 어디에서나 Session 객체로 이용할 수 있다. 세션에 값을 저장하려면 아래와 같이 한다:

 Session.set('pageTitle', 'A different title');
브라우저 콘솔

세션에서 값을 읽을 때는 Session.get('mySessionProperty');로 한다. 이것은 반응형 데이터 소스이다. 이 의미는 만약 헬퍼에 이것을 넣으면, 세션 변수가 변경될 때 헬퍼의 출력값이 반응형으로 변경된다는 것이다.

직접 해보자. 아래와 같은 코드를 layout 템플릿에 추가한다:

<header class="navbar">
  <div class="navbar-inner">
    <a class="brand" href="{{pathFor 'postsList'}}">{{pageTitle}}</a>
  </div>
</header>
client/templates/application/layout.html
Template.layout.helpers({
  pageTitle: function() { return Session.get('pageTitle'); }
});
client/templates/application/layout.js

미티어의 (“hot code reload” 또는 HCR로 알려진) 자동 리로드는 세션 변수를 보존한다. 따라서, nav bar에서 “A different title”이 보일 것이다. 만약 보이지 않으면, 이전의 명령어인 Session.set()을 다시 한 번 입력하면 된다.

더욱이 우리가 그 값을 (브라우저 콘솔에서) 한 번 더 바꾸면, 바뀐 제목이 나타나는 것을 볼 수 있다:

 Session.set('pageTitle', 'A brand new title');
브라우저 콘솔

세션은 전역에서 이용할 수 있으므로, 앱의 어디에서나 이렇게 바꿀 수 있다. 이는 큰 힘이 되지만, 너무 많이 사용하면 함정이 될 수도 있다.

그런데, Session 객체가 이용자 사이에, 또는 심지어 브라우저의 탭사이에서 조차 공유되지 않는다는 점을 유의하는 것이 중요하다. 그래서 새로운 탭에서 앱을 열면, 사이트 타이틀이 빈 이유가 바로 여기에 있다.

같은 값으로 변경

만약 세션 변수를 Session.set()으로 변경하되 동일한 값으로 변경하면, 미티어는 똑똑하게도 반응 체인에서 이를 그냥 건너뛰어 불필요한 함수 호출을 피한다.

Autorun 소개

우리는 반응형 데이터 소스의 예제를 본 적이 있고, 템플릿 헬퍼의 내부에서 실제로 그것이 동작하는 것을 보았다. 그런데 미티어의 (템플릿 헬퍼 같은) 일부 컨텍스트는 태생부터 반응형이지만, 미티어의 앱 코드의 대부분은 여전히 전통적인 비-반응형 Javascript이다.

우리 앱의 일부에 아래와 같은 코드가 있다고 가정해보자:

helloWorld = function() {
  alert(Session.get('message'));
}

우리가 세션변수를 호출한다 해도, 이를 호출하는 컨텍스트는 반응형이 아니다. 이는 변수를 변경할 때마다 새로운 alert가 구동되는 것은 아니라는 의미이다.

여기에서 Autorun이 등장한다. 그 이름에서 의미하는 바와 같이, autorun 블럭의 내부의 코드는 자동적으로 실행이 되고 그 안에서 사용된 반응형 데이터 소스가 변경될 때마다 계속 실행된다.

아래 코드를 브라우저 콘솔에서 입력해보자:

 Tracker.autorun( function() { console.log('Value is: ' + Session.get('pageTitle')); } );
Value is: A brand new title
브라우저 콘솔

예상한대로, autorun 내부에 입력된 코드 블럭이 실행되고, 그 결과는 콘솔에 나타난다. 이제, 제목을 변경해보자:

 Session.set('pageTitle', 'Yet another value');
Value is: Yet another value
브라우저 콘솔

마술같다! 세션값이 변경되니, autorun이 이를 인지하고 그 내부의 내용을 모두 재실행하여 그 출력을 콘솔로 내보냈다.

따라서 이전 예제로 돌아가서, 세션변수가 변경될 때마다 경고가 구동되게 하려면, 우리가 할 일은 코드를 autorun 블럭에 넣는 것이다:

Tracker.autorun(function() {
  alert(Session.get('message'));
});

방금 본 바와 같이, autorun은 반응형 데이터 소스를 추적하고, 그것에 즉각 대응하는 데 매우 유용하다.

Hot Code Reload

Microscope를 개발하는 동안, 우리는 개발 시간을 줄여주는 미티어의 다양한 기능들 중의 하나인 hot code reload(HCR)를 이용해왔다. 우리가 소스 코드 파일 중의 하나를 저장할 때마다, 미티어는 이를 감지하고 현재 실행중인 미티어 서버를 재실행하고 각 클라이언트에 해당 페이지를 다시 로드하도록 통지한다.

이것은 페이지의 자동 리로드와 비슷하지만 중요한 차이점이 있다.

그것이 무엇인지를 알기 위해서, 우리가 사용한 세션 변수를 리셋해보자:

 Session.set('pageTitle', 'A brand new title');
 Session.get('pageTitle');
'A brand new title'
브라우저 콘솔

만약 브라우저 창을 수동으로 리로드하면, 세션 변수는 자연적으로 소멸될 것이다(이렇게 하면 새로운 세션이 생성되기 때문이다). 반면에 hot code reload 기능을 구동하면 (예를들면, 소스파일의 일부를 저장하여), 페이지는 리로드되지만 세션변수는 여전히 값을 유지할 것이다. 지금 해보라!

 Session.get('pageTitle');
'A brand new title'
브라우저 콘솔

그러므로, 사용자가 하는 일을 정확하게 추적하는 데 세션 변수를 사용한다면, 사용자는 HCR을 거의 느끼지 못할 것이다. 왜냐면 이것은 모든 세션변수의 값을 보존할 것이기 때문이다. 따라서 우리는 미티어 앱의 새 버전의 제품을 배포할 때, 사용자에게 지장을 최소화할 것이라는 확신을 가진다.

잠깐 이에 대하여 숙고해보자. 우리가 URL와 세션에 모든 상태값을 저장하게 되면, 각 클라이언트 앱의 실행중인 소스코드를 최소한의 지장을 주는 수준에서 사용자가 느끼지 못하게 변경할 수 있다.

이제 페이지를 수동으로 갱신할 때 무슨 일이 일어나는지 살펴보자:

 Session.get('pageTitle');
null
브라우저 콘솔

페이지가 리로드되면, 세션 정보는 사라진다. HCR의 경우, 미티어는 세션을 브라우저의 로컬 저장소에 저장하며, 리로드 후에 그것을 다시 읽어들인다. 그런데, 수동 리로드에서의 이 다른 행태는 이해가 된다: 사용자가 페이지를 리로드하면, 그것은 사용자가 동일한 URL로 브라우징을 다시 한 것으로 인식한다. 그래서 세션 정보는 사용자가 해당 URL을 방문할 때 보는 초기상태로 리셋된다.

이 모두에서 얻는 중요한 가르침은 다음과 같다:

  1. HCR이 일어날 때 사용자가 받는 영향을 최소화하도록 항상 사용자 상태를 세션이나 URL에 저장하라.
  2. 사용자들 사이에 공유되기를 원하는 상태가 있다면 이를 URL에 저장하라.

사용자 추가

6

이제까지, 우리는 일부 정적 데이터를 합리적인 방식으로 생성하고 화면에 보여주었으며, 이들을 서로 연결하여 간단한 프로토타입을 구성하여 보았다.

UI가 데이터의 변경에 어떻게 반응하는 지도 보았고, 추가되거나 수정된 데이터가 바로 화면에 나타나는 것도 보았다. 하지만, 아직 우리 사이트는 데이터를 입력하지 못하고 있다. 사실 우리는 아직 사용자도 없다!

이런 문제를 처리하는 방법을 알아보자.

계정: 간단하게 만들어지는 사용자

대부분의 웹 프레임워크에서 사용자 계정을 추가하는 일은 익숙하면서도 지루한 일거리다. 확실히, 이 일은 거의 모든 프로젝트에서 구현해야 하지만, 결코 쉽지 않다. 더군다나, OAuth나 다른 서드파티 인증 체계를 처리해야 하는 경우에는 일거리는 불쾌할 정도로 늘어난다.

다행히, 미티어가 대신해준다. 미티어 패키지가 서버(Javascript)와 클라이언트(JavaScript, HTML, 그리고 CSS) 양쪽에서 작동하는 덕분에 계정 시스템이 거의 공짜로 구현된다.

우리는 미티어의 계정에 대한 빌트인 UI(meteor add accounts-ui 명령으로)를 사용하면 되는데, 현재 앱을 Bootstrap으로 구현하고 있으므로, 대신에 ian:accounts-ui-bootstrap-3 패키지(걱정마라. 다른 점은 오로지 스타일뿐이다)로 구현한다. 커맨드 라인에서 다음과 같이 입력한다:

meteor add ian:accounts-ui-bootstrap-3
meteor add accounts-password
터미널

위의 두 명령어는 특별한 accounts 템플릿을 제공하며, 우리는 이들을 사이트에서 {{> loginButtons}} 헬퍼를 사용하여 적용한다. 도움이 되는 팁: 로그인 드롭다운 박스에 대한 정렬은 align 속성으로 구현한다(예를 들면, {{> loginButtons align="right"}}).

헤더에 버튼을 추가한다. 그리고 헤더가 좀 커질 수 있으므로, 템플릿에 공간을 좀 더 주도록 하자(이것을 client/templates/includes/에 넣는다). 또한 일부 추가 마크업과 Bootstrap 클래스를 사용하여 외관을 멋지게 마무리한다:

<template name="layout">
  <div class="container">
    {{> header}}
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html
<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 navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

이제, 브라우저로 앱을 열면, 사이트의 우상단에 계정 로그인 버튼을 볼 수 있다.

미티어의 빌트인 accounts UI
미티어의 빌트인 accounts UI

이것으로 회원 가입, 로그인, 비밀번호 변경 요청, 그리고 비밀번호 기반의 계정 시스템에서 요구되는 그 밖의 모든 것을 구현할 수 있다.

이 계정 시스템에서 username으로 로그인을 구현하도록 설정하려면, client/helpers/ 내부에 config.js 파일을 만들고 여기에 Accounts.ui 설정 블록을 만들어 적용한다:

Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY'
});
client/helpers/config.js

Commit 6-1

계정을 추가하고 헤더에 템플릿을 추가했다.

첫 사용자 등록하기

계속해서 계정을 등록해보자: “Sign in” 버튼이 등록한 username으로 바뀌어 보여질 것이다. 이것은 계정이 생성되었음을 의미한다. 그런데 이 계정 데이터는 어디서 온 것일까?

accounts 패키지를 추가함으로써, 미티어는 특별한 컬렉션을 생성하였고, 이것은 Meteor.users로 접근할 수 있다. 이를 보려면, 브라우저 콘솔을 열고 다음과 같이 입력해보라:

 Meteor.users.findOne();
브라우저 콘솔

콘솔은 방금 입력한 사용자 정보를 담은 객체를 리턴할 것이다; 이를 살펴보면, 등록한 username이 있고, 유일한 키인 _id도 들어있다. Meteor.user() 명령으로도 현재 로그인한 사용자 정보를 얻을 수 있다.

이제 로그아웃하고 다른 계정을 등록해보자. Meteor.user() 명령은 두 번째 사용자 정보를 리턴할 것이다. 그런데, 다음을 실행해보자:

 Meteor.users.find().count();
1
브라우저 콘솔

콘솔은 1을 리턴한다. 잠깐, 2이어야 하지 않나? 첫 번째 등록한 계정은 삭제된 것일까? 첫 번째 계정으로 다시 로그인해보면 그것은 아니라는 것을 알게 될 것이다.

규범적(canonical) 데이터 저장소인 Mongo 데이터베이스에서 확인해보자. Mongo에 로그인(터미널에서 meteor mongo를 입력한다)하여 확인해보자:

> db.users.count()
2
Mongo 콘솔

분명히 두 명의 사용자가 있다. 그러면, 왜 브라우저에서는 한 명만 보이는 것일까?

미스터리한 발행(Publication)!

4장을 돌이켜보면, autopublish 기능을 꺼두었던 일을 기억할 것이다. 이것으로 서버의 컬렉션에 담긴 모든 데이터가 연결된 클라이언트의 로컬 컬렉션으로 자동으로 보내지는 것을 중지한 것이다. 우리는 발행과 구독의 쌍을 구현하여 그 데이터가 서로 전달되도록 해야 한다.

그런데, 우리는 계정에 대한 발행은 설정한 적이 없다. 그러니 우리가 어떻게 사용자 정보를 볼 수 있겠는가?

답은 accounts 패키지는 현재 로그인한 사용자 계정에 대하여만 “auto-publish"를 한다는 것이다. 이것이 작동하지 않으면, 사용자는 결코 사이트에 로그인 할 수가 없다!

accounts 패키지는 현재 사용자 계정만을 발행한다. 이것이 바로 한 사용자가 또 다른 사용자의 계정 정보를 볼 수 없는 이유를 설명해준다.

그러므로 발행은 로그인한 사용자당 하나의 계정 객체(로그인하지 않으면 하나도 없다)만을 발행한다.

더욱이, 사용자 계정 컬렉션의 내용을 보면 서버에서와 클라이언트에서 동일한 필드를 보여주지 않는다는 것도 알 수 있다. Mongo에서 사용자 계정은 많은 데이터를 가지고 있다. Mongo 터미널에서 다음을 입력해보자:

> db.users.findOne()
{
  "createdAt" : 1365649830922,
  "_id" : "kYdBd9hr3fWPGPcii",
  "services" : {
    "password" : {
      "srp" : {
        "identity" : "qyFCnw4MmRbmGyBdN",
        "salt" : "YcBjRa7ArXn5tdCdE",
        "verifier" : "df2c001edadf4e475e703fa8cd093abd4b63afccbca48fad1d2a0986ff2bcfba920d3f122d358c4af0c287f8eaf9690a2c7e376d701ab2fe1acd53a5bc3e843905d5dcaf2f1c47c25bf5dd87764d1f58c8c01e4539872a9765d2b27c700dcdedadf5ac82521467356d3f91dbeaf9848158987c6d359c5423e6b9cabf34fa0b45"
      }
    },
    "resume" : {
      "loginTokens" : [
        {
          "token" : "BMHipQqjfLoPz7gru",
          "when" : 1365649830922
        }
      ]
    }
  },
  "username" : "tmeasday"
}
Mongo 콘솔

한편, 브라우저에서 사용자 계정 정보는 동일한 명령어를 입력하여 보면, 훨씬 줄어들어 있다는 것을 볼 수 있다:

 Meteor.users.findOne();
Object {_id: "kYdBd9hr3fWPGPcii", username: "tmeasday"}
브라우저 콘솔

이 예제는 로컬 컬렉션이 어떻게 실제 데이터베이스의 보안이 강화된 부분집합이 되는 지를 보여준다. 로그인 사용자는 실제 작동에 필요한 데이터만을 볼 수 있는 것이다 (이 경우, 로그인). 이는 배워둘 만한 유용한 패턴으로 나중에 볼 것이다.

이것은 보다 많은 데이터를 공개하고 싶어도 이를 할 수 없다는 의미는 아니다. Meteor docs를 참조하면 Meteor.users 컬렉션에서 더 많은 필드를 공개하는 방법을 알 수 있다.

반응성(Reactivity)

Sidebar 6.5

컬렉션이 미티어의 핵심 기능이라면, 반응성(reactivity)은 그 핵심 기능을 쓸모있게 하는 껍질이다.

컬렉션은 애플리케이션이 데이터의 변경을 처리하는 방법을 확 바꾼다. 데이터의 변경 여부를 직접 체크(예를 들면, AJAX 호출을 통해)하여 그 변경 사항을 HTML에 반영하기 보다는, 데이터의 변경이 어느 시점에든 발생하면 미티어가 사용자 인터페이스에 부드럽게 반영한다.

잠깐 이에 대하여 생각해보자: 미티어는 컬렉션이 변경될 때, 연관된 사용자 인터페이스의 어느 부분이든지 은연중에 바꿀 수 있다.

이렇게 하기 위한 필수적인 방법은 .observe()를 사용하는 것인데, 이것은 커서 함수로 해당 커서에 일치하는 도큐먼트가 변경될 때 콜백들을 호출한다. 그러면, 이 콜백들을 통해서 DOM (웹페이지의 렌더링된 HTML)을 수정할 수 있다. 그 결과 코드는 아래와 같은 형태가 될 것이다:

Posts.find().observe({
  added: function(post) {
    // when 'added' callback fires, add HTML element
    $('ul').append('<li id="' + post._id + '">' + post.title + '</li>');
  },
  changed: function(post) {
    // when 'changed' callback fires, modify HTML element's text
    $('ul li#' + post._id).text(post.title);
  },
  removed: function(post) {
    // when 'removed' callback fires, remove HTML element
    $('ul li#' + post._id).remove();
  }
});

이런 코드가 얼마나 빨리 복잡해지는 지는 아마도 이미 볼 것이다. Post의 각 속성의 변경을 처리하는 경우와 post의 <li> 내부에 있는 복잡한 HTML을 변경해야 하는 경우를 상상해보라. 우리가 실시간으로 변하는 정보의 다중 소스에 의존하기 시작할 때 발생하는 복잡한 경우는 말할 것도 없다.

언제 observe()사용해야 할까?

때로는 위의 패턴을 사용할 필요가 있는데, 특히 써드파티 위젯을 처리할 때이다. 예를 들어, 지도에 있는 컬렉션 데이터(이를테면, 현재 로그인한 사용자들의 위치를 보여주는)에 기반한 핀을 실시간으로 추가하거나 삭제하고자 하는 경우를 상상해보자.

이런 경우에, 지도와 미티어 컬렉션과의 “통신"을 구현하고 데이터 변경에 반응하는 방법을 알기 위해서는 observe() 콜백이 필요할 것이다. 예를 들면, 지도 API의 dropPin()이나 removePin() 메서드를 호출하기 위해서 addedremoved 콜백에 의존하게 될 것이다.

선언적 접근법

미티어는 반응성의 구현에 있어 더 나은 방법을 제공한다: 그 핵심은 선언적 접근법이다. 선언적이란 객체들 사이의 관계를 한 번 정의하면 그들이 동기화를 유지하는 것이다. 모든 가능한 변경에 대하여 일일이 명시하지 않아도 된다.

이것은 강력한 개념인데, 그 이유는 실서비스되는 시스템에서는 많은 입력이 예측할 수 없는 시점에 변경되기 때문이다. 어떤 반응형 데이터 소스이든 HTML을 렌더링하는 방법을 선언적으로 지정함으로써, 미티어는 그 소스들을 모니터하면서 사용자 인터페이스를 최신의 상태로 유지하는 복잡한 작업을 수행한다.

말하자면, observe 콜백을 생각하는 대신에, 미티어는 다음과 같이 작성하기를 권한다:

<template name="postsList">
  <ul>
    {{#each posts}}
      <li>{{title}}</li>
    {{/each}}
  </ul>
</template>

그리고 post 목록을 아래와 같이 구한다:

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});

보이지 않는 내부에서, 미티어는 observe() 콜백을 호출하여 반응형 데이터가 변경될 때, 적절한 HMTL 영역을 다시 그린다.

미티어에서의 의존성 추적: 컴퓨테이션(Computation)

미티어가 실시간, 반응형 프레임워크이지만, 미티어 앱 내부의 모든 코드가 반응형인 것은 아니다. 만약 그렇다면, 무엇이든 변경될 때마다 앱 전체가 재구동될 것이다. 그게 아니라, 반응성은 코드의 특정한 영역에 한정되는데, 이 영역을 컴퓨테이션(computation)이라 부른다.

다시말하면, 컴퓨테이션은 연계된 반응형 데이터 소스 가운데 하나라도 변경되면 실행되는 코드 블록이다. 만약 반응형 데이터 소스(예를 들면, 세션 변수)가 있고 이 변경에 반응형으로 응답하게 하려면, 이 데이터 소스에 대한 컴퓨테이션을 설정해야 한다.

보통 이런 작업을 명시적으로 구현해야 할 필요는 없는데, 이것은 미티어가 각 템플릿마다 특정한 컴퓨테이션(템플릿 헬퍼와 콜백의 코드는 기본적으로 반응형이라는 것을 의미한다)을 이미 부여한 상태이기 때문이다.

모든 반응형 데이터 소스는 이를 이용하는 모든 컴퓨테이션을 추적하여 이들에게 그 값이 언제 변경되는 지를 알려준다. 이를 위해, 반응형 데이터 소스는 컴퓨테이션에 invalidate() 함수를 호출한다.

컴퓨테이션은 일반적으로 무효화(invalidation)가 발생했을 때 그 콘텐츠를 재계산하도록 설정되어 있다. 그리고 이것이 템플릿 컴퓨테이션(비록 템플릿 컴퓨테이션이 페이지를 보다 효율적으로 다시 그리는 마술을 부리기도 하지만)에서 일어난다. 무효화가 일어나면 컴퓨테이션이 할 일에 대하여 제어를 더 잘할 수 있겠지만, 실제로 이것은 거의 항상 독자가 이용하고 있을 것이다.

컴퓨테이션 설정

컴퓨테이션의 배경 이론을 이해하면, 실제로 이를 설정하는 것은 매우 쉽다. 컴퓨테이션의 코드 블럭을 감싸고 이를 반응형으로 만들기 위해서는 단순히 Tracker.autorun 함수를 사용하면 된다:

Tracker.autorun(function() {
  console.log('There are ' + Posts.find().count() + ' posts');
});

Tracker 블록을 Meteor.startup() 블록 내부에 두어야 한다는 점에 유의하기 바란다. 이것은 미티어가 Posts 컬렉션 로딩을 끝낸 후에 한 번만 실행하도록 하기 위함이다.

보이지 않는 내부에서, autorun은 컴퓨테이션을 만들고, 연계된 데이터소스가 변경될 때마다 재계산한다. 우리는 콘솔에 post의 숫자를 로그로 출력하는 간단한 컴퓨테이션을 설정했다. 이것은 Posts.find()가 반응형 데이터 소스이므로 post 갯수가 변경될 때마다 재계산하도록 컴퓨테이션에게 지시할 것이다.

> Posts.insert({title: 'New Post'});
There are 4 posts.

이 전체의 실질적 결론은 우리가 아주 자연스럽게 반응형 데이터를 사용하는 코드를 작성할 수 있다는 것이다. 은연중에 그 연관 시스템이 적절한 시간에 재실행시키는 것도 알면서 말이다.

Post 등록하기

7

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목록을 정렬한다.

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

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

대기시간 보정(Latency Compensation)

Sidebar 7.5

이전 장에서 우리는 미티어 세계의 새로운 개념인 메서드(Method)에 대하여 소개했다.

대기시간 보정이 없을 때
대기시간 보정이 없을 때

미티어 Method는 서버에 있는 일련의 명령어들을 구조화된 방식으로 실행하는 수단이다. 이전 예제에서 Method를 사용한 이유는 새 post에 등록일시뿐 아니라 저자의 이름과 id를 함께 저장하기를 원했기 때문이다.

그런데, Method를 가장 기본적인 방식으로 실행하면 문제가 생긴다. 다음 일련의 이벤트에 대하여 숙고해보자(주의: 시간은 설명을 위해서만 무작위로 정한 값이다):

  • +0ms: 사용자가 등록 버튼을 누르고 브라우저는 Method를 호출한다.
  • +200ms: 서버가 Mongo 데이터베이스에 변경내용을 저장한다.
  • +500ms: 클라이언트가 변경 내용을 받고 이를 UI에 반영한다.

미티어가 이렇게 동작한다면, 각 단계별 작업과 그 결과를 보는 시간 사이에 약간의 지체(이 시간 지체는 사용자와 서버사이의 거리에 다소 의존성이 존재한다)가 발생한다. 현대적 웹 애플리케이션에서는 이를 용납할 수 없다!

대기시간 보정

대기시간 보정이 있을 때
대기시간 보정이 있을 때

이 문제점을 피하기 위하여, 미티어는 대기시간 보정(Latency Compensation)이라는 개념을 도입했다. 우리는 post Method를 정의하여 collections/ 디렉토리에 있는 파일에 넣었다. 이렇게 하면 서버와 그리고 클라이언트 양쪽에서 이용할 수 있다 - 그리고 동시에 양쪽에서 구동될 수 있다!

Method를 호출하는 순간, 클라이언트는 그 요청을 서버에 보내는 동시에 그 로컬 컬렉션에서 그 Method의 작업을 흉내낸다. 그래서 워크플로우는 다음과 같이 된다:

  • +0ms: 사용자가 등록버튼을 클릭하고 브라우저는 Method를 호출한다.
  • +0ms: 클라이언트는 로컬 컬렉션에 Method 호출의 작용을 흉내내어 그 변경내용을 UI에 반영한다.
  • +200ms: 서버는 Mongo 데이터베이스에 변경 내용을 저장한다.
  • +500ms: 클라이언트는 그 변경내용을 받고 흉내낸 변경 작업을 취소한 다음, 서버의 변경내용(일반적으로 서로 같다)으로 바꿔치기한다. UI의 변경은 이를 반영한다.

이 결과로 사용자는 변경이 순간적으로 이루어지는 것으로 인식한다. 서버의 반응이 약간의 시차를 두고 리턴할 때, 서버의 canonical 도큐먼트가 내려오면 눈에 띄는 변화가 있을 수도 있고 그렇지 않을 수도 있다. 여기서 한 가지 배울 것은 실제 도큐먼트를 우리가 할 수 있는 한 최대한 비슷하게 흉내를 내어야 한다는 것이다.

대기시간 보정 관찰하기

이를 실제로 보기 위해서 post Method 호출을 살짝 바꿔보자. 우리는 Method의 객체의 삽입 시간을 지체시키는 futures npm package를 이용하여 다소 고급 코딩을 시도할 것이다.

isSimulation을 사용하여 미티어에게 Method가 현재 stub으로 구동된 것인지를 묻는다. “실제” Method는 서버에서 구동되는 반면, stub는 미티어가 클라이언트에서 병행적으로 실행하는 Method simulation이다.

그러므로, 우리는 미티어에게 이 코드가 클라이언트에서 실행되는 지를 묻는다. 만약 그렇다면, 문자열 (client)을 글 제목의 끝부분에 추가하고, 그렇지 않으면 문자열 (server)을 추가한다:

Posts = new Mongo.Collection('posts');

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    if (Meteor.isServer) {
      postAttributes.title += "(server)";
      // wait for 5 seconds
      Meteor._sleepForMs(5000);
    } else {
      postAttributes.title += "(client)";
    }

    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
    };
  }
});
collections/posts.js

우리가 여기서 멈춘다면, 이 시연에서 결정적인 모습은 보이지 않을 것이다. 이 시점에서, post 등록폼은 사용자에게 redirect하기 전에 5초간 쉬었다가 post 목록으로 되돌아가는 것일 뿐 별다른 일이 일어나지 않는다.

그 이유를 이해하기 위해서, 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);

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

우리는 Router.go()를 method 호출의 콜백 내부에서 호출하였다. 이것은 폼이 redirect하기 전에 method의 성공을 기다리는 것을 의미한다.

이것은 올바른 작동이 구현되는 과정이다. 결론은 그 post 등록의 결과가 유효한지 아닌지를 알기 전에 사용자를 redirect 시킬 수 없다는 사실이다. 왜냐하면, 한 번 redirect했다가 몇 초 안에 데이터를 수정하도록 다시 원래의 post 등록폼 페이지로 되돌린다면 매우 혼란스러울 것이기 때문이다.

그러나, 이 예제에서 우리는 그 등록 결과를 즉시 보기를 원한다. 그러므로 우리는 라우팅 호출을 postsList 루트로 redirect하도록 바꾸고 (우리는 post로 루트를 지정하지는 못한다. 왜냐하면 그 method 외부에서는 _id값을 알 수 없기 때문이다.) 콜백에서 꺼내어 무슨 일이 일어나는 지 보려고 한다:

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('postsList');  

  }
});
client/templates/posts/post_submit.js

Commit 7-5-1

Sleep 기능을 사용하여 post 목록이 나타나는 순서를 보여준다.

이제 새 post를 등록하면, 대기시간 보정을 명백하게 관찰할 수 있다. 우선 post는 제목(GitHub로 링크가 잡힌 목록의 첫 post)에 (client)이 추가되어 나타난다:

처음 클라이언트 컬렉션에 저장된 post
처음 클라이언트 컬렉션에 저장된 post

그리고, 5초가 지나면, 서버에 등록된 실제 도큐먼트로 대체된다:

서버 컬렉션으로부터 수정본을 받은 후의 post
서버 컬렉션으로부터 수정본을 받은 후의 post

클라이언트 컬렉션 Methods

이 과정을 공부하고 나서 메서드가 복잡하다고 생각할 지 모르겠지만, 사실 메서드는 아주 단순하다. 실제로 우리는 이미 아주 단순한 3개의 메서드를 본 적이 있다: 컬렉션을 다루는 메서드인 insert, update 그리고 remove이다.

‘posts'라는 이름의 서버 컬렉션을 정의할 때, 이미 3개의 메서드를 은연중에 정의하는 것이다: posts/insert, posts/update 그리고 posts/delete. 다른 말로 표현하면, 클라이언트 컬렉션에서 Posts.insert()을 호출하는 것은 다음 두 가지 작업을 수행하는 대기시간 보정 메서드를 호출하는 것이다:

  1. 콜백 함수 allow 와 deny를 호출하여 변경을 가할 수 있는 지를 검사한다.
  2. 해당 데이터 저장소에 실제로 수정을 가한다.

메서드를 호출하는 메서드

이제까지의 과정을 계속 따라왔다면, post 메서드가 post를 등록할 때 또 다른 메서드 (posts/insert)를 호출하였다는 것을 알았을 것이다. 이것은 어떻게 작동할까?

Simulation(메서드의 클라이언트 쪽 버전)이 실행중일 때, 우리는 insert의 simulation(그래서 클라이언트 컬렉션에 삽입한다)을 실행한다. 그러나 우리는 실제인 서버쪽의 insert는 호출하지 않는데, 그것은 post의 서버쪽 버전이 이 작업을 할 것이기 때문이다.

결과적으로, 서버쪽의 post 메서드가 insert를 호출할 때, simulation에 대하여 걱정할 필요없이 그 등록과정은 부드럽게 진행된다.

Post 수정

8

이제 우리는 post를 등록할 수 있다. 다음 단계는 이들을 수정하고, 삭제하는 것이다. 이를 처리하는 UI 코드는 비교적 단순하니, 이 시점에서 미티어의 사용자 권한 제어방식에 대하여 알아보자.

먼저 라우터를 열어보자. Post 수정 페이지에 접근하는 route를 추가하고 그 데이터 컨텍스트를 설정한다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postEdit', {
    path: '/posts/:_id/edit',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn())
      this.render('loading')
    else
      this.render('accessDenied');

    this.stop();
  }
}

Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Post 수정 템플릿

이제 템플릿에 초점을 맞춘다. postEdit 템플릿은 비교적 표준적인 폼이다:

<template name="postEdit">
  <form class="main">
    <div class="control-group">
        <label class="control-label" for="url">URL</label>
        <div class="controls">
            <input name="url" type="text" value="{{url}}" placeholder="Your URL"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="title">Title</label>
        <div class="controls">
            <input name="title" type="text" value="{{title}}" placeholder="Name your post"/>
        </div>
    </div>

    <div class="control-group">
        <div class="controls">
            <input type="submit" value="Submit" class="btn btn-primary submit"/>
        </div>
    </div>
    <hr/>
    <div class="control-group">
        <div class="controls">
            <a class="btn btn-danger delete" href="#">Delete post</a>
        </div>
    </div>
  </form>
</template>
client/views/posts/post_edit.html

그리고 post_edit.js 매니저는 다음과 같다:

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
        alert(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/views/posts/post_edit.js

지금까지 대부분의 코드는 익숙하다. 첫째, 템플릿 헬퍼는 현재의 post를 가져와서 이를 템플릿으로 전달한다.

그리고 두 개의 템플릿 이벤트 콜백이 있다: 하나는 폼의 submit 이벤트를 처리하는 것이고, 다른 하나는 삭제 링크의 click 이벤트를 처리하는 것이다.

삭제 콜백은 정말 간단하다: 초기설정의 클릭 이벤트를 중지시키고, 삭제여부를 다시 확인한다. 확인을 하면, 템플릿의 데이터 컨텍스트에서 현재 post의 ID를 얻어서, 해당 post를 삭제한 다음, 사용자를 홈페이지로 리다이렉트 처리한다.

수정 콜백은 약간 더 길지만, 훨씬 복잡한 정도는 아니다. 초기설정 이벤트를 중지시킨 다음 현재 post를 얻은 후, 페이지에서 새로운 폼 필드값을 얻어 이를 postProperties 객체에 저장한다.

그리고는, 이 객체를 미티어의 Collection.update() Method로 전달한다. 그리고 수정이 실패하면 오류를 보여주는 콜백을 사용하고, 수정이 성공하면 post페이지를 다시 사용자에게 보여준다.

링크 추가

또한 post에 링크를 추가하여 사용자가 post 수정 페이지로 접근할 수 있는 방법을 제공한다:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}}
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html

물론, 다른 사용자의 수정 폼으로의 링크를 보여주길 원하지는 않는다. 이것이 ownPost 헬퍼가 필요한 이유다:

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/views/posts/post_item.js
Post 수정폼.
Post 수정폼.

Commit 8-1

Post 수정폼을 추가했다.

수정 폼은 좋아 보이지만, 실제로는 지금 바로 무엇을 수정할 수는 없을 것이다. 무엇이 문제인가?

권한 설정

앞서 insecure 패키지를 제거하였기 때문에, 모든 클라이언트 쪽에서의 수정은 현재 거부되고 있다.

이를 고치기 위해서, 몇 가지의 권한 규정을 설정한다. 첫째, 새로운 permissions.js 파일을 lib 디렉토리에 만든다. 이 파일은 권한제어 로직을 먼저 구동한다(그리고 양쪽에서 이용할 수 있다):

// check that the userId specified owns the documents
ownsDocument = function(userId, doc) {
  return doc && doc.userId === userId;
}
lib/permissions.js

Post 등록하기장에서, 우리는 새 post의 등록을 서버 (allow()를 지나치는) 메서드를 통해서만 등록을 하고 있었기 때문에 allow() 메서드를 제거했다.

그러나, 지금은 클라이언트로부터 post를 수정하고 삭제하려고 한다. 그러므로 posts.jsallow() 블럭을 추가한다:

Posts = new Meteor.Collection('posts');

Posts.allow({
  update: ownsDocument,
  remove: ownsDocument
});

Meteor.methods({
  ...
collections/posts.js

Commit 8-2

Post의 소유자를 검사하는 기본적인 접근권한 제어 기능을 추가했다.

수정 범위의 제한

단지 post를 수정할 수 있다고 해서, 모든 속성을 수정할 수 있다는 의미는 아니다. 예를 들면, 사용자들에게 post 등록권한을 부여하지 않고, 등록한 post를 다른 사람에게 배정한다.

우리는 미티어의 deny() 콜백을 이용하여 사용자들이 특정한 필드만을 수정할 수 있게 한다:

Posts = new Meteor.Collection('posts');

Posts.allow({
  update: ownsDocument,
  remove: ownsDocument
});

Posts.deny({
  update: function(userId, post, fieldNames) {
    // may only edit the following two fields:
    return (_.without(fieldNames, 'url', 'title').length > 0);
  }
});
collections/posts.js

Commit 8-3

Post의 특정 필드만 수정할 수 있게 허용한다.

fieldNames 배열은 수정될 필드 목록을 담고 있다. 그리고 Underscorewithout() 메서드를 사용하여 url이나 title아닌 필드들을 담은 부분 배열을 리턴한다.

모두 정상이면, 그 배열은 비어있어야 하고, 길이는 0이어야 한다. 누군가 고약한 시도를 한다면, 그 배열의 길이는 1보다 클 것이고, 콜백은 true를 리턴한다(따라서 수정이 거부된다).

메서드 호출 대 클라이언트에서의 데이터 가공

Post를 등록하기 위해서, 우리는 post 메서드를 사용하는 반면에, 이를 삭제하기 위해서는 updateremove를 클라이언트에서 직접 호출하며 allowdeny를 통해서 접근을 제어한다.

이런 방식이 언제 적절하고 어느 때 부적절할까?

일이 상대적으로 수월하고 규칙을 allowdeny로 충분히 표현할 수 있다면, 클라이언트에서 직접 처리하는 것이 더 간단하다.

클라이언트에서 데이터베이스를 직접 조작하는 것은 즉시 인지할 수 있으며, (서버가 요청 처리를 실패했다고 리턴할 때) 실패 처리를 매끄럽게 한다는 것을 명심한다면 더 나은 사용자 경험을 구현할 수 있다.

그런데, 사용자의 권한을 넘는 작업(새로운 post의 등록일시를 저장하거나, 올바른 사용자에게 배정하는 것 같은)의 필요성을 느끼기 시작하는 순간, 메서드를 사용하는 것이 더 바람직하다.

메서드 호출은 다음의 몇 가지 시나리오에서 더 적합하다:

  • 반응성과 동기화가 전파되는 것을 기다리기 보다는 콜백을 통해 값을 리턴하거나 알아야 할 필요가 있는 경우.
  • 대형의 컬렉션을 전송하기에는 너무 부담되는 무거운 데이터베이스 함수의 경우.
  • 데이터를 요약하거나 모으기 위한 경우(예, 수량, 평균, 합계 등).

Allow와 Deny

Sidebar 8.5

미티어의 보안시스템 덕분에, 우리는 변경하고자 할 때마다 메서드를 정의하지 않고도 데이터베이스 수정을 제어할 수 있다.

왜냐하면 post를 등록할 때 ‘post’ 메서드를 사용하는 경우, post에 속성을 추가하거나 URL이 이미 등록되어 있을 때 특정한 작업을 해야 하는 것 같은 보조적인 과제를 해야 했기 때문이다.

한 편, post를 변경하거나 삭제할 때에는 새로운 메서드를 만들 필요는 없었다. 단지 사용자가 이런 작업을 할 수 있는 권한을 가지고 있는지를 검사하기만 하면 되었다. 그리고 이 작업은 allowdeny 콜백을 사용하여 쉽게 할 수 있었다.

이들 콜백을 사용함으로써 데이터베이스 수정을 선언적으로 처리할 수 있고, 어떤 수정작업을 할 지를 지정할 수 있다. 그리고 이들이 계정 시스템과 통합되어 있다는 사실은 추가 보너스이다.

다중 콜백

우리는 필요한 만큼 allow 콜백을 정의할 수 있다. 단지 변경이 발생했을 때 그 콜백들 중에서 최소한 하나만 true를 리턴하면 된다. 그러므로 브라우저에서 Posts.insert가 호출될 때(애플리케이션의 클라이언트 코드이든, 브라우저 콘솔이든 상관없이), 서버는 순서대로 호출하여 그 중 하나가 true를 리턴할 때까지 insert 검사를 한다. 만약 하나도 발견하지 못하면 insert는 허용되지 않고, 클라이언트에 403 오류를 리턴한다.

유사한 방식으로 하나 이상의 deny 콜백을 정의할 수 있다. 만약 이들 가운데 어느 것이라도 true를 리턴하면, 변경은 취소되고 403이 리턴된다. 이 로직은 성공적인 insert를 위해서는 하나 또는 그 이상의 allow와 모든 deny 콜백이 실행된다는 것을 의미한다.

주: n/e는 실행되지 않음을 의미한다.
주: n/e는 실행되지 않음을 의미한다.

다시말하면, 미티어는 콜백 목록을 따라서 실행하면서 처음엔 deny로 시작하여 다음엔 allow, 그리고 모든 콜백을 그 중 하나가 true를 리턴할 때까지 실행한다.

이 패턴의 실제 예제는 두 개의 allow() 콜백 - 하나는 post가 현재 사용자의 소유인지를 검사하고, 두 번째는 현재 사용자가 관리자 권한을 가지고 있는지를 검사하는 - 을 가질 수 있다. 만약 현재 사용자가 관리자이면 이는, 그 두 콜백 중의 하나는 적어도 true를 리턴하므로, 어떤 post도 수정할 수 있다는 것을 의미한다.

대기시간 보정

데이터베이스를 변경시키는 메서드들(.update()와 같은)은 다른 메서드와 마찬가지로 대기시간 보정이 적용된다는 사실을 기억하라. 그래서 만약 브라우저 콘솔에서 독자가 작성하지 않은 post를 삭제하려고 시도하면, post가 로컬 컬렉션의 처리에 따라서 일단 삭제되었다가 서버로부터의 정보 - 사실은 삭제가 되지 않았다는 - 가 오면 다시 나타나는 모습을 볼 수 있을 것이다.

물론 이런 행태가 콘솔에서 구동될 때는 문제가 되지 않는다(결국 사용자가 콘솔에서 시도하여 엉망이 되어도, 자기 브라우저에서 일어나는 것이니까 문제는 아닌거다). 하지만, 이것이 사용자 인터페이스에서는 일어나지 않도록 확실하게 해 둘 필요는 있다. 이를테면, 삭제할 수 없는 도큐먼트에 대하여는 삭제 버튼이 보여지지 않도록 할 필요는 있는 것이다.

고맙게도, 접근권한에 대한 코드가 클라이언트와 서버에서 공유(이를테면, 라이브러리 함수 canDeletePost(user, post)를 작성하여 공유되는 /lib 디렉토리에 둘 수 있다)될 수 있기 때문에, 이렇게 코딩하는 것이 보통 과도하게 많은 코드를 요구하는 것은 아니다.

서버에서의 접근 권한

접근제어 시스템이 클라이언트로부터 시도되는 데이터베이스 변경시도에 한하여 적용되는 것을 기억하라. 서버에서 미티어는 모든 연산이 허용된다.

클라이언트에서 호출될 수 있는 deletePost 미티어 메서드를 서버 쪽에 작성한다면, 누구나 어떤 post든지 삭제할 수 있게 된다. 그러므로 그 메서드 내부에서 사용자의 접근 권한을 검사하지 않으면 안될 것이다.

Deny 함수를 콜백으로 사용하기

마지막으로, deny로 할 수 있는 한 가지 묘기는 이것을 “onX” 콜백으로 사용하는 것이다. 이를테면, 다음과 같은 코드를 사용하면 최종 수정 시간을 얻을 수 있다:

Posts.deny({
  update: function(userId, doc, fields, modifier) {
    doc.lastModified = +(new Date());
    return false;
  },
  transform: null
});

deny 콜백이 모든 성공적인 update에서 실행되므로, 이 콜백이 실행되면서 구조화된 방식으로 도큐먼트를 변경한다는 것을 알 수 있다.

명백히, 이 기술은 일종의 핵이므로 대신 메서드를 사용하여 update를 수행하기를 원할 수 있다. 그럼에도 불구하고, 이것을 알아 둘 만하며, 언젠가는 일종의 beforeUpdate 콜백 같은 것이 이용되기를 바란다.

오류

9

사용자의 폼 입력 처리과정에서 문제가 발생했을 때 사용자에게 경고하기 위해 그저 브라우저의 표준 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의 유효성을 검사한다.

Meteor 패키지 만들기

Sidebar 9.5

오류에 대한 재사용 가능한 패턴을 구축했는데, 이를 묶어 스마트 패키지를 만들고 이를 미티어 커뮤니티에서 공유하도록 하면 어떨까?

첫째, 이 패키지의 구조를 만들어야 한다. 우리는 이 패키지를 packages/errors 라는 이름의 디렉토리에 넣는다. 이렇게 하면 자동으로 사용되는 커스텀 패키지가 된다(Meteor가 packages/ 디렉토리 에 symlink로 패키지를 설치한다는 사실을 알지 모르겠다).

둘째, 그 폴더에 package.js를 만든다. 이 파일은 미티어에게 해당 패키지를 사용하는 방법과 그 패키지가 내보내는 symbol들을 알려준다.

Package.describe({
  summary: "A pattern to display application errors to the user"
});

Package.on_use(function (api, where) {
  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

  api.add_files(['errors.js', 'errors_list.html', 'errors_list.js'], 'client');

  if (api.export) 
    api.export('Errors');
});
packages/errors/package.js

이 패키지에 세 개의 파일들을 추가한다. 이 파일들은 Microscope에서 가져오는데 네임스페이스를 바꾸는 것과 약간 개선된 API를 제외하면 거의 변화가 없다:

Errors = {
  // Local (client-only) collection
  collection: new Meteor.Collection(null),

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  },
  clearSeen: function() {
    Errors.collection.remove({seen: true});
  }
};

packages/errors/errors.js
<template name="meteorErrors">
  {{#each errors}}
    {{> meteorError}}
  {{/each}}
</template>

<template name="meteorError">
  <div class="alert alert-error">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
packages/errors/errors_list.html
Template.meteorErrors.helpers({
  errors: function() {
    return Errors.collection.find();
  }
});

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.defer(function() {
    Errors.collection.update(error._id, {$set: {seen: true}});
  });
};
packages/errors/errors_list.js

Microscope로 패키지 테스트하기

작성한 코드가 작동하는 지를 확인하기 위해 로컬에서 Microscope로 테스트를 진행한다. 패키지를 프로젝트에 연동하기 위해 meteor add errors 명령을 수행한다. 그 다음, 이 새 패키지로 인해서 필요없게 된 기존의 파일들을 삭제한다:

$ rm client/helpers/errors.js
$ rm client/views/includes/errors.html
$ rm client/views/includes/errors.js
bash 콘솔에서 예전 파일들을 삭제하기

한 가지 더 할 일은 올바른 API를 사용하도록 약간 수정을 하는 것이다:

Router.onBeforeAction(function() { Errors.clearSeen(); });
lib/router.js
  {{> header}}
  {{> meteorErrors}}
client/views/application/layout.html
Meteor.call('post', post, function(error, id) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

client/views/posts/post_submit.js
Posts.update(currentPostId, {$set: postProperties}, function(error) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);
client/views/posts/post_edit.js

Commit 9-5-1

기본적인 오류 패키지를 만들고 이를 연동했다.

이 수정작업이 완료되고 나면, 패키지 이전의 상태로 되돌려야 한다.

테스트 작성하기

패키지를 개발하는 첫 단계는 애플리케이션에서 테스트하는 것이지만, 그 다음 단계는 패키지의 작동을 적절하게 테스트하는 테스트 세트를 작성하는 것이다. 미티어 자체에 Tinytest(빌트인 패키지 테스터)가 들어있는 데, 이것을 이용하면 테스트를 쉽게 할 수 있고, 다른 사람들과 이 패키지를 공유할 때 평정심을 유지할 수 있다.

오류 코드로 몇 가지 테스트를 수행하기 위해 Tinytest를 사용하는 테스트 파일을 만들어보자:

Tinytest.add("Errors collection works", function(test) {
  test.equal(Errors.collection.find({}).count(), 0);

  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  Errors.collection.remove({});
});

Tinytest.addAsync("Errors template works", function(test, done) {  
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({seen: false}).count(), 1);

  // render the template
  OnscreenDiv(Spark.render(function() {
    return Template.meteorErrors();
  }));

  // wait a few milliseconds
  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({seen: false}).count(), 0);
    test.equal(Errors.collection.find({}).count(), 1);
    Errors.clearSeen();

    test.equal(Errors.collection.find({seen: true}).count(), 0);
    done();
  }, 500);
});
packages/errors/errors_tests.js

이 테스트에서는 기본함수인 Meteor.Errors이 동작하는 지를 검사한다. 뿐만 아니라 템플릿에서 rendered 코드가 여전히 기능하는 지에 대해서도 검사한다.

미티어 패키지 테스트를 작성하는 과정의 세세한 것까지 여기서 다루지는 않을 것이다(API는 아직 확정된 것이 아니고 매우 유동적이다). 하지만 희망하건데 이것의 작동 방법은 자체로 입증이 될 것이다.

미티어에게 package.js에서의 테스트 실행 방법을 지시하려면 다음 코드를 사용하기 바란다:

Package.on_test(function(api) {
  api.use('tmeasday:errors', 'client');
  api.use(['tinytest', 'test-helpers'], 'client');  

  api.add_files('errors_tests.js', 'client');
});
packages/errors/package.js

Commit 9-5-2

패키지에 테스트 기능을 추가했다.

그러면 다음 명령어로 테스트를 실행할 수 있다:

$ meteor test-packages tmeasday:errors
터미널
모든 테스트 통과
모든 테스트 통과

패키지 릴리즈

이제, 패키지를 릴리즈하여 모두가 이를 이용할 수 있게 하려고 한다. 그렇게 하려면 Atmosphere에 올리면 된다.

우선, smart.json을 추가하여 Meteor와 Atmosphere에 그 패키지에 대한 중요한 상세 내용을 알린다:

{
  "name": "errors",
  "description": "A pattern to display application errors to the user",
  "homepage": "https://github.com/tmeasday/meteor-errors",
  "author": "Tom Coleman <tom@thesnail.org>",
  "version": "0.1.0",
  "git": "https://github.com/tmeasday/meteor-errors.git",
  "packages": {
  }
}
packages/errors/smart.json

Commit 9-5-3

smart.json 파일을 추가했다.

그 패키지에 대한 정보를 제공하기 위하여 몇 가지 기본적인 메타데이터를 넣는다. 여기에는 이것의 주요 기능, 호스트 git 주소, 그리고 초기 버전 정보가 포함된다. 만약 이 패키지가 다른 Atmosphere 패키지에 의존한다면, 그 의존성 정보를 기술하는 "packages" 섹션을 추가한다.

이 모두가 마무리되면 릴리즈는 쉽다. git 저장소를 만들고 원격 git 서버로 올리고, smart.json에 그 주소에 대한 연결정보를 추가한다.

GitHub에서 이 작업을 하려면, 우선 새 저장소를 만든 다음, 그 저장소에서 패키지의 코드를 얻는 일반적인 과정을 따른다. 그리고, meteor release 명령어를 사용하여 게재한다:

$ git init
$ git add -A
$ git commit -m "Created Errors Package"
$ git remote add origin https://github.com/tmeasday/meteor-errors.git
$ git push origin master
$ meteor release .
Done!
터미널 (`packages/errors` 내에서 실행한다)

주: 패키지 이름은 유일해야 한다. 위의 내용을 문자 그대로 따라하여 동일한 패키지 이름을 사용하면, 충돌이 발생해서 작동하지 않게 될 것이다. 미래에 Atmosphere는 저자에 따른 네임스페이스를 가지게 될 것이므로, 이 이름이 바뀔 수 있다는 점을 감안하기 바란다.

두 번째 주: http://atmosphere.meteor.com에 로그인하여 meteor release를 호출할 때 커맨드 라인에서 입력할 username과 비밀번호를 만들어야 한다.

이제 패키지가 릴리즈되면, 프로젝트에서 그것을 제거하고 그것을 직접 추가하여 복원한다:

$ rm -r packages/errors
$ meteor add errors
터미널 (앱의 루트 경로에서 실행한다)

Commit 9-5-4

개발 트리에서 패키지를 삭제했다.

이제 Meteor에서 패키지를 다운로드하는 것을 처음으로 볼 수 있을 것이다. 잘했다!

댓글(Comment)

10

쇼셜 뉴스 사이트의 목표는 사용자 커뮤니티를 만드는 것인데, 사람들이 서로 대화하는 수단을 제공하지 않는다면 이를 달성하기는 어려울 것이다. 그래서 이 장에서는 댓글을 추가해본다!

출발점은 댓글을 저장할 새로운 컬렉션을 만들고 여기에 기본적인 데이터 구조를 추가하는 것이다.

Comments = new Mongo.Collection('comments');
lib/collections/comments.js
// Fixture data
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000)
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000)
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000)
  });
}
server/fixtures.js

새 컬렉션을 발행하고 이를 구독하는 것을 잊지말라:

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function() {
  return Comments.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('comments')];
  }
});
lib/router.js

Commit 10-1

Comments 컬렉션, 발행/구독, 초기화 데이터를 추가했다.

위의 초기 설정 코드를 구동하려면, 데이터베이스를 초기화하는 meteor reset를 실행해야 한다는 점에 유의하라. 리셋 후에 새로 계정을 만들고 로그인하는 것도 잊지 말라!

우선, 우리는 (완전히 가짜인) 두 개의 사용자 계정을 만들고, 이들을 데이터베이스에 넣고, id를 이용하여 나중에 데이터베이스에서 추출한다. 그리고 우리는 첫 번째 post에 각 사용자 명의로 댓글을 추가한다. 이 때 댓글을 post와 (postId로) 연결하고 사용자와 (userId로) 연결한다. 또한 등록일시와 댓글 내용을 정규화하지 않은 author와 함께 각 댓글에 추가한다.

또한 라우터에게 comment와 post를 기다리는 코드를 추가했다.

댓글 보여주기

데이터베이스에 댓글을 넣는 것은 좋지만, 또한 토론 페이지에도 이들을 보여줄 필요가 있다. 아마도, 지금까지의 이 과정은 익숙할 것이고, 다음 단계도 알고 있다:

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> commentItem}}
    {{/each}}
  </ul>
</template>
client/templates/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/templates/posts/post_page.js

{{#each comments}} 블록은 post 템플릿 내부에 넣는다. 여기서 thiscomments 헬퍼 안에 있는 post를 가리킨다. 적절한 댓글 목록을 찾기 위해서는 postId 속성으로 post에 연결된 것들을 검색한다.

헬퍼와 Spacebars에 대하여 배운 바에 따르면, 댓글을 화면에 그리는 것은 바로 알 수 있다. 댓글 정보를 저장하기 위해서 templates 디렉토리에 comments 디렉토리를 만든다:

<template name="commentItem">
  <li>
    <h4>
      <span class="author">{{author}}</span>
      <span class="date">on {{submittedText}}</span>
    </h4>
    <p>{{body}}</p>
  </li>
</template>
client/templates/comments/comment_item.html

템플릿 헬퍼를 설정하여 submitted 일시를 읽기 편한 형식으로 바꾼다.

Template.commentItem.helpers({
  submittedText: function() {
    return this.submitted.toString();
  }
});
client/templates/comments/comment_item.js

그 다음, 각 post에 댓글의 갯수를 보여준다:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

그리고 post_item.jscommentsCount 헬퍼를 추가한다:

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  },
  commentsCount: function() {
    return Comments.find({postId: this._id}).count();
  }
});
client/templates/posts/post_item.js

Commit 10-2

`postPage`에 댓글 목록을 보인다.

이제 댓글 목록을 보여주는 아래와 같은 화면을 볼 수 있을 것이다:

댓글 목록 보이기
댓글 목록 보이기

댓글 등록하기

사용자가 새로운 댓글을 등록하는 방법을 추가해보자. 그 과정은 이전의 사용자가 새로운 post를 등록하게 했던 과정과 아주 유사하다.

각 post의 하단에 comment 등록 상자를 추가한다:

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> commentItem}}
    {{/each}}
  </ul>

  {{#if currentUser}}
    {{> commentSubmit}}
  {{else}}
    <p>Please log in to leave a comment.</p>
  {{/if}}
</template>
client/templates/posts/post_page.html

그리고 comment 폼 템플릿을 만든다:

<template name="commentSubmit">
  <form name="comment" class="comment-form form">
    <div class="form-group {{errorClass 'body'}}">
        <div class="controls">
            <label for="body">Comment on this post</label>
            <textarea name="body" id="body" class="form-control" rows="3"></textarea>
            <span class="help-block">{{errorMessage 'body'}}</span>
        </div>
    </div>
    <button type="submit" class="btn btn-primary">Add Comment</button>
  </form>
</template>
client/templates/comments/comment_submit.html
댓글 등록폼
댓글 등록폼

댓글을 등록하기 위해서, post를 등록할 때와 비슷한 방식으로 작동하는 comment_submit.jscomment 메서드를 호출한다:

Template.commentSubmit.created = function() {
  Session.set('commentSubmitErrors', {});
}

Template.commentSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('commentSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('commentSubmitErrors')[field] ? 'has-error' : '';
  }
});

Template.commentSubmit.events({
  'submit form': function(e, template) {
    e.preventDefault();

    var $body = $(e.target).find('[name=body]');
    var comment = {
      body: $body.val(),
      postId: template.data._id
    };

    var errors = {};
    if (! comment.body) {
      errors.body = "Please write some content";
      return Session.set('commentSubmitErrors', errors);
    }

    Meteor.call('commentInsert', comment, function(error, commentId) {
      if (error){
        throwError(error.reason);
      } else {
        $body.val('');
      }
    });
  }
});
client/templates/comments/comment_submit.js

이전에 서버쪽 미티어 메서드인 post를 설정할 때와 같이, 댓글을 등록하는 comment 미티어 메서드를 설정한다. 모든 것이 잘 되었는 지를 확인하고 comments 컬렉션에 새로운 comment를 등록한다.

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {
    check(this.userId, String);
    check(commentAttributes, {
      postId: String,
      body: String
    });

    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);

    if (!post)
      throw new Meteor.Error('invalid-comment', 'You must comment on a post');

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    return Comments.insert(comment);
  }
});
lib/collections/comments.js

Commit 10-3

댓글 등록폼을 만들었다.

이것이 무슨 근사한 작업을 하는 것은 아니며, 사용자가 로그인했는 지를 확인하고, comment에 본문이 있는지 그리고 post에 연결되었는 지를 확인하는 것이다.

Comments 구독을 제어하기

현 상태에서, 우리는 연결된 모든 클라이언트들에게 모든 comments를 발행한다. 이것은 다소 낭비적이다. 따지고 보면, 실제로는 어느 시점이든 이 전체 데이터의 작은 일부만을 사용한다. 그러니, 발행과 구독을 개선하여 댓글 목록에서 발행될 것들만 정확하게 제어하고자 한다.

생각해보면 comments 발행에 대하여 구독할 유일한 시간은 사용자가 post의 개별 페이지에 접근할 때이고, 그 특정 post에 연결된 comment들만 로드하면 된다.

첫 단계는 comments에 구독하는 방식을 바꾸는 것이다. 지금까지 우리는 라우터 레벨에서 구독해왔다. 이것은 라우터가 초기화될 때 한 번 우리 모든 데이터를 로드하는 것을 의미한다.

그러나 이제 우리는 경로 매개변수에 따라서 달라지는 구독을 원한다. 그리고 그 매개변수는 어느 때나 변경될 수 있다. 그러므로, 구독 코드를 라우터(router) 레벨에서 루트(route) 레벨로 이동할 필요가 있다.

이것으로 또 다른 결과도 도출된다: 데이터 로딩 시점을 앱을 초기화시킬 때 하는 대신에, 이제 우리가 route를 hit할 때마다 로드하게 될 것이다. 이것은 앱에서 브라우징을 하는 동안 로딩타임이 증가함을 의미한다. 하지만, 이것은 데이터 전체를 초기에 영구히 로드하려고 하지 않으려면 피할 수 없는 부작용이다.

먼저, configure 블럭에서 모든 comments를 미리 로드하는 것을 중지하도록 (바꾸어 말하면, 이전의 상태로 되돌아가도록) Meteor.subscribe('comments')를 삭제한다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return Meteor.subscribe('posts');
  }
});
lib/router.js

그리고 postPage route에 새로운 루트(route) 레벨의 waitOn 함수를 추가한다.

Router.map(function() {

  //...

  this.route('postPage', {
    path: '/posts/:_id',
    waitOn: function() {
      return Meteor.subscribe('comments', this.params._id);
    },
    data: function() { return Posts.findOne(this.params._id); }
  });

  //...

});
lib/router.js

이 때, subscribe 함수의 매개변수로 this.params._id를 전달한다. 그래서 현재 post에 속하는 댓글들로만 제한하도록 새로운 정보를 이용한다.

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});
server/publications.js

Commit 10-4

댓글에 대한 간단한 발행/구독을 만들었다.

한 가지 문제가 남았다: 홈페이지로 돌아가보면, 모든 댓글의 갯수가 0으로 나타난다:

댓글이 사라졌다!
댓글이 사라졌다!

댓글 갯수 세기

이렇게 되는 이유는 명백하다: 우리는 postPage 루트에 있는 댓글들만을 로드한다. 그래서 commentsCount 헬퍼에서 Comments.find({postId: this._id})를 호출할 때, 결과를 제공하는데 필요한 클라이언트 쪽의 데이터를 찾지 못하게 된다.

이 문제를 해결하는 최선의 방법은 post에 연결되는 comment의 숫자를 비정규화하는 것이다(이게 무슨 뜻인지 몰라도 걱정마시라, 다음 사이드바 장에서 다룰테니까!). 보다시피 약간 복잡한 코드가 추가되기는 하겠지만, post 목록을 보여주려고 comment 전체를 발행하는 작업을 하지 않아서 얻는 성능의 개선이 충분히 가치가 있다는 것을 알게 될 것이다.

post 데이터 구조에 commentsCount 속성을 추가한다. 이를 구현하려면, post 초기화 파일을 갱신한다 (그리고 meteor reset를 실행하여 데이터를 리로드한다 - 그 후에 계정을 다시 만드는 것을 잊지말라):

// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });
}
server/fixtures.js

그러면, 모든 새 post는 comment 0으로 시작한다:

//...

var post = _.extend(postAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date(),
  commentsCount: 0
});

var postId = Posts.insert(post);

//...
collections/posts.js

그리고 새로운 코멘트를 만들 때, Mongo의 $inc 연산자(숫자 필드를 1씩 증가 시키는)를 사용하여 관련 commentsCount를 갱신한다:

//...

comment = _.extend(commentAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date()
});

// update the post with the number of comments
Posts.update(comment.postId, {$inc: {commentsCount: 1}});

return Comments.insert(comment);

//...
collections/comments.js

마지막으로, 이제 post에서 직접 필드 값을 읽어 올 수 있으므로 client/views/posts/post_item.js에서 commentsCount 헬퍼를 제거한다.

Commit 10-5

Post에 댓글 갯수를 비정규화하여 넣었다.

이제 사용자들은 서로 대화할 수 있게 되었다. 그들이 만약 새 댓글들을 놓친다면 부끄러운 일이 될 것이다. 그리고 다음 장에서 이런 일을 방지하기 위하여 알림 기능을 구현해 볼 것이다!

비정규화

Sidebar 10.5

데이터를 비정규화한다는 것은 데이터를 “정규화"한 형태로 저장하지 않는다는 것을 의미한다 - 다른 말로 표현하면, 비정규화란 데이터의 같은 조각에 대한 여러 복사본을 가진다는 것을 의미한다.

이전 장에서, 우리는 매 시점마다 댓글 전체를 로드하는 것을 피하기 위해서 post 객체에 연결된 comment의 숫자를 비정규화했다. 데이터 모델링의 관점에서는 불필요한 작업이다. 댓글의 숫자를 (성능 문제를 제외한다면) 어느 때나 계산할 수 있기 때문이다.

비정규화는 개발자에게는 종종 추가작업을 의미한다. 위의 예제에서 댓글을 추가하거나 삭제하는 매 순간, post 정보를 갱신하여 항상 commentsCount 속성의 값을 정확하게 유지하여야 한다. 이런 이유로 MySQL과 같은 관계형 데이터베이스는 이런 방식을 못마땅해한다.

그런데, 정규화 방식도 단점이 있다: commentsCount 속성이 없다면, 우리는 모든 댓글들을 내려보내어 그 갯수를 셀 수 있도록 해야 한다. 초기에는 우리가 이렇게 하고 있었다. 비정규화는 이를 피할 수 있게 해준다.

특별한 발행(Publication)

우리가 관심있는 댓글 갯수(즉, 현재 우리가 열람중인 post들의 댓글 갯수로 서버에서의 query 모음을 통해 얻는다)만을 내려주는 특별한 publication을 만드는 것이 가능할 수도 있을 것이다.

하지만, 이것은 그런 발행 코드의 복잡성이 비정규화에 따른 어려움을 초과하지 않을 때에나 고려할 가치가 있다…

물론, 이러한 고려는 애플리케이션에 따라 달라진다: 만약 데이터의 정합성이 극히 중요한 곳에 코드를 작성한다면, 데이터의 비정합성을 회피하는 것이야 말로 성능상의 이익을 얻는 것보다 훨씬 더 중요하고 더 높은 우선순위를 가지는 것이다.

도큐먼트를 임베드할 것인가 다중 컬렉션을 사용할 것인가

Mongo에 경험이 있다면, 댓글에 대한 두 번째 컬렉션을 만든 것에 놀랐을 것이다: 댓글을 post 도큐먼트안에 목록 형태로 임베드하면 어떨까?

미티어의 많은 도구들이 컬렉션 수준에서 더 잘 작동하는 것이 밝혀졌다. 예를 들면:

  1. {{#each}} 헬퍼는 (collection.find()의 결과) 커서를 따라서 반복할 때 매우 효율적이다. 이것이 큰 도큐먼트에서 객체의 배열을 따라서 반복할 때는 그렇지 않다.
  2. allowdeny는 도큐먼트 수준에서 작동한다. 그리고 이 경우에 개별 댓글을 수정하는 것도 쉽다. 상대적으로 post 수준에서 작동하면 더 복잡하다.
  3. DDP는 도큐먼트의 탑-레벨 속성의 수준에서 작동한다 – 이것은 commentspost의 속성이었다면, post에서 comment가 생성될 때마다 서버가 각 연결된 클라이언트들에게 post에 연결된 comment 목록 전체를 보냈을 것이라는 사실을 의미한다.
  4. 발행과 구독은 도큐먼트 수준에서 제어하기가 훨씬 쉽다. 예를 들어, post의 comment들을 페이징 처리를 하려면, comment가 자체로 컬렉션이 아니라면 매우 어렵다는 것을 알게 될 것이다.

Mongo에서는 도큐먼트를 가져오는 값비싼 쿼리의 숫자를 줄이기 위해 도큐먼트를 임베딩하는 것을 권한다. 그런데, 이것은 미티어의 구조를 고려할 때 그다지 이슈는 아니다: comments에 대하여 쿼리를 요청하는 대부분의 시간을 클라이언트에서 보낸다. 여기는 데이터베이스 접속이 필연적으로 자유로우니까.

비정규화의 불리한 점

데이터를 비정규화해서는 안된다는 좋은 글이 있다. 비정규화에 반대하는 좋은 글로 우리는 Sarah Mei의 Why You Should Never Use MongoDB를 추천한다.

알림(Notification)

11

이제 사용자들은 서로의 post에 댓글을 달 수 있게 되었으므로, 대화가 시작되었음을 각 사용자들에게 알려주면 좋을 것이다.

그렇게 하기 위해서, post의 작성자에게 댓글이 추가되었음을 알리고 댓글을 보기 위한 링크를 제공할 것이다.

이것이야말로 미티어가 진짜로 빛나는 기능이다: 왜냐면 미티어는 기본적으로 실시간이기 때문에 이런 알림을 즉시 보여줄 것이다. 우리는 사용자가 페이지를 새로고침하거나 어떤 식으로는 확인하느라 기다리게 하지 않는다. 단지 어떤 특별한 코드를 작성하지 않고도 새 알림 메시지가 간단하게 나타나게 할 수 있다.

알림 생성하기

우리는 작성한 post에 누군가 댓글을 달면 알림을 주게 하려고 한다. 나중에, 알림은 많은 다른 상황에도 확장될 수 있다. 그러나 지금은 사용자에게 일어나고 있는 것을 알려주는 것으로 충분할 것이다.

Notifications 컬렉션을 만들고, 작성한 post에 새로운 댓글이 등록될 때마다 이에 대응하는 알림을 삽입하는 createCommentNotification 함수를 함께 만든다.

우리가 클라이언트에서 알림을 갱신하기 때문에, allow 호출을 하는 것을 반드시 확인해야 한다. 그래서 우리는 다음 사항을 체크한다:

  • update 호출을 하는 사용자가 수정될 알림에 대한 소유권을 가지고 있는가
  • 그 사용자가 단일 필드만을 수정하려고 하는가
  • 그 단일 필드가 알림의 read 속성인가
Notifications = new Mongo.Collection('notifications');

Notifications.allow({
  update: function(userId, doc, fieldNames) {
    return ownsDocument(userId, doc) && 
      fieldNames.length === 1 && fieldNames[0] === 'read';
  }
});

createCommentNotification = function(comment) {
  var post = Posts.findOne(comment.postId);
  if (comment.userId !== post.userId) {
    Notifications.insert({
      userId: post.userId,
      postId: post._id,
      commentId: comment._id,
      commenterName: comment.author,
      read: false
    });
  }
};
lib/collections/notifications.js

Posts나 Comments와 마찬가지로, 이 Notifications 컬렉션은 클라이언트와 서버에 의해 공유될 것이다. 사용자가 알림을 열람했으면 notification을 갱신해야 하므로, 또한 수정을 가능하게 하되, 사용자가 소유한 데이터로 수정 권한을 한정한다.

또한, 사용자가 댓글을 추가한 post를 바라보는 간단한 함수를 만들어 그곳에서 알림을 받아야 할 사람들을 찾아서 새로운 알림을 등록할 것이다.

우리는 이미 서버쪽 메서드로 comment를 생성하고 있다. 그래서 이 함수를 호출하는 메서드를 추가하면 된다. 우리는 return Comments.insert(comment); 코드를 comment._id = Comments.insert(comment)로 바꾸어 새로 생성된 comment의 _id를 변수에 저장한다. 그리고 createCommentNotification 함수를 호출한다:

Comments = new Mongo.Collection('comments');

Meteor.methods({
  comment: function(commentAttributes) {

    //...

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    // update the post with the number of comments
    Posts.update(comment.postId, {$inc: {commentsCount: 1}});

    // create the comment, save the id
    comment._id = Comments.insert(comment);

    // now create a notification, informing the user that there's been a comment
    createCommentNotification(comment);

    return comment._id;
  }
});
lib/collections/comments.js

또한 notification을 발행하고 클라이언트에서 구독한다:

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find();
});
server/publications.js

그리고 클라이언트에서 구독한다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('notifications')]
  }
});
lib/router.js

Commit 11-1

기본적인 Notifications 컬렉션을 추가했다.

알림 보이기

이제 계속해서 header에 알림 목록을 추가한다.

<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">
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

그리고 notificationsnotification 템플릿(이들은 하나의 notifications.html파일에 작성한다)을 만든다:

<template name="notifications">
  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
    Notifications
    {{#if notificationCount}}
      <span class="badge badge-inverse">{{notificationCount}}</span>
    {{/if}}
    <b class="caret"></b>
  </a>
  <ul class="notification dropdown-menu">
    {{#if notificationCount}}
      {{#each notifications}}
        {{> notificationItem}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notificationItem">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> commented on your post
    </a>
  </li>
</template>
client/templates/notifications/notifications.html

계획은 각 알림은 댓글이 등록된 post에 대한 링크 정보를 담고, 댓글을 한 사용자의 이름을 담는 것이다.

다음, 매니저에서 올바른 알림 목록을 추출하고, 사용자가 링크를 클릭할 때, notification 정보를 “read"로 갱신한다.

Template.notifications.helpers({
  notifications: function() {
    return Notifications.find({userId: Meteor.userId(), read: false});
  },
  notificationCount: function(){
    return Notifications.find({userId: Meteor.userId(), read: false}).count();
  }
});

Template.notificationItem.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
});

Template.notificationItem.events({
  'click a': function() {
    Notifications.update(this._id, {$set: {read: true}});
  }
});
client/templates/notifications/notifications.js

Commit 11-2

헤더에서 알림을 보여준다.

알림 기능이 오류 기능과 크게 다르지 않다고 생각할 지도 모르겠다. 사실이다. 그 구조에서 유사한 점이 있다. 그렇지만 핵심 차이는: 클라이언트-서버 동기화 상태로 컬렉션을 만들었다는 점이다. 이 의미는 알림 기능이 영구적(persistent)이라는 것인데, 동일한 사용자 계정이면 다른 브라우저나 다른 단말기로 접속해도 유지된다는 것이다.

직접 해보자: 두 번째 브라우저(이를테면 파이어폭스)을 열고, 새 계정을 만든다음, 기존 계정(이미 크롬으로 열고 있는)으로 작성한 post에 comment를 등록한다. 그러면 다음과 같은 화면을 보게 될 것이다:

알림 보이기.
알림 보이기.

알림에 대한 접근제어

알림은 잘 작동한다. 그런데, 한 가지 작은 문제가 있다: 알림은 공개되어 있다.

만약 두 번째 브라우저가 열려 있다면, 아래 코드를 브라우저 콘솔에서 실행해보라:

 Notifications.find().count();
1
브라우저 콘솔

이 (댓글을 등록한) 새로운 사용자는 어떤 알림도 받지 않은 상태이어야 한다. 여기서 보는 알림은 (post를 등록한) 원래 사용자에 속하는 Notifications 컬렉션의 정보이다.

잠재적인 프라이버시 이슈는 제쳐 놓더라도, 모든 다른 사용자의 브라우저에 모든 사용자의 알림이 로드되어 공개되도록 할 수는 없다. 충분히 큰 사이트에서라면, 이것은 브라우저의 메모리 문제를 일으킬 수 있고, 심각한 성능 문제를 일으킬 수 있다.

이 이슈는 발행으로 해결한다. 각각의 브라우저와 공유할 컬렉션의 정확한 부분을 지정하는 발행을 사용한다.

이를 처리하기 위해서, 발행에서 Notifications.find()와는 다른 커서를 리턴할 필요가 있다. 말하자면, 현재 사용자의 알림에 대응하는 커서를 리턴하려고 한다.

이렇게 하는 방법은 publish 함수가 this.userId에서 이용가능한 현재 사용자의 _id를 가지고 있기 때문에, 그대로 하면 된다:

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId, read: false});
});
server/publications.js

Commit 11-3

사용자에게 해당하는 알림만을 동기화한다.

이제 두 개의 브라우저 창에서 확인해보면, 두 개의 다른 notification 컬렉션을 보게 될 것이다:

 Notifications.find().count();
1
브라우저 콘솔 (사용자 1)
 Notifications.find().count();
0
브라우저 콘솔 (사용자 2)

사실, 알림 목록은 로그인 상태에 따라서 변할 수 있다. 그것은 사용자 계정이 변할 때마다 발행이 갱신되기 때문이다.

우리의 앱은 점점 기능이 많아져 간다. 그리고 더 많은 사용자가 가입하고, post를 등록하게 되면 끝없는 홈페이지가 될 위험에 빠진다. 이를 조정하려고 다음장에서 페이징 기법을 구현할 것이다.

반응성(Reactivity) 고급편

Sidebar 11.5

의존성 추적 코드를 직접 작성하는 경우는 거의 없지만, 의존성 해소가 진행되는 경로를 추적하는데는 반응성을 이해하는 것이 확실히 도움이 된다.

현재 사용자의 페이스북 친구들 중에서 몇 명이 Microscope의 각 post에 “좋아요"를 눌렀는 지를 추적한다고 상상해보자. 사용자를 페이스북으로 인증하고, 적절한 API를 호출하고, 필요한 데이터를 파싱하는 작업은 이미 구현했다고 가정하자. 이제 우리는 좋아요의 숫자를 리턴하는 클라이언트 쪽의 비동기 함수 getFacebookLikeCount(user, url, callback)를 가지고 있다.

이런 함수에 대하여 기억해 둘 중요한 사항은 이것이 비 반응형이고 비 실시간이라는 점이다. 이것은 페이스북에 HTTP 요청을 보내고, 일부 데이터를 가져오며, 비동기 콜백에서 애플리케이션에 적용할 수 있게 한다. 그러나 그 기능은 페이스북에서 숫자가 변경되어도 스스로 재실행되지 않을 것이다. 그 데이터가 변해도 우리 UI는 변경되지 않을 것이다.

이를 해결하려고, 몇 초 간격으로 setInterval를 사용하여 함수를 호출한다:

currentLikeCount = 0;
Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId).url, 
      function(err, count) {
        if (!err)
          currentLikeCount = count;
      });
  }
}, 5 * 1000);

currentLikeCount 변수를 체크할 때마다, 우리는 5초 정도의 오차를 가지고 정확한 숫자를 얻을 것이라고 기대할 수 있다. 이제 우리는 이 변수를 헬퍼에서 다음과 같이 사용할 수 있다:

Template.postItem.likeCount = function() {
  return currentLikeCount;
}

그런데, currentLikeCount이 변경될 때 템플릿을 다시 그리도록 어떤 것도 아직 지시하지 않는다. 비록 변수가 스스로 변경된다는 점에서는 실시간에 준한다고 하겠지만, 이것이 반응형은 아니므로 나머지 미티어 에코시스템과 적절하게 통신하는 것은 아니다.

반응성 추적: 컴퓨테이션(Computation)

미티어의 반응성은 의존성(dependency) - 컴퓨테이션 집합을 추적하는 데이터 구조 - 에 의해서 중개된다.

이전의 반응성 사이드바 장에서 보았듯이, 컴퓨테이션은 반응형 데이터를 사용하는 코드영역이다. 이 경우, postItem 템플릿에 대한 컴퓨테이션이 은연중에 만들어진 바 있다. 그 템플릿 매니저의 모든 헬퍼는 그 컴퓨테이션 내부에서 작동한다.

컴퓨테이션을 반응형 데이터에 "관심을 가지는” 코드 영역이라고 생각해도 좋다. 데이터가 변경될 때, (invalidate()에 의해서) 알림을 받는 것이 이 컴퓨테이션이고 무슨 일을 할 지를 결정하는 것이 컴퓨테이션이다.

변수를 반응형 함수로 바꾸기

변수 currentLikeCount을 반응형 데이터 소스로 바꾸려면, 이에 대한 의존성을 가지는 모든 컴퓨테이션을 추적해야 한다. 따라서 이것을 변수에서 (임의의 값을 리턴하는) 함수로 바꾸어야 한다는 요구가 발생한다:

var _currentLikeCount = 0;
var _currentLikeCountListeners = new Tracker.Dependency();

currentLikeCount = function() {
  _currentLikeCountListeners.depend();
  return _currentLikeCount;
}

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err && count !== _currentLikeCount) {
          _currentLikeCount = count;
          _currentLikeCountListeners.changed();
        }
      });
  }
}, 5 * 1000);

우리가 한 것은 _currentLikeCountListeners의 의존성을 설정한 것인데, 이는 currentLikeCount()이 내부에서 사용된 모든 컴퓨테이션을 추적한다. _currentLikeCount값이 변경되면, 그 의존성에 따라 changed() 함수를 호출하는데 이는 모든 추적된 컴퓨테이션을 무효화(invalidate)시킨다.

이 컴퓨테이션들은 진행하면서 각 케이스별로 그 변경 사항을 처리한다.

이것이 간단한 반응형 데이터소스를 위한 전형으로 보였다면, 제대로 본 것이다. 미티어는 이 과정을 보다 쉽게 할 수 있는 (직접 컴퓨테이션을 사용해야 할 필요없이, 단지 autorun을 사용하면 되도록) 빌트인 도구를 제공한다. 플랫폼 패키지에 reactive-var라는 이름의 패키지가 있는데 이것이 바로 currentLikeCount() 함수가 하는 작업을 처리한다. 이것을 다음과 추가한다:

meteor add reactive-var

그리고 이것으로 우리 코드를 다소 단순화할 수 있다:

var currentLikeCount = new ReactiveVar();

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err) {
          currentLikeCount.set(count);
        }
      });
  }
}, 5 * 1000);

이제 헬퍼에서 currentLikeCount.get()를 호출해보면, 이전과 같이 작동할 것이다. 그리고 또 다른 패키지 reactive-dict가 있는데, 이것은 (Session과 거의 같은) 반응형의 키-밸류 저장소를 제공하는 데, 이것 역시 매우 유용할 것이다.

Tracker와 Angular 비교

Angular는 구글의 직원들이 개발한 클라이언트 쪽의 반응형 렌더링 라이브러리이다. 미티어의 의존성 추적에 의한 접근 방식을 Angular와 비교해보면 이것의 접근 방식은 완전히 다르다.

우리는 미티어의 모델이 컴퓨테이션이라 불리는 코드 블록을 사용하는 것을 보았다. 이 컴퓨테이션들은 적절한 시점에 무효화(invalidate)하는 특별한 “반응형” 데이터 소스들(함수들)에 의해서 추적된다. 그래서 이 데이터 소스는 명시적으로 그것이 의존하는 모두에게 invalidate()를 호출해야 하는 시점을 알린다. 이 시점은 일반적으로 데이터가 변경되었을 때이지만, 그 데이터소스는 잠재적으로 다른 이유로도 무효화를 구동할 지 말지를 결정할 수 있다는 점을 유의하기 바란다.

추가로, 컴퓨테이션이 보통은 무효화될 때 재실행하지만, 독자가 원하는 방식으로 행동하도록 설정할 수 있다. 이 모든 것이 반응성에 대한 고수준의 제어를 가능하게 한다.

Angular에서, 반응성은 scope 객체에 의해서 이루어진다. Scope는 특별한 메서드들을 가진 평이한 JavaScript 객체로 간주된다.

임의의 scope에서 어떤 값에 반응적으로 의존하기를 원하면, scope.$watch를 호출하여 (scope의 일부 영역에) 관심있는 expression을 제공하고 그 expression이 변경되는 매 시점마다 실행되는 listener 함수를 제공한다. 따라서 그 expression의 값이 변경되는 매 시점에 하고자 하는 바를 명시적으로 정확하게 지정한다.

위의 페이스북 예제로 돌아가면, 우리는 다음과 같이 작성한다:

$rootScope.$watch('currentLikeCount', function(likeCount) {
  console.log('Current like count is ' + likeCount);
});

물론, 미티어에서 컴퓨테이션을 거의 설정하지 않는 것처럼, Angular에서는 ng-model directives{{expressions}}이 변경될 때 다시 그리기를 할 watch들을 자동적으로 설정하기 때문에 $watch를 명시적으로 자주 호출하지는 않는다.

그러한 반응형 값이 변경되었을 때, scope.$apply()가 호출되어야 한다. 이것은 그 scope의 모든 watcher를 재평가하지만, expression의 값이 변경된 watcher의 listener 함수를 호출하기만 한다.

그러므로 scope.$apply()dependency.changed()와 유사한데, scope의 수준에서 동작한다는 점이 다르며, 어떤 listener가 재평가되어야 하는지를 정확하게 말할 수 있는 제어능력을 주지는 않는다. 말하자면, 이 제어 능력의 부족으로 Angular는 어떤 listener가 재평가가 필요한지를 정확하게 결정하는 데 있어서 매우 똑똑하고 효율적인 능력을 가지게 되었다.

Angular에서 getFacebookLikeCount() 함수 코드를 작성한다면 다음과 같은 형태가 될 것이다:

Meteor.setInterval(function() {
  getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
    function(err, count) {
      if (!err) {
        $rootScope.currentLikeCount = count;
        $rootScope.$apply();
      }
    });
}, 5 * 1000);

확실히 미티어는 대부분의 복잡한 것들도 처리한다. 그리고 많은 작업을 하지 않고도 반응성의 잇점을 얻을 수 있다. 하지만, 희망하건데 더 발전하고 싶다면, 이들 패턴에 대하여 공부하는 것이 도움이 될 것이다.

페이지 만들기

12

Microscope는 훌륭해 보인다. 그리고 우리는 이것이 세계에 모습을 드러낼 때 큰 환영을 기대한다.

그래서 우리는 이것이 등장할 때, 등록될 수 많은 post들로 인한 성능에의 영향을 걱정을 할 수도 있다!

우리는 앞서 클라이언트 쪽의 컬렉션이 서버에 있는 데이터의 부분집합을 가지는 방법에 대하여 다룬 적이 있다. 그리고 알림과 댓글 컬렉션에서 이 방식을 구현하여 왔다.

현재까지는 여전히 우리는 모든 post를 연결된 모든 사용자에게 발행하고 있다. 결국 수 천 개의 링크가 등록된다면 이것은 문제가 될 것이다. 이를 위해서 우리의 post에 대하여 페이징 처리를 할 필요가 있다.

더 많은 Post 추가하기

우선, 초기 설정 데이터에 페이징이 의미를 가지는 충분한 post 목록을 로드하도록 한다:

// Fixture data 
if (Posts.find().count() === 0) {

  //...

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000),
      commentsCount: 0
    });
  }
}
server/fixtures.js

meteor reset을 실행하고 나면, 다음과 같은 화면을 보게 될 것이다:

더미 데이터 보이기.
더미 데이터 보이기.

Commit 12-1

페이징이 필요할 만큼의 충분한 post 목록을 추가했다.

무한 방식의 페이지

우리는 “무한” 방식의 페이징을 구현할 것이다. 이것이 의미하는 바는, 처음에는 화면에 10개의 posts를 보여주고 아랫쪽에 “더 보기” 링크를 보여준다. 이 링크를 누르면 10개의 추가 목록을 아래에 붙인다. 이렇게 무한 반복한다. 이 의미는 화면에 보여주는 post의 갯수를 지정하는 하나의 매개변수로 전체 페이징을 제어할 수 있다는 것이다.

이제 필요한 것은 서버에게 이 매개변수에 대하여 알려주는 방법이다. 그래서 클라이언트에 얼마나 많은 post를 보낼 것인지를 알 수 있게 한다. 우리는 이미 라우터에서 posts 발행을 구독해왔다. 그래서 우리는 이를 이용하여 라우터가 페이징도 조작할 수 있게 할 것이다.

이를 설정하는 가장 쉬운 방법은 post의 갯수를 경로의 일부로, http://localhost:3000/25와 같은 형태의 폼으로 URL을 지정하는 것이다. 다른 방식에 비해서 이런 URL을 사용하는 또 하나의 장점은 현재 25개의 post를 보여주는데 실수로 브라우저 창을 새로고침한다면, 화면에는 여전히 25개의 post가 다시 보일 것이다.

이를 적절하게 구현하려면, 우리가 post에 구독하는 방식을 바꿀 필요가 있다. 우리가 댓글 장에서 했던 방식과 마찬가지로, 우리는 구독 코드를 라우터 수준에서 route 수준으로 이동할 필요가 있다.

이 모두를 한 번에 받아들이기에는 많아 보이지만, 코드를 보면 훨씬 명확해질 것이다.

우선, Router.configure() 블록에서 posts 발행에 구독하는 것을 중지할 것이다. 그리고, Meteor.subscribe('posts')를 삭제하고 notifications 구독만 남겨둔다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

그 다음, 경로에 매개변수 postsLimit를 추가한다. 매개변수 명 다음에 ?를 추가하는 것은 이것이 선택적이라는 의미이다. 그러므로 route는 http://localhost:3000/50뿐 아니라 이전의 http://localhost:3000도 수용한다.

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...
lib/router.js

유의할 사항은 /:paramter?의 형태를 가지는 경로는 모든 가능한 경로와 다 맞는다는 점이다. 각 route가 순차적으로 파싱이 이루어지면서 현재 경로와 맞는 것을 찾으므로, route의 순서를 특수성을 줄이는 방향으로 정렬을 시켜야 한다.

다른 말로 표현하면, /posts/:_id 와 같이 특정한 route를 타겟으로 하는 route는 먼저 오고 postList route는 거의 모든 경로와 맞게 되므로 마지막으로 이동하여야 한다.

이제 올바른 데이터를 찾고 구독하는 어려운 문제를 해결해야 할 시간이다. 우리는 매개변수 postsLimit의 값이 없는 경우 여기에 초기값을 지정하는 방법을 처리해야 한다. 초기값으로는 “5”를 지정하여 페이징처리 과정에서 충분한 여유를 두려고 한다.

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  }
});

//...
lib/router.js

주목할 점은 posts 발행의 이름 다음에 JavaScript 객체({limit: postsLimit})를 매개변수로 전달하고 있는 것이다. 이 객체는 서버쪽의 Posts.find()문에 대하여 options 매개변수를 제공할 것이다. 서버쪽 코드를 이것을 구현한 것으로 바꾸어 보자:

Meteor.publish('posts', function(options) {
  check(options, {
    sort: Object,
    limit: Number
  });
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

매개변수 전달

위의 발행 코드는 요컨데 find()문의 매개변수 options에 대하여 클라이언트(여기서는 {limit: postsLimit})가 어떤 Javascript 객체를 보내더라도 신뢰할 수 있다고 서버에게 말하는 것이다. 그러므로 사용자가 브라우저 콘솔을 통해서 임의의 options을 전송하는 것도 가능하다.

우리의 경우, 이것은 상대적으로 해가 되지는 않는데, 사용자가 할 수 있는 것이 post를 다르게 재정렬하는 것이거나 또는 (우리가 처음에 원했던) 한계치를 변경하는 것이기 때문이다.

그러나, 발행되지 않은 필드에 데이터를 저장할 때에는 이 패턴을 사용해서는 안되는 데, 사용자가 그것에 접근하려고 fields 옵션을 조작할 수 있기 때문이다. 그리고 아마도 그것을 find() 문의 셀렉터 매개변수에 사용하는 것도 동일한 보안상의 이유로 피했을 것이다.

보다 안전한 패턴은 데이터를 확실하게 통제하기 위하여 전체 객체 대신에 개별 매개변수를 전달하는 것이다:

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

이제 route 수준에서 구독하려면, 같은 자리에 데이터 컨텍스트를 설정하는 것이 의미가 있다. 우리는 이전의 패턴에서 약간 벗어나서 data 함수가 커서를 리턴하는 대신에 Javascript 객체를 리턴하도록 할 것이다. 이렇게 하여 우리가 named data context를 생성하는데, 이를 posts라 부른다.

이 의미는 단순히 템플릿 내에서 this로 은연중에 사용하는 대신에 데이터 컨텍스트에서 posts를 이용할 수 있게 될 것이다. 이 작은 요소는 제외하면 나머지 코드는 익숙할 것이다:

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

//...
lib/router.js

라우터 수준에서 데이터 컨텍스트를 지정하였으므로, 우리는 posts_list.js 파일 내에 있는 posts 템플릿 헬퍼를 안전하게 제거할 수 있다. 그리고 우리가 데이터 컨텍스트를 posts라고 (헬퍼와 동일한 이름으로) 명명하였으므로 postsList 템플릿을 건들 필요도 없다!

다시 보자. 새로운 개선된 router.js 코드는 다음과 같다:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

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 12-2

제한을 걸기 위해서 postsList route에 코드를 추가했다.

이 새로운 페이징 시스템을 체험해보자. 이제 URL 매개변수를 바꾸기만 하면 홈페이지의 post 숫자를 임의로 조정하여 보여줄 수 있는 상태가 되었다. 예를 들어, http://localhost:3000/3으로 접속해보면, 다음과 같은 화면을 보게 될 것이다:

주소창에서 페이징 갯수를 조절하기.
주소창에서 페이징 갯수를 조절하기.

복수의 페이지가 아닌 이유?

구글 검색결과와 같이 연속되는 10개의 post를 각각 보여주는 대신에 “무한 페이징” 방식을 사용하는 이유는 무엇일까? 그것은 사실은 미티어가 받아들인 실시간 패러다임 때문이다.

구글 결과 페이징 패턴을 사용하여 Posts 컬렉션에 대한 페이징을 하는데, 현재 2 페이지에 위치하여 10번째에서 20번째의 post 목록을 보여주고 있다고 가정해보자. 만약 다른 사용자가 이전 10개의 post 중의 어느 것이라도 삭제한다면 무슨 일이 일어날까?

우리 앱은 실시간이므로, 데이터 세트가 변경될 것이다. 10번 post는 이제 9번 post가 되므로, 우리 시야에서 빠지는 반면에, 11번 post는 범위안에 있게 된다. 궁극적 결과는 사용자는 아무런 이유도 없이 post목록이 갑자기 변하는 것을 보게 될 것이다!

우리가 설사 이런 기묘한 UX를 받아들인다 해도, 전통적인 페이징 방식은 기술적 이유로도 구현하기가 어렵다.

이전 예제로 돌아가보자. 우리는 Posts 컬렉션에서 10번째에서 20번째 까지의 post 목록을 발행하고 있다. 클라이언트에서 어떻게 그 post 목록을 찾을까? 클라이언트 쪽의 데이터 세트에는 10개의 post만 있으므로 10번째에서 20번째까지의 post 목록을 추출하지 못한다.

한 가지 해법은 서버에서 그 10개의 post 목록을 발행하는 것이다. 그리고 클라이언트 쪽에서 Posts.find()를 호출하여 모든 발행 post 목록을 가져온다.

이것은 하나의 구독만 있다면 동작한다. 그러나 곧 해보겠지만, post 구독을 하나 이상의 갯수로 시작하면 어떻게 하나?

하나의 구독이 10에서 20까지의 post 목록을 요구한다고 해보자. 그리고 또 다른 것이 30에서 40까지의 목록을 요구한다고 하자. 이제 클라이언트에서 어느 구독에 속하는 지 모른채로 총 20개의 post를 가지게 된다.

이런 이유로, 전통적인 페이징 방식은 미티어에서는 별 의미가 없다.

Route Controller 만들기

다음의 var limit = parseInt(this.params.postsLimit) || 5; 줄이 두 번 반복되었음을 눈치챘을지 모르겠다. 여기에, 숫자 “5”이 하드코딩된 것도 바람직하지 않다. 이것이 다는 아니다. 할 수만 있다면 DRY(Don’t Repeat Yourself) 원칙을 따르는 것은 항상 좋으니까, 이것을 어떻게 고치면 좋을 지 알아보자.

Iron Router의 새로운 관점, Route Controller를 소개한다. Route controller는 어떤 route도 상속받을 수 있는 멋진 재사용가능한 패키지에 라우팅 기능들을 모아서 그룹을 만드는 간단한 방법이다. 여기서는 단일 route에 대하여만 이것을 사용하지만, 다음 장에서는 이 기능이 얼마나 편리한 지를 보게될 것이다.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...
lib/router.js

단계별로 해보자. 첫째, RouteController를 확장한 controller를 만든다. 다음, template 속성을 전에 했던 것처럼 지정한다. 그 다음, increment 속성을 지정한다.

그리고 새로 limit 함수를 만들어 현재 목록 한계치를 리턴하게 한다. 그리고 options 객체를 리턴하는 findOptions 함수를 만든다. 지금은 불필요한 일 같지만 나중에 이를 사용할 것이다.

다음 단계로, 이전처럼 waitOndata 함수를 정의한다. 여기서 새로 만든 findOptions 함수를 사용한다.

마지막 단계로 할 일은 새로운 controller에 대한 route를 controller 속성에 postsList로 지정하는 것이다.

Commit 12-3

postsLists route 코드를 RouteController로 리팩토링하였다.

더보기 링크 추가하기

페이징이 작동한다. 그리고 코드도 좋다. 한 가지 문제만 남았다: URL을 수동으로 바꾸는 것 말고는 페이징을 실제로 이용할 방법이 없다. 이 상태로는 훌륭한 사용자 체험을 줄 수 없다. 이 문제를 해결해보자.

우리가 원하는 것은 단순하다. Post 목록의 아랫쪽에 “load more” 버튼을 추가할 것이다. 이 버튼을 클릭할 때마다 보여지는 post 목록 갯수가 5씩 증가한다. 따라서 현재 위치가 URL http://localhost:3000/5인 주소에 있다면, “load more” 버튼을 누르면 http://localhost:3000/10 주소로 이동해야 한다. 벌써 이걸 알았다면, 산수 좀 할 줄 안다고 믿어주겠다!

이전과 같이, 우리의 route에 페이징 로직을 추가하려고 한다. 우리가 익명의 커서를 사용하지 않고 명시적으로 데이터 콘텍스트를 명명했던 때가 언제인지 기억하는가? 글쎄, data 함수가 커서만 전달할 수 있다는 그런 규칙은 없다. 그러므로 “load more” 버튼의 URL을 사용하는데 동일한 기법을 사용할 것이다.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

이 마술같은 라우터를 깊이있게 들여다보자. (현재 작동하는 PostsListController controller에서 상속받을) postsList route는 매개변수 postsLimit를 가진다는 점을 기억하기 바란다.

그러므로 this.route.path(){postsLimit: this.postsLimit() + this.increment}를 지정하는 것은 postsList route에 그 Javascript 객체를 데이터 컨텍스트로 사용하는 경로를 구축하라는 의미이다.

다른 말로 표현하면, 이것은 우리가 은연중에 this를 우리가 만든 데이터 컨텍스트로 바꾸는 것을 제외하면 {{pathFor 'postsList'}} Handlebars 헬퍼를 사용하는 것과 같다.

우리는 그 경로를 채택하고 이를 템플릿의 데이터 컨텍스트에 추가하되, 보여줄 post들이 더 있을 때에만 그렇다. 우리가 하는 방식은 다소 기교적이다.

this.limit()는 우리가 보여주려는 post의 현재 갯수를 리턴하는데 이 값은 현재 URL에 있는 값이거나 또는 URL에 매개변수가 없을 경우의 초기 설정값 (5)이다.

한 편, this.posts는 현재 커서를 가리키므로, this.posts.count()는 커서에 실제로 존재하는 posts의 갯수를 가리킨다.

그러므로 여기서 우리가 말하려는 것은 n개의 post를 요구하면 n개를 얻고, 계속 “load more” 버튼을 보여준다. 그러나 n개를 요구했는데 n개보다 적은 갯수를 얻게되면, 한계에 온 것을 의미하고 버튼을 보여주는 것을 중단하라는 것이다.

말하자면, 우리 시스템은 다음의 경우에는 실패한다: 데이터베이스에 있는 아이템의 갯수가 정확하게 n개일 경우다. 만약 이 경우라면, 클라이언트는 n개를 요구하고 n개를 받고, 계속 “load more” 버튼을 보여주지만, 남은 아이템이 없다는 것은 모르고 있는 상태가 된다.

안타깝지만, 이 문제를 간단하게 우회하는 방법은 없어서, 현재까지는 이 덜 완벽한 구현에 만족할 수 밖에 없다.

남은 할 일은 post 목록의 아랫쪽에 “load more” 링크를 추가하는 것이다. 그리고 로드할 post가 더 있는 지를 보여주는 것이다:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Post 목록 화면은 다음과 같이 보일 것이다:

“load more” 버튼.
“load more” 버튼.

Commit 12-4

Controller에 nextPath()을 추가하고 이를 post의 페이지 이동을 구현했다.

더 나은 프로그레스 바

페이징은 현재 잘 작동하지만, 한 가지 짜증나는 일이 있다: “load more” 버튼을 누를 때마다 라우터는 더 많은 post를 요구하고, Iron Router의 waitOn 기능으로 인해 새로운 데이터를 받을 때까지 loading 템플릿이 구동된다. 이 결과로 로딩될 때마다 페이지의 탑으로 이동해서 아랫쪽으로 스크롤해야 하는 사태가 일어난다.

그러므로 먼저, Iron Router에게 구독을 waitOn하지 않도록 해야 한다. 대신 구독을 subscriptions hook 내부에 정의할 것이다.

또한 데이터 컨텍스트에 this.postsSub.ready를 참조하는 ready 변수를 만들어 전달할 것이다. 이것은 post 구독의 로딩이 완료되는 시점을 템플릿에게 알려준다.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

그리고 템플릿에서 이 ready 변수를 검사하여 post 목록의 하단에 새로운 post 목록을 로드하는 동안 spinner를 보여준다:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Commit 12-5

spinner를 추가하여 페이징을 더 멋지게 만들었다.

Post 열람페이지 접속

우리는 현재 초기 설정값으로 첫 5개의 post를 보여주게 하고 있다. 그런데, 특정 post페이지로 브라우징하면 무슨 일이 일어날까?

빈 템플릿.
빈 템플릿.

해보면, 빈 post 템플릿으로 채워진 페이지를 보게 될 것이다. 이것은 말이 된다: 우리는 라우터에게 postsList 경로를 로드할 때 posts 발행을 구독하라고 했지만, postPage 경로에 대하여는 지시한 바가 없다.

그러나 지금까지, 우리가 알고 있는 방법은 n 개의 최근 post 목록에 구독하는 것이다. 그러면 한 개의 단일 post에 대하여 서버에 어떻게 요청할까? 여기서 약간의 비밀을 알려줄 것이다: 각 컬렉션에 한 개 이상의 발행이 가능하다!

이 빠진 부분을 채워넣기 위해서, 우리는 _id로 구하는 하나의 post만을 발행하는 새로운 singlePost 구독을 만든다.

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...
server/publications.js

이제 클라이언트 쪽의 post 목록을 구독한다. 우리는 이미 postPage route의 waitOn 함수에 comments 발행을 구독하고 있다. 그래서 여기에 singlePost에 대한 구독을 추가한다. 그리고 같은 데이터를 요구하는 postEdit route에도 동일한 구독을 추가하는 것을 잊지 말라.:

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return [
      Meteor.subscribe('singlePost', this.params._id),
      Meteor.subscribe('comments', this.params._id)
    ];
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  waitOn: function() { 
    return Meteor.subscribe('singlePost', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

Commit 12-6

항상 올바른 post를 열람하도록 단일 post 구독을 사용하였다.

페이징이 구현되니 우리 앱은 이제 더 이상 확장성 문제로 어려움을 겪지 않는다. 그리고 사용자들은 전보다 더 많은 링크를 등록할 수 있다. 이제 이 링크에 순위를 매기는 방법이 있다면 좋지 않을까? 여러분이 생각하시는 대로, 이것이 바로 다음 장의 주제이다!

투표(Voting)

13

사이트가 점점 인기가 올라가면, 최고 링크를 찾는 일은 빠르게 기교를 요구하게 된다. 이제 필요한 것은 post에 대한 일종의 순위 시스템이다.

우리는 이용자의 활동, 시간 경과에 따른 포인트의 감소, 그리고 기타 다양한 방식을 가지는 복잡한 순위 시스템을 구축할 수 있다 (이 대부분이 Microscope의 빅브라더인 Telescope에는 구현되어 있다). 하지만 여기서는 단순하게 각 post가 받는 투표 숫자로 순위를 매기기로 한다.

post에 사용자가 투표를 할 수 있는 방법을 구현하여 시작해보자.

데이터 모델

우리는 사용자에게 투표 버튼을 보여줄 지 말지를 판단하고 사용자들이 투표를 두 번 하지 않도록 하기 위하여 각 post별로 투표자의 목록을 저장하려고 한다.

데이터 프라이버시와 발행

이 투표자 목록은 모든 사용자에게 발행될 것이고, 따라서 브라우저 콘솔을 통해서 이 데이터를 공개적으로 접근할 수 있도록 할 것이다.

이로 인해서 컬렉션의 작동 방식으로부터 일종의 데이터 프라이버시 문제가 발생한다. 예를 들면, 사람들이 각 post에 누가 투표했는지를 볼 수 있도록 하는 것을 우리가 원할까? 이 경우, 이들 정보를 공개하는 것이 정말로 주목을 받지는 않겠지만, 최소한 이런 이슈가 있다는 것을 알고 있는 것은 중요하다.

또한, 우리가 이러한 정보의 일부라도 제한하기를 정말로 원한다면, 클라이언트가 서버쪽에서 속성을 제거하거나 또는 클라이언트에서 서버로 전체 옵션 객체를 전달하지 않는 식으로, 발행의 ‘fields’ 옵션을 조작할 수 없다는 것을 확실하게 해두어야 한다.

또한 임의의 post에 대한 투표자의 총 숫자를 비정규화하여 그 값을 보다 쉽게 얻을 수 있게 할 것이다. 그래서 post에 두 개의 속성, upvotersvotes를 추가한다. 먼저 이를 초기화 파일에 추가하도록 하자:

// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: now - 7 * 3600 * 1000,
    commentsCount: 2,
    upvoters: [], votes: 0
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: now - 5 * 3600 * 1000,
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: now - 3 * 3600 * 1000,
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: now - 10 * 3600 * 1000,
    commentsCount: 0,
    upvoters: [], votes: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: now - 12 * 3600 * 1000,
    commentsCount: 0,
    upvoters: [], votes: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: now - i * 3600 * 1000,
      commentsCount: 0,
      upvoters: [], votes: 0
    });
  }
}
server/fixtures.js

그래 왔던대로, 앱을 중지하고, meteor reset를 실행하고, 앱을 재시동하고, 새로 계정을 등록한다. 그리고 post가 등록될 때, 두 속성이 초기화되는 것을 확인하기 바란다:

//...

// check that there are no previous posts with the same link
if (postAttributes.url && postWithSameLink) {
  throw new Meteor.Error(302, 
    'This link has already been posted', 
    postWithSameLink._id);
}

// pick out the whitelisted keys
var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
  userId: user._id, 
  author: user.username, 
  submitted: new Date().getTime(),
  commentsCount: 0,
  upvoters: [], 
  votes: 0
});

var postId = Posts.insert(post);

return postId;

//...
collections/posts.js

투표 템플릿 구축하기

먼저, post 부분에 지지 버튼을 추가한다:

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn"></a>
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        {{votes}} Votes,
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html
upvote 버튼
upvote 버튼

다음, 사용자가 버튼을 클릭할 때, 서버의 upvote 메서드를 호출한다:

//...

Template.postItem.events({
  'click .upvote': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/views/posts/post_item.js

마지막으로, collections/posts.js 파일에 post의 투표 숫자를 증가시키는 서버쪽 메서드를 추가한다:

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    var user = Meteor.user();
    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to upvote");

    var post = Posts.findOne(postId);
    if (!post)
      throw new Meteor.Error(422, 'Post not found');

    if (_.include(post.upvoters, user._id))
      throw new Meteor.Error(422, 'Already upvoted this post');

    Posts.update(post._id, {
      $addToSet: {upvoters: user._id},
      $inc: {votes: 1}
    });
  }
});
collections/posts.js

Commit 13-1

기본적인 upvote 알고리즘을 추가했다.

이 메서드는 쭉 따라가면 된다. 사용자가 로그인 상태인지를 검사하고, post가 정말 존재하는 지를 검사한다. 그리고 사용자가 해당 post에 이미 투표했는 지를 검사하고, 하지 않았으면 총 투표수를 1 증가시킨 다음 투표자 명단에 그 사용자를 추가한다.

이 마지막 단계가 흥미로운 것은, Mongo의 특별한 연산자를 두 번 사용해서다. 이 부분에 배울 것이 많이 있지만, 특히 이 두 개는 특별히 도움이 된다: $addToSet은 항목을 배열 속성에 이미 존재하지 않는 경우에 추가한다. 그리고 $inc는 정수 필드를 단순히 1 증가시킨다.

사용자 인터페이스 살짝 바꾸기

만약 사용자가 로그인 상태가 아니거나, 이미 그 post에 투표했다면, 그들은 다시 투표할 수 없다. 이것을 우리 UI에 반영하기 위해, 헬퍼를 사용하여 조건적으로 upvote 버튼에 disabled CSS 클래스를 추가한다.

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn {{upvotedClass}}"></a>
    <div class="post-content">
      //...
  </div>
</template>
client/views/posts/post_item.html
Template.postItem.helpers({
  ownPost: function() {
    //...
  },
  domain: function() {
    //...
  },
  upvotedClass: function() {
    var userId = Meteor.userId();
    if (userId && !_.include(this.upvoters, userId)) {
      return 'btn-primary upvotable';
    } else {
      return 'disabled';
    }
  }
});

Template.postItem.events({
  'click .upvotable': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/views/posts/post_item.js

우리는 class를 .upvote에서 .upvotable로 바꾼다. 따라서, 클릭 이벤트 핸들러도 바꾸는 것을 잊지 말기 바란다.

upvote 버튼을 기능을 중지하기.
upvote 버튼을 기능을 중지하기.

Commit 13-2

로그인 상태가 아니거나 이미 투표했을 때 upvote 링크의 기능을 중지한다.

다음, 투표수가 1인 post는 “1 votes"라고 표시되는 것을 볼 수 있다. 이 부분을 적절하게 복수처리를 하도록 하자. 복수처리는 복잡한 프로세스가 될 수 있지만, 우리는 매우 단순한 방법으로 할 것이다. 어디에서나 사용할 수 있는 일반적인 Handlebars helper를 만든다:

UI.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});
client/helpers/handlebars.js

우리가 전에 만든 헬퍼들은 적용되는 관리자와 템플릿에 묶여 있었다. 그러나 Handlebars.registerHelper를 사용함으로써, 어느 템플릿에서나 사용할 수 있는 전역 헬퍼를 만든다:

<template name="postItem">
//...
<p>
  {{pluralize votes "Vote"}},
  submitted by {{author}},
  <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
  {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>
//...
</template>
client/views/posts/post_item.html
완벽한 복수 처리
완벽한 복수 처리

Commit 13-3

텍스트 포맷을 더 좋게 하기 위한 복수 처리 헬퍼를 추가했다.

이제 "1 vote"로 표시되는 것을 볼 수 있을 것이다.

더 똑똑한 투표 알고리즘

투표 관련 코드는 좋아 보이지만, 아직도 더 개선할 수 있다. upvote 메서드에서 Mongo에 두 번의 호출을 한다: 하나는 post를 가져오는 것이고 다른 하나는 그것을 갱신하는 것이다.

여기에는 두 개의 이슈가 있다. 첫째, 데이터베이스를 두 번 호출하는 것은 다소 비효율적이다. 하지만 더 중요한 것은, 이것이 경쟁 조건을 도입한다는 것이다. 우리는 다음 알고리즘을 따르고 있다:

  1. 데이터베이스에서 post를 가져온다.
  2. 사용자가 투표를 했는지 검사한다.
  3. 투표하지 않았으면, 투표를 실행한다.

동일한 사용자가 위의 1~3단계 사이에 있을 때 다시 투표를 하면 어떻게 될까? 현재 코드는 이런 경우의 두 번 투표하는 것이 가능하게 되어 있다. 다행히 Mongo에는 위의 3단계를 1개의 Mongo 명령어로 처리할 수 있는 방법이 있다:

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    var user = Meteor.user();
    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to upvote");

    Posts.update({
      _id: postId, 
      upvoters: {$ne: user._id}
    }, {
      $addToSet: {upvoters: user._id},
      $inc: {votes: 1}
    });
  }
});
collections/posts.js

Commit 13-4

더 나은 upvote 알고리즘.

위 코드의 의미는 "사용자가 아직 투표하지 않은 이 id값을 가지는 모든 post를 찾아서, 이 방법으로 갱신하라"이다. 만약 사용자가 아직 투표하지 않았다면, 그 id를 가지는 post를 찾게 될 것이다. 한 편 사용자가 투표했다면, 그 쿼리는 결과를 찾지 못하고 결과적으로 아무 일도 일어나지 않을 것이다.

유일한 단점은, 그것은 사용자가 이미 투표를 했을 경우, 투표했다는 메시지를 보여줄 수 없다는 것이다(이를 체크하는 데이터베이스 요청을 삭제했기 때문이다). 그러나, 사용자들은 사용자 인터페이스 상에서 "지지” 버튼이 disable 상태가 되는 것으로 알게 될 것이다..

대기시간 보정

독자가 시스템을 속여서 특정한 post의 투표 숫자를 바꿔서 목록의 상단으로 보내려고 시도한다고 하자:

> Posts.update(postId, {$set: {votes: 10000}});
브라우저 콘솔

(여기서 postId는 독자가 작성한 post들 중의 하나의 id이다)

이런 뻔뻔스런 시도는 deny() 콜백 (collections/posts.js 안에 있다. 기억하시나?)에 걸려서 바로 거절된다.

그러나 주의깊게 관찰하면, 이 동작에서 대기시간 보정을 볼 수 있을지 모른다. 순간적이지만, post는 제 위치로 돌아오기 전에 목록의 상단으로 점프할 것이다.

무슨 일이 일어났을까? 당신의 로컬 Posts 컬렉션에서, 갱신이 문제없이 일어난 것이다. 이것은 순간적으로 일어나고, post는 목록의 상단으로 올라간다. 그 사이에 서버에서 갱신은 거절된다. 그래서 잠시 뒤에 (측정하면 수 밀리초이내에) 서버는 오류를 리턴하고 로컬 컬렉션에게 되돌리도록 지시한다.

최종 결과: 서버가 응답하는 것을 기다리는 동안, 사용자 인터페이스는 로컬 컬렉션을 믿지 않을 수 없다. 서버의 리턴결과가 변경을 거절하고, 사용자 인터페이스는 그 결과를 반영한다.

프론트 페이지 Post 목록에 순위 매기기

이제 투표수에 기반한 각 post별 점수를 가지게 되었으니 최고 post의 목록을 보여주도록 해보자. 그렇게 하기 위해, post 컬렉션에 대한 두 개의 분리된 구독을 관리하는 방법을 알아보고, postsList 템플릿을 보다 범용으로 만들어보자.

정렬 방식에 따른 개의 구독을 구현하려고 한다. 여기에 적용할 기법은 두 구독이 동일한 posts 발행에 구독하되 매개변수만 다르게 한다는 것이다!

또한 두 개의 새 route를 만드는데 그 이름은 newPostsbestPosts이며 그 URL은 각각 /new/best(물론 페이징을 적용하면 /new/5/best/5)이다.

이를 구현하기 위하여, PostsListController확장하여 NewPostsListControllerBestPostsListController controller들을 만든다. 이것은 homenewPosts route에 대한 것과 정확하게 똑같은 route option을 재사용한다. 이것이 바로 Iron Router가 얼마나 유연한 지를 보여주는 훌륭한 사례이다.

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  limit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: this.sort, limit: this.limit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.limit();
    return {
      posts: this.posts(),
      nextPath: hasMore ? this.nextPath() : null
    };
  }
});

NewPostsListController = PostsListController.extend({
  sort: {submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.newPosts.path({postsLimit: this.limit() + this.increment})
  }
});

BestPostsListController = PostsListController.extend({
  sort: {votes: -1, submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.bestPosts.path({postsLimit: this.limit() + this.increment})
  }
});

Router.map(function() {
  this.route('home', {
    path: '/',
    controller: NewPostsListController
  });

  this.route('newPosts', {
    path: '/new/:postsLimit?',
    controller: NewPostsListController
  });

  this.route('bestPosts', {
    path: '/best/:postsLimit?',
    controller: BestPostsListController
  });
  // ..
});
lib/router.js

이제 route가 하나 이상이 되었으므로, nextPath 로직을 PostsListController의 외부로 뽑아내어 NewPostsListControllerBestPostsListController의 내부로 넣는다. 이 각각의 경로가 다르기 때문이다.

추가적으로, votes로 정렬을 할 때, 정렬이 올바르게 처리되도록 두 번째 정렬 조건에 시간을 넣는다.

새 컨트롤러를 넣었으면, 우리는 이제 이전의 postsList route를 안전하게 제거할 수 있다. 다음 코드를 삭제하면 된다:

 this.route('postsList', {
  path: '/:postsLimit?',
  controller: PostsListController
 })
lib/router.js

그리고 header에 링크를 추가한다:

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'home'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          <li>
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li>
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav pull-right">
          <li>{{> loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/include/header.html

또한 post를 삭제하는 이벤트 핸들러를 수정한다:

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('home');
    }
  }
client/views/posts_edit.js

이 모두가 완료되면 베스트 post 목록은 다음과 같다:

포인트에 의한 순위
포인트에 의한 순위

Commit 13-5

다양한 post 목록 페이지를 위한 route와 이를 보여주는 페이지를 추가했다.

더 나은 Header

이제 두 개의 post 목록 페이지가 생겼는데, 독자가 어떤 목록을 현재 보고 있는지 알기는 어렵다. 그래서 header 파일을 고쳐 이를 좀 더 알아보기 쉽게 바꾼다. 우리는 header.js파일을 만들고 네비게이션 항목에 active 클래스를 지정하기 위해서 현재 경로와 하나 이상의 이름있는 route를 사용하는 헬퍼를 만든다.

우리가 다중으로 이름있는 route들을 지원하는 이유는 homenewPosts 경로 모두가 (각각 //new URL에 대응한다) 동일한 템플릿을 가져오기 때문이다. 그리고 activeRouteClass가 똑똑해서 이 두 경우 모두 <li> 태그를 active 상태로 바꾸어야 한다.

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'home'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          <li class="{{activeRouteClass 'home' 'newPosts'}}">
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li class="{{activeRouteClass 'bestPosts'}}">
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li class="{{activeRouteClass 'postSubmit'}}">
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav pull-right">
          <li>{{> loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html
Template.header.helpers({
  activeRouteClass: function(/* route names */) {
    var args = Array.prototype.slice.call(arguments, 0);
    args.pop();

    var active = _.any(args, function(name) {
      return Router.current() && Router.current().route.name === name
    });

    return active && 'active';
  }
});
client/views/includes/header.js
현재 활성화된 페이지 보여주기
현재 활성화된 페이지 보여주기

헬퍼 매개변수

지금까지 우리는 특별한 패턴을 사용하지는 않았지만, 다른 Handlebars 태그와 같이 템플릿 헬퍼 태그는 매개변수를 가질 수 있다.

그리고 당연히 특정한 이름의 매개변수를 함수로 전달할 수 있고, 또한 지정하지 않은 갯수의 익명의 매개변수를 전달하고 이를 함수 내부에서 arguments 객체를 호출하여 얻을 수 있다.

바로 위의 예에서 우리는 arguments 객체를 Javascript 배열로 변환하고자 했고, Handlebars로 끝에 추가된 해시를 제거하려고 pop()를 호출했다.

네비게이션 요소별로, activeRouteClass 헬퍼가 경로목록을 읽어서, Underscore의 any() helper를 사용하여 테스트(즉, 대응하는 URL이 현재 경로와 같은지)를 통과하는 route가 있는지를 찾는다.

현재 경로와 일치하는 route가 있다면, any()true를 리턴한다. 결국, 우리는 boolean && string Javascript 패턴을 이용하여 false && myStringfalse를 리턴하고, true && myStringmyString를 리턴하는 결과를 얻는다.

Commit 13-6

헤더에 active 클래스를 추가했다.

이제 사용자들은 실시간으로 post에 투표를 할 수 있게 되었고, 그 순위가 변하게 되면 자동으로 위, 아래로 이동하는 링크를 볼 수 있다. 그런데, 이것이 멋진 애니메이션으로 부드럽게 이루어지면 더 훌륭하지 않을까?

발행(Publication) 고급편

Sidebar 13.5

이제 발행과 구독의 상호 작용에 대하여 잘 이해하게 되었을 것이다. 이제 보조 바퀴는 빼고 보다 고급의 시나리오를 알아보자.

컬렉션을 여러 번 발행하기

발행(publication)에 대한 이전 장에서, 우리는 보다 일반적인 발행과 구독 패턴의 몇 가지를 보았다. 그리고 _publishCursor 함수를 이용하여 사이트를 매우 쉽게 구축할 수 있음을 배웠다.

먼저, _publishCursor가 정확하게 무슨 일을 하는 지 돌이켜보자: 이것은 주어진 커서에 일치하는 모든 도큐먼트들을 가진다. 그리고 이들을 클라이언트 쪽의 같은 이름을 가진 컬렉션으로 보낸다. publication이란 이름은 어디에도 포함되지 않았음을 주목하라.

이것은 우리가 어떤 컬렉션이든지 클라이언트와 서버를 연결하는 복수의 발행을 만들 수 있음을 의미한다.

우리는 이런 패턴을 페이지 만들기 장에서 이미 본 적이 있다. 그 때 우리는 현재 보여지는 post 발행에 추가하여 전체 post의 페이징된 부분 집합을 발행하였다.

또 다른 유사한 용례로는 한 아이템의 전체 상세와 함께 도큐먼트의 규모가 큰 집합에 대한 개요를 발행하는 경우이다:

컬렉션을 두 번 발행하기
컬렉션을 두 번 발행하기
Meteor.publish('allPosts', function() {
  return Posts.find({}, {fields: {title: true, author: true}});
});

Meteor.publish('postDetail', function(postId) {
  return Posts.find(postId);
});

클라이언트가 (postDetail 구독에 올바른 postId를 보내기 위해 autorun을 사용하는) 이 두 발행에 구독할 때, 그 ‘posts’ 컬렉션은 두 개의 소스에서 얻어진다: 첫 구독으로부터 제목과 저자명의 목록, 두 번째에서 post의 전체 상세 데이터.

postDetail에 의해 발행된 post가 (비록 그 속성의 일부만이기 하지만) allPosts에 의해서도 발행된다는 것을 알 지도 모른다. 그런데 미티어는 필드를 결합하면서 발생하는 중복을 처리하여 중복된 post가 없도록 한다.

이것은 대단하다. 왜나면 post 요약본의 목록을 화면에 그릴 때, 우리에게 필요한 것을 보여주기에 충분한 데이터를 가진 객체들로 처리하기 때문이다. 그런데, 단일 post에 대한 페이지를 렌더링할 때, 우리는 그것을 보여주기에 필요한 모든 것을 가지고 있다. 물론 클라이언트에서 이 경우 모든 post 목록에서 이용가능한 모든 필드를 기대할 수는 없다 –- 이건 제대로 걸렸다!

다양한 도큐면트 속성들에만 제한을 받는 것은 아님을 유념하기 바란다. 두 개의 발행에서 동일한 속성을 발행할 수 있지만, 아이템들을 다르게 주문해야 한다.

Meteor.publish('newPosts', function(limit) {
  return Posts.find({}, {sort: {submitted: -1}, limit: limit});
});

Meteor.publish('bestPosts', function(limit) {
  return Posts.find({}, {sort: {votes: -1, submitted: -1}, limit: limit});
});
server/publications.js

하나의 발행에 여러 번 구독하기

우리는 방금 하나의 컬렉션을 여러 번 발행하는 방법을 알아보았다. 또 다른 패턴으로 유사한 결과을 얻을 수 있다: 하나의 발행을 만들고 여기에 여러 번 구독하기.

Microscope에서 우리는 posts 발행에 여러 번 구독하지만, Iron Router는 각각의 구독을 설정하고 분리한다. 하지만 동시에 여러 번 구독을 할 수 없는 이유는 없다.

예를 들면, post의 최신 목록과 최고 목록을 동시에 로드하기를 원한다고 하자:

하나의 발행에 두 번 구독하기
하나의 발행에 두 번 구독하기

우리는 단일 발행을 설정한다:

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

그리고 이 발행에 여러 번 구독한다. 사실 이것은 우리가 Microscope에서 대체로 정확하게 하고 있는 것이다:

Meteor.subscribe('posts', {submitted: -1, limit: 10});
Meteor.subscribe('posts', {baseScore: -1, submitted: -1, limit: 10});

그럼 무슨 일이 벌어지는가? 각 브라우저는 개의 다른 구독을 연다. 각각은 서버에서 동일한 발행에 연결되어 있다.

각 구독은 그 발행에 다른 매개변수를 전달하는데, 각 시점마다 (다른) 도큐먼트들이 posts 컬렉션으로부터 추출되어 클라이언트 쪽 컬렉션으로 보내진다.

단일 구독에서의 복수의 컬렉션

joins을 사용하는 MySQL같은 전통적인 관계형 데이터베이스와는 달리, Mongo와 같은 NoSQL 데이터베이스는 본질적으로 비정규화임베딩이 불가피하다. 미티어에서 이것이 어떻게 되는지 살펴보도록 하자.

구체적인 사례를 보자. 우리는 post에 댓글을 달았다. 그리고 사용자가 열람중인 단일 post에 달린 댓글 목록만을 발행하였다.

그런데, 우리가 프론트 페이지에 모든 post 목록(이 목록들은 페이징에 따라 달라질 수 있다는 점을 기억하라)의 댓글들을 보여주길 원한다고 가정해보자. 이 사례는 post에 comment 목록을 임베딩해야 하는 이유를 제공한다. 그리고 사실 댓글의 갯수를 비정규화하여 넣은 이유이기도 하다.

물론 우리는 post에 댓글을 임베딩하고 Comments 컬렉션을 완전히 제거할 수도 있었다. 하지만, 우리가 이전에 비정규화 장에서 본 바와 같이 그렇게 하는 것은 별도의 컬렉션으로 처리할 때의 여러 잇점을 잃는 결과를 가져온다.

그러나, 별도의 컬렉션 상태로 유지하면서 댓글을 임베딩하는 것이 가능한 구독을 포함하는 기법이 있다.

프론트 페이지의 post 목록과 함께, 각 post의 상위 두 개의 댓글 목록을 구독하려고 하는 경우를 가정해보자.

이를 독립된 댓글 발행으로 처리하기는 어려울 것이다. 특히 post 목록이 (말하자면, 최근 10개와 같이) 제한을 가지면 그렇다. 우리는 아래와 같은 발행을 작성해야 한다:

단일 구독에서의 두 컬렉션
단일 구독에서의 두 컬렉션
Meteor.publish('topComments', function(topPostIds) {
  return Comments.find({postId: topPostIds});
});

이것은 성능 측면에서 문제가 될 것이다. 이 발행은 topPostIds 목록이 변경될 때마다 무효화되고 새로 구성되어야 하기 때문이다.

하지만, 이를 해결하는 방안이 있다. 컬렉션 당 하나 이상의 발행을 가질 수도 있지만, 발행 당 하나 이상의 컬렉션을 가질 수도 있다는 사실을 이용한다:

Meteor.publish('topPosts', function(limit) {
  var sub = this, commentHandles = [], postHandle = null;

  // send over the top two comments attached to a single post
  function publishPostComments(postId) {
    var commentsCursor = Comments.find({postId: postId}, {limit: 2});
    commentHandles[post._id] = 
      Meteor.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(post._id);
      sub.added('posts', id, post);
    },
    changed: function(id, fields) {
      sub.changed('posts', id, fields);
    },
    removed: function(id) {
      // stop observing changes on the post's comments
      commentHandles[id] && commentHandles[id].stop();
      // delete the post
      sub.removed('posts', id);
    }
  });

  sub.ready();

  // make sure we clean everything up (note `_publishCursor`
  //   does this for us with the comment observers)
  sub.onStop(function() { postsHandle.stop(); });
});

주목할 것은, 이 발행에서 어떤 값도 리턴하지 않으며, sub에게 (.added()와 기타 동료들을 통해) 수동적으로 메시지를 보내기만 한다는 점이다. 따라서 _publishCursor 에게 커서를 리턴하여 이를 처리하도록 요구할 필요가 없다.

이제 post를 발행할 때마다, 우리는 자동으로 여기에 연결된 상위 두 개의 댓글을 발행한다. 그리고 이 모든 것이 한 번의 구독요청으로 이루어진다!

미티어가 이 방식을 아직은 이해하기 쉽게 제공하지는 못하지만, Atmosphere의 publish-with-relations 패키지를 검토하여 보기 바란다. 이것은 이 패턴을 보다 사용하지 쉽게 구현하는 것을 목표로 하고 있다.

다른 컬렉션들을 연결하기

구독의 유연성에 대한 새로운 지식은 그 밖의 무엇을 줄까? 글쎄, _publishCursor를 사용하지 않는다면 서버의 소스 컬렉션이 클라이언트의 타겟 컬렉션과 동일한 이름을 가져야 한다는 제약을 따를 필요는 없다.

두 개의 구독에 대한 하나의 컬렉션
두 개의 구독에 대한 하나의 컬렉션

이를 하려는 한 가지 이유는 Single Table Inheritance이다.

우리가 post로부터 다양한 형태의 객체 - 각 객체는 공통의 필드를 저장하지만 그 내용은 약간 다를 수 있다 - 를 참조하는 경우를 상상해보자. 예를 들면, 우리는 Tumblr같은 블로깅 엔진을 구축하고 있는데, 거기서 각 post는 ID, timestamp, 그리고 제목을 가진다; 여기에 추가로 image, video, link 또는 텍스트까지 있다.

우리는 이 모든 객체를 단일의 'resources' 컬렉션에 저장할 수 있다. 이 때 객체의 종류를 가리키는 type 속성(video, image, link, 등)을 사용한다.

그리고 서버에서는 단일 Resources 컬렉션 형태로 있지만, 클라이언트에서는 이를 복수의 Videos, Images, 등의 컬렉션들로 변환할 수 있다. 이를 처리하는 마술같은 코드는 다음과 같다:

  Meteor.publish('videos', function() {
    var sub = this;

    var videosCursor = Resources.find({type: 'video'});
    Meteor.Collection._publishCursor(videosCursor, sub, 'videos');

    // _publishCursor doesn't call this for us in case we do this more than once.
    sub.ready();
  });

우리는 _publishCursor에게 그 커서가 (리턴) 하는 비디오 목록을 발행하라고 지시하고 있다. 한 편으로는, 클라이언트에 resources 컬렉션을 발행하기 보다는 'resource'에서 'videos'로 발행을 하라고 지시하는 것이다.

이렇게 하는 것이 좋은 생각일까? 우리가 판단할 일은 아니다. 어쨋든, 미티어를 최대한 활용하기 위해서라면 가능한 것이 무엇인지를 아는 것은 좋다!

애니메이션

14

이제 실시간 투표, 점수 계산, 순위 지정을 하는 기능이 구현되었다. 그런데, 홈페이지에서 post가 점프하여 이동하는 모양이 거슬리고, 이상한 사용자 경험을 준다. 이를 애니메이션으로 부드럽게 처리하려고 한다.

미티어와 DOM

우리가 (화면이 움직이는) 신나는 부분으로 들어가기 전에, 미티어가 DOM(Document Object Model – 페이지를 구성하는 HTML 엘리먼트들의 컬렉션)과 어떻게 상호작용을 하는지 이해할 필요가 있다.

기억해 둘 중요한 포인트는 DOM의 엘리먼트들은 실제로는 “움직일 수 없다”는 것이다; 이들은 삭제되거나 생성될 수만 있다(이것은 DOM의 한계이지 미티어의 한계가 아니다). 그러므로, A와 B가 자리를 바꾸는 착시 효과를 주기 위해서, 미티어는 실제로는 B를 제거하고 새 복제물(B’)을 A앞에 삽입하는 것이다.

이것은 애니메이션을 약간 어렵게 한다. 우리가 B를 새 위치로 이동하는 것을 애니메이션 방식으로 구현하지 못하는 이유는, 미티어가 페이지를 다시 그리는 순간(이것은 반응성 덕분에 순간적으로 일어난다) B가 사라질 것이기 때문이다. 하지만, 걱정마라, 방법은 있으니까.

소련의 육상 선수

그 해는 냉전의 한창일 때인 1980년이었다. 올림픽이 모스코바에서 열리고 있었고, 소련은 무슨 수를 써서라도 100미터 달리기에서 우승하기로 결심했다. 그래서 소련의 뛰어난 과학자 그룹이 그 육상 선수중의 한 선수에게 순간이동기를 입혔다. 총소리가 들리자마자 그 선수는 피니시라인으로 순간적으로 옮겨졌다.

다행히도 경기 심판들이 즉시 그 위반을 인지했고, 그 선수는 선택의 여지가 없이 출발점으로 순간이동해서 돌아와서, 다른 선수들과 함께 경주에 참가하여 달리기를 해야 했다.

이 역사적 자료는 그리 그럴듯하게 들리지 않으니 어느 정도 감안해서 듣기 바란다. 하지만, 이 “소련의 순간이동기를 가진 달리기 선수” 이야기는 이 장 내내 기억하기 바란다.

차근차근 살펴보기

미티어가 업데이트 알림을 받고 DOM을 반응형 방식으로 수정할 때, post는 순간적으로 그 최종 위치로 소련의 육상선수처럼 이동할 것이다. 그러나 올림픽에서든 우리 앱에서든 순간이동기 같은 수단을 가질 수는 없다. 그래서 우리는 엘리먼트를 “출발점”으로 재이동시키고, 피니시 라인까지 “달리기”(다시 말하면, 애니메이션 시키기)를 하도록 한다.

그래서 post A와 B(각각 p1, p2에 있다고 하자)를 위치를 바꾸려면, 우리는 다음과 같은 단계를 진행할 것이다:

  1. B를 삭제한다
  2. DOM에서 A 앞에 B'를 생성한다
  3. B'를 p2로 이동한다
  4. A를 p1으로 이동한다
  5. A를 p2로 애니메이션 처리한다
  6. B'를 p1으로 애니메이션 처리한다

아래 다이어그램은 이 단계를 보다 상세하게 보여준다:

두 post 사이의 자리 바꾸기
두 post 사이의 자리 바꾸기

3단계와 4단계는 A와 B를 애니메이션으로 처리하는 것이 아니라 순간적으로 “공간이동”시킨다. 이것이 순간적으로 일어나기 때문에 B가 삭제되지 않고 두 요소가 새 위치로 적절하게 자리하는 착각을 준다.

다행히, 미티어가 1과 2단계는 알아서 한다. 그래서 우리는 3단계에서 6단계까지만 걱정하면 된다.

더군다나, 5단계와 6단계에서 우리가 할 일은 그 요소들을 적절한 자리로 이동시키는 것이다. 그러므로 우리가 정말로 걱정할 부분은 3, 4단계로 요소들을 에니메이션의 시작위치로 보내는 것 뿐이다.

적절한 타이밍

지금까지 우리는 post를 애니메이션 하는 방법에 대하여 논의를 했을 뿐 애니메이션하는 시점에 대하여는 논의하지 않았다.

3단계와 4단계의 경우, 그 시점은 post의 _rank 속성(순서가 여기에 따라 달라진다)이 변경될 때이다.

5와 6단계는 약간 더 기교를 부려야 한다. 이런 방식으로 생각해보자: 완벽하게 논리적인 로붓에게 북쪽으로 5분간 달린 다음, 다시 남쪽으로 5분간 달리라고 지시하면, 로봇은 추론을 하기를 결국 같은 자리에 있게 되니까 에너지를 아껴 그냥 달리지 않을 것이다.

그래서 만약 로봇이 10분간을 달리도록 하려면, 처음 5분을 달리기가 완료될 때까지 기다려야 한다. 그리고 달렸으면 다시 돌아오라고 지시해야 한다.

브라우저도 비슷하게 작동한다: 만약 두 지시를 동시에 하면 새 좌표는 단지 옛 좌표를 바꿔치기 할 뿐 아무 일도 일어나지 않는다. 다시 말하면, 브라우저는 위치의 변경을 시간상의 다른 시점으로 등록해야 한다. 그렇지 않으면 애니메이션이 일어나지 않는다.

미티어는 이에 대한 빌트인 콜백을 제공하지 않지만, 우리는 ‘Meteor.setTimeout()`을 이용하여 속일 수 있는데, 이것은 단순 함수로 수 밀리초 정도를 실행을 지연시킨다.

CSS 위치 지정

Post 목록이 페이지 내에서 재정렬되는 것을 애니메이션으로 처리하기 위해서는 CSS 영역에 도전해야 한다. 다음 순서는 CSS 위치 지정에 대하여 빠르게 훑어볼 시점이다.

페이지에서 엘리먼트는 초기값으로 static position으로 설정되어 있다. Static position 상태의 엘리먼트는 페이지의 흐름에 맞추며, 화면에서 그 좌표는 변경되거나 애니메이션이 될 수 없다.

한 편, relative position은 엘리먼트 페이지 흐름에 맞추지만, 그 원래 위치에 상대적으로 자리한다.

Absolute position은 한 단계 더 나아가서 엘리먼트에게 도큐먼트나 또는 첫 번째 absolute 또는 relative-position상태의 부모 엘리먼트에 상대적으로 특정한 x/y 좌표로 위치한다.

Post의 애니메이션에는 relative position을 사용할 것이다. 이미 CSS를 제공했지만, 직접 해보고 싶다면 아래 코드를 스타일 시트에 추가하면 된다:

.post{
  position:relative;
  transition:all 300ms 0ms ease-in;
}
client/stylesheets/style.css

이렇게 하면 5와 6단계는 매우 쉽게 구현된다: 우리가 top0px(초기 설정값)으로 리셋하면 post들은 그 “normal” 위치로 미끄러져 되돌아 갈 것이다.

그러므로 기본적으로, 우리의 유일한 도전과제는 애니메이션을 구동할 위치(3, 4단계)를 새 위치에 상대적인 좌표로 지정하는 것이다. 다른 말로 표현하면, 그들의 위치를 얼마나 변이(offset)시키는 가이다. 하지만, 이것이 아주 어려운 것은 아니다: 올바른 위치 이동폭은 단순히 post의 이전 위치에서 새 위치를 빼면 된다.

Position:absolute

엘리먼트의 위치를 지정하는데 부모 엘리먼트에 상대적으로 position:absolute를 사용할 수 있다. 하지만 절대 좌표를 사용하는 엘리먼트의 큰 단점은 페이지의 흐름으로부터 완전히 빠지게 되어, 이것이 비게 되면 그 부모 컨테이너가 공간을 잃게 된다는 점이다.

결국 이 의미는 JavaScript를 통해서 인위적으로 그 컨테이너의 height를 지정해야 한다는 것이고, 브라우저의 엘리먼트 재정렬 기능에서 자연스럽게 빠지게 된다. 결과적으로 가능하다면 relative position을 고수하는 것이 최선이다.

토탈 리콜

아직 한 가지 문제가 더 남았다. DOM에서 엘리먼트 A는 저장되어 그 이전의 위치를 “기억"하고 있는 반면, 엘리먼트 B는 새로 만들어져서 B'로 돌아왔기에 그 기억은 완전히 지워진 상태가 된다.

그러므로 우리가 할 일은 페이지에서 post의 현재 위치를 찾는 것이다. 그리고 그 위치를 로컬 컬렉션에 저장한다. 로컬 컬렉션은 정규 미티어 컬렉션처럼 작동한다. 다만, 브라우저의 메모리에서만 존재한다(즉, 서버에는 존재하지 않는다). 이 방법으로, post가 삭제되거나 새로 만들어진다 해도, 우리는 애니메이션을 시작할 위치를 알 수 있게 된다.

Post 순위 매기기

그 동안 post의 순위에 대하여 이야기해왔는데, 이 "순위"는 post의 속성으로 실제 존재하는 것이 아닌, 단지 컬렉션에서 post목록이 정렬된 결과일 뿐이다. 이 순위에 따라서 post 목록을 애니메이션 처리하려고 한다면, 이 속성을 불쑥 마술이라도 부려서 만들어내어야 할 것이다.

유의할 사항은 이 rank 속성을 데이터베이스에는 넣을 수 없다는 것이다. 왜냐면, 이것은 post 목록을 정렬하는 방법(즉, post는 처음에는 날짜에 의해 순위가 정해지지만, 세 번째에는 포인트에 의해서 정렬된다)에 의존하는 상대적 속성이기 때문이다.

이상적으로는 newPoststopPosts 컬렉션에 그 속성을 넣는 것이지만, 미티어는 아직은 이 부분에 대한 쉬운 방법을 제공하지 않는다.

그래서 대신에, rank를 마지막 가능한 단계인, postList 템플릿 매니저에 삽입할 것이다:

Template.postsList.helpers({
  postsWithRank: function() {
    this.posts.rewind();
    return this.posts.map(function(post, index, cursor) {
      post._rank = index;
      return post;
    });
  }
});
/client/views/posts/posts_list.js

단순히 이전의 posts 헬퍼와 같은 커서인 Posts.find({}, {sort: {submitted: -1}, limit: postsHandle.limit()})을 리턴하는 대신에 postsWithRank는 커서로부터 각 도큐먼트에 _rank 속성을 추가한다.

postsList 템플릿을 다음과 같이 갱신하는 것을 잊지말라:

<template name="postsList">
  <div class="posts">
    {{#each postsWithRank}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
/client/views/posts/posts_list.html

친절한 Rewind

미티어는 가장 유망한 첨단의 웹 프레임워크중 하나다. 그렇지만 이 기능 중의 하나로 VCR과 비디오 카세트 녹음기의 시대로 되돌아가는 듯한 느낌을 주는 이름의 rewind() 함수가 있다.

forEach(), map(), 또는 fetch()로 커서를 사용할 때마다, 이 커서를 다시 사용하기 전에 rewind할 필요가 있을 것이다.

그러므로 때에 따라서, 안전한 쪽으로 선택하여 버그를 만드는 위험을 감수하기 보다는 예방적으로 커서를 rewind()하는 것이 낫다.

모두 모아서

애니메이션이 DOM 엘리먼트의 CSS 속성과 클래스에 영향을 주므로, 우리는 동적 헬퍼 {{attributes}}postItem 템플릿에 추가한다:

<template name="postItem">
  <div class="post" {{attributes}}>

  //..

</template>
/client/views/posts/post_item.html

{{attributes}}헬퍼를 이런 방식으로 사용함으로써, 우리는 Spacebars의 숨겨진 기능을 푼다: 리턴되는 attributes객체의 어떤 속성이라도 DOM 엘리먼트의 HTML 속성(class, style, 등과 같은)으로 매핑될 것이다.

attributes헬퍼를 만들어 이 모두를 모으자:

var POST_HEIGHT = 80;
var Positions = new Meteor.Collection(null);

Template.postItem.helpers({

  //..

  attributes: function() {
    var post = _.extend({}, Positions.findOne({postId: this._id}), this);
    var newPosition = post._rank * POST_HEIGHT;
    var attributes = {};

    if (! _.isUndefined(post.position)) {
      var offset = post.position - newPosition;      
      attributes.style = "top: " + offset + "px";
      if (offset === 0)
        attributes.class = "post animate"
    }

    Meteor.setTimeout(function() {
      Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
    });

    return attributes;
  }
});

//..
/client/views/posts/post_item.js

우리는 도큐먼트의 최상단에 각 DOM 엘리먼트의 높이, 다시 말하면 .post div의 높이를 지정하고 있다. 이로 인해서 이 높이가 변경되면 (예를 들면, post의 제목이 두 줄로 표현되는 경우) 애니메이션 로직이 깨지는 명백한 문제점이 발생한다. 하지만 문제를 단순하게 하기 위해 지금은 모든 post의 높이가 정확하게 80 픽셀이라고 가정할 것이다.

다음, 우리는 Positions 라는 이름의 로컬 컬렉션을 선언하고 있다. 매개변수로 null값을 전달하면 이것이 로컬(클라이언트에서만 동작하는) 컬렉션이라고 미티어에게 알리는 것이다.

이제 attributes 헬퍼를 구축할 준비가 되었다.

실행 일정

일부 반응형 코드가 정확하게 언제 실행되는 지를 알아내는 것은 종종 어려운 일이다. 그러므로 attributes 헬퍼에 대하여 좀 더 깊이있게 살펴보도록 하자.

모든 헬퍼와 같이, 이것은 템플릿이 처음 그려질 때 한 번 실행될 것이다. 이것이 _rank 속성에 의존성을 가지기 때문에, 이것은 post의 순위가 변경되는 때마다 재실행될 것이다. 그리고 Positions 컬렉션에 대한 의존성으로 해당 아이템이 수정될 때마다 재실행될 것이다.

이 의미는 헬퍼는 한 줄에 두 번 또는 세 번 실행될 수 있다는 것이다. 처음 보기에는 이것이 낭비적으로 보일 지 모르지만, 이것이 반응형 동작의 방식이다. 여기에 익숙해지면, 코드에 대하여 이런 방식도 있을 수 있구나 하고 생각하게 될 것이다.

Attributes 헬퍼

우선, 우리는 Positions 컬렉션에 있는 post의 위치를 살펴보고, (헬퍼 내부에서 현 post에 대응하는) this를 쿼리 결과와 함께 extend한다. 그리고는 _rank 속성을 사용하여 DOM 엘리먼트의 페이지 상단의 상대적인 위치 값을 새로 알아낸다.

우리는 이제 두 가지 경우를 처리해야 한다: 헬퍼가 템플릿이 그려지기 때문에 실행되는 경우 (A), 혹은 속성이 변경되었기 때문에 반응형으로 실행되는 경우(B).

우리는 B 경우의 엘리먼트만을 애니메이션하기를 원하는 데, 이를 위해서 post.position이 정의되는 지를 확인하는 것이다. (우리는 이를 간단히 정의하는 방법을 보게 될 것이다).

더욱이, B 경우는 두 가지 상세 경우 B1, B2를 생각할 수 있다: 우리가 DOM 엘리먼트를 “출발점” (이전 위치를 말한다)으로 순간이동시키거나 또는 우리가 이것을 이전 위치에서 새 위치로 애니메이션 시키거나 하는 경우를 말한다.

여기에 offset 변수가 등장한다. 우리가 상대적 위치를 사용하기 때문에, 우리는 엘리먼트를 이동시킬 현재 위치의 상대적 좌표를 알아야 한다. 이 의미는 이전 좌표에서 새 좌표를 빼는 것이다.

우리가 B1의 경우인지 B2의 경우인지를 알기 위해서는, 우리는 단순히 offset을 본다: 만약 offset이 0이 아니면, 이것은 원위치에서 엘리먼트를 이동시킨다는 것을 의미한다. 다른 한 편, 만약 offset이 0이라면, 이것은 우리가 원 좌표로 엘리먼트를 애니메이션시킨다는 것을 의미하고, animate 클래스를 그 엘리먼트에 추가하여 그 이동이 느리게 일어나도록 한다.

타임 아웃

이 세 가지 상황 (A, B1, 그리고 B2)은 모두 특정한 속성이 변경될 때 반응형으로 구동된다. 이 경우, setTimeout 함수를 사용하여 Positions 컬렉션을 수정하는 것으로 반응형 컨텍스트의 재평가를 구동한다.

그러므로 사용자가 처음 페이지를 로딩할 때, 전체 반응형의 진행은 다음과 같은 형태가 될 것이다:

  • attributes 헬퍼가 처음 실행된다.
  • post.position은 정의되지 않는다 (A).
  • setTimeout이 실행되어 post.position을 정의한다.
  • attributes 헬퍼는 반응형으로 재실행된다.
  • 이동은 일어나지 않는다. 그러므로 offset은 0에서 0으로 이동한다 (눈에 보이는 애니메이션은 일어나지 않는다). (B2).

그리고 upvote가 감지될 때 일어나는 것은 다음과 같다:

  • _rank가 수정되고, attributes` 헬퍼의 재평가가 구동된다.
  • post.position이 정의된다 (B).
  • offset은 0이 아니다. 그러므로 애니메이션은 일어나지 않는다 (B1).
  • setTimeout이 실행되고, post.position를 재정의한다.
  • attributes 헬퍼가 반응형으로 재실행된다.
  • offset은 (애니메이션을 일으키며) 0으로 되돌아간다 (B2).

이제 사이트를 열고 upvote을 시작하라. 그러면 post가 발레와 같은 우아함으로 부드럽게 위, 아래로 움직이는 것을 볼 수 있을 것이다!

Commit 14-1

post를 다시 정렬하는 애니메이션을 추가했다.

새 post 등록 애니메이션

이제 post들은 적절하게 순위 변경이 일어나지만, 우리는 아직 "새 post"의 애니메이션을 실제 구현하지는 않았다. 새 post를 목록의 상단에 단순하게 나타나게 하는 대신, 페이드 인 형태로 나타나도록 해보자.

//..

attributes: function() {
  var post = _.extend({}, Positions.findOne({postId: this._id}), this);
  var newPosition = post._rank * POST_HEIGHT;
  var attributes = {};

  if (_.isUndefined(post.position)) {
    attributes.class = 'post invisible';
  } else {
    var delta = post.position - newPosition;      
    attributes.style = "top: " + delta + "px";
    if (delta === 0)
      attributes.class = "post animate"
  }

  Meteor.setTimeout(function() {
    Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
  });

  return attributes;
}

//..
/client/views/posts/post_item.js

우리가 여기서 하려는 작업은 (A) 경우를 분리하여 엘리먼트에 invisible CSS 클래스를 추가하는 것이다. 헬퍼가 다음에 반응형으로 재실행되고 그 엘리먼트에 animate 클래스가 적용될 때, 불투명도의 차이가 애니메이션되면서 페이드 인 효과를 가지면서 엘리먼트가 나타날 것이다.

Commit 14-2

항목이 그려질 때 페이드 인 된다.

CSS와 JavaScript

우리가 top에서 했던 것처럼 CSS opacity 속성을 직접 애니메이션하는 대신에 .invisible CSS 클래스를 사용하여 애니메이션을 구동하는 것을 주목하였을 지 모르겠다. 이것은 top의 경우 우리가 속성값을 인스턴스 데이터에 의존하는 특정한 값이 될 때까지 애니메이션 시켜야 하기 때문이다.

한 편, 여기서 우리는 엘리먼트를 그 데이터와 무관하게 보여주거나 감추기를 원한다. CSS를 가능한 JavaScript와 분리하는 것이 좋으므로, 우리는 여기에서 클래스를 추가하거나 제거하기만 하고 애니메이션의 세부적인 지정은 스타일 시트에서 하도록 할 것이다.

우리는 마침내 우리가 원하는 애니메이션을 구현할 수 있게 될 것이다. 앱을 로드하여 직접 시도해 보기 바란다! 독자여러분은 또한 .post.animated 클래스를 다루면서 다른 변이 방식도 적용해 볼 수 있을 것이다. 힌트: CSS easing functions가 좋은 출발점이다!

미티어 용어

Sidebar 14.5

이 책에서, 독자는 새로울 수도 있고, 또는 미티어의 문맥에서 새로운 방식으로 사용되는 몇몇 단어들을 보게 될 것이다. 이 장에서는 이 단어들을 정의한다.

클라이언트

우리가 클라이언트를 언급할 때는 사용자의 웹브라우저에서 실행되는 코드를 의미하는 데, 이 때의 웹브라우저란 파이어폭스나 사파리와 같은 전통적인 브라우저, 또는 아이폰 전용 애플리케이션에서의 UIWebView와 같이 복잡한 형태를 가리킨다.

컬렉션(Collection)

미티어 컬렉션은 클라이언트와 서버 사이에서 자동적으로 동기화되는 데이터 저장소이다. 컬렉션은 이름(‘posts'와 같은)을 가지며 보통 클라이언트와 서버 양쪽에 존재한다. 이들은 서로 다르게 동작하지만 Mongo의 API에 기반한 공통 API를 가진다.

컴퓨테이션(Computation)

컴퓨테이션은 코드 블럭으로 이 코드가 의존하는 반응형 데이터 소스들 중의 하나라도 변경될 때마다 실행된다. 만약 반응형 데이터 소스(예를 들면, Session 변수)가 있고 그것에 따라서 반응이 일어나도록 하려면 이에 대하여 컴퓨테이션을 설정해야 한다.

커서(Cursor)

커서는 Mongo 컬렉션에서 쿼리를 실행한 결과이다. 클라이언트에서, 커서는 단지 결과의 배열이 아니라, 연관된 컬렉션의 객체들이 추가, 삭제, 변경되는 것을 관찰할 수 있는 반응형 객체이다.

DDP

DDP는 미티어의 분산 데이터 프로토콜(Distributed Data Protocol)로서 컬렉션들을 동기화하거나 메서드(Method)를 호출하는데 사용되는 와이어 프로토콜(wire protocol)이다. DDP는 일반적인 프로토콜로 계획되었으며 데이터가 많은 실시간 애플리케이션에서 HTTP를 대체한다.

Deps

Deps는 미티어의 반응형 시스템이다. Deps는 HTML과 연계된 데이터 모델의 동기화를 자동적으로 유지하기 위하여 배후에서 드러나지 않는 방식으로 사용된다.

도큐먼트(Document)

Mongo는 도큐먼트 기반의 데이터 저장소인데, 이 컬렉션들에서 추출된 객체들은 “도큐먼트"라 불린다. 이 도큐먼트는 평범한 JavaScript 객체(함수를 포함하지 않는)로 유일한 특별한 속성, ’_id’, 를 가지는 데 미티어는 이를 이용하여 DDP를 통해서 다른 속성값들을 읽는다.

헬퍼(Helpers)

템플릿에서 도큐먼트 속성보다 좀 더 복잡한 것을 화면에 그리고자 할 때, 헬퍼를 호출할 수 있다. 헬퍼란 화면에 그리는 것을 돕는 목적으로 사용되는 함수를 말한다.

대기시간 보정(Latency Compensation)

대기시간 보정이란 서버의 응답을 기다리는 동안 발생하는 시간의 지연을 회피할 목적으로 클라이언트에서 메서드 호출을 흉내내도록 하는 기술이다.

미티어 개발 그룹 (MDG)

미티어 프레임워크 자체가 아닌 미티어를 개발하는 실제 회사

메서드(Method)

미티어 메서드는 클라이언트에서 서버로 요청하는 원격 프로시저 호출로서 컬렉션의 변경을 추적하고 대기시간 보정(Latency Compensation)을 허용하는 특별한 로직을 가진다.

MiniMongo

클라이언트에 존재하는 컬렉션은 Mongo 스타일의 API를 제공하는 메모리 데이터 저장소이다. 이런 동작을 지원하는 라이브러리를 "MiniMongo"라고 하는데, 이는 전적으로 메모리에서 동작하는 Mongo의 소형 버전임을 의미한다.

패키지(Package)

미티어 패키지는 다음으로 이루어진다:

  1. 서버에서 실행되는 JavaScript 코드.
  2. 클라이언트에서 실행되는 JavaScript 코드.
  3. 리소스(SASS에서 CSS를 다루는 것과 같은)를 처리하는 방법에 대한 지침.
  4. 처리될 리소스.

패키지는 초강력 라이브러리와 같다. 미티어에는 핵심 패키지들의 광범위한 집합이 제공된다. 또한 Atmosphere는 커뮤니티에서 공급하는 써드파티 패키지들의 모음이다.

발행(Publication)

발행이란 이름을 가지는 데이터 집합으로 이것에 구독(subscription)하는 각 사용자에 따라 변경된다. 발행은 서버에서 설정한다.

서버

미티어 서버는 node.js에 의해서 실행되는 HTTP와 DDP 서버이다. 이것은 모든 미티어 라이브러리뿐 아니라 독자가 작성한 서버에서 구동되는 Javascript 코드가 함께 이루어진다. 미티어 서버가 구동될 때, 이것은 몽고 데이터베이스(개발과정에서는 스스로 구동된다)에 접속한다.

세션

미티어에서 세션이란 독자가 작성하는 애플리케이션에서 사용자의 상태를 추적하기 위하여 사용하는 클라이언트 쪽의 반응형 데이터 소스를 의미한다.

구독(Subscription)

구독이란 특정한 클라이언트가 발행(publication)에 접속하는 것을 의미한다. 구독은 브라우저에서 실행되는 코드로서 서버에 존재하는 발행과 통신하여 데이터를 동기화 상태로 유지한다.

템플릿(Template)

템플릿은 JavaScript로 HTML을 생성하는 수단이다. 미티어는 현재 logic-less 템플릿 시스템인 Handlebars를 지원하지만, 미래에 더 많은 템플릿 언어를 지원할 계획이다.

Template Data Context

템플릿이 화면을 그릴 때, 여기에 필요한 특정한 데이터를 제공하는 JavaScript 객체를 참조한다. 보통 이런 객체들은 평범한 JavaScript 객체들(POJOs)인데, 종종 컬렉션에서 추출된 도큐먼트들이기도 하다. 이들은 좀 더 복잡하거나 함수들을 포함하기도 한다.