라우팅(Routing)

5

번역 완료율

이 장에서는:

  • 미티어의 라우팅에 대하여 배운다.
  • 유일한 URL을 갖는 토론 페이지를 만들어본다.
  • 이 URL에 적절하게 링크를 거는 방법을 배운다.
  • 이제 (결국엔 사용자가 등록할) 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)로 이루어졌다는 사실로부터 유래한다고 한다.