Caution: You are browsing the legacy symfony 1.x part of this website.
SymfonyWorld Online 2020
100% online
30+ talks + workshops
Live + Replay watch talks later

symfony advent calendar day five: forms and pager

1.0
Language

지난 줄거리

지난 시간 에는 코드를 해당 코드의 속성에 맞는 다른 파일들로 이동시키는 작업을 통해 리펙토링을 살펴보았습니다. 또한 모델의 속성에 관한 코드를 액션에서 모델로 옮기는 것도 살펴보았습니다.

지금까지 개발된 코드는 깔끔하지만 아직 더 개발해야 할 기능이 많습니다. 오늘은 askeet 사이트를 사용자와 상호작용할 수 있도록 만들어 보겠습니다. 하이퍼링크 (hyperlink) 이외에 HTML 을 사용자와 상호작용하도록 하기 위해서는 form 을 사용합니다.

오늘의 목표는 사용자가 로그인을 할 수 있도록 하고, 메인페이지의 질문 목록을 페이지를 나누어 보여주는 것입니다. 오늘은 오래 걸리지 않을 것이기 때문에, 어제의 긴 튜토리얼에 지치셨다면 걱정하지 않으셔도 좋을 것입니다.

로그인 폼

테스트 데이터들에는 사용자 정보가 포함되어 있습니다만 시스템이 이를 인식할 수 있는 방법은 사실 없습니다. 어플리케이션의 모든 페이지에서 로그인 폼을 사용할 수 있도록 하겠습니다. 글로벌 레이아웃 파일인 askeet/apps/frontend/templates/layout.php 파일을 열고 about 링크 앞에 다음을 추가합니다.

<li><?php echo link_to('sign in', 'user/login') ?></li>

참고: 현재의 레이아웃은 웹 디버그 툴바 아래에 가려져 있습니다. 방금 추가한 링크를 보시기 위해서는 'Sf' 아이콘을 클릭하셔서 툴바를 닫으셔야 합니다.

이제 user 모듈을 만들도록 하겠습니다. 두번째날에 만들었던 question 모듈과는 달리, 이번에는 빈 모듈을 만들고 코드를 직접 채워넣도록 할 것입니다.

$ symfony init-module frontend user

참고: 위 명령을 통해 index 액션과 indexSuccess.php 템플릿이 생성됩니다. 하지만 우리는 둘 모두가 필요없으므로 액션과 템플릿 파일을 지우도록 합니다.

user/login 액션 만들기

askeet/apps/frontend/modules/ 디렉토리의 user/action/action.class.php 파일에 login 액션을 추가합니다.

public function executeLogin()
{
  $this->getRequest()->setAttribute('referer', $this->getRequest()->getReferer());
 
  return sfView::SUCCESS;
}

위 액션에서는 사용자 요청 값으로 레퍼러 (referer) 값을 저장하고 있습니다. 이는 로그인시 레퍼러를 폼의 숨겨진 값들 중 하나로 사용하여, 사용자가 어느 페이지에서 로그인을 하였는지를 확인하여 로그인 후 사용자를 원래 있었던 페이지로 돌려 보내기 위함입니다.

return sfView::SUCCESS 는 액션의 결과를 loginSuccess.php 템플릿으로 전달합니다. 이 문장은 액션이 아무것도 반환하지 않는 경우에도 자동으로 실행됩니다. 따라서 만약 액션이 반환값없이 종료된다면, <액션이름>Success.php 가 자동으로 호출될 것입니다.

액션을 더 고치기 전에, 템플릿을 살펴보도록 하겠습니다.

loginSuccess.php 템플릿 만들기

인간과 컴퓨터가 상호작용하기 위해서 웹에서는 폼이 사용됩니다. 심포니는 폼의 생성과 관리를 돕기 위해서 폼 헬퍼 를 제공합니다.

askeet/apps/frontend/modules/user/templates/ 디렉토리에서 loginSuccess.php 템플릿 파일을 생성합니다.

<?php echo form_tag('user/login') ?>
 
  <fieldset>
 
  <div class="form-row">
    <label for="nickname">nickname:</label>
    <?php echo input_tag('nickname', $sf_params->get('nickname')) ?>
  </div>
 
  <div class="form-row">
    <label for="password">password:</label>
    <?php echo input_password_tag('password') ?>
  </div>
 
  </fieldset>
 
  <?php echo input_hidden_tag('referer', $sf_request->getAttribute('referer')) ?>
  <?php echo submit_tag('sign in') ?>
 
