커스텀 포스트 검색: 커스텀 필드도 포함되도록 조정

이번 포스팅은 포스트 목록에서 커스텀 필드(메타 테이블 값)도 검색되게 하는 방법에 대해 적고자 합니다. 거두절미하고 커스텀 포스트를 만들어 보겠습니다.

커스텀 포스트 생성

포 스트 타입은 ‘my-test-post’입니다. 그냥 별다른 설정 없이 기본값으로만 만들었어요. 그리고 이 타입의 포스트에는 ‘my_test_value_1’, ‘my_test_value_2’라는 커스텀 필드를 가지도록 설정을 했습니다. 플러그인이 활성화 되면 임시로 2개의 포스트를 자동 생성하도록 했습니다. 그리고 비활성화되면 해당 포스트와 커스텀 필드는 삭제하도록 만들었습니다.

커스텀 포스트 화면
커스텀 포스트 화면
커스텀 필드와 같이 커스텀 포스트 화면
포스트 편집 화면. 아래 커스텀 필드를 입력할 수 있도록 설정되었습니다.

※ 스크린샷을 찍을 때는 편의를 위해 별도의 플러그인을 사용해 커스텀 필드를 생성했습니다만, 나중에 플러그인 구현에는 그냥 커스텀 포스트를 생성하는 것으로 변경했습니다. 플러그인 소스를 직접 활성화 했을 때는 위 스크린샷과는 약간 다를 수 있습니다.

기본적인 포스트 검색

이 렇게 커스텀 포스트를 생성한 후, 포스트 목록에서 몇몇 키워드를 넣고 검색을 해 봅니다. 여기 검색은 그다지 똑똑한 편이 아니라 키워드를 정확하게 넣어야만 합니다. 그런데 여기서 제가 지적하고픈 문제가 생깁니다. 검색은 포스트 제목과 내용에만 국한된다는 점입니다. 아마 기본 포스트 목록도 마찬가지일 것입니다.

보통 커스텀 포스트는 단지 포스트 제목(post_title), 포스트 내용(post_content), 혹은 발췌(post_excerpt) 같은 포스트 테이블(보통 wp_post 테이블) 기본 필드 뿐만 아니라 메타 테이블(보통 wp_postmeta)을 활용한 다양한 커스텀 필드를 활용하고 싶기 때문에 생성합니다. 또한 그런 커스텀 필드들은 별도의 조정을 해서 목록 테이블에서도 표시되도록 만들기도 하구요. 바로 아래 그림처럼 말이죠.

커스텀 포스트의 칼럼. 필요로 하는 커스텀 필드를 별도로 표시하고 있습니다.
커스텀 포스트의 칼럼. 필요로 하는 커스텀 필드를 별도로 표시하고 있습니다.

위 그림은 위치 정보를 기록하기 위한 다른 형태의 커스텀 포스트입니다. 위도/경도를 출력하는 ‘Position’ 칼럼, 지도의 상세 주소를 표시하는 ‘Address’ 칼럼, 그리고 그 장소에 대해 임의로 평점을 매기는 ‘Rating’ 칼럼은 워드프레스 자체에서는 정의되지 않았죠. 그러나 커스텀 포스트를 통해 저런 별도의 값도 저장하도록 확장이 가능하고, 또 이렇게 목록에 원하는 형태로 출력되록 조정도 가능합니다.

이렇게 테이블이 구성되면 당연히 사용자는 “주소”를 기반으로 검색이 될 거라 생각할 수 있습니다. 가령 “서울특별시”라는 검색어로 서울에만 있는 장소만 나타나게요. 그러나 주소는 커스텀 필드이므로 아무리 검색을 해도 나타날 수가 없답니다. 왜냐면 커스텀 필드는 기본 검색 대상이 아니니까요. 다시 말씀드리지만 기본 검색 대상에 포함되는 것은 단지 포스트 제목과 본문 내용 뿐입니다.

검색 작동 방식 분석

URL 테스트

이 렇게 커스텀 필드가 검색에서 제외될 수 밖에 없는 이유는 당연합니다. 코어가 어떤 커스텀 값을 기준으로 검색해야 할지 알 수 없기 때문이죠. 알게 모르게 하나의 포스트에는 여러 메타 키와 값들이 붙습니다. 그 중에는 사용자가 지정한 값들이 있을 수도 있고, 아니면 시스템이 관리 목적으로 붙여둔 것들도 있습니다. 그러므로 사용자가 별도의 플러그인을 작성하여 명시적으로 그 값이 검색되도록 흐름을 수정하는 수 밖에는 없습니다.

