symfony advent calendar day thirteen: Tags ========================================== 지난 줄거리 ----------- 이제 askeet 어플리케이션은 웹 페이지, RSS 피드, 그리고 이메일을 사용할 수 있게 되었습니다. 질문과 대답들을 작성할 수도 있습니다. 하지만 질문들에 관한 체계가 아직은 부족합니다. 카테고리와 서브 카테고리를 사용하는 체계는 결국에는 수천개의 가지를 가지고 어떤 카테고리가 사용자가 찾고있는 것인지 알수 없는 트리 구조로 끝나고 말 것입니다. 하지만 웹 2.0 어플리케이션들은 태그라는 새로운 체계를 내놓았습니다. 태그는 카테고리와 마찬가지로 해당 내용을 함축하는 단어들입니다. 하지만 태그에는 계층구조가 없고, 하나의 내용이 여러개의 태그들을 가질 수 있다는 것이 차이점입니다. 카테고리를 사용하여 고양이에 관한 내용을 찾는 것은 매우 어렵지만 (animal/mammal/four-legged/feline/, 또는 다른 이상한 카테고리 이름들), 태그를 사용하면 매우 쉽습니다 (애완동물, 귀여운). 모든 사용자가 주어진 질문에 태그를 달 수 있도록 허용함으로써, 유명한 컨셉인 [folksonomy][1] 를 달성할 수가 있습니다. 이것이 바로 우리가 askeet 질문들에 적용하고자 하는 것입니다. 이것은 시간이 좀 걸릴 것이지만 (오늘과 내일), 그 결과는 충분히 가치가 있을 것입니다. 이것은 역시 Creole 에서 복잡한 SQL 요청을 어떻게 다룰 것인지에 관해 알아볼 기회이기도 합니다. 그럼 시작하겠습니다. `QuestionTag` 클래스 -------------------- 태그를 구현하는데에는 몇가지 방법이 있습니다. 우리는 `QuestionTag` 테이블을 아래와 같은 구조로 만들 것입니다. ![ERD](/images/askeet/mcd3.gif) 사용자가 질문에 태그를 추가하면, `question_tag` 테이블에 `user` 테이블과 `question` 테이블에 함께 조인된 레코드가 생성됩니다. 태그 레코드에는 사용자가 입력한 그대로의 태그와 인덱싱을 위한 정규화된 형태의 태그 (특수문자를 제외한 소문자로 변환), 두 가지 형태의 태그가 저장됩니다. ### 스키마 수정 평소와 같이, 심포니 프로젝트에 테이블을 추가하는 것은 `schema.xml` 파일에 Propel 정의를 추가함으로써 이뤄집니다. [xml] ...
객체 모델을 재생성합니다: $ symfony propel-build-model ### 사용자 클래스 `askeet/lib/` 디렉토리에 `Tag.class.php` 파일을 추가하고 다음을 입력합니다. [php] $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` 가 함께 입력되도록 합니다. [php] 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` 객체가 레코드를 입력할때 `Question` 과 `User` 테이블에서 조인할 데이터를 찾을 수 있습니다. 아래 명령으로 테스트 데이터를 입력합니다. $ php batch/load_data.php 이제 태그를 구현할 준비가 되었습니다. 하지만, 그 전에 `Question` 모델 클래스를 확장하도록 하겠습니다. 질문의 태그 출력하기 -------------------- 콘트롤러단에 다른 것들을 추가하기 전에 새로운 `tag` 모듈을 추가하도록 하겠습니다. $ symfony init-module frontend tag ### 모델 확장하기 우리는 해당 질문에 대해 모든 사용자가 남긴 태그들을 모두 출력해야 할 것입니다. 관계된 태그들을 모두 출력하는 것은 `Question` 클래스가 담당해야 할 역할이기 때문에, 우리는 해당 클래스를 확장하도록 하겠습니다 (`askeet/lib/model/Question.php` 파일). 여기서 주의할 것은 중복된 태그를 피하기 위하는 것입니다 (두개의 동일한 태그는 한 번만 보여야 합니다). 이 새로운 메쏘드는 태그들의 배열을 반환할 것입니다. [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일째](7.txt) 에 만들어진 컴포넌트 슬롯이고, 우리는 여기에 question 모듈에서만 사용가능한 컴포넌트를 추가할 수가 있습니다. `askeet/apps/frontend/modules/question/config/view.yml` 파일에 다음 설정을 추가합니다. showSuccess: components: sidebar: [sidebar, question] 사이드바에 들어갈 `sidebar` 모듈이 아직 만들어지진 않았지만, 사실 아주 간단합니다. (`modules/sidebar/actions/components.class.php`) [php] public function executeQuestion() { $this->question = QuestionPeer::getQuestionFromTitle($this->getRequestParameter('stripped_title')); } 가장 코드가 긴 부분은 조각 파일입니다 (`modules/sidebar/templates/_question.php`): [php]

question tags