</form>

위 템플릿에서는 몇가지 기본적인 폼 헬퍼들이 사용되고 있습니다. 위 폼 헬퍼들을 통해 폼 생성을 자동화할 수 있습니다. form_tag() 헬퍼는 기본 매쏘드로 POST 를 사용하고, 폼 액션값으로 인자로 받은 값을 사용하는 폼을 시작합니다. input_tag() 헬퍼는 <input> 테크를 생성하고 첫번째 인자를 id 값을 같도록 합니다. 두번째 인자가 있을 경우에는 이를 <input> 테그의 기본값으로 설정합니다. 온라인 문서중 폼 헬퍼 부분 에서 폼 헬퍼 및 폼 헬퍼가 생성하는 HTML 코드들에 대해 자세히 알아보실 수 있습니다.

여기서 중요한 것은 사용자가 폼의 확인버튼을 눌렀을때, form_tag() 가 인자로 받은 login 액션이 실행될 것이라는 것입니다. 이제 액션으로 돌아가보겠습니다.

Handle the login form submission

login 액션을 아래와 같이 수정합니다.

public function executeLogin()
{
  if ($this->getRequest()->getMethod() != sfRequest::POST)
  {
    // display the form
    $this->getRequest()->setAttribute('referer', $this->getRequest()->getReferer());
  }
  else
  {
    // handle the form submission
    $nickname = $this->getRequestParameter('nickname');
 
    $c = new Criteria();
    $c->add(UserPeer::NICKNAME, $nickname);
    $user = UserPeer::doSelectOne($c);
 
    // nickname exists?
    if ($user)
    {
      // password is OK?
      if (true)
      {
        $this->getUser()->setAuthenticated(true);
        $this->getUser()->addCredential('subscriber');
 
        $this->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
        $this->getUser()->setAttribute('nickname', $user->getNickname(), 'subscriber');
 
        // redirect to last page
        return $this->redirect($this->getRequestParameter('referer', '@homepage'));
      }
    }
  }
}

로그인 액션은 로그인 폼을 보여주는 역할도 하고, 로그인을 처리하기도 하도록 만들어졌습니다. 어떤 경우에 폼을 보여주고, 어떤 경우에 로그인을 처리해야 하는지 알기 위해서, 어떤 메소드를 통해 해당 액션이 호출되었는지를 파악합니다. 만약 액션이 POST 메쏘드로 호출되지 않았다면, 이는 하이퍼링크를 통해 호출되었다는 의미가 되고 이 경우에는 폼을 출력합니다. 만약 액션이 POST 메쏘드로 호출되었다면, 사용자가 폼의 확인 버튼을 누른 것이고 이 경우에는 로그인을 처리합니다.

액션은 사용자 요청값들 중에서 nickname 필드의 값이 있는지 확인하고, 이 nickname 값이 User 테이블에 존재하는지 확인합니다.

추후에는 암호를 통해서 사용자의 권한을 확인하도록 할 것입니다. 지금은 단지 사용자의 idnickname 을 세션값에 저장하도록 하고 있습니다. 마지막으로, 액션은 처음 우리가 숨겨진 값으로 저장했던 referer 값을 사용하여 사용자를 사용자가 로그인을 호출한 URL 로 돌려보냅니다. 만약 이 값이 비어있다면, 우리가 두번째로 지정한 @homepage, 즉 라우팅 규칙 중 하나인 question/list, 이 사용될 것입니다.

위의 코드에서는 두가지 다른 속성값들이 사용되었습니다. 하나는 사용자 요청값 으로 ($this->getRequest()->setAttribute()), 이는 템플릿에서 사용하기 위해 값을 잠시 기억하는 용도로 사용됩니다. 이 값들은 페이지가 표시된 이후에는 모두 사라집니다. 또 다른 하나는 세션 값 으로 ($this->getUser()->setAttribute()) 사용자의 세션이 유지되는 동안에는 계속 보존되며, 이후 다른 액션들에서 이 값들을 사용가능하게 됩니다. 이에 대해 좀 더 알고 싶으시다면 온라인 문서들 중 파라미터 관련 부분 을 참조하시기 바랍니다.

권한 설정