워드프레스 관리자 화면에서 검색어를 넣어 질의를 하면 URL이 변경됩니다. 예를 들어 “테스트”라는 검색어를 넣으면 다음과 같은 URL로 연결이 됩니다. 여기서 검색어와 관련된 쿼리 변수는 ‘s’임을 쉽게 눈치챌 수 있겠죠?

edit.php?s=테스트&post_status=all&post_type=my-test-post&action=-1&m=0&paged=1&mode=list&action2=-1

그렇다면 워드프레스는 어떤 작업을 통해 “s=테스트”를 실제 DB 검색까지 되도록 만들까요? 코어가 동작하는 흐름을 따라가보면 알 수 있겠죠.

워드프레스 코어의 흐름 따라가 보기

워드프레스 버전 4.2.2 기준, wp-admin/edit.php 163번째 줄에서 테이블이 표시될 아이템을 준비하는 코드가 나옵니다. 이 부분은 프로그램 코드를 따라가므로 설명이 약간 복잡할 수 있습니다.

$wp_list_table->prepare_items();

prepare_items() 메소드는 wp-admin/includes/class-wp-posts-list-table.php 소스의 ‘WP_Posts_List_Table’ 클래스에서 정의된 것입니다. 해당 부분인 105번째 줄을 살펴 보면, wp_edit_posts_query() 함수가 호출됩니다.

$avail_post_stati = wp_edit_posts_query();

이 함수는 wp-admin/includes/post.php 975번째 줄에 정의되어 있습니다. 여기서 1048번째 줄에 wp() 함수가 호출되는 것을 찾을 수 있습니다.

한 편 wp() 함수는 전역으로 선언되어 있는 WP 인스턴스의 main() 메소드를 호출합니다. WP 클래스는 wp-includes/class-wp.php 에 선언되어 있고 main() 메소드는 이 파일 607줄에서 찾을 수 있습니다. 전역으로 선언된 WP 인스턴스는 wp-settings.php 279번째 줄에서 찾을 수 있습니다.

$GLOBALS['wp'] = new WP();

URL 로 질의한 내용을 바탕으로 포스트를 가져오는 단계는 WP::query_posts() 입니다. 이 메소드 안에서 WP_Query 클래스의 query() 메소드를 호출합니다. WP_Query는 wp-includes/query.php 라인 837에 선언되어 있습니다.

WP_Query::query()는 WP_Query::get_posts()를 호출합니다(라인 2380). WP_Query::get_posts() 안에서 WP_Query::parse_search()가 호출됩니다(라인 2684).WP_Query::parse_search() 함수(라인 2055)는 SQL 쿼리의 일부분을 만들어냅니다. 아래는 WP_Query::parse_search() 결과의 한 예입니다.

AND (((wp_posts.post_title LIKE ‘%테스트%’) OR (wp_posts.post_content LIKE ‘%테스트%’)))

정리하면 이렇습니다.

  • edit.php: WP_Posts_List_Table::prepare_items() 호출
  • WP_Posts_List_Table::prepare_items() 에서 wp_edit_posts_query() 호출
  • wp_edit_posts_query() 에서 WP::main() 호출
  • WP::main(), 여기서 WP::parse_request() 호출, 다시 WP::query_posts() 호출
  • WP::query_posts() 에서 WP_Query::query() 호출, WP_Query::get_posts() 호출, WP_Query::parse_search() 호출

검색 쿼리 부분 결과를 보면 포스트 제목과, 포스트 내용만 가지고 LIKE 질의를 만들고 있습니다. 이렇기 때문에 포스트들은 제목과 내용 밖에 검색되지 않는 것입니다.

커스텀 필드가 검색되도록 수정

그렇다면 커스텀 필드를 검색할 수 있도록 SQL을 수정하도록 해 보지요. 우선 쿼리를 해석하는 과정에서 메타 필드를 검색하도록 조정해야 합니다. 이 때 ‘pre_get_posts’ 액션을 사용하는 것이 적절해 보입니다. 왜냐하면 WP_Query::get_posts() 메소드의 구현을 보면, WP_Query::parse_query() 메소드 호출 이후 처음 불리는 액션이기 때문입니다. 여기서 해석된 쿼리를 일부 변경하는 것이 가능합니다.

