Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages in full color showing how to combine Symfony with Docker, APIs, queues & async tasks, Webpack, Single-Page Applications, etc.

Buy printed version

symfony advent calendar day thirteen: Tags

1.0

지난 줄거리

이제 askeet 어플리케이션은 웹 페이지, RSS 피드, 그리고 이메일을 사용할 수 있게 되었습니다. 질문과 대답들을 작성할 수도 있습니다. 하지만 질문들에 관한 체계가 아직은 부족합니다. 카테고리와 서브 카테고리를 사용하는 체계는 결국에는 수천개의 가지를 가지고 어떤 카테고리가 사용자가 찾고있는 것인지 알수 없는 트리 구조로 끝나고 말 것입니다.

하지만 웹 2.0 어플리케이션들은 태그라는 새로운 체계를 내놓았습니다. 태그는 카테고리와 마찬가지로 해당 내용을 함축하는 단어들입니다. 하지만 태그에는 계층구조가 없고, 하나의 내용이 여러개의 태그들을 가질 수 있다는 것이 차이점입니다. 카테고리를 사용하여 고양이에 관한 내용을 찾는 것은 매우 어렵지만 (animal/mammal/four-legged/feline/, 또는 다른 이상한 카테고리 이름들), 태그를 사용하면 매우 쉽습니다 (애완동물, 귀여운). 모든 사용자가 주어진 질문에 태그를 달 수 있도록 허용함으로써, 유명한 컨셉인 folksonomy 를 달성할 수가 있습니다.

이것이 바로 우리가 askeet 질문들에 적용하고자 하는 것입니다. 이것은 시간이 좀 걸릴 것이지만 (오늘과 내일), 그 결과는 충분히 가치가 있을 것입니다. 이것은 역시 Creole 에서 복잡한 SQL 요청을 어떻게 다룰 것인지에 관해 알아볼 기회이기도 합니다. 그럼 시작하겠습니다.

QuestionTag 클래스

태그를 구현하는데에는 몇가지 방법이 있습니다. 우리는 QuestionTag 테이블을 아래와 같은 구조로 만들 것입니다.

ERD

사용자가 질문에 태그를 추가하면, question_tag 테이블에 user 테이블과 question 테이블에 함께 조인된 레코드가 생성됩니다. 태그 레코드에는 사용자가 입력한 그대로의 태그와 인덱싱을 위한 정규화된 형태의 태그 (특수문자를 제외한 소문자로 변환), 두 가지 형태의 태그가 저장됩니다.

스키마 수정

평소와 같이, 심포니 프로젝트에 테이블을 추가하는 것은 schema.xml 파일에 Propel 정의를 추가함으로써 이뤄집니다.

...
<table name="ask_question_tag" phpName="QuestionTag">
  <column name="question_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_question">
    <reference local="question_id" foreign="id" />
  </foreign-key>
  <column name="user_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_user">
    <reference local="user_id" foreign="id" />
  </foreign-key>
  <column name="created_at" type="timestamp" />
  <column name="tag" type="varchar" size="100" />
  <column name="normalized_tag" type="varchar" size="100" primaryKey="true" />
  <index name="normalized_tag_index">
    <index-column name="normalized_tag" />
  </index>
</table>

객체 모델을 재생성합니다:

$ symfony propel-build-model

사용자 클래스

askeet/lib/ 디렉토리에 Tag.class.php 파일을 추가하고 다음을 입력합니다.

<?php
 
class Tag
{
  public static function normalize($tag)
  {
    $n_tag = strtolower($tag);
 
    // remove all unwanted chars
    $n_tag = preg_replace('/[^a-zA-Z0-9]/', '', $n_tag);
 
    return trim($n_tag);
  }
 