사용자들이 askeet 웹사이트에 로그인 할 수 있다는 것은 좋은 일이지만, 사용자들이 그냥 하지는 않겠지요? 로그인된 사용자만이 새로운 질문을 쓰거나, 질문에 대한 흥미도를 추가하거나, 또는 답변들을 평가할 수 있도록 할 계획입니다. 다른 모든 액션들은 로그인하지 않은 사용자들도 사용할 수 있도록 할 것입니다.

사용자의 권한을 설정하기 위해서는 sfUser 객채의 ->setAuthenticated() 메쏘드를 호출해 주어야 합니다. 이 객체는 세밀한 권한을 설정하기 위해서 증명서 체계 (credential mechanism) 을 사용합니다. 좀 더 자세한 내용은 사용자 증명서 부분 을 참조하시기 바랍니다.

다음 두 줄을 통해 권한을 설정합니다.

$this->getContext()->getUser()->setAuthenticated(true);
$this->getContext()->getUser()->addCredential('subscriber');

사용자 이름이 확인 된 이후에 세션값들에 사용자의 정보만을 저장하는 것이 아니라, 사이트의 제한된 구역을 사용할 수 있는 권한을 부여합니다. 사용자의 권한에 따라 사이트의 이용을 제한하는 것에 대해서는 내일 계속해서 살펴볼 것입니다.

user/logout 액션

->setAttribute() 메쏘드에는 우리가 설명하지 않고 넘어간 부분이 있습니다. ->setAttribute() 메쏘드의 마지막 인자는 (위의 경우에 subscriber) 값들이 저장될 이름공간 을 정의합니다. 이름공간은 다른 여러개의 같은 이름을 갖는 값들을 저장하기 위한 것 이외에도, 해당 이름공간이 가지는 값들을 쉽게 지울 수 있게 합니다.

public function executeLogout()
{
  $this->getUser()->setAuthenticated(false);
  $this->getUser()->clearCredentials();
 
  $this->getUser()->getAttributeHolder()->removeNamespace('subscriber');
 
  $this->redirect('@homepage');
}

이름공간을 사용함으로써 여러개의 값들을 하나씩 지우는 대신 하나의 명령으로 모두 지울 수가 있습니다. 게으름은 좋은 것입니다!

레이아웃 변경

사용자가 로그인을 한 이후에도 레이아웃을 'login' 링크를 표시하고 있습니다. 이를 고쳐보도록 하겠습니다. askeet/apps/frontend/templates/layout.php 파일을 열고 오늘 수정한 부분을 다음과 같이 고칩니다.