add_action( 'pre_get_posts', 'my_test_get_posts' );
function my_test_get_posts( $query ) {
   global $pagenow;
   global $post_type;

   $term = $query->get( 's' );

   if( !is_admin() || $post_type != 'my-test-post' || $pagenow != 'edit.php' || empty( $term ) ) {
      return;
   }

   $meta_query = $query->get( 'meta_query' );

   $search = array(
      'relation' => 'OR',
      array(
         'key'     => 'my_test_value_1',
         'value'   => $term,
         'compare' => 'LIKE'
      ),
      array(
         'key'     => 'my_test_value_2',
         'value'   => $term,
         'compare' => 'LIKE'
      )
   );

   $meta_query[] = $search;
   $query->set( 'meta_query', $meta_query );
}

그런데 아직은 문제가 있습니다. 커스텀 필드의 값으로 검색해도 값은 검색되지 않을 것입니다. 완성된 SQL 쿼리를 보면 이유를 알 수 있습니다.

SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
WHERE 1=1
AND (((wp_posts.post_title LIKE ‘%다섯%’) OR (wp_posts.post_content LIKE ‘%다섯%’)))
AND ( (
( wp_postmeta.meta_key = ‘my_test_value_1’ AND CAST(wp_postmeta.meta_value AS CHAR) LIKE ‘%다섯%’ )
OR ( wp_postmeta.meta_key = ‘my_test_value_2’ AND CAST(wp_postmeta.meta_value AS CHAR) LIKE ‘%다섯%’ )
) )
AND wp_posts.post_type = ‘my-test-post’
AND (
wp_posts.post_status = ‘publish’
OR wp_posts.post_status = ‘demo’
OR wp_posts.post_status = ‘lock’
OR wp_posts.post_status = ‘pending’
OR wp_posts.post_status = ‘future’
OR wp_posts.post_status = ‘draft’
OR wp_posts.post_status = ‘demo’
OR wp_posts.post_status = ‘lock’
OR wp_posts.post_status = ‘private’
)
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_title LIKE ‘%다섯%’ DESC, wp_posts.post_date DESC
LIMIT 0, 20

붉은색으로 된 글씨는, 위에서 메타키와 값을 쿼리 조건으로 주었을 때 생성되는 부분인데. 기본적으로 메타 키/값을 이용해 검색을 할 경우 무조건 ‘AND’로 시작되게 짜여 있습니다. 이렇게 메타 쪽 쿼리를 작성하는 코어 소스는 wp-includs/meta.php 부분입니다. 다행히 바로 저 붉은 색 부분만 조절할 수 있는 필터도 제공되는데, 해당 파일의 1197번째 줄 ‘get_meta_sql’ 필터입니다. 대개 포스트 메타 테이블은 항상 포스트 테이블과 조인되며, AND 조건절로 이어져 진행된다는 가정이 있는 것 같습니다. 하지만 이 경우에는 적합하지 않지요. 이 부분을 변경해야 합니다.

가장 간단한 해결책은 붉은색 부분의 AND를 바로 OR로 교체하는 것입니다. 코드 마지막 부분에 다음처럼 필터를 걸어 주면 됩니다.

