[글쓴이:] changwoo

  • 테마를 별도의 디렉토리로 옮기기

    개발 의도상 테마를 별도의 디렉토리에 두고 싶은 생각이 들었다. 플러그인을 놓고 쓰는 것처럼 말이다. 그런데 wp-config.php 코덱스를 참고해도 플러그인의 경로를 바꾸는 일은 허용되나, 테마에 대해서는 이런 설정이 공식적으로는 존재하지 않는다. 테마를 별도로 쓰려면,  wp-contents 디렉토리를 벗어나지 않는 한에서 변경하기를 권장하기도 한다.

    사실 실사용 서버에 이런 일을 할 필요는 없다. 단지 개발 서버상에서만 편하자고 하는 일이다.

    나는 코어와 플러그인의 디렉토리를 분리해서 사용한다. 이렇게 해서 여러 사이트에 대해 개발을 할 때 단 한 벌의 코어로 대응할 수 있다. 이렇게 사용하는 법은 다른 포스트를 통해 소개한 바 있다.

    테마 디렉토리는 플러그인과 같은 레벨에 둔다고 가정한다.  그리고 MU 플러그인을 하나 만들고 거기에 그냥 다음과 같이 적으면 된다.

    register_theme_directory( dirname(__DIR__) . '/themes' );

     이렇게 하면 이동한 테마 디렉토리에 있는 테마 목록이 워드프레스 ‘외모’메뉴에서 보이게 된다.

    그런데 이렇게만 하면 문제가 발생한다. 왜냐하면 이 시점에서 정적 자원들을 웹서버로 접근할 때의 URL이 정해지지 않았기 때문이다. 테마가 나오지만, 이미지나 자바스크립트는 404 에러를 내면서 죽을 것이다. 당연히 웹서버에 현재 /themes 디렉토리에 대해 URL로 접근할 수 있도록 처리해야 한다. 플러그인의 WP_PLUGIN_DIR과 WP_PLUGIN_URL 설정이 두 개로 나뉘어져 있는 것과 동일한 이치다.

    이 때는 아래와 같은 필터를 통해 해결할 수 있다.

    add_filter( 'theme_root_uri', function( $theme_root_uri, $site_url, $stylesheet_or_template ) {  
      $theme_root_path = dirname( __DIR__ );
      if( strpos( $theme_root_uri, $theme_root_path ) === 0 ) {
        return substr( $theme_root_uri, strlen( $theme_root_path ) );
      }
      return $theme_root_uri;
    }, 10, 3 );

     합쳐진 MU 플러그인의 레시피는 아래와 같다. 단, 완벽한 동작을 보장할 수 없으니 개발용으로만 참고하시라.

    <?php
    
    add_filter( 'theme_root_uri', function( $theme_root_uri, $site_url, $stylesheet_or_template ) {  
      $theme_root_path = dirname( __DIR__ );
      if( strpos( $theme_root_uri, $theme_root_path ) === 0 ) {
        return substr( $theme_root_uri, strlen( $theme_root_path ) );
      }
      return $theme_root_uri;
    }, 10, 3 );
    
    register_theme_directory( dirname(__DIR__) . '/themes' );
    

     

  • 특정 포스트의 권한을 제어하는 레시피

    User Role Editor 보다 더욱 세밀한 권한 체크를 진행시킬 수 있는 레시피. ‘user_has_cap’ 필터를 잘 활용하면 된다.

    좀 더 구체적인 예로 설명을 하자. 만일 내가 포스트 아이디 1141번을 임시 글로 등록해 두었다고 가정하자. 그리고 단지 이 포스트에 대해서만은 editor들은 편집을 허용하지 않게 만들고 싶다. 그렇다면 다음처럼 코드르 만들 수 있다. 커스텀 포스트 타입은 ‘music_collection’이고 적절히 이에 따라 권한 세트를 생성했다.

    add_filter( 'user_has_cap', 'my_user_has_cap', 10, 4 );
    
    function my_user_has_cap( $all_caps, $caps, $args, \WP_User $user ) {
    
      if ( sizeof( $caps ) && in_array( $caps[0], array( 'edit_music_collections', 'edit_others_music_collections' ) ) ) {
    
        if ( sizeof( $args ) >= 3 && $args[2] == 1141 ) {
    
          if ( $user->has_cap( 'editor' ) ) {
    
            unset( $all_caps[ $caps[0] ] );
          }
        }
      }
    
      return $all_caps;
    
    }

     콜백 함수는 4개의 인자를 가지고 있으며, 이 인자의 확인이 은근히 복잡하다. 디버깅 및 설명의 편의를 위해 하나의 if로 처리하지 않고 3개의 if로 잘라 넣었다. 실전 코드에서는 당연히 하나로 붙이도록 하자.

    첫번째 if는 현재 요구되는 primitive 권한을 체크한다. 어떤 커스텀 포스트에서 요구하는 권한이 내가 제어하고 싶은 범위의 것인지를 검사한다.

    두번째 if는 특정 단일 포스트에 대한 권한 요구인지, 아니면 전반적인 복수의 포스트에 대한 요구인지를 체크한다. args에 인자로 0번째는 매핑되기 전의 권한 템플릿 이름, 1번째는 유저의 ID, 2번째로 post의 ID가 있다. 이 부분이 매우 중요하다. 단일 포스트의 권한 검사이므로 args 길이는 3이상이어야 한다.

    세번째 if에서 다시 사용자의 권한 체크를 한다. 지금 권한 체크에 대한 콜백 함수인데, 다시 권한 체크를 재귀적으로 부르고 있다. 권한 필터링을 실제 구현하는 것이 은근히 쉽지 않음을 짐작할 수 있다. 물론 익숙해지면 이야기는 다르겠지만, 주 역할과 메타 역할의 개념 등등을 잘 이해하지 못하면 이 권한 체크에 번번히 실패할 가능성이 높다.  재귀 함수의 오버헤드를 줄이려면 바로 $user->caps 를 활용할 수도 있다.

    이렇게 설정하면,  edtor들은 1141번 포스트에 대해 다음과 같은 화면을 만나게 된다.

    특정 포스트를 특정 권한에게 접근 제한했을 때의 결과 예제 스크린샷

    글에는 두 가지 draft가 있다. 첫번째 draft는 편집이 가능하지만, 두번째 draft는 접근이 막혀 있다. 이렇게 단일 포스트 단위로 접근을 제어하는 방법은 우리가 흔히 접할 수 있는 user role editor로도 쉽게 구현하기 어려운 기능이다. 게다가 코어의 자연스러운 접근 권한 체크를 하기 때문에 엉뚱한 곳에서 적당히 땜빵 코드로 접근을 막는 것보다 더욱 확실하고 자연스러운 제어가 가능하다. 스크린샷으로도 보이듯 접근 제어가 안 되는 항목은 확실하게 UI적으로도 막혀 있는 것이 보인다. 이렇게 구현하면 예상치 못한 URL로 접근하더라도 코어가 확실히 접근을 차단해 줌을 기대할 수 있는 것이다.

    물론 보통은 커스텀 포스트에 이 정도로 세밀한 접근 제어 기능을 구현하지는 않으나, 이와 유사한 요구 사항은 실무에서 많이 발생할 수 있을 것이라 생각한다. 이걸 자유자재로 사용할 수 있다면 정말 훌륭한 플러그인 구현이 되리라 생각한다.

    워드프레스의 역할과 권한은 잘 이해하는 사람도 흔치 않을 것 같다 코드가 지저분하든 더럽든 어쨌든 간에, 나는 워드프레스가 훌륭한 CMS라고 생각하며 그 근거 중의 하나로 이 강력하고 유연한 역할과 권한 시스템을 든다.  이걸 사용자가 쉽고 간편하게 쓸 수 있는 UI가 없는 것은 아쉽지만 (아니, 그런 UI를 쉽게 사용하게 만드는 것 자체가 미친 난이도지만) 이러한 시스템이 기저에 있다는 것 자체가 놀라움이다.

    덧글 ) 권한 체크는 상당히 어렵다. 비활성화된 항목에 마우스를 가져다 대어 보자.

    어이쿠, 이게 뭔가. Trash? 편집은 못하지만 지울 수는 있다. 편집자 역할은 휴지통에 있는 글도 영구 삭제 가능하다. 물론 같은 스태프끼리 그럴 일은 없겠지만… 아, 권한 체크는 세심해야 함을 강조한다. 해당 권한 목록을 꼼꼼하게 살펴서 이런 구멍이 없도록 잘 대비하기를 권한다.

  • 어드민 화면의 열 수를 1개로 고정하는 레시피

    add_filter( 'screen_layout_columns', function ( $columns ) {
      $screen = get_current_screen();
      $columns[ $screen->id ] = 1;
      return $columns;
    } );
    
    add_filter( 'get_user_option_screen_layout_kpm_paper', function ( $value ) {
      return 1;
    } );
    
    add_action( 'in_admin_header', function () {
      $screen = get_current_screen();
      if ( $screen->id == 'kpm_paper' ) {
        $screen->remove_option( 'layout_columns' );
      }
    } );

     커스텀 포스트에 활용할 수 있다.

     

  • 안녕 iPhone 4S

    2017년 02월 18일 토요일자로 6년간 사용한 아이폰을 교체했다. 갤럭시 A5 2017 모델이 이제 내 새로운 폰이 되었다.

    그동안 많은 일을 같이 겪어준 폰인데, 물건에 감정을 너무 쓴 걸까? 마음이 짠하다. 대학원 때, 아르헨티나 때, 귀국 후 인턴, 게으른 프리랜서(?), 일산 고시원 시절하며 최근에 이르기까지… 늘 내 손, 호주머니, 가방에 있었던 녀석이었다.

    그렇지만 이제는 너무나 느려지고, 한 번 갈았던 배터리도 시원찮아졌다. 추억은 많지만 더 이상은 같이 하기 어려울 것 같다. 일산 원룸으로 옮기고 나서 인터넷 사정이 그닥 좋지 않은데, 후진 3G 망으로는 도저히 인터넷 사용량을 감당할 수 없었다.

    언젠가는 애플 휴대폰을 다시 쓸 날이 올 거라 생각한다. 일산에 와서 많은 것이 새롭게 변해 간다. 새로워지는 일은 나쁜 일은 아니지만, 익숙한 것을 버린다는 것은 상당히 자주 짠한 감정을 가져 와서, 그걸 안고 가기 버거울 때가 많다. 쓸데 없이 감상적이다. 밤이라 그렇다.

    그래 안녕 내 아이폰 4S, 오늘까지 나랑 같이 있어 줘서 정말 고마웠어.

     

  • 워드프레스 플러그인 개발 세팅

    팁이라고 하기는 너무 거창하고… 현재의 내 상태를 기록해 두는 뜻으로 포스팅을 해 봅니다. 워드프레스를 개발할 때 즐겨 사용하는 세팅을 기록합니다. 고도로 숙련된 세팅이라고 할 수는 없으니, 저 아닌 다른 분들은 “아, 얘는 이런 식으로 쓰는 구나” 하고 참고만 해 두셨으면 합니다.

    기본 환경

    OS

    OS는 리눅스 민트를 사용합니다. 리눅스 중에서는 가장 대중적이고, 무탈하고 쓰기 편합니다. 여러 리눅스 OS를 거쳐가며 삽질을 해 봤지만, 초기 부팅 및 설치 후 사용성까지 생각하면 민트를 따라잡을 OS는 없다고 개인적으로 생각합니다.

    윈도우 10이 리눅스 서브시스템을 지원하고, 윈도우에서 apt나 bash를 실행시킬 수 있도록 많이 변화하기는 했지만, 아직 안정적이라고 볼 수 있는 단계는 아닙니다. 개인적으로 윈도우는 게임을 할 때 사용하는 OS로 굳어져 있습니다.

    물론 맥OS도 좋은 개발 환경을 제공합니다만, 리눅스를 택하는 이유는 강력한 패키지 매니저 때문이죠. apt로 엄청나게 빠르고 편하게 패키지 설치를 할 수 있습니다. 개발 환경은 이렇게 무난하게 꾸미는 것이 최선이라고 생각합니다.

    옵션: Vagrant

    한때는 vagrant를 활용해서 개발 환경 세팅은 모두 vagrant를 활용한 가상 OS에 밀어 넣은 적이 있었습니다. 이것은 이것대로 엄청난 장점이 있죠. Provision 스크립트를 미리 만들어 둔 덕에, 초기 개발 환경 세팅이 단 2~3분안에 뚝딱 끝나는데다, 항상 균일한 환경을 유지할 수 있다는 점은 엄청나게 매력적이었습니다. 이 때는 가상 OS로 우분투 서버를 사용했습니다. 소스 코드, 웹브라우저, IDE는 호스트에 놓이고, 개발 환경만 게스트에 놓이도록 만들었습니다.

    하지만 장점에는 단점도 있는 법. 개인적인 개발을 할 때는 조금 불편한 면이 종종 있었습니다. 호스트와 게스트는 별도의 OS라, 제대로 IDE에서 디버깅을 하기 위해서는 두 OS간 프로젝트의 경로 매핑이 필요합니다. 플러그인을 새로 제작할 때마다 경로 매핑을 하는 거나, 호스트와 게스트의 경로를 각각 숙지해야 하는 점은 좀 짜증나더군요.

    개인적으로 워드프레스 플러그인 개발을 위해서 이렇게까지 vagrant 까지 도입해야할 필요성은 좀 없지 않나 하여 이제는 쓰지 않습니다.

    개발 스택

    Apache 2.4, PHP, MySQL을 사용합니다. 세 콤포넌트 모두 패키지 관리자의 기본 환경을 사용하고 있습니다. 개발 환경에서 그렇게 세세하게 세팅을 할 이유가 없다고 생각하기 때문에 nginx나 mariadb는 그렇게 고려하지 않고 씁니다. 가상호스트 설정 등등에서 apache가 세팅하기 수월하며,  debian 설치 관리자 apt에서는  mysql-server 패키지를 설치할 때가 mariadb-server 패키지를 설치할 때보다 훨씬 간결하게 설치가 끝납니다. phpmyadmin 또한 설치 명령어 한 방에 끝납니다.

    참고로 우분투 14.04에서는 PHP 5.6이 기본이지만 16.04에 와서 PHP 7.0이 기본입니다. 원한다면 리포지터리를 추가해서 가장 최신의 PHP 버전을 설치할 수도 있습니다. 소스 컴파일 설치는 별로 선호하지 않습니다.

    IDE

    에디터로 Atom이나 Sublime Text도 선호되고 있지만, 저는 IDE는 PhpStorm을 사용하고 있습니다. 몇 년째 사용 중인 훌륭한 IDE입니다. 제게는 다른 대안이 없습니다.

    워드프레스를 위한 레이아웃

    플러그인을 몇 번 제작해 보니 경로를 조직적으로 구성하는 편이 효율적으로 보였습니다. 그래서 머리를 굴려 나름 레이아웃을 구상해 보았습니다.

    기본적인 경로는 이렇게 되어 있습니다. .php, .dist 확장자를 제외한 항목은 모두 디렉토리입니다.

    .
    ├── apache2
    ├── bin
    ├── db-conf.php
    ├── db-conf.php.dist
    ├── niu-plugins
    ├── plugins
    ├── scripts
    ├── wp-config.php
    ├── wp-core
    └── wp-settings

    apache2 디렉토리

    각 워드프레스 환경은 아파치의 가상호스트로 구분합니다. 각 호스트는 apache2 디렉토리 안에 .conf 파일을 두어 설정합니다. 이 설청 파일을,  /etc/apache2/sites-available에 심볼릭 링크를 걸고, a2ensite로 활성화 시켜 줍니다. 아래 코드는 예입니다.

    ln -s ./apache2/wordpress-vhosts.conf /etc/apache2/site-available
    sudo a2ensite wordpress-vhosts.conf

     이렇게 하면 본래 wordpress-vhosts.conf는 내 계정 소유의 파일이기 때문에 일일이 관리자 권한 없이도 편집이 가능하죠.

    아파치 가상호스트 설정

    wordpress-vhosts.conf 파일은 비교적 단순하게 구성하면 됩니다. 예입니다.

    Alias /plugins <path-to-root>/plugins
    <Directory "<path-to-root>">
      Require all granted
      Options +FollowSymlinks -Indexes
      AllowOverride All
      php_admin_value open_basedir "<path-to-root>"
    </Directory>
    
    #####################################################################
    <VirtualHost *:80>
      ServerAdmin  <email>
      DocumentRoot <path-to-root>/wp-core/4.7.1
      ServerName   wp.4.7.1
      ServerAlias  wp.latest
      ErrorLog  ${APACHE_LOG_DIR}/wp.4.7-error.log
      CustomLog ${APACHE_LOG_DIR}/wp.4.7-access.log combined
    </VirtualHost>
    
    
    #####################################################################
    <VirtualHost *:80>
      ServerAdmin  <email>
      DocumentRoot <path-to-root>/wp-core/4.6.2
      ServerName   wp.4.6.2
      ErrorLog  ${APACHE_LOG_DIR}/wp.4.6.2-error.log
      CustomLog ${APACHE_LOG_DIR}/wp.4.6.2-access.log combined
    </VirtualHost>
    
    # vim: syntax=apache ts=4 sw=4 sts=4 sr noet
    

    이후 다시 설명하겠지만, 위 코드는 워드프레스를 버전별로 수동 관리하는 케이스입니다. 각 버전별로 “http://wp.<버전 번호>/” URL로 접근하도록 처리했습니다.

    bin 디렉토리

    wp-cliphpunit 같은 바이너리 파일을 여기에 두었습니다. wp-cli는 사용할 때마다 헷갈립니다만, 가끔씩 워드프레스 동작을 스크립트화 해 두기에는 좋을 겁니다.

    niu-plugins 디렉토리

    디렉토리 중 mu-plugin (must-use plugin)이라고 해서, 항상 사용하는 플러그인을 넣어 두는 곳이 있습니다. 그걸 따서 niu-plugin (not-in-use plugin) 디렉토리를 별도로 만들었습니다. 큰 의미는 없고 잠시 사용하지 않을 플러그인을 이 쪽에 놓아 둘 목적으로 만든 것입니다. 활성/비활성 차원이 아니라 아예 존재를 이 쪽으로 옮겨 두는 거죠.

    plugins 디렉토리

    플러그인만 이 곳에 놓아둡니다. 모든 워드프레스 개발 사이트는 이 플러그인을 공유합니다. 활성/비활성으로 적절히 조절하면 되니까 이렇게 몽땅 놔두는 것도 큰 문제가 없습니다.

    scripts 디렉토리

    wp-cli를 위한 bash-completion 스크립트나 유용한 쉘 스크립트들을 저장하기 위한 디렉토리입니다.

    wp-core 디렉토리

    워드프레스의 코어가 여기 있습니다. 코어를 관리하는 방법은 크케 나눠 두 가지가 있습니다.

    1. 1벌의 코어를 직접 디렉토리 아래에 두고, 워드프레스에서 코어 업데이트가 발표되면 같이 따라간다.
    2. 버전별로 코어를 관리. 업데이트는 막되, 버전별로 하위 디렉토리에 위치.

    코어 버전이 업데이트될 때 종종 데이터베이스도 업데이트됩니다. 큰 변화는 별로 없지만,  그래도 테이블이 한 번 업데이트되면 되돌리기 어렵습니다. 개인적으로는 예전에는 단순한 까닭에 1번 방식을 썼는데, 버전별 차이에도 좀 대응하고 싶어 이제는 손이 좀 더 가더라도 2번을 쓰려고 합니다.

    wp-settings 디렉토리

    도메인별 워드프레스 설정을 놓아두는 디렉토리입니다.

    워드프레스 설정

    wp-config.php 파일은  워드프레스 코어에 있어도 되지만, 보안상 코어 하나 위 디렉토리에 두어도 무방하도록 되어 있습니다. 여러 사이트가 특별히 각자의 세팅을 가질 필요 또한 크지 않으므로 wp-core의 상위 디렉토리에 wp-config.php 파일을 위치시켰습니다.

    만약 버전별로 코어를 관리하는 경우, wp-core 하위에 각 버전별 코어가 위치하므로 wp-congif.php가 1계층이 아닌 2계층 차이가 납니다. 이 때에는 wp-core 디렉토리에 wp-config.php의 심볼릭 링크를 설정해 주면 됩니다.

    도메인과 세팅 경로

    서버 도메인인 $server_name 변수와 세팅 디렉토리를 얻는 코드를 둡니다.

    $server_name  = $_SERVER['SERVER_NAME'];
    $setting_path = __DIR__ . "/wp-settings/{$server_name}.php";
    

     플러그인 경로와 URL

    설정 파일에서 중요한 것은 공통의 플러그인 경로를 지정하는 것입니다. 그러므로 다음과 같은 플러그인 설정을 넣습니다.

    define('WP_PLUGIN_DIR',   __DIR__ . '/plugins');
    define('WP_PLUGIN_URL', "http://{$server_name}/plugins");
    

    디버깅 환경 설정

    개발 환경이므로, 디버깅은 항상 켜 둡니다. 단, 디버깅 용 로그를 웹브라우저로 띄우면 화면이 매우 지저분해지므로 로그로만 볼 수 있도록 만듭니다. wp-content 디렉토리는 기본적으로는 각 코어별로 사용하도록 두었으므로 로그 파일은 각 <코어>/wp-content/debug.log가 됩니다. 스크립트 디버깅도 켜 둡니다.

    define('WP_DEBUG', true);
    define('WP_DEBUG_LOG', true);
    define('WP_DEBUG_DISPLAY', false);
    define('SCRIPT_DEBUG', true);

     기타

    포스트 리비전이나 업데이트 관련 사항을 세팅해 두면 됩니다. 코어를 수동으로 관리한다면 자동 업데이트 항목도 꺼 두어야 할 것입니다.

    define('WP_POST_REVISIONS', 0);
    define('AUTOMATIC_UPDATER_DISABLED', true);
    define('WP_AUTO_UPDATE_CORE', false );

    이렇게 해도 워드프레스 코어를 업데이트하라는 알림이 관리자 화면에 출력될 수 있습니다. 워드프레스 업데이트를 완전히 꺼 두는 플러그인이 있습니다. 이런 플러그인을 활용해 혹시 실수라도 업데이트를 진행하지 않도록 처리하면 좋겠죠.

     도메인별 세팅

    도메인별로 다른 데이터베이스 테이블을 관리할 수도 있고, 또 모든 도메인이 같은 테이블을 공유할 수도 있습니다. 그러나 버전별로 워드프레스를 관리하려면 반드시 버전별로 다른 테이블을 사용해야 합니다. 앞서 말씀드렸듯 워드프레스 버전마다 데이터베이스 버전도 달라자기 때문입니다.

    버전별로 워드프레스를 관리하기 위해 다음과 같은 도메인 규칙을 만들었습니다.

    wp.<워드프레스 버전>

    그러므로 워드프레스 4.6.2 버전은 “http://wp.4.6.2/”, 4.7 버전은 “http://wp.4.7/”로 접속합니다.

    물론 이렇게 하려면 /etc/hosts 파일에 해당 도메인으로 접속할 수 있도록 추가해야 합니다.

    127.0.0.1 wp.latest wp4.7 wp.4.6.2 wp4.6.1

    그리고 wp-settings/wp.4.6.2.php, wp-settings/wp.4.7.php 같은 도메인 이름과 같은 php 파일을 두고, 그 안에 도메인별 설정을 넣으면 됩니다.

    코어 하나만을 사용하는 경우에도 비슷합니다. /etc/hosts 파일 안에 각 도메인에 IP를 127.0.0.1로 대응시키고, wp-settings/<domain>.php 파일을 만들면 됩니다.

    설정 파일에는 최소 하나의 변수가 있어야 합니다. 아래는 그 예입니다.

    <?php 
    $table_prefix = 'wp471_';

    마치며

    위 설명한 구조는 제 github repository에 있습니다. 손은 좀 가지만 그리 어려운 것은 아니므로 다운로드 받아서 한 두번 살펴보면 쉽게 파악할 수 있을 것입니다.

    그리고 이 곳에 우분투 16.04 서버를 위해 만든 provision 스크립트가 있습니다. 개인적으로는 APM 설치 및 세팅에 알찬 도움이 되고 있습니다.

    테마는 제가 잘 다루지 않으므로 이 레이아웃이 테마까지 효율적으로 관리할 수 있을지는 모르겠습니다만, plugins 디렉토리를 응용해 테마 디렉토리를 별도로 관리한다면 플러그인과 큰 차이는 없을 것이라 생각해 봅니다.

  • 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가 양파처럼 구성된 것을 보며 약간의 혐오감까지 느낀 적이 있던 터라, 오히려 “오 여기가 드디어 변경되었나?” 하는 반가움이 든다.