  public static function splitPhrase($phrase)
  {
    $tags = array();
    $phrase = trim($phrase);
 
    $words = preg_split('/(")/', $phrase, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
    $delim = 0;
    foreach ($words as $key => $word)
    {
      if ($word == '"')
      {
        $delim++;
        continue;
      }
      if (($delim % 2 == 1) && $words[$key - 1] == '"')
      {
        $tags[] = trim($word);
      }
      else
      {
        $tags = array_merge($tags, preg_split('/\s+/', trim($word), -1, PREG_SPLIT_NO_EMPTY));
      }
    }
 
    return $tags;
  }
}
 
?>    

첫번째 메쏘드는 정규화된 태그를 반환하고, 두번째 메쏘드는 문자열을 받아서 태그들의 배열을 반환합니다. 이 두 메쏘드들은 태그를 수정할때 아주 유용하게 사용될 것입니다.

lib/ 디렉토리에 클래스를 위치시킴으로써 얻는 이점은 우리가 해당 클래스를 필요로 할때, 'require' 하지 않아도 자동으로 호출된다는데에 있습니다. 이것을 자동적재 (autoloading) 라고 합니다.

모델 확장하기

askeet/lib/model/QuestionTag.php 에 다음 메쏘드를 적용해서 tag 이 입력될때 nomalized_tag 가 함께 입력되도록 합니다.

public function setTag($v)
{
  parent::setTag($v);
 
  $this->setNormalizedTag(Tag::normalize($v));
}

미리 만들어둔 헬퍼 클래스가 벌써 많은 도움이 되고 있습니다. 해당 클래스 덕분에 이 클래스에는 오직 두 줄의 코드만 필요하게 되었습니다.

테스트 데이터 추가

askeet/data/fixtures/ 디렉토리에 몇몇 태그들을 입력해 보겠습니다.

QuestionTag:
  t1: { question_id: q1, user_id: fabien, tag: relatives }
  t2: { question_id: q1, user_id: fabien, tag: girl }
  t4: { question_id: q1, user_id: francois, tag: activities }
  t6: { question_id: q2, user_id: francois, tag: 'real life' }
  t5: { question_id: q2, user_id: fabien, tag: relatives }
  t5: { question_id: q2, user_id: fabien, tag: present }
  t6: { question_id: q2, user_id: francois, tag: 'real life' }
  t7: { question_id: q3, user_id: francois, tag: blog }
  t8: { question_id: q3, user_id: francois, tag: activities }

이 파일의 이름이 다른 파일의 이름보다 알파벳상으로 뒤에 오도록 해야만 sfPropelData 객체가 레코드를 입력할때 QuestionUser 테이블에서 조인할 데이터를 찾을 수 있습니다. 아래 명령으로 테스트 데이터를 입력합니다.

$ php batch/load_data.php

이제 태그를 구현할 준비가 되었습니다. 하지만, 그 전에 Question 모델 클래스를 확장하도록 하겠습니다.

질문의 태그 출력하기

콘트롤러단에 다른 것들을 추가하기 전에 새로운 tag 모듈을 추가하도록 하겠습니다.

$ symfony init-module frontend tag

모델 확장하기

우리는 해당 질문에 대해 모든 사용자가 남긴 태그들을 모두 출력해야 할 것입니다. 관계된 태그들을 모두 출력하는 것은 Question 클래스가 담당해야 할 역할이기 때문에, 우리는 해당 클래스를 확장하도록 하겠습니다 (askeet/lib/model/Question.php 파일). 여기서 주의할 것은 중복된 태그를 피하기 위하는 것입니다 (두개의 동일한 태그는 한 번만 보여야 합니다). 이 새로운 메쏘드는 태그들의 배열을 반환할 것입니다.

public function getTags()
{
  $c = new Criteria();
  $c->clearSelectColumns();
  $c->addSelectColumn(QuestionTagPeer::NORMALIZED_TAG);
  $c->add(QuestionTagPeer::QUESTION_ID, $this->getId());
  $c->setDistinct();
  $c->addAscendingOrderByColumn(QuestionTagPeer::NORMALIZED_TAG);
 
  $tags = array();
  $rs = QuestionTagPeer::doSelectRS($c);
  while ($rs->next())
  {
    $tags[] = $rs->getString(1);
  }
 
  return $tags;
}

이번 경우에는 사실 하나의 컬럼만 (normalized_tag) 필요하므로 Propel 에게 Tag 객채의 배열을 반환하도록 지시할 필요가 없습니다 (이 프로세스는 수화 (hydrating) 이라고 합니다). 따라서 우리는 간단한 쿼리를 하고, 이에 대한 결과를 직접 배열에 저장하겠습니다. 이 방법의 수행속도가 훨씬 빠릅니다.

뷰 수정

이제 상세 질문 페이지가 태그 목록을 출력할 차례입니다. 우리는 태그 목록을 사이드바에 출력할 계획입니다. 사이드바는 7일째 에 만들어진 컴포넌트 슬롯이고, 우리는 여기에 question 모듈에서만 사용가능한 컴포넌트를 추가할 수가 있습니다.

askeet/apps/frontend/modules/question/config/view.yml 파일에 다음 설정을 추가합니다.

showSuccess:
  components:
    sidebar: [sidebar, question]

사이드바에 들어갈 sidebar 모듈이 아직 만들어지진 않았지만, 사실 아주 간단합니다. (modules/sidebar/actions/components.class.php)

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

가장 코드가 긴 부분은 조각 파일입니다 (modules/sidebar/templates/_question.php):

<?php include_partial('sidebar/default') ?>
 
<h2>question tags</h2>
 
<ul id="question_tags">
  <?php include_partial('tag/question_tags', array('question' => $question, 'tags' => $question->getTags())) ?> 
</ul>

태그 목록은 조금 후에 AJAX 를 활용하여 새로고침 될 것이기 때문에, 조각파일에 넣기로 했습니다.

modules/tag/templates/_question_tags.php 조각 파일을 만듭니다.

<?php foreach($tags as $tag): ?>
  <li><?php echo link_to($tag, '@tag?tag='.$tag, 'rel=tag') ?></li>
<?php endforeach; ?>

rel=tag 속성은 마이크로포맷 입니다. 이것은 현재까지는 아무런 의미가 없는 것이지만, 그냥 달아놓도록 하겠습니다.

@tag 라우팅 규칙을 routing.yml 에 추가합니다.

tag:
  url:   /tag/:tag
  param: { module: tag, action: show }

테스트

사이드바에 나타난 질문의 태그 목록을 살펴보십시오.

http://askeet/question/what-can-i-offer-to-my-step-mother

태그 목록

질문들 목록에 짧은 인기 태그 표시하기

사이드바는 질문에 대한 모든 태그의 목록을 표시하기에는 좋은 장소지이지만, 질문들 목록에는 어떻게 태그를 표시할 수 있을까요? 각각의 질문에 대해 우리는 몇개의 태그만 표시해야 할 것입니다. 하지만 어떤 태그를 표시해야 할까요? 우리는 가장 인기있는 태그, 예를 들면, 해당 질문에 자주 올라온 태그를 표시할 것입니다. 우리는 사용자들에게 기존의 태그를 또 입력하게 되면, 해당 질문에 대한 태그의 인기도가 올라갈 것이라고 알려주도록 할 것입니다. 만약 사용자들이 그렇게 하지 않는다면, 아마도 "관리자" 가 나서야 할지도 모릅니다.

모델 확장하기

어쨌는, 이 말은 Question 객체에 ->getPopularTags() 메쏘드를 추가해야 한다는 뜻입니다. 하지만 이번에는 데이터베이스 요청이 간단하지만은 않습니다. Propel 을 이용하게되면, 데이터베이스 질의가 굉장히 많아질 것입니다. Symfony 는 필요한 경우 SQL 의 강력함을 활용할 수 있도록 해 두었습니다. 우리는 Creole 객체를 사용해서 직접 SQL 질의를 실행하도록 하겠습니다.

이 질의는 다음과 같습니다.

SELECT normalized_tag AS tag, COUNT(normalized_tag) AS count
FROM question_tag
WHERE question_id = $id
GROUP BY normalized_tag
ORDER BY count DESC
LIMIT $max

하지만 실제 컬럼과 테이블 이름을 사용하는 것은 데이터베이스에 대한 의존성을 키우고, 추상데이터를 사용하지 않는 결과를 낳습니다. 만약, 장래에, 테이블 이름이나 컬럼 이름이 바뀐다면, 이 SQL 쿼리는 동작하지 않을 것입니다. 이것이 symfony 에서 실제 이름대신 추상화된 이름을 사용하는 이유입니다. 이렇게 되면, 읽기에는 조금 더 불편하지만, 유지보수는 훨씬 쉬워집니다.

public function getPopularTags($max = 5)
{
  $tags = array();
 
  $con = Propel::getConnection();
  $query = '
    SELECT %s AS tag, COUNT(%s) AS count
    FROM %s
    WHERE %s = ?
    GROUP BY %s
    ORDER BY count DESC
  ';
 
  $query = sprintf($query,
    QuestionTagPeer::NORMALIZED_TAG,
    QuestionTagPeer::NORMALIZED_TAG,
    QuestionTagPeer::TABLE_NAME,
    QuestionTagPeer::QUESTION_ID,
    QuestionTagPeer::NORMALIZED_TAG
  );
 
  $stmt = $con->prepareStatement($query);
  $stmt->setInt(1, $this->getId());
  $stmt->setLimit($max);
  $rs = $stmt->executeQuery();
  while ($rs->next())
  {
    $tags[$rs->getString('tag')] = $rs->getInt('count');
  }
 
  return $tags;
}

먼저 데이터베이스 연결을 $con 에 생성합니다. SQL 질의는 %s 토큰들을 추상 데이터의 컬럼과 테이블 이름으로 치환하여 생성됩니다. Statement 객체는 질의를 저장하고, ResultSet 객체는 질의에 대한 결과를 저장합니다. 이들은 모두 Creole 객체들입니다. 이들에 대한 자세한 설명은 Creole 문서 에서 찾으실 수 있습니다. Statement 객체의 ->setInt() 메쏘드는 SQL 질의의 첫번째 ? 를 질문의 id 로 치환합니다. $max 값은 반환되는 결과값을 ->setLimit() 메쏘드를 사용하여 제한하기 위해 사용되었습니다.

이 메쏘드는 한번의 데이터베이스 질의로, 인기도 순으로 내림차순 정렬된 정규화 태그와 인기도의 결합 배열 (associative array) 을 반환합니다.

뷰 수정

이제 modules/question/templates/ 디렉토리의 _list.php 조각파일에 있는 질문 목록에 태그들을 표시할 차례입니다.

<?php use_helper('Text', 'Date', 'Global', 'Question') ?>
 
<?php foreach($question_pager->getResults() as $question): ?>
  <div class="question">
    <div class="interested_block" id="block_<?php echo $question->getId() ?>">
      <?php include_partial('question/interested_user', array('question' => $question)) ?>
    </div>
 