add_filter( 'posts_search', function ( $sql ) {
add_filter( 'get_meta_sql', function( $sql ) {
    $sql['where'] = preg_replace( '/\s*AND\s+(.+)/ms', ' OR $1', $sql['where'] );
    return $sql;
});

이 때 주의할 점이 있습니다. 연산자가 AND에서 OR로 변경되면서 잠재적인 버그의 요소가 있을 수 있습니다(OR가 연산 순위가 AND보다 낮으므로 AND 연산자가 먼저 고려된 다음 OR가 계산됩니다). 원래 쿼리는 사실상 AND 연산으로만 구성되어 있습니다. 그런데 여기서 불쑥 OR가 발생하므로,

1=1 AND (포스트 테이블 검색 조건) AND (메타 테이블 검색 조건) AND (포스트 타입 조건) AND (포스트 상태 조건)

와 같은 구조는

1=1 AND (포스트 테이블 검색 조건) OR (메타 테이블 검색 조건) AND (포스트 타입 조건) AND (포스트 상태 조건)

으로 변경됩니다.

이 때 1=1은 항상 참이고, 또 (포스트 타입 조건) 또한 이미 콜백 함수 처음에 if 문으로 검사를 하므로 여기까지 흐름이 왔다면 항상 참입니다. 그러므로

(포스트 테이블 검색 조건) OR (메타 테이블 검색 조건) AND (포스트 상태 조건)

이 경우 (메타 테이블 검색 조건) AND (포스트 상태 조건) 부분이 우선 검사됩니다. 여기서 약간의 문제가 발생할 수 있습니다. 포스트의 제목과 내용이 검색어와 일치하는 경우 포스트 상태에 관계 없이 검색될 수 있다는 점입니다. 원래 검색은 휴지통에 넣지 않은 포스트를 대상으로 해야 하는 것인데, 이렇게 쿼리가 변경될 경우에는 포스트 상태가 지워져도 검색 결과에 포함될 수 있을 것입니다. 이것은 워드프레스의 기본 동작 방식이 아닙니다. 그러므로 쿼리 구조를 대대적으로 변경하여,

1=1 AND (포스트 테이블 검색 조건 OR (메타 테이블 검색 조건)) AND (포스트 타입 조건) AND (포스트 상태 조건)

와 같이 WHERE절을 변경해야 할 것입니다. 그러므로 위 필터를 삭제하고 아래 세 개의 필터를 더해 주면 됩니다. 개인적으로 전역 변수의 사용을 꺼리지만, 설명의 편의를 위해 사용했습니다.

$my_test_search_query = '';
$my_test_meta_query = '';

// post_title, post_content 검색 쿼리 부분 기록
add_filter( 'posts_search', function ( $sql ) {
 global $my_test_search_query;
 $my_test_search_query = $sql;
 return $sql;
} );

// meta 부분 쿼리 기록 (join, where 키로 구성된 array)
add_filter( 'get_meta_sql', function ( $sql ) {
 global $my_test_meta_query;
 $my_test_meta_query = $sql;
 return $sql;
} );

// where 절을 최종 편집
add_filter( 'posts_where', function ( $sql ) {
 global $my_test_search_query, $my_test_meta_query;

 $m = NULL;
 // search_query 문자열은 보통 괄호 두 개 안에 post_title, post_content 각 필드 조건을 OR로 묶음.
 if( preg_match('/\(\((.+)\)\)/ms', $my_test_search_query, $m ) ) {

 $meta_part = $my_test_meta_query['where'];

 // WHERE 절에서 AND ... 로 시작하는 meta 검색을 삭제
 $sql = str_replace( $meta_part, '', $sql );

 $post_part = $m[1];
 $meta_altered = preg_replace( '/\s*AND\s+(.+)/ms', ' OR $1', $meta_part );
 $substitute = 'AND ((' . $post_part . $meta_altered . '))';

 // post title, post content 검색과 같이 커스텀 필드도 OR 조건으로 검색
 $sql = str_replace( $my_test_search_query, $substitute, $sql );
 }

 return $sql;
} );

이렇게 쿼리를 대대적으로 수정하면 다음처럼 쿼리가 출력됩니다.

SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (((wp_posts.post_title LIKE ‘%01%’) OR (wp_posts.post_content LIKE ‘%01%’) OR (
(
( wp_postmeta.meta_key = ‘my_test_value_1’ AND CAST(wp_postmeta.meta_value AS CHAR) LIKE ‘%01%’ )
OR
( wp_postmeta.meta_key = ‘my_test_value_2’ AND CAST(wp_postmeta.meta_value AS CHAR) LIKE ‘%01%’ )
)
))) AND wp_posts.post_type = ‘my-test-post’ AND (wp_posts.post_status = ‘publish’ OR wp_posts.post_status = ‘demo’ OR wp_posts.post_status = ‘lock’ OR wp_posts.post_status = ‘pending’ OR wp_posts.post_status = ‘future’ OR wp_posts.post_status = ‘draft’ OR wp_posts.post_status = ‘demo’ OR wp_posts.post_status = ‘lock’ OR wp_posts.post_status = ‘private’) GROUP BY wp_posts.ID ORDER BY wp_posts.post_title LIKE ‘%01%’ DESC, wp_posts.post_date DESC LIMIT 0, 20

 

마치며

워드프레스의 막강한 플러그인들이 워낙 많고, 또 이러한 단순 키워드 검색은 그다지 효율적이지 않을 수 있습니다. 그러나 때로는 이러한 검색이 필요한 순간이 있을 것입니다. 특히 커스텀 포스트에서 검색 확장이 용이하지 않다는 점이 아쉬워서 이렇게 글을 남겨 보았습니다. 설명한 플러그인 소스 전문은 여기에서 확인하실 수 있습니다.