태그 목록은 조금 후에 AJAX 를 활용하여 새로고침 될 것이기 때문에, 조각파일에 넣기로 했습니다. `modules/tag/templates/_question_tags.php` 조각 파일을 만듭니다. [php]
  • `rel=tag` 속성은 [마이크로포맷](http://microformats.org/wiki/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 ![태그 목록](/images/askeet/tag_list_question.gif) 질문들 목록에 짧은 인기 태그 표시하기 ------------------------------------- 사이드바는 질문에 대한 모든 태그의 목록을 표시하기에는 좋은 장소지이지만, 질문들 목록에는 어떻게 태그를 표시할 수 있을까요? 각각의 질문에 대해 우리는 몇개의 태그만 표시해야 할 것입니다. 하지만 어떤 태그를 표시해야 할까요? 우리는 가장 인기있는 태그, 예를 들면, 해당 질문에 자주 올라온 태그를 표시할 것입니다. 우리는 사용자들에게 기존의 태그를 또 입력하게 되면, 해당 질문에 대한 태그의 인기도가 올라갈 것이라고 알려주도록 할 것입니다. 만약 사용자들이 그렇게 하지 않는다면, 아마도 "관리자" 가 나서야 할지도 모릅니다. ### 모델 확장하기 어쨌는, 이 말은 `Question` 객체에 `->getPopularTags()` 메쏘드를 추가해야 한다는 뜻입니다. 하지만 이번에는 데이터베이스 요청이 간단하지만은 않습니다. Propel 을 이용하게되면, 데이터베이스 질의가 굉장히 많아질 것입니다. Symfony 는 필요한 경우 SQL 의 강력함을 활용할 수 있도록 해 두었습니다. 우리는 Creole 객체를 사용해서 직접 SQL 질의를 실행하도록 하겠습니다. 이 질의는 다음과 같습니다. [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 에서 실제 이름대신 추상화된 이름을 사용하는 이유입니다. 이렇게 되면, 읽기에는 조금 더 불편하지만, 유지보수는 훨씬 쉬워집니다. [php] 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 문서][2] 에서 찾으실 수 있습니다. `Statement` 객체의 `->setInt()` 메쏘드는 SQL 질의의 첫번째 `?` 를 질문의 `id` 로 치환합니다. `$max` 값은 반환되는 결과값을 `->setLimit()` 메쏘드를 사용하여 제한하기 위해 사용되었습니다. 이 메쏘드는 한번의 데이터베이스 질의로, 인기도 순으로 내림차순 정렬된 정규화 태그와 인기도의 결합 배열 (associative array) 을 반환합니다. ### 뷰 수정 이제 `modules/question/templates/` 디렉토리의 `_list.php` 조각파일에 있는 질문 목록에 태그들을 표시할 차례입니다. [php] getResults() as $question): ?>
    $question)) ?>

    getTitle(), '@question?stripped_title='.$question->getStrippedTitle()) ?>

    asked by getUser(), '@user_profile?nickname='.$question->getUser()->getNickname()) ?> on getCreatedAt(), 'f') ?>
    getHtmlBody()), 200) ?>
    tags:
    우리는 태그들을 `+` 기호를 사용하여 구분할 것입니다. 하지만 템플릿안에서는 너무 많은 코드 사용을 피해야 하기 때문에, 우리는 `tags_for_question()` 헬퍼를 `lib/helper/QuestionHelper.php` 에 만들었습니다. [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/ ![질문 목록의 인기 테그들](/images/askeet/popular_tags_question_list.gif) 태그 질문 목록 -------------- 태그를 출력할때 우리는 `@tag` 라우팅 규칙을 사용한 링크를 붙입니다. 이 링크는 해당 태그가 사용된 질문들중 인기도 순 목록을 출력하는 페이지로 연결될 것입니다. 이것은 간단하기 때문에, 바로 작성을 해보도록 하겠습니다. ### `tag/show` 액션 `tag` 모듈에 `show` 액션을 생성합니다. [php] public function executeShow() { $this->question_pager = QuestionPeer::getPopularByTag($this->getRequestParameter('tag'), $this->getRequestParameter('page')); } ### 모델 확장하기 평소와 같이 모델들을 다루는 코드들은 모델에 저장됩니다. 이번 경우에는 `Question` 객체들을 반환해야 하기 때문에 `QuestionPeer` 클래스에 코드를 작성할 것입니다. 우리는 질문의 인기도를 흥미도 순으로 판단할 것이기 때문에, 특별히 복잡한 질의가 필요하진 않아, Propel 의 `->doSelect()` 호출로도 충분할 것입니다. [php] 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` 템플릿은 아래와 같이, 아주 간단히 만드실 수 있습니다. [php]

    popular questions for tag "get('tag') ?>"

    $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' 태그가 입력된 질문들](/images/askeet/tagged_question_list.gif) 내일 이 시간에 -------------- Creole 데이터베이스 추상화 단은 symfony 가 복잡한 SQL 문을 질의할 수 있도록 합니다. Propel 객체-관계 맵핑은 여러분을 객체 지향 세계에서, 데이터베이스에 대한 고민없이, 놀 수 있도록 돕습니다. 몇몇 분들은 위의 질의들로 인한 데이터베이스 로드를 걱정하실지도 모르겠습니다. 그런 경우라면 최적화에 대한 여지가 남아 있습니다. 예를 들면, `popular_tags` 컬럼을 `Question` 테이블에 만들고, `QuestionTag` 가 생성될 때마다 이 필드를 업데이트 하는 방법이 있습니다. 이를 통해 질문 목록을 출력하는 것이 상당히 간편해질 것입니다. 하지만 캐쉬 시스템을 이용하면 - 몇일 후에 살펴볼 것입니다 - 이런 최적화도 필요가 없습니다. 내일은 askeet 어플리케이션의 태그 기능을 마무리 짓도록 하겠습니다. 사용자들은 이제 질문에 태그를 붙일 수 있을 것이고, 태그 버블이 만들어질 것입니다. 내일 또 오시는 것을 잊지 마십시오. 오늘 작성된 코드들은 `/tags/release_day_13/` 으로 [askeet SVN 저장소](http://svn.askeet.com/tags/release_day_13/) 에서 다운로드 가능합니다. 오늘 내용 중 질문이 있으시다면, [askeet 포럼](http://www.symfony-project.com/forum/index.php/f/8/) 에 남겨주시기 바랍니다. [1]: http://en.wikipedia.org/wiki/Folksonomy "Folksonomy definition at Wikipedia" [2]: http://creole.phpdb.org/trac/wiki/Documentation/CreoleGuide "Creole Guide"