<?php if ($sf_user->isAuthenticated()): ?>
  <li><?php echo link_to('sign out', 'user/logout') ?></li>
  <li><?php echo link_to($sf_user->getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?></li>
<?php else: ?>
  <li><?php echo link_to('sign in/register', 'user/login') ?></li>
<?php endif ?>

어플리케이션의 아무 페이지나 클릭하셔서 위 링크가 제대로 표시가 되는지, 'login' 링크를 클릭하고 적당한 사용자 이름을 입력한 후 ('anonymous' 정도가 적당하겠지요?) 로그인이 제대로 되는지 확인합니다. 만약 'login' 링크가 이후에 'sign out' 으로 바뀌신다면 제대로 하신 것입니다. 마지막으로, 로그아웃을 해보시고 'login' 링크가 다시 나타나는지 확인합니다.

로그인 된 상태

사용자 세션 조작에 관한 더 자세한 내용은 온라인 문서들 중 사용자 세션 부분 에서 확인하실 수 있습니다.

질문목록 페이지 나누기

심포니에 관심을 가지고 있는 많은 분들이 askeet 사이트로 몰려들 것이기 때문에, 메인페이지에 출력되는 질문 목록이 금세 길어질 것이 분명합니다. 느린 반응과 스크롤의 압박을 견뎌내기 위해서는 질문 목록의 페이지를 나누어야 합니다.

심포니는 위의 목적에 부합하는 sfPropelPager 라는 객체를 제공합니다. 이 객체는 데이터베이스 요청을 감싸서 (encapsulate) 현재 페이지에 출력될 자료들만 요청하도록 합니다. 예를들어 이 객체가 한 페이지에 10개의 자료만 출력하도록 되어 있다고 하면, 데이터베이스 요청은 10개로 제한 될 것이고 페이지에 맞는 위치를 선정하여 자료를 요청할 것입니다.

question/list 액션을 수정하기

3일째 에 만들었던 question 모듈의 list 액션은 상당히 간단했습니다.

public function executeList ()
{
  $this->questions = QuestionPeer::doSelect(new Criteria());
}

이 액션을 수정하여서 배열 대신에 sfPropelPager 객체를 템플릿에 전달하도록 할 것입니다. 동시에, 질문목록이 흥미도를 기준으로 정렬되도록 하겠습니다.

public function executeList ()
{
  $pager = new sfPropelPager('Question', 2);
  $c = new Criteria();
  $c->addDescendingOrderByColumn(QuestionPeer::INTERESTED_USERS);
  $pager->setCriteria($c);
  $pager->setPage($this->getRequestParameter('page', 1));
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();
 
  $this->question_pager = $pager;
}

sfPropelPager 객체의 초기화에는 어떠한 객체를 대상으로 하는 질의인지, 그리고 한 페이지에 얼만큼의 자료를 보여줄 것인지 (위의 예에서는 2개) 를 명시합니다. ->setPage() 현재 페이지를 지정하는데 사용되며, 위의 예에서는 요청값들을 바탕으로 메쏘드를 호출하고 있습니다. 예를들어, page 라는 요청값이 2 라는 값을 가지고 있다면 sfPropelPager 는 3번째부터 4번째 결과를 반환할 것입니다. 만약 page 값이 없다면 기본값인 1 이 사용되고, 객체는 첫번째부터 2번째까지의 결과를 반환할 것입니다. sfPropelPager 객체와 그 메쏘드들에 관해서는 온라인 문서들 중 페이저 부분 살펴보시면 됩니다.

설정 값 사용하기

보통 상수값들은 설정파일에 두는 것이 좋습니다. 예를들어, 한 페이지당 표시되는 결과 수는 (위의 예에서는 2개), 설정파일의 값으로 대체될 수 있습니다. 위의 new sfPropelPager 부분을 다음과 같이 수정합니다.

...
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));

어플리케이션의 사용자 설정파일인 askeet/apps/frontend/config/app.yml 을 열고 다음을 추가합니다.

all:
  pager:
    homepage_max: 2

pager 키는 일종의 이름공간으로 사용되었고, 위의 코드에서 pager 가 사용된 것을 확인하실 수 있습니다. 사용자 설정값들과 이를 정의하는 방법에 대해서는 온라인 문서중 설정 관련 을 보시면 됩니다.

listSuccess.php 템플릿 수정하기

listSuccess.php 템플릿을 열고 아래 줄을

<?php foreach($questions as $question): ?>

다음과 같이 수정합니다.

<?php foreach($question_pager->getResults() as $question): ?>

이제 페이저에 저장된 자료들이 출력될 것입니다.

페이지 네비게이션 추가하기

이제 템플릿에 페이지 네비게이션을 추가해 주어야 합니다. 템플릿이 첫번째 두 질문을 출력하고 있기는 하지만, 다음 페이지로 갈 수 있도록 만들어주어야 합니다. 이를 위해 템플릿의 마지막 부분에 다음을 추가합니다.

<div id="question_pager">
<?php if ($question_pager->haveToPaginate()): ?>
  <?php echo link_to('&laquo;', 'question/list?page=1') ?>
  <?php echo link_to('&lt;', 'question/list?page='.$question_pager->getPreviousPage()) ?>
 
  <?php foreach ($question_pager->getLinks() as $page): ?>
    <?php echo link_to_unless($page == $question_pager->getPage(), $page, 'question/list?page='.$page) ?>
    <?php echo ($page != $question_pager->getCurrentMaxLink()) ? '-' : '' ?>
  <?php endforeach; ?>
 
  <?php echo link_to('&gt;', 'question/list?page='.$question_pager->getNextPage()) ?>
  <?php echo link_to('&raquo;', 'question/list?page='.$question_pager->getLastPage()) ?>
<?php endif; ?>
</div>

이 코드는 sfPropelPager 객체가 제공하는 여러 메쏘드들을 활용하고 있습니다. ->haveToPaginate() 는 자료 갯수가 한 페이지에 출력될 수 있는 자료 수보다 많을때 true 를 반환합니다. ->getPreviousPage() ->getNextPage(), 그리고 ->getLastPage() 는 특별히 설명하지 않아도 이해하시리라 믿습니다. ->getLinks() 는 페이지 번호들의 배열을 반환하고, ->getCurrentmaxLink() 는 마지막 페이지 번호를 반환합니다.

