Blog

  • WP_Hook이 새로 만들어졌다고?

    요즘 워드프레스에 뜸했다. 며칠 전 워드프레스 4.7.1을 보다가 훅의 구현이 엄청나게 변한 것을 알게 되었다. 구체적으로 어떤 점이 변경되었는지 알아 보자.

    우선 이 포스트에서 WP_Hook 이란 클래스가 새롭게 도입되었다는 사실을 발견할 수 있었다. 저 포스트에서 발견한 trac 페이지를 참고하면 대략 다음과 같은 이유로 도입이 되었다는 사실을 접할 수 있다.

    필터와 액션은 아주 오래전부터 워드프레스의 플러그인 기능의 기반으로 있어 왔습니다. (중략) 하지만 몇 년동안 엣지 케이스(edge case)가 불거져 나오게 되었습니다. 특히 훅을 재귀적으로 실행하거나, 훅을 스스로 지우려고 하는 경우 기존의 구현은 매우 복잡한 노력을 기울여야만 했습니다 (중략)

    사실 4.7에서 WP_Hook 클래스 때문에 발생할 수 있는 변경점은 크게 없다. 다만 $wp_filter 등 훅과 관련된 전역 변수에 직접적으로 접근한 경우, 인용한 글과 같이 훅을 재귀적으로 쓰는 코드는 더러 문제가 발생할 수도 있다. 허나 $wp_filter 등을 직접 접근은 완전 변태 코드. 피할 수 없는 문제가 아닌 이상 잘못된 접근이고, 또 훅을 재귀적으로 쓰는 일 또한 그리 흔치 않다. 그러니 기존의 코드는 거의 변함 없이 유지될 수 있을 것이다.

    하지만 나는 궁금하다. 도대체 어떤 문제 때문에 훅 매커니즘은 이제 세대교체를 해야만 했을까? 그래서 알아 보기로 했다.

    Action & Filter

    다들 알 거라 생각하겠다. 액션(action)과 필터(filter)는 사실 같은 존재라는 걸. 워드프레스는 미리 시나리오를 다 짜놓고 필요한 구간에 ‘훅’ 이란 것을 걸 수 있도록 장치했다. 이 훅에 대한 콜백 함수를 편리하게 쓸 수 있도로 만든 장치가 액션과 필터이다. 다시 말하지만 액션과 필터는 같은 콜백 함수이며, 단 하나의 차이점은 액션은 리턴이 없고, 필터는 값을 리턴한다는 점 뿐이다.

    웹 프레임워크들은 모든 기능들을 블록처럼 만들어 놓고 개발자가 의도에 맞게 그 모든 블록을 완전히 구현시켜 쌓아가는 방식이다. 반면 워드프레스는 이미 워드프레스 코어라는 완성품을 만들어 놓은 상태이다. 단, 그 코어에는 아주 많은 ‘구멍’을 뚫어 놓았다. 플러그인이나 테마는 그 구멍에 맞춰 적절히 콜백 함수를 끼워 넣는다. 이미 완성된 덩어리에 개발자가 자기 의도대로 부가 확장하는 방식이다.

    기존 구현의 문제점

    기존 혹 관련 API는 wp-includes/plugin.php 파일에 있으며, 4.7 버전에도 변함없이 존재한다. 단 WP_Hook이 있으므로 직접 $wp_filter를 조작하는 코드 일부를 WP_Hook에 넘겨 주었다. 이를 테면, 4.6 버전에서는 add_filter 함수의 구현은 이렇게 되어 있다.

    function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
      global $wp_filter, $merged_filters;
    
      $idx = _wp_filter_build_unique_id($tag, $function_to_add, $priority);
      $wp_filter[$tag][$priority][$idx] = array('function' => $function_to_add, 'accepted_args' => $accepted_args);
      unset( $merged_filters[ $tag ] );
      return true;
    }

    함수 내부에서 $wp_filter에 훅 이름($tag)과 우선순위($priority)에 맞춰 콜백이 등록된다. array가 잔뜩 들어간 지저분한 구현이고, 개인적으로 PHP의 array는 정말 이상한 물건이라 생각하지만, 아무튼 그렇다. $wp_filter는 무려 4중첩의 array이다.

    1. 훅의 이름인 $tag 키의 배열
    2. 같은 훅에서 우선순위인 $priority 키의 배열
    3. 같은 priority에서 유일한 이름의 키 $idx로 구별되는 배열
    4. 하나의 키에 해당하는 콜백 함수 이름과 인자 수를 기록한 배열

    4.7.1 버전의 add_filter()는 이렇게 되어 있다.

    function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
      global $wp_filter;
      if ( ! isset( $wp_filter[ $tag ] ) ) {
        $wp_filter[ $tag ] = new WP_Hook();
      }
      $wp_filter[ $tag ]->add_filter( $tag, $function_to_add, $priority, $accepted_args );
      return true;
    }

     WP_Hook() 객체를 사용하고 WP_Hook::add_filter()를 사용하는 것이 보인다.

    WP_Hook 클래스는 wp-includes/class-wp-hook.php에 구현되어 있다. 재미난 것은 이 클래스에 $nesting_level이라는 private 변수가 하나 있다. 만일 필터(또는 액션, 앞으로는 액션과 필터를 필터로만 언급)가 재귀적으로 실행되는 경우에 이전 구현에서는 이를 처리할 방법이 없었지만, 이제는 이것을 감지해 낼 수 있게 되었다.

    필터가 재귀적으로 실행되는 경우 기존의 구현은 확실히 문제가 있다. 다음 4.6 버전의  appy_filters() 코드 조각을 살펴 보자.

    ...
    do {
      foreach ( (array) current($wp_filter[$tag]) as $the_ )
        if ( !is_null($the_['function']) ){
          $args[1] = $value;
          $value = call_user_func_array($the_['function'], array_slice($args, 1, (int) $the_['accepted_args']));
        }
    
    } while ( next($wp_filter[$tag]) !== false );
    ...

     약속된 모든 모든 콜백함수를 경유하면서 PHP의 call_user_func_array()를 이용해 콜백 호출을 하는 것이 보인다. 마지막 줄에서  do ~ while 루프 안에서 next()로 array pointer를 이용해 각 priority를 단순하게 순회하는 것이 보이는가? 여기서 재귀적인 apply_filter()가 호출되면 문제가 발생한다.

    이 함수가 2번 재귀적으로 호출된다고 가정해 보자. 그러면 do ~ while 루프 안쪽에 같은 구조의 do ~ while 루프가 만들어지는 것과 비슷하게 된다. 그런데 next( $wp_filter )라는 부분은 두 함수가 공유하는 부분이다. 그러므로 안쪽의 루프가 먼저 array를 소진해버린다. 결국 번째 함수의 실행이 끝나고 첫번째 함수로 돌아온 시점에서 next( $wp_filter )는 첫번째 함수가 의도한 유효한 현재 이후의 순위를 가진 콜백 함수 목록을 가져오지 못한다.

    필터 적용은 전역적이며, 일괄적으로 동작한다

    필터의 콜백 선언은 전역적이며 일괄적이어야 한다. 만약 10개의 콜백 함수가 등록되었다면,  apply_filters(), do_action() 호출로 필터를 적용하면 언제나 등록된 그대로 10개의 콜백이 실행되어야 한다.

    그런데 기존의 구현은 어떤 조건에서 이 가정을 올바르게 지키지 못하는 경우가 있다. 그 대표적인 케이스로 ‘필터 적용이 재귀적으로 일어날 때’를 들 수 있다. 다음과 같은 조건이라면 이 현상이 일어난다.

    1. 콜백 함수가 종료되기 전에 해당 훅에 대한 필터를가 재차 적용된다.
    2. 콜백 함수에허 해당 훅에 대한 편집이 이루어진다.

    흔히 잘 알려진 재귀적인 필터 사용의 예로 들 수 있는 것 중 하나는  ‘save_post‘ 액션이다. 이 액션은 포스트가 저장될 때 사용되며, 해당 콜백에서 포스트 저장 시 동작을 일부 변경, 확장 가능하도록 해 준다. 흔히 이 함수 내부에서 플러그인이나 테마의 특정 데이터를 데이터베이스에 저장하도록 프로그래밍을 하는데, 흔히 이 콜백에서 어떤 포스트를 업데이트하는 코드를 작성하곤 한다. 포스트를 “저장”하려는 시점에 실행 중인 콜백이 포스트를 재차 “저장”하려고 시도하는 것이다. 만약 부주의하게 프로그래밍하는 경우 콜백은 무한으로 증식해 에러가 난다. 그래서 save_post 액션에 대한 코덱스에서는 ‘Avoiding infinite loops‘라는 항목을 따로 두어 주의를 주고 있다.

    코덱스에서 서술하는 것과 같이 무한 루프만을 막기 위한 일시적인 필터 등록 해제, 재등록 정도는 아무런 부작용이 없다. 그러나 해당 콜백 내부에서 자신의 필터를 변경하려고 하는 경우에는 문제가 발생하게 된다. 자세한 것은 실험을 통해 알아보자.

    실험: 4.6과 4.7의 콜백 동작 차이

    WP_Hook 클래스의 도입 후 변경된 훅의 동작을 실험해 보기 위해 실험 플러그인을 작성하여 gist에 등록해 두었다.

    코드 설명

    플러그인에서 ‘wph47_test01’이라는 훅을 실행한다. 이 훅의 콜백에서는 자기 자신을 변경한다. 또한 콜백 함수 안에서 다시 필터를 적용하는 기행(?)을 저지른다.

    add_action( 'wph47_test01', 'wph47_test01', 10);
    
    /**
     * 재귀적인 do_action 호출
     */
    function wph47_test01_body() {
        error_log('test begin');
        do_action( 'wph47_test01' );
        error_log("test end");
    }
    
    function wph47_test01() {
        static $flag = false; if ( $flag ) return; $flag = true;
        add_action( 'wph47_test01', 'wbh47_cb_9', 9 );
        add_action( 'wph47_test01', 'wbh47_cb_11', 11 );
        add_action( 'wph47_test01', 'wbh47_cb_12', 12 );
        add_action( 'wph47_test01', 'wbh47_cb_13', 13 );
        do_action( 'wph47_test01' );
        $flag = false;
    }

     wph_47_test01_body()는 wph47_test01 필터를 적용한다. 콜백 함수인 wph_test01() 안에서 자신의 훅을 편집한 후 재차 wph47_test01 필터를 적용하고 있다.  ‘wbh47_cb_x ‘ 함수들은 간단하게 ‘x’를 로그로 출력한다.

    코드 결과

    4.6 버전과 4.7 버전의 코드 결과는 약간 다르다.  먼저 4.6의 실행 결과는 이렇다

    test begin
    9
    11
    12
    13
    test end

    그리고 4.7의 실행 결과는 이렇다. 4.6과 다른 부분은 별표를 뒤에 붙였다.

    test begin
    9
    11
    12
    13
    11 *
    12 *
    13 *
    test end

    9, 11, 12, 13은 각각 우선순위 9, 11, 12, 13에 의해 등록된 함수에 의해 출력된 것이다. 그런데 왜 버전 4.7에서는 11, 12, 13이 반복된 것일까?

    위 코드에서 우선순위 10으로 실행된 콜백 함수 wph47_test01()안에서는 같은 이름의 wph47_test01 훅을 변경한다. 우선순위 9, 11, 12, 13의 액션을 등록하고, 다시 해당 액션을 재차 부르는 것이다. 그러면 재귀적으로 불려진 액션의 콜백에 의해 9, 11, 12, 13이 출력된다.

    여기서 주의해야 한다. 이 시점에서 훅의 상태는 변경되었다. 콜백은 우선순위 9, 10, 11, 12, 13로 구성되어 있다. 우선 순위 10번은 무한 루프 방지를 위한 코드 때문에 한 번만 실행된다고 간주하더라도, 10번 이후의 11, 12, 13이 등록되어 있는 상태이다. 그렇다면, 재귀 호출이 끝나고 한 단계 위의 함수로 올라가게 되면 (콜스택이 하나 줄어들면) 저기서 등록된 훅들은 실행되어야 하는가? 아니면 무시되어야 하는가?

    앞서 말했듯이 필터는 전역적이다. 필터를 명시적으로 해제하는 명령이 없는 이상, 프로그램이 마음대로 콜백을 호출하고 말고를 결정할 수는 없다. 그러나 잘 살펴보면 버전 4.6에서는 이를 무시하는 결과가 나오고 있다. 물론 훅의 변화가 재귀적으로 호출된 함수 내부에서 일어나는 것이 특이하긴 하다. 그러나 그깟 재귀함수가 뭐라고?

    4.7은 콜백 내부에서 등록된 필터라도 그 콜백이 끝난 후에 잘 반영된다. 11부터 반복된 것은 wph47_test01() 함수가 우선순위 10을 가지기 때문이다. 훅의 증가 뿐만 아니라 훅의 감소에도 당연히 올바르게 동작한다.

    변한 것은 없다. 더 정교해졌을 뿐.

    워드프레스가 제공한 API 함수만을 이용해 플러그인을 작성한 경우 특별히 코드를 변경할 이슈는 거의 없으며, 이렇게까지 트리키한 코드를 사용하지 않는 이상 딱히 이 변경점을 고려할 이유는 없다.

    WP_Hook을 부분적으로 이해한 현시점에서 어떠한 개선이 이뤄진 것인지 조목조목 파악하지는 못했지만 보다 정교해진 코드가 나로서는 반갑다. 사실 예전에 디버깅을 위해 콜백 함수를 덤프해 보면 array가 양파처럼 구성된 것을 보며 약간의 혐오감까지 느낀 적이 있던 터라, 오히려 “오 여기가 드디어 변경되었나?” 하는 반가움이 든다.

  • National Geographic: Photo of the Day All List CSV

    photo-of-the-day는 National Geographic photo of the day 페이지의 모든 서비스 사진들의 목록을 제공합니다.

    드롭박스 링크는 매일 갱신되며 photo of the day 서비스를 시작한 2009년 01월 01일부터 현재까지 모든 사진의 URL과 이미지 링크 목록을 담고 있습니다.

    CSV 목록 다운로드

  • predix-seed 자동 빌드 스크립트

    요즘 predix를 열나게 파고 있는 중이다. 엔터프라이즈급의 프레임워크라 따라 하기가 매우 버겁지만, 어쨌든 천천히 진행중이다.

    predix-seed라고 github에 있는 샘플 앱을 가져다가 빌드해 보았는데, 생각보다 매우 여러 점에서 실수할 확률이 높다는 것을 알았다. 그래서 부족한 쉘 스크립트 능력을 끌어 모아 어쨌든 자동화해 보았다.

    gist 참고

    참고할 문서들

    • https://www.predix.io/docs/?r=291908#zPpm2vcE
    • https://forum.predix.io/answers/2756/view.html

    구글에서는 잘 검색이 되지 않고, 포럼을 활용하는 것이 더 낫다.

  • 커스텀 포스트 역할과 권한 옵션 집중 분석

    역할과 권한에 대한 항목에는 다음 옵션들이 있다.

    • capability_type
    • capabilities
    • map_meta_cap

    전체 옵션은 이 포스트에서 확인할 수 있다.

    역할과 권한

    워드프레스의 권한(authority) 시스템은 매우 잘 구축되어 있다. 권한은 세부적으로 역할과 권한(capability)으로 쪼개어져 문맥(context)에 따라 어떤 작업을 허가할 수도, 거부할 수도 있도록 조직되어 있다.

    역할 (Roles)

    사실 ‘역할’은 ‘권한’보다는 커스텀 포스트와는 덜 밀접한 관계이긴 하지만 권한과 역할이 워낙 가까운 관계라 언급은 해야 할 것 같다. 쉽게 말해 사용자의 그룹이라 볼 수 있다. 한 사용자가 사이트 내부에서 어떤 담당인지를 나타내 준다.

    각 역할 별로 일정한 권한의 목록이 기본으로 주어진다. 물론 새로운 역할도 추가할 수 있고, 특정 권한을 더 추가할 수도, 덜어낼 수도 있다. 역할에 대해서는 자세한 설명을 생략하도록 하겠다. 코덱스를 참고하기 바란다.

    권한 (Capabilities)

    워드프레스에서 동작에 대한 문맥을 나타낸다. 예를 들어 포스트를 편집, 삭제, 발행하는 각각에 대한 동작들을 의미한다. 역할과 마찬가지로 사용자별로 권한을 더해줄 수도, 덜어낼 수도 있으며 완전히 새로운 권한을 만드는 것도 가능하다. 단, 여기서는 커스텀 포스트 옵션과 관련된 사항에서만 설명을 하도록 하겠다.

    커스텀 포스트의 권한 대응

    커스텀 포스트는 기본 포스트 타입을 확장하여 사용하는 것이므로, 기본 포스트가 가진 권한 시스템을 그대로 물려받게 된다.

    포스트, 혹은 페이지에 대해 다수의 권한이 있고, 커스텀 포스트를 생성하면서 이 권한들을 1:1로 모두 새롭게 재정의해 주어야 코어 내부에서 올바르게 권한을 체크할 수 있다.

    예를 들어 publish_posts 권한을 살펴 보자. 이 권한이 주어지지 않은 사용자는 글을 쓰고 편집할 수는 있어도 편집 화면에서 ‘공개하기(publish)’ 버튼이 나오지 않는다.

    custom_post_cap_001
    Contributor의 글 작성 화면. Publish 대신 Submit for Review 버튼으로 나온다.

    위 그림을 보자. 어떤 기여자(Contributor) 역할의 사용자가 새 포스트를 작성 중이다. Publish 메타 박스를 보면 보통 볼 수 있는 ‘Publish’ 버튼 대신 ‘Submit for Review’라는 버튼으로 변경되어 있다. 왜냐하면 기여자는 새 글을 쓸 수는 있고 그 글을 편집할 수는 있지만 글을 최종 단계인 발행 상태로는 만들 수 없기 떄문이다.

    자, 이제 커스텀 포스트를 생성해야 한다고 보자. 그럼 publish_posts 권한에 대해 두 가지 결정을 할 수 있다. 기존의 포스트(혹은 페이지)와 동일한 권한을 가질 것인가, 아니면 별도의 권한으로 나누어야 할 것인가.

    동일한 권한이라면, 글(혹은 페이지)를 편집, 수정 발행할 수 있는 권한을 가진 사용자는 커스텀 포스트에 대해 동일한 작업이 될 것이다. 반면 별도의 권한을 가지도록 처리한다면, 조금 성가시기는 하겠지만, 보다 세밀하게 권한을 나누어 원하는 대로 분업을 진행할 수 있을 것이다.

    그럼 이제 PHP 코드 관점에서 권한의 구조를 생각해 보자. 권한은 연관 배열(혹은 stdClass)을 사용한다. 권한의 공통적인, 즉 ‘문맥상’의 호칭은 연관 배열의 키(혹은 속성)로, 해당 실제 권한명은 값으로 기록한다. 아래는 그 예이다.

    register_post_type(
      'sample_audio',
      array(
        ....
        'capabilities' => array(
          ....
          'publish_posts' => 'publish_posts',
          ....
        ),
      ....
    );
    
    register_post_type(
      'sample_video',
      array(
        ....
        'capabilities' => array(
          ....
          'publish_posts' => 'publish_videos',
          ....
        ),
      ....
    ); 
    
    

    두 포스트 타입, ‘sample_audio’와 ‘sample_video’를 등록할 때 publish_posts라는 문맥상의 권한에서 audio는 포스트의 것을 그대로 가져오는 반면 video는 ‘publish_videos’라는 새로운 권한 이름을 지어 냈다.

    $caps[] = $post_type->cap->publish_posts;

    반면 코어 코드에서는 이렇게 쓰이기도 한다. 내부적으로 array를 stdClass로 타입 변환하는 과정이 생겨 키가 아닌 속성으로 불리긴 하나, 이 코드에서 audio 포스트타입이라면 $caps[]에 저장되는 문자열은 ‘publish_posts’, video 포스트 타입이라면 ‘publish_videos’가 될 것이다.

    map_meta_cap: 옵션, 함수, 그리고 필터의 이름

    여기저기서 map_meta_cap이라는 문자열이 언급되는데, 워드프레스 코어에서 이 것이 세 가지 의미로 쓰이고 있다. 애매모호함을 없애기 위해 우선 이것부터 명확히 정의하고 넘어가자.

    첫번째로 map_meta_cap은 register_custom_post() 함수의 옵션 인자로 쓰인다. 자명하다.

    두번째로 map_meta_cap은 함수 이름으로 쓰인다. map_meta_cap() 처럼 뒤에 괄호를 붙여 구분하기도 한다. 이후 메타 권한 설명에서 설명하겠지만, map_meta_cap() 함수는 권한 판별에 매우 중요한 역할을 하는 함수이다.

    셋째로 map_meta_cap은 필터 이름으로 쓰인다. map_meta_cap() 함수 내부에서 가장 마지막에 함수의 연산 결과를 재정의할 때 사용된다.

    이 셋이 치명적인 오해를 불러일으키는 것은 아니지만, 코어 코드를 보면, map_meta_cap이 콜백 함수로 불리기 때문에 마치 이것이 필터처럼 보이기도 해서 처음 이 쪽 코드를 볼 때 오해하고 살짝 헤맨 경험이 있어 혹시나 해서 언급한다.

    // class-wp-user.php, has_cap() function
    $caps = call_user_func_array( 'map_meta_cap', $args );
    // capabilities.php, map_meta_cap()
    return apply_filters( 'map_meta_cap', $caps, $cap, $user_id, $args );

    기본 권한과 메타 권한

    코덱스에서 언급되는 이해하기 쉽지 않은 개념 중에 기본 권한(primitive capabilities)와 메타 권한(meta capabilities)가 있다. 말장난 같기도 하고 좀 어렵지만, 이해해 보면 워드프레스가 권한에 매우 세심하게 디자인을 했다는 것을 알 수 있다.

    메타 권한이란 고정되어 있지 않은 권한들을 의미한다. 메타 권한은 조건에 때라 각각 다른 기본 권한으로 대응될 수 있다. 쉬운 예를 들어 설멍해 보자. 에디터 A, B 둘이 있다. 모든 포스트에 대해 A, B 동일한 권한이 있다. 그러나 어느 특정 카테고리에서만 A는 편집을 허용하고, B는 허용하지 않게 해야 한다고 하자. 이렇게 권한을 설정하게 하려면 어떻게 해야 할까? 이렇게 특정한 조건에 따라 유동적으로 설정 가능한 권한이 메타 권한이다.

    반면 기본 권한은 고정된 권한이다. 메타 권한은 조건에 따라 기본 권한 하나로 대응된다. 일반적으로는 어떤 개체 하나에 대한 권한은 메타 권한, 복수의 개체에 대한 권한은 기본 권한으로 설정되어 있다.

    메타 권한 동작의 예

    메타 권한 중의 하나인 ‘edit_post’ 권한을 예로 들어, 이 권한의 코어 내부에서 어떻게 동작하는지 간략히 설명해 본다.

    사용자가 포스트의 편집 화면을 접근한다고 하자. URL은 wp-admin/post.php?post=<post_id>&action=edit&…. 와 비슷하게 구성된다. wp-admin/post.php 스크립트는 편집 화면을 구성하는데, 먼저 사용자에게 포스트의 편집 권한이 있는지를 확인한다.

    // wp-admin/post.php, Line 116
      if ( ! current_user_can( 'edit_post', $post_id ) )
        wp_die( __( 'You are not allowed to edit this item.' ) );

    현재 사용자가 $post_id에 대해 ‘edit_post’ 권한을 가지고 있는지 물어보고 아니면 wp_die()에 의해 중단된다. current_user_can() 함수는 현재 사용자를 파악하고, 현재 사용자에 대해 class-wp-user.php의 WP_User::has_cap() 함수를 호출한다.

    이어 WP_User::has_cap() 함수는 내부에서 map_meta_cap() 함수를 호출한다.

    public function has_cap( $cap ) {
      ....
      $args = array_slice( func_get_args(), 1 );
      $args = array_merge( array( $cap, $this->ID ), $args );
      $caps = call_user_func_array( 'map_meta_cap', $args );   // 여기
      ....

    map_meta_cap() 내부에는 긴 switch ~ case 문이 있다. 여기서 case ‘edit_post’ 부분을 타고 들어가 보자. 해당 포스트의 작성자와 포스트의 상태에 따라 메타 권한의 결과가 변한다. if 분기를 세심하게 체크해 보면 어떤 경우에는 ‘edit_published_posts’, 또 어떤 경우에는 ‘edit_posts’나 ‘edit_others_posts’, 또 어떤 경우에는 ‘edit_private_posts’ 같은 기본 권한들로 대응되는 것을 확인할 수 있다.

    // If the post author is set and the user is the author...
    if ( $post->post_author && $user_id == $post->post_author ) {
      // If the post is published or scheduled...
      if ( in_array( $post->post_status, array( 'publish', 'future' ), true ) ) {
        $caps[] = $post_type->cap->edit_published_posts;
      } ...
    } else {
      // The user is trying to edit someone else's post.
      $caps[] = $post_type->cap->edit_others_posts;
      ...
    }

    map_meta_cap() 함수는 $caps라는 배열을 리턴한다. 이 배열은 해당 문맥에서 사용자에게 요구하는 필수 요구 권한의 목록이다. 이 예에서는 $caps 배열의 길이가 아마도 1이겠지만, 조건에 따라 길이가 더 길어질 수도 있다.

    $capabilities = apply_filters( 'user_has_cap', $this->allcaps, $caps, $args, $this );
    
    // Everyone is allowed to exist.
    $capabilities['exist'] = true;
    
    // Must have ALL requested caps.
    foreach ( (array) $caps as $cap ) {
      if ( empty( $capabilities[ $cap ] ) )
        return false;
    }

    위 코드는 WP_User::has_cap()의 마지막 부분이다. 사용자의 모든 권한을 가져와서 필수 요청 권한 목록인 $caps와 대조하는 코드이다. 전체 권한 중에서 요구된 권한 하나라도 발견되지 않는다면, 해당 사용자는 권한이 없는 것으로 간주된다.

    각 옵션 설명

    권한은 그 수가 많고 만큼, 쉽게 사용할 수 있어야 한다. 쉽게 쓰려고 의도했으면 쉽게 사용할 수 있도록 되어야 한다. 권한에 대해 고민하지 않고 그냥 포스트나 페이지처럼 만드려면 권한 관련 옵션을 아예 언급조차 하지 않으면 된다. 그러면 포스트나 페이지에 의거해 동일한 권한으로 자동 생성된다.

    capapbility 옵션

    권한 설정에 관련된 함수는 wp-includes/posts.php get_post_type_capabilities()이다. 소스 코드를 첨부한다.

    function get_post_type_capabilities( $args ) {
      if ( ! is_array( $args->capability_type ) )
        $args->capability_type = array( $args->capability_type, $args->capability_type . 's' );
    
      // Singular base for meta capabilities, plural base for primitive capabilities.
      list( $singular_base, $plural_base ) = $args->capability_type;
    
      $default_capabilities = array(
        // Meta capabilities
        'edit_post'          => 'edit_'         . $singular_base,
        'read_post'          => 'read_'         . $singular_base,
        'delete_post'        => 'delete_'       . $singular_base,
        // Primitive capabilities used outside of map_meta_cap():
        'edit_posts'         => 'edit_'         . $plural_base,
        'edit_others_posts'  => 'edit_others_'  . $plural_base,
        'publish_posts'      => 'publish_'      . $plural_base,
        'read_private_posts' => 'read_private_' . $plural_base,
      );
    
      // Primitive capabilities used within map_meta_cap():
      if ( $args->map_meta_cap ) {
        $default_capabilities_for_mapping = array(
          'read'                   => 'read',
          'delete_posts'           => 'delete_'           . $plural_base,
          'delete_private_posts'   => 'delete_private_'   . $plural_base,
          'delete_published_posts' => 'delete_published_' . $plural_base,
          'delete_others_posts'    => 'delete_others_'    . $plural_base,
          'edit_private_posts'     => 'edit_private_'     . $plural_base,
          'edit_published_posts'   => 'edit_published_'   . $plural_base,
        );
        $default_capabilities = array_merge( $default_capabilities, $default_capabilities_for_mapping );
      }
    
      $capabilities = array_merge( $default_capabilities, $args->capabilities );
    
      // Post creation capability simply maps to edit_posts by default:
      if ( ! isset( $capabilities['create_posts'] ) )
        $capabilities['create_posts'] = $capabilities['edit_posts'];
    
      // Remember meta capabilities for future reference.
      if ( $args->map_meta_cap )
        _post_type_meta_capabilities( $capabilities );
    
      return (object) $capabilities;
    }

    capability 옵션은 문자열, 혹은 배열을 값으로 쓸 수 있다. 문자열은 단수형 단어(내부에서 문자 뒤에 s를 붙여 크기 2짜리 배열로 조정한다), 그리고 배열은 크기 2짜리로 단수, 복수로 권한에 대한 단어를 입력해 주면 된다. (소스코드 2~6라인 참고)

    코어는 capability 옵션에 대해 다음 권한을 기본적으로 생성한다.

    • edit_post
    • read_post
    • delete_post

    위 세 권한은 메타 권한이다. (소스코드 9~12라인)

    • edit_posts
    • edit_others_posts
    • publish_posts
    • read_privte_posts

    이 네 권한은 기본 권한으로, map_meta_caps() 함수 내부에서는 언급되는 일이 없다. 그러나 코어 구석구석에서 권한 검사에 사용되고 있을 것이다. (소스코드 13~17라인)

    만일 map_meta_caps 옵션을 적지 않거나(null), true로 설정했다면 7개의 기본 권한이 별도로 생성된다. 다음은 그 목록이다. (소스코드 20~32라인)

    • read
    • delete_posts
    • delete_private_posts
    • delete_published_posts
    • delete_others_posts
    • edit_private_posts
    • edit_published_posts
    • create_posts

    capabilities 옵션

    위 소스 코드 34라인이다.

    $capabilities = array_merge( $default_capabilities, $args->capabilities );

    $args->capabilities가 capabilities 옵션이다. 여기서는 권한의 종류를 더욱 자세하게 정의할 수 있다. 보다시피 capability 옵션으로 인해 생긴 기본 설정에 더불어 별도의 권한을 추가할 수도 있고, 기본 설정을 변경할 수도 있게 짜여 있다.

    map_meta_cap

    map_meta_cap 옵션 값은 true/false, 그리고 기본으로는 null을 가질 수 있다. null로 값을 주면 내부에서 true로 변환시킨다. 즉 기본값이 true.

    간단히 말해 이 옵션은 해당하는 커스텀 타입이 선언한 메타 권한이 기본 권한으로 대응시키는 작업을 허용할지 말지를 결정한다. 이 작업은 map_meta_cap() 함수에 정의되어 있다. 만일 false라면 일부 기본 권한은 수동으로 생성해야 하며, 메타 권한의 기본 권한 대응 작업은 별도로 제작해야 한다.

    보다 세밀한 설명은 이렇다. 이 옵션은 다음과 같은 역할을 한다.

    첫째로 get_post_type_capabilities() 함수 내부에서 true(혹은 null)일 때 일곱가지 기본 권한(read, delete_posts, 등…)을 설정해주는 역할을 맡는다. 만일 이 값이 false라면 이 일곱가지 기본 권한을 map_meta_cap 필터를 이용해 별도로 보충해 줘야 한다.

    둘째로 이 값이 true(혹은 null)이어야 map_meta_cap() 함수 내부에서 post, page와 관련된 메타 권한 체크 로직에서 포스트와 페이지의 여러 상태에 따라 적절한 기본 권한으로 해석되도록 하는 코어의 기본 로직이 동작한다.

    map_meta_cap() 함수의 메타 권한 체크 부분 중 포스트나 페이지와 관련된 곳을 보면 다음과 비슷한 코드가 있는 것을 볼 수 있다.

    if ( ! $post_type->map_meta_cap ) {
      $caps[] = $post_type->cap->$cap;
      // Prior to 3.1 we would re-call map_meta_cap here.
      if ( 'edit_post' == $cap )
        $cap = $post_type->cap->$cap;
      break;
    }

    map_meta_cap 옵션 값이 false가 되면 map_meta_caps() 함수 내에서 메타 권한은 더이상 메타 권한으로써 동작하지 않는다. 즉, 해당 capability를 액면 그대로 요구한다. 그러면 이렇게 반환된 capability에 대해서는 별도의 필터 콜백으로 검사해 주어야 한다.

    셋째로 메타 권한인 read_post, delet_post, edit_post, 이 셋의 권한 이름을 변경했을 때 추적할 수 있도록 한다. _post_type_meta_capabilities() 함수와 map_meta_cap() 함수 switch-case 구문의 default 레이블에 구현되어 있다.

    예제 코드 #1 특정 사용자의 특정 카테고리 편집 제한

    메타 권한에 대한 설명에서 에디터 B에 대해 특정 카테고리의 글은 편집 제한을 거는 실제 코드를 구현해 보자.

    에디터 B의 ID는 ‘5’이고, 카테고리의 택소노미 이름은 ‘wphack_taxonomy”이고, 카테고리 이름은 ‘a-only’이다. 포스트를 적당히 만들고 a-only 카테고리로 설정하고, 다음 코드를 작성한다. 접근 제한에 대한 세련된 예는 아니지만  권한에 대한 하나의 예제로써 이해하기를 바란다.

    add_filter( 'map_meta_cap', 'wphack_meta_cap', 99, 4 );
    
    function wphack_meta_cap( $caps, $cap, $user_id, $args ) {
    
      global $pagenow, $post_type;
    
      if ( $pagenow == 'post.php' && $post_type == 'wphack' && $cap == 'edit_post' ) {
    
        $post_id    = $args[0];
        $categories = wp_get_post_terms( $post_id, 'wphack_taxonomy', array( 'fields' => 'names' ) );
    
        if ( in_array( 'a-only', $categories ) ) {
          $a_blacklist = array( 5, 10, 11, 12, );
          if ( in_array( $user_id, $a_blacklist ) ) {
            $caps = array( 'do_not_allow' );
          }
        }
      }
    
      return $caps;
    }

    에디터 B가 해당 포스트의 편집 화면에 접근하면 다음과 같은 에러가 발생한다.

    custom_post_cap_002

    예제 코드 #2 읽기 전용 커스텀 포스트

    물론 다른 웹프레임워크를 이용해 UI를 다 만들 수도 있겠지만, 워드프레스를 사용하면 나름 괜찮은 UI를 손쉽게 가져다 쓸 수 있다. 이 UI를 통해 개체 수정 및 임의 작성도 가능하니 금상첨화다.

    다만, 어떤 다른 후단부(backend)가 존재하여 포스트의 데이터를 모두 일괄적으로 생성하는 시나리오가 있다고 가정하자. 이 커스텀 포스트는 워드프레스 UI 상에서 읽기, 조회만 가능하고 포스트의 생성과 수정은 별도의 스크립트에서 담당해야 한다. 단, 삭제의 경우는 UI 상에서 데이터를 체크하고 수동으로 지우는 일이 가능하다고 하자.

    이러한 시나리오에서 커스텀 포스트는 포스트의 목록과 상세 화면 UI가 필요하다. 그러나 커스텀 포스트의 상세 화면에서는 포스트의 어떤 필드도 수정할 수 없도록 처리해야 한다.

    먼저 커스텀 포스트 타입을 생성해 보자. ‘init’ 훅에서 register_post_type의 인자를 설정해 준다. 여기서 레이블을 바꿔치기했다. ‘edit’은 이제 편집이 아니라 세부항목을 보는 것으로 의미가 변경된다. 주석 부분을 해제하면 삭제도 불가능하게 만들 수 있다.

      $args = array(
        'labels'               => array(
          'name'          => 'RO Posts',
          'signular_name' => 'RO Post',
          'edit_item'     => 'RO Post Details',
        ),
        'capabilities'         => array(
          'create_posts' => 'do_not_allow',
    //          'delete_posts'           => 'do_not_allow',
    //          'delete_private_posts'   => 'do_not_allow',
    //          'delete_published_posts' => 'do_not_allow',
    //          'delete_others_posts'    => 'do_not_allow',
        ),
        'map_meta_cap'         => TRUE,
        'public'               => TRUE,
        'show_ui'              => TRUE,
        'supports'             => FALSE,
        'register_meta_box_cb' => 'rocp_customize_meta_boxes',
      );
    
      $rocp = register_post_type( 'rocp', $args );

    그리고 activation 훅과 deactivation 훅에서 적절히 예제 포스트를 생성하고 삭제하는 코드를 넣는다.

    register_activation_hook( __FILE__, 'rocp_activation' );
    function rocp_activation() {
      rocp_register_post_type();
      for ( $i = 1; $i <= 5; ++ $i ) {
        wp_insert_post(
          array(
            'post_title'     => sprintf( 'Read Only Custom Post Sample #%02d', $i ),
            'post_status'    => 'publish',
            'post_type'      => 'rocp',
            'comment_status' => 'closed',
            'ping_status'    => 'closed',
            'post_name'      => sprintf( 'rocp-%02d', $i ),
            'meta_input'     => array(
              'rocp_meta_01' => "meta_01_$i",
              'rocp_meta_02' => "meta_02_$i",
            ),
          )
        );
      }
      flush_rewrite_rules();
    }
    
    register_deactivation_hook( __FILE__, 'rocp_clear' );
    function rocp_clear() {
      $query = new WP_Query(
        array(
          'post_type' => 'rocp',
          'nopaging'  => TRUE,
          'fields'    => 'ids',
        )
      );
    
      $posts = $query->get_posts();
      foreach ( $posts as $post ) {
        wp_delete_post( $post, TRUE );
      }
    }

    그리고 나머지 UI를 조정해 문맥에 맞지 않은 행동은 삭제한다. 우선 row action 항목에서의 edit도 detail로 변경한다.

    function rocp_row_actions( $actions, $post ) {
    
      if ( $post->post_type == 'rocp' ) {
        if ( $actions['edit'] ) {
          $actions['edit'] = sprintf(
            '<a href="%s" aria-label="%s">%s</a>',
            get_edit_post_link( $post->ID ),
            /* translators: %s: post title */
            esc_attr( sprintf( __( 'Detail &#8220;%s&#8221;', 'rocp' ), $post->post_title ) ),
            __( 'Detail', 'rocp' )
          );
        }
    
        if ( $actions['inline hide-if-no-js'] ) {
          unset( $actions['inline hide-if-no-js'] );
        }
      }
    
      return $actions;
    }

    그리고 목록 UI에서 벌크 작업 목록에서 편집 항목을 제거한다.

    add_filter( 'bulk_actions-edit-rocp', 'rocp_remove_bulk_edit' );
    function rocp_remove_bulk_edit( $actions ) {
    
      if ( isset( $actions['edit'] ) ) {
        unset( $actions['edit'] );
      }
    
    //  if ( isset( $actions['trash'] ) ) {
    //      unset( $actions['trash'] );
    //  }
    
      return $actions;
    }

    Detail 스크린에서 어떤 메타박스도 볼 수 없도록 register_post_type의 ‘register_meta_box_cb’ 콜백 함수에서 지저분하게 남아 있는 메타 박스를 모조리 제거한다.

    function rocp_customize_meta_boxes() {
    
      // disable slug metabox
      remove_meta_box( 'slugdiv', 'rocp', 'normal' );
    
      // disable post update metabox
      remove_meta_box( 'submitdiv', 'rocp', 'side' );
    }

    화면 상단의 스크린 옵션을 제거해 보았다. 메타 박스 등을 고려한다면 나타나게 할 수도 있을 것이다. 또한 예제에서는 어떤 메타박스도 쓰지 않기 때문에 화면을 1열로만 쓰는 것이 더 깔끔하기 때문에 화면 레이아웃도 1단으로 고정시켰다. 포스트 타입에 따라 적절히 조절할 수 있을 것이다.

    add_filter( 'screen_options_show_screen', 'rocp_disable_screen_option', 10, 2 );
    function rocp_disable_screen_option( $show_screen, $wp_screen ) {
    
      if ( $wp_screen->id == 'rocp' ) {
        $show_screen = FALSE;
      }
    
      return $show_screen;
    }
    
    add_filter( 'screen_layout_columns', 'rocp_screen_layout_columns', 10, 2 );
    function rocp_screen_layout_columns( $columns, $screen_id ) {
    
      if ( $screen_id == 'rocp' ) {
        $columns['rocp'] = 1;
      }
    
      return $columns;
    }
    
    add_filter( 'get_user_option_screen_layout_rocp', 'rocp_user_option_screen_layout' );
    function rocp_user_option_screen_layout( $result ) {
      
      return 1;
    }

    상세화면의 본문 구성은 ‘edit_form_after_editor’ 훅을 이용해 삽입 가능하다. 물론 메타 박스를 이용해 구성하는 것도 관계 없다.

    add_action( 'edit_form_after_editor', 'rocp_display_detail' );
    function rocp_display_detail( $post ) {
    
      $rocp_meta_01 = get_post_meta( $post->ID, 'rocp_meta_01', TRUE );
      $rocp_meta_02 = get_post_meta( $post->ID, 'rocp_meta_02', TRUE );
    
      echo '<table class="widefat">';
      echo '<thead><tr><th>Name</th><th>Value</th></tr></thead>';
      echo '<tbody>';
      echo '<tr><th>ROCP Meta 01</th><td>' . esc_html( $rocp_meta_01 ) . '</td></tr>';
      echo '<tr><th>ROCP Meta 02</th><td>' . esc_html( $rocp_meta_02 ) . '</td></tr>';
      echo '</tbody>';
      echo '</table>';
      echo '<p><a href="' . esc_url( admin_url( 'edit.php?post_type=rocp' ) ) . '"><button type="button" class="button button-primary">Go back</button></a></p>';
    
    }

    코드 전문은 여기서 확인할 수 있다. 이 플러그인을 활성화시키면 다음과 같은 화면을 볼 수 있다.

    custom_post_cap_003
    포스트 목록에서 ‘Edit’ 텍스트가 ‘Detail’ 텍스트로 변경되었다. 커스텀 포스트의 목록은 플러그인을 활성화하면 자동으로 생성되고, 비활성화하면 자동으로 삭제된다.
    custom_post_cap_004
    Edit 화면의 레이블이 변경되었다. 어떤 입력 위젯도 출력되지 않으며, 스크린 옵션도 삭제되었다. 커스텀 포스트에 예제로 만든 2개의 커스텀 필드만 표로 출력되게 조작되었다.
    custom_post_cap_005
    편집 권한이 없으므로 대량 편집 기능도 삭제하는 것이 맞다.

     

  • 커스텀 포스트 말단지점(endpoint)과 다시 쓰기(rewrite) 옵션 집중 분석

    말단지점(endpoint) 및 다시 쓰기(rewrite) 기능은 URL 축약과 고유주소 생성과 관련 깊은 옵션이다.

    여기에 해당하는 옵션은 단 2개로 수가 적지만, 커스텀 포스트 옵션 중 서버의 기능과 밀접한 부분이다. 내가 포스트를 쓰면서 가장 염두에 둔 포인트이기도 하고.

    • permalink_epmask
    • rewrite

    나머지 옵션에 대해서는 이 포스트를 참고하자.

    워드프레스의 rewrite에 대해

    커스텀 포스트의 옵션 값과는 약간 거리가 있지만, 우선 워드프레스가 어떻게 다시 쓰기를 하는지에 대해 이야기를 먼저 하고자 한다. 옵션이 워낙 적어 분량 채우기기도 하지만… 워드프레스의 다시 쓰기 원리를 이해하고 이 옵션을 보는 것이 더 낫지 않을까?

    웹사이트를 돌아다니다 보면 URL이 엄청나게 긴 것들을 볼 수 있다. 그에 비하면 엄청 양반이지만, 워드프레스에서도 이렇게 긴 URL로 쿼리를 할 수 있다.

    /index.php?post_type=post&paged=1

    위 URL예는 블로그 포스트 1페이지를 불러 오는 예이다. 파라미터가 단 두개 뿐이라 부담은 없지만, 이런 URL은 그다지 기억하기 쉽지도 않고, 예쁘지도 않다. 또 SEO (Search Engine Optimization) 관점에서도 이렇게 장황한 URL은 권장할 만한 사안은 아니다.

    깔끔한 URL을 사용하게 되면 URL 자체로도 보다 의미 있어진다. 하지만 어떻게 하면 깔끔하고 좋은 URL을 만드는지 그 요령에 대해서는 논외로 하고, 어떻게 이렇게 URL을 쓸 수 있는지에 대해서만 쓰겠다.

    웹서버가 서비스를 하는 가장 기본적인 방법은 파일 기반이다. 그림 파일, 자바스크립트 등등, 또 정적인 HTML 파일들이 그렇다. 그냥 있는 그대로 클라이언트들에게 전달하면 되는 구조다.

    그러나 서버 사이트 스크립트 언어인 PHP로 짜여신 파일의 경우는 조금 다르다. PHP 파일 스크립트를 (소스) 그대로 클라이언트에게 전달하는 일은 없다. PHP 스크립트 또한 파일 기반이라는 점은 변함이 없지만, 그 파일에 접근할 때 어떠한 파라미터를 주는지에 따라, 또는 GET인지 POST인지 전달 방식에 따라 결과는 달라질 수 있다.

    워드프레스에서도 마찬가지다. 단지 워드프레스 코어가 index.php 이외의 주소로 접근하는 것에 대해 잘 대비했을 뿐이다. 워드프레스의 index.php 외 여러 php 파일을 대상으로 웹브라우저에서 접근해 보라. 그저 빈 스크린만 나올 뿐이다. 물론 로그인이나 관리자 같은 몇몇 특별한 접근 포인트들은 예외지만.

    그러면 워드프레스에서는 어떻게 깔끔한 URL을 처리하도록 만드는 것일까? 우선 웹서버에서 rewrite를 지원해야 한다. Apache나 nginx 둘 다 rewrite를 지원할 수 있으므로 이 기능에 의지해 URL을 찾아내는 것이다. 단, rewrite 기능에 모든 것을 의지하지는 않는다. 개략적인 룰만 지정하고, 나머지 복잡한 규칙은 내부의 DB에 저장한 다음 동적으로 처리한다.

    기본적인 컨셉은 이렇다.

    1. .htaccess 파일에 rewrite 룰을 다음과 같이 설정해 둔다: URL의 경로를 모두 무시하고 무조건 /index.php 파일에서 요청을 처리하도록 서버 흐름을 조작한다. 어차피 클라이언트가 어떤 경로를 요청했는지는 PHP의 $_SERVER 변수에 다 기록되어 있다.
    2. $_SERVER[‘REQUEST_URI’]에서 읽어온 URL과 데이터베이스에 저장된 rewrite 규칙을 대조한다. 여기서 규칙에 맞는 것을 찾는다면, 안 예쁜 URL로 변경한다. 즉, ‘index.php?param1=val1&param2=val2…’ 식으로 URL이 변경된다.
    3. 파라미터에 맞게 데이터베이스 쿼리를 한다.
    4. 템플릿을 불러와 적절히 응답을 한다.

    워드프레스는 아파치를 서버로 사용한 경우에 내부에 .htaccess 파일을 생성한다. 생성하지 못하면 .htaccess 파일을 따로 업로드하라고 안내를 한다. 코덱스에서 기술하는 기본적인 .htaccess 파일 구조는 이렇다.

    # BEGIN WordPress
    <IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /
    RewriteRule ^index\.php$ - [L]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule . /index.php [L]
    </IfModule>
    # END WordPress
    • ‘<IfModule> … </IfMoudle>’은 아파치 서버의 rewrite 모듈이 동작하는 경우에만 그 안의 내용을 읽으라는 뜻이다.
    • ‘RewriteEngine On’은 다시 쓰기를 작동한다는 뜻이다.
    • ‘RewriteBase /’은 다시 쓰기를 ‘/’를 대상으로 이뤄 진다는 것이다.
    • 첫번째 RewriteRule은 URL이 그냥 index.php로 시작하고, 또 그대로 끝난다면, 이 주소는 더 건드리지 않고 놔 두라는 뜻이다.
    • 두 번째 RewriteRule 위에 두 개의 RewriteCond가 있다. 두번째 RewriteRule의 선결조건을 의미한다.
      • %{REQUEST_FILENAME}는 서버 내에서 가져와야 할 파일의 이름을 의미하는 변수이다.
      • ‘!-f’, ‘!-d’는 각각 ‘파일이 아닌 경우’와 ‘디렉토리가 아닌 경우’를 뜻한다.
      • 그래서 %{REQUEST_FILENAME}이 파일도 아니고, 디렉토리도 아니라면 URL은 무시하고 index.php로 들어와 처리하라는 뜻으로 해석된다.

    예를 들어 ‘<home_url>/category/foods’라는 깔끔한 URL을 웹브라우저에서 요청했다. 사실 이런 경로는 서버에 존재하지 않는다. 그러므로 아파치 웹서버는 그냥 index.php 파일로 접속하도록 처리를 한다.

    그리고 wp() 함수를 호출하고, WP::main()에서 WP::parse_request() 함수를 처리하는 동안 데이터베이스에 있는 다시 쓰기 규칙을 읽어와 ‘category/foods’라는 경로는 원래 어떤 index.php?쿼리 문자열이었는지를 조사한다.

    아마 워드레스에서는 기본적으로 ‘category/food’를 ‘index.php?category_name=food’로 치환할 것이다. 그러면 예로 제시한 URL은 사실 ‘index.php?category_name=food’와 같으며 웹브라우저에서 이 URL로 요청을 해도 같은 응답이 오게 될 것이다.

    이 다시 쓰기 규칙은 워드프레스 options 테이블의 ‘rewrite_rules’라는 이름으로 저장되어 있다. 규칙은 배열을 serialize 된 상태로 존재한다.

    custom_post_rewrite_001
    rewrite_rule들이 데이터베이스의 레코드로 저장되어 있다. 그 위 고유주소(permalinks) 메뉴의 옵션값도 저장되어 있는 것이 보인다.

    또다른 예로, 가끔 워드프레스를 이사하거나 정비하는 과정에서 전면 페이지(frontend) 홈은 보이지만 네비게이션 메뉴에 있는 다른 페이지로 옮기면 404 에러가 발생하는 경우가 있다. 이 문제는 .htaccess에서 존재하지 않는 서버상의 경로를 무조건 index.php로 처리하라는 명령이 누락되어 있기 때문이다.

    Nginx도 크게 다른 것이 없다. 아파치처럼 .htaccess를 활용하지는 않지만, 어떤 URL로 요청이 들어오든지간에 처리를 index.php에 맡긴다는 점은 동일하다.

    permalink_epmask

    사실 이 옵션을 별로 건드려야 할 이유는 거의 없다. register_post_type 함수 내부를 들여다 봐도 이 옵션은 단지 EP_PERMALINK라는 기본값을 채워 넣는 것 이외에 언급되는 일도 없고.

    사실 epmask를 코어에 유효하도록 설정하려면 add_rewrite_endpoint() 함수를 써야만 한다. 또 이 옵션에 값을 집어넣는다고 해서 register_post_type이 별도로 이 함수를 쓰는 것도 아니다.

    말단지점에 대한 부가 설명

    말단지점(endpoint)란 http://example.com/service/path 처럼 웹서버의 어떤 경로라고 생각할 수 있는데 이것이 왜 생겼고, 어떻게 쓰여지는지는 여기에 간략히 설명되어 있다. 간단히 말해 서버의 어떤 단일 개체를 위한 엔드포인트에 별도의 패스를 덧붙일 수 있도록 한 것이다.

    예를 들어 wphack 커스텀 포스트에 개체에 접근하기 위해서 ‘/wphack/<post-slug>’ 이라는 말단지점을 사용할 수 있다. 그런데 어떤 목적 때문에 포스트의 제목만, 정말 제목만 출력하는 URL이 필요하다고 가정해보자. 물론 URL을 별도로 꾸밀 수도 있지만, 이러한 URL이 포스트 타입이나 페이지 타입 전반에 걸쳐 필요로 하다면? 이러한 경우를 위해 도입된 것이 바로 endpoint api이다.

    마스킹을 위해 쓰이는 상수는 다음과 같다.

    • EP_NONE
    • EP_PERMALINK
    • EP_ATTACHMENT
    • EP_DATE
    • EP_YEAR
    • EP_MONTH
    • EP_DAY
    • EP_ROOT
    • EP_COMMENTS
    • EP_SEARCH
    • EP_CATEGORIES
    • EP_TAGS
    • EP_AUTHORS
    • EP_PAGES
    • EP_ALL_ARCHIVES
    • EP_ALL
    add_action( 'init', 'wphack_ep_title' );
    
    function wphack_ep_title() {
      add_rewrite_endpoint( 'title', EP_PERMALINK | EP_PAGES );
    }

    말단지점에 대해 선언한다. 이 말단지점을 인식하고 동작시키는 코드는 아래와 같다.

    add_action( 'template_redirect', 'wphack_ep_title_redirect' );
    
    function wphack_ep_title_redirect() {
      global $wp_query;
    
      if( !isset( $wp_query->query_vars['title'] ) || !is_singular() ) {
        return;
      }
    
      global $post;
    
      header( 'Content-TYpe: text/plain' );
      echo $post->post_title;
    
      exit;
    }

    이렇게 하면 wphack, 또는 어떤 포스트, 페이지 타입에 대해 /title/ 이라는 경로를를 추가로 입력하면 개체의 제목만 출력한다. add_rewrite_endpoint 함수에 EP_PERMALINK | EP_PAGES 마스크를 씌웠으므로 각각 포스트, 페이지에 적용되었다. 나머지 상수도 마찬가지이다. 예륻 들어 EP_DAY라는 마스크는 엔드포인트에 날짜가 사용된다면 그 엔드포인트에 추가 경로를 붙일 수 있도록 한다.

    이렇게 코드를 짜면, 데이터베이스를 갱신해 주어 서버에서 인식되도록 처리해야 한다. 간단한 방법으로는 설정 (settings) > 고유주소 (permalinks)로 가서 저장 버튼을 한 번 눌러 주는 것이다. 내용을 변화시킬 필요는 없다. 이렇게 하면 options 테이블의 rewrite_rules 내용이 갱신된다.

    그리고 이렇게 갱신된 내용은 ‘Debug Bar’와 ‘Debug Bar Rewrite rules’ 플러그인을 사용해 진단해 볼 수 있다.

    custom_post_rewrite_002
    두 플러그인을 받고 활성화 한다.
    custom_post_rewrite_003
    로그인하면 어드민 바에 Debug 항목이 추가된 것이 보인다. 이 버튼을 누르면 그림과 같은 항목이 나온다. 좌측 메뉴에서 ‘Rewrite Rules’항목을 선택한다.
    custom_post_rewrite_004
    title 부분만 골라 보았다. 이렇게 정규식 매칭 표현과 매칭된 규칙이 어떻게 변환될지 정의되어 있다.

    rewrite

    rewrite 옵션은 하나의 배열을 값으로 받으며, 이 배열은 아래의 키를 가진다. 이 값을 변경하면 반드시 새로 고침 레코드를 갱신해 주어야 한다.

    slug

    query_var 옵션이 질의 문자열에 쓰이는 반면, slug 옵션은 rewrite에 사용된다. 만약 slug의 값으로 ‘ask’라는 문자열을 사용했다면 커스텀 포스트 타입 wphack은 전단부에 다음과 같은 URL로 접근 가능해진다.

    <site_url>/ask/                      # 목록 (아카이브)
    <site_url>/ask/page/PAGE_NUMBER      # 페이지 번호로
    <site_url>/ask/slug/                 # 특정 개체만

    with_front

    true/false를 가질 수 있는다. Front라는 것은 고유주소 설정에서 태그 항목 이전에 고정으로 생성한 문자열을 말한다. 아래의 그림을 보면 이해가 빠를 것이다.

    custom_post_rewrite_005
    /with-front 부분이 front이다.

    고유주소에 /with-front 문자열을 앞에 덧붙였는데, 이렇게 저장을 하면 워드프레스의 대부분의 기본적인 고유주소는 /with-front로 시작한다. 그러나 이 값을 false로 설정하면 현재 동록하는 커스텀 포스트만큼은 이 문자열을 허용하지 않는다.

    slug가 ‘ask’로 설정되었고 front가 ‘/with-front’라면

    • with_front가 false 일 때: <site_url>/ask로 개채의 목록 접근
    • with_front가 true 일 때: <site_url>/with-front/ask로 개체의 목록 접근

    feeds

    포스트 타입을 만들면 이에 대한 피드도 간단하게 만들어진다. 피드는 atom, rss, rss2 , rdf 다 지원을 한다. 이 값을 true로 하면 주소 끝에 /feed, /rss, /rss2 만 붙이면 바로 해당 형식으로 콘텐츠를 지원한다.

    custom_post_rewrite_006
    /feed, /rss, /atom, /feed/rss, 같은 주소만 붙여주면 아주 쉽게 피드를 작성할 수 있다.

    pages

    목록에서 페이징을 가능하게 한다. 이 항목이 true로 되어 있어야 <site_url>/ask/page/PAGE_NUMBER 처럼 된 URL로 접근 시 제대로 페이징된 목록을 보여 주게 된다.

    페이지 기능 자체를 이 옵션으로 켜고 끄는 것이 아니다. 단지 URL 규칙을 제대로 설정하는 것이다.

    ep_mask

    워드프레스 3.4 이후부터 permalink_epmask 옵션을 대체하는 옵션이다.

    마치며

    3.4에 오면서 permalink_epmask 옵션이 rewrite 옵션과 통합되면서, 사실상 이 포스트에서 다룬 옵션은 고작 하나였다. 그렇지만 rewrite는 워드프레스 코어 기능과 매우 밀접한 중요한 기능이라 생각해서 별도의 포스트로 따로 작성한 것이다.

    쿼리 문자열은 코어에 있어 엄청나게 중요한 부분이다. 어떤 콘텐츠를 어떻게 보여줄지는 모두 쿼리 파라미터에 달려 있기 때문에 사실 이 쿼리 파라미터가 워드프레스를 움직이는 중추라고 할 수 있다. 쿼리 문자열을 직관적인 URL로 변환시켜 주는 옵션이 rewrite이며 이것은 서버의 다시쓰기 기능을 필요로 하지만, 복잡한 다시쓰기 규칙은 워드프레스 코어 자체에서 별도로 수행하고 있다.

  • 커스텀 포스트 분류 및 기타 옵션 집중 분석

    이 포스트에서는 분류(taxonomy) 및 기타 옵션에 대해 다루어 본다. 옵션 목록은 다음과 같다.

    • can_export
    • description
    • hierarchical
    • taxonomies

    모든 목록은 이 포스트에서 확인할 수 있다.

    can_export

    내보내기 기능에 해당 커스텀 타입도 포함시키는 옵션이다. 이것이 true 이면 도구(tools) > 내보내기 (export) 메뉴에서 커스텀 타입도 포함된다.

    내보내기를 하면 XML 형태로 사이트의 콘텐츠를 다운로드 받을 수 있다. 단, 주의해야 할 점이 있다. 이것은 콘텐츠를 내보내기 위한 용도이며 범위는 콘텐츠에 한해 진행된다.

    백업을 하려면 데이터베이스를 sql 형태로 백업받고, FTP 등을 이용해 사이트 전체 파일을 받아 두는 것이 낫다. 사이트 전반에 걸쳐 저장된 옵션, 예를 들어 테마 설정 및 업로드된 첨부 파일의 원본까지 보관해 두어야 하기 때문이다.

    custom_post_misc_001

    description

    이 커스텀 포스트 타입이 어떤 것인지 기술하기 위한 용도로 사용된다. 이 옵션 자체가 어딘가 직접적으로 사용되는 일은 없다.

    hierarchical

    True 이면 ‘페이지’ 타입처럼 처럼 상위/하위 개념이 존재하는 개체로 만들 수 있다. 반대로 false이면 ‘포스트’ 타입처럼 모든 포스트는 동등한 위치를 가진다.

    True/false 값의 차이는 supports 옵션에서 ‘page-attributes’를 넣었을 때 확연이 난다. true일 때는 (parent) 옵션이 나타나고, false일 때에는 이것이 보이지 않는다.

    custom_post_misc_002

    또 true면 목록을 출력할 때 하위 개체는 상위 개체에 그룹이 지어져 나온다.

    custom_post_misc_003

    • HackPost #6 (최상위)
      • HackPost #5 (부모: #6)
        • HackPost #4 (부모: #5)
    • HackPost #3 (최상위)
      • HackPost #1 (부모: #3)
      • HackPost #2 (부모: #3)

    taxonomies

    배열로 값을 채워 넣을 수 있다. 이 커스텀 포스트에 대한 분류 체계를 설정해 줄 수 있다. 그렇지만 이렇게 분류 체계를 연관지어 주려면 먼저 분류 체계를 미리 등록해야 한다. 만약 새 분류 체계를 사용하나면 별도로  register_taxonomy() 함수로 분류 체계를 등록해야 한다.

    이 함수는 register_post_type()과 꽤 유사한 형태를 가진다. 예제 코드로 살펴 보자.

    add_action( 'init', 'wphack_taxonomy' );
    function wphack_taxonomy() {
    
      $args = array(
        'labels'       => array(
          'name'          => 'HackTaxoes',
          'singular_name' => 'HackTaxo',
        ),
        'hierarchical' => TRUE,
      );
    
      register_taxonomy( 'wphack_taxonomy', 'wphack', $args );
    }

    이렇게 코드를 추가하면 wphack 타입을 위해 ‘카테고리’형태의 분류 체계가 추가된다.

    register_taxonomy의 옵션 인자에도 ‘hierarchical’을 발견할 수 있다. 이것이 true이면 분류 체계가 상/하위로 구분되는 ‘카테고리’형태로 구성된다. 반대로 false면 단순 태그가 된다.

    custom_post_misc_004 custom_post_misc_005