    <h2><?php echo link_to($question->getTitle(), '@question?stripped_title='.$question->getStrippedTitle()) ?></h2>
 
    <div class="question_body">
      <div>asked by <?php echo link_to($question->getUser(), '@user_profile?nickname='.$question->getUser()->getNickname()) ?> on <?php echo format_date($question->getCreatedAt(), 'f') ?></div>
      <?php echo truncate_text(strip_tags($question->getHtmlBody()), 200) ?>
    </div>
 
    tags: <?php echo tags_for_question($question) ?>
 
  </div>
<?php endforeach; ?>
 
<div id="question_pager">
  <?php echo pager_navigation($question_pager, $rule) ?>
</div>

우리는 태그들을 + 기호를 사용하여 구분할 것입니다. 하지만 템플릿안에서는 너무 많은 코드 사용을 피해야 하기 때문에, 우리는 tags_for_question() 헬퍼를 lib/helper/QuestionHelper.php 에 만들었습니다.

function tags_for_question($question, $max = 5)
{
  $tags = array();
 
  foreach ($question->getPopularTags($max) as $tag => $count)
  {
    $tags[] = link_to($tag, '@tag?tag='.$tag);
  }
 
  return implode(' + ', $tags);
} 

테스트

이제 질문 목록은 각 질문의 인기 태그들을 표시할 것입니다.

http://askeet/

질문 목록의 인기 테그들

태그 질문 목록

태그를 출력할때 우리는 @tag 라우팅 규칙을 사용한 링크를 붙입니다. 이 링크는 해당 태그가 사용된 질문들중 인기도 순 목록을 출력하는 페이지로 연결될 것입니다. 이것은 간단하기 때문에, 바로 작성을 해보도록 하겠습니다.

tag/show 액션

tag 모듈에 show 액션을 생성합니다.

public function executeShow()
{
  $this->question_pager = QuestionPeer::getPopularByTag($this->getRequestParameter('tag'), $this->getRequestParameter('page'));
}

모델 확장하기

평소와 같이 모델들을 다루는 코드들은 모델에 저장됩니다. 이번 경우에는 Question 객체들을 반환해야 하기 때문에 QuestionPeer 클래스에 코드를 작성할 것입니다. 우리는 질문의 인기도를 흥미도 순으로 판단할 것이기 때문에, 특별히 복잡한 질의가 필요하진 않아, Propel 의 ->doSelect() 호출로도 충분할 것입니다.

public static function getPopularByTag($tag, $page)
{
  $c = new Criteria();
  $c->add(QuestionTagPeer::NORMALIZED_TAG, $tag);
  $c->addDescendingOrderByColumn(QuestionPeer::INTERESTED_USERS);
  $c->addJoin(QuestionTagPeer::QUESTION_ID, QuestionPeer::ID, Criteria::LEFT_JOIN);
 
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->init();
 
  return $pager;
}

이 메쏘드는 인기도 순으로 정렬되고, 페이지가 나뉜, 질문들 목록을 반환합니다.

템플릿 만들기

modules/tag/templates/showSuccess.php 템플릿은 아래와 같이, 아주 간단히 만드실 수 있습니다.

<h1>popular questions for tag "<?php echo $sf_params->get('tag') ?>"</h1>
 
<?php include_partial('question/list', array('question_pager' => $question_pager, 'rule' => '@tag?tag='.$sf_params->get('tag'))) ?>

라우팅 규칙에 page 값 추가하기

routing.yml@tag 라우팅 규칙에 :page 와 그 page 의 기본값을 추가합니다.

tag:
  url:   /tag/:tag/:page
  param: { module: tag, action: show, page: 1 }

테스트

activities 테그를 클릭해서 해당 태그가 입력된 질문들을 살펴봅니다.

http://askeet/tag/activities

'activities' 태그가 입력된 질문들

내일 이 시간에

Creole 데이터베이스 추상화 단은 symfony 가 복잡한 SQL 문을 질의할 수 있도록 합니다. Propel 객체-관계 맵핑은 여러분을 객체 지향 세계에서, 데이터베이스에 대한 고민없이, 놀 수 있도록 돕습니다.

몇몇 분들은 위의 질의들로 인한 데이터베이스 로드를 걱정하실지도 모르겠습니다. 그런 경우라면 최적화에 대한 여지가 남아 있습니다. 예를 들면, popular_tags 컬럼을 Question 테이블에 만들고, QuestionTag 가 생성될 때마다 이 필드를 업데이트 하는 방법이 있습니다. 이를 통해 질문 목록을 출력하는 것이 상당히 간편해질 것입니다. 하지만 캐쉬 시스템을 이용하면 - 몇일 후에 살펴볼 것입니다 - 이런 최적화도 필요가 없습니다.

내일은 askeet 어플리케이션의 태그 기능을 마무리 짓도록 하겠습니다. 사용자들은 이제 질문에 태그를 붙일 수 있을 것이고, 태그 버블이 만들어질 것입니다. 내일 또 오시는 것을 잊지 마십시오.

오늘 작성된 코드들은 /tags/release_day_13/ 으로 askeet SVN 저장소 에서 다운로드 가능합니다. 오늘 내용 중 질문이 있으시다면, askeet 포럼 에 남겨주시기 바랍니다.