위의 예에서는 link_to_unless() 라는 또하나의 편리한 심포니 링크 헬퍼를 사용하고 있습니다. 이 헬퍼는 첫번째 인자가 false 일 경우에는 일반 link_to() 와 같은 결과를 반환하고, 그렇지 않은 경우에는 링크없이 <span> 으로 쌓여진 일반 텍스트를 반환합니다.

페이저를 테스트해 보셨습니까? 해보셔야 합니다. 수정작업은 직접 눈으로 결과를 확인하기 전에는 끝난 것이 아닙니다. 테스트를 위해서는 세번째 날에 만들었던 테스트 데이터 파일을 열고, 몇가지 질문들을 추가합니다. 그리고 테스트 데이터를 입력하고, 홈페이지에 다시 접속해 보십시오. 성공입니다!

페이지 목록

페이징 관련 라우팅 규칙 추가하기

기본적으로, 하부 페이지들은 아래와 같은 URL 을 갖습니다.

http://askeet/frontend_dev.php/question/list/page/XX

라우팅 규칙을 바꿔서 아래의 URL 을 위의 URL 처럼 인식하도록 해보겠습니다.

http://askeet/frontend_dev.php/index/XX

apps/frontend/config/routing.yml 파일을 열고 맨 위에 다음 내용을 추가합니다.

popular_questions: 
  url:   /index/:page 
  param: { module: question, action: list } 

그리고 나서, 로그인 페이지를 위한 라우팅도 추가하도록 하겠습니다.

login: 
  url:   /login 
  param: { module: user, action: login } 

리펙토링

모델

question/list 액션은 모델과 관계되는 코드들을 가지고 있습니다. 이 코드들을 모델 속으로 옮기도록 하겠습니다. question/list 액션을 아래 처럼 수정합니다.

public function executeList () 
{ 
  $this->question_pager = QuestionPeer::getHomepagePager($this->getRequestParameter('page', 1)); 
}

그리고 lib/model 아래에 있는 QuestionPeer.php 클래스에 아래 메쏘드를 추가합니다.

public static function getHomepagePager($page)
{
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $c = new Criteria();
  $c->addDescendingOrderByColumn(self::INTERESTED_USERS);
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();
 
  return $pager;
}

어제 작성된 question/show 액션도 마찬가지로 작업합니다. 치환된 제목으로 질문을 질의하는 것도 모델에 속해야 합니다. question/show 액션을 아래와 같이 수정합니다.

public function executeShow()
{
  $this->question = QuestionPeer::getQuestionFromTitle($this->getRequestParameter('stripped_title'));
 
  $this->forward404Unless($this->question);
}

QuestionPeer.php 에 아래를 추가합니다.

public static function getQuestionFromTitle($title)
{
  $c = new Criteria();
  $c->add(QuestionPeer::STRIPPED_TITLE, $title);
 
  return self::doSelectOne($c); 
}

템플릿

question/templates/listSuccess.php 페이지의 질문 목록은 나중에 다른 어디선가 이용될 수 있을 것입니다. 이제 템플릿 코드를 _list.php 파일에 넣고 listSuccess.php 를 간단하게 정리하도록 하겠습니다.

<h1>popular questions</h1> 
 
<?php echo include_partial('list', array('question_pager' => $question_pager)) ?> 

_list.php 파일은 askeet SVN 저장소 에서 찾으실 수 있습니다.

내일 이 시간에

로그인 폼과 목록 페이지 나누기는 거의 모든 웹 어플리케이션에서 사용되고 있습니다. 오늘 심포니를 통해 이들을 얼마나 쉽게 구현할 수 있는지를 보셨습니다.

오늘도 우리는 리펙토링을 통해 오늘 하루의 개발을 마쳤습니다. 이것은 우리가 큰 그림 없이 개발을 진행하고 있기 때문에 해야만 하는 일입니다.

내일은 로그인 과정을 좀 더 처리하겠습니다. 사이트의 몇몇 부분을 로그인한 사용자만 접근할 수 있도록 제한할 것이고, 부적절한 폼 제출을 막기 위해서 폼 확인 작업을 하도록 할 것입니다.