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

댓글 남기기