
워드프레스의 검색 기능이 형편없다는 건 누구나 아는 사실입니다. 디자인적인 측면은 비교적 쉽게 해결할 수 있지만, 작동 방식은 또 다른 문제입니다. 설정 옵션이 전혀 없을 뿐만 아니라, 성능까지 형편없기 때문입니다.
워드프레스 사이트에 수천 개의 게시물이 쌓이면 기본 검색 기능이 제대로 작동하지 않게 됩니다. 속도가 느려져 사실상 쓸모없게 될 정도입니다. 검색을 수행하면 워드프레스는 데이터베이스에서 LIKE %단어% 쿼리를 실행하여 wp_posts 테이블의 전체 콘텐츠를 스캔합니다. 이로 인해 SQL 쿼리가 생성되는데, 처리 시간이 몇 초에서 아주 오랜 시간까지 걸릴 수 있으며, 메모리 사용량이 급증하여 결국 서버가 멈추는 현상을 유발할 수 있습니다.
이건 바로 제가 겪고 있던 문제였습니다. (스페인어 게시물만) 6,040개가 게시된 상태에서, 검색 결과가 많은 경우 응답 시간이 10초 이상으로 늘어나는 등 정말 지옥 같은 상황이었습니다. 그리고 마침내 이 문제를 단번에 해결할 날이 왔습니다.
오랫동안 고민한 끝에, 이 문제를 해결할 수 있는 방법이 적어도 세 가지 있다는 것을 알게 되었습니다. 첫 번째이자 가장 간단한 방법은 Relevanssi와 같은 유료 플러그인(무료 버전은 기능이 부족합니다)이나 Algolia와 같은 외부 서비스를 활용하는 것이었지만, 제가 원하는 것은 기본 기능으로 검색 속도를 높이는 것이었기 때문에 이 방법은 처음부터 배제했습니다.
남은 두 가지 옵션 중, MATCH() AGAINST() 쿼리를 사용하여 MySQL에 FULLTEXT 인덱스를 생성하는 방법을 시도해 보았지만 처참하게 실패했습니다. InnoDB 엔진 매개변수를 조정하고 인덱스를 최적화했음에도 불구하고, MySQL은 수천 개의 복잡한 키워드를 처리할 때 계속해서 멈춰 버렸고, 용납할 수 없는 응답 시간을 보이며 서버를 마비시켰습니다. 그때서야 데이터베이스에 무거운 부하를 지우는 방안을 완전히 배제하고, 세 번째 방법인 JSON에 정적 인덱스를 구축하는 것을 선택했습니다.
이 튜토리얼에서는 MySQL의 부하가 큰 쿼리를, 초고속 검색 인덱스 역할을 하는 정적 JSON 파일로 대체하여 이 문제를 해결하는 방법을 설명하고자 합니다.
1단계: 부하가 크거나 중복된 쿼리 탐지 및 제거
JSON 인덱스를 적용하기 전에, 먼저 환경을 정리하는 것이 필수적입니다. 많은 플러그인(디렉토리, 레이아웃 생성기, 보조 검색 플러그인 등)이 후크 (pre_get_posts, posts_where 등)를 통해 워드프레스의 검색 프로세스에 개입하여, 데이터베이스에 불필요한 추가 쿼리를 발생시킵니다.
Query Monitor를 사용하여 이러한 쿼리를 어떻게 파악할 수 있을까요?
- 무료 플러그인인 Query Monitor를 설치하고 활성화하세요.
- 웹사이트에서 원하는 내용을 검색해 보세요.
- 상단 바에서 쿼리 모니터(Query Monitor)의 드롭다운 메뉴를 열고 ‘데이터베이스 쿼리 (Database Queries) ’를 선택합니다.
- ‘느린 쿼리’로 필터링하거나 목록을 살펴보며 처리 시간이 가장 오래 걸리는 쿼리를 찾아보세요. 이왕 하는 김에 중복된 쿼리도 기록해 두고, 가능하다면 이 문제도 해결해 두세요.
- 테이블의 상세 정보 열을 확인해 보세요. 거기서 해당 쿼리를 실행하고 있는 플러그인이나 파일이 정확히 무엇인지 확인할 수 있습니다.
제 경우에는 Name_Directory 플러그인의 중복 쿼리 몇 개와 게시물 템플릿의 GenerateBlocks 블록 몇 개를 해결해야 했지만, 어떤 플러그인이든, 사용자 정의 코드든, 추가된 요소든 응답 속도를 저하시키는 쿼리를 발생시킬 수 있습니다.
코드를 통해 해당 훅을 비활성화하는 방법
느린 쿼리를 유발하는 함수나 클래스의 이름을 파악했다면, functions.php 파일에서 해당 코드를 비활성화하거나 코드 스니펫 플러그인( Code Snippets, PerfmattersCode 등)을 통해 비활성화할 수 있습니다:
* 참고. 블록 1에는 쿼리 성능을 저하시키는 후크를 추가해야 합니다. 해당 사항이 아니라면 이 부분을 삭제하고, 스니펫에 블록 2만 추가해도 됩니다.
/**
* 1. Desactivar funciones invasivas o redundantes en las búsquedas globales de WP
*/
add_action('init', function() {
// Ejemplo: Si Query Monitor muestra que un plugin de directorio se ejecuta en pre_get_posts:
// remove_action('hook_de_wordpress', 'nombre_de_la_funcion_del_plugin', prioridad);
remove_action('pre_get_posts', 'name_directory_search', 10);
remove_filter('posts_where', 'name_directory_insert_sitewide_search_results', 10);
}, 20);
/**
* 2. Bloquear la búsqueda SQL nativa tipo LIKE %palabra% en MySQL
*/
add_filter('posts_search', function($search, $wp_query) {
if (!is_admin() && is_search()) {
return ''; // Neutraliza el LIKE %palabra% en MySQL globalmente en páginas de búsqueda
}
return $search;
}, 10, 2);
참고: remove_action 또는 remove_filter를 사용하여 후크를 해제하려면, 정확히 동일한 이벤트 이름(pre_get_posts), Query Monitor에서 확인한 것과 동일한 함수 이름, 그리고 추가될 때 지정된 것과 동일한 우선순위(기본값 10)를 사용해야 합니다.
2단계: 검색용 JSON 파일 생성 및 최신 상태 유지
MySQL을 대체하기 위해서는 게시물의 주요 데이터(ID, 제목, 일반 텍스트)가 포함된 JSON 형식의 정적 파일을 생성해야 합니다. 이 파일은 wp-content/uploads/ 폴더에 저장되며, 게시물을 작성, 수정 또는 삭제할 때마다 자동으로 업데이트됩니다.
PHP
/**
* Actualizar el archivo search-es.json automáticamente al guardar o borrar un post
*/
add_action('save_post', 'mi_sitio_actualizar_indice_busqueda_json', 10, 3);
add_action('deleted_post', 'mi_sitio_actualizar_indice_busqueda_json', 10, 1);
function mi_sitio_actualizar_indice_busqueda_json($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_revision($post_id)) return;
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') return;
// Opcional: Filtrar por idioma si usas un plugin multilingüe como Polylang
if (function_exists('pll_get_post_language') && pll_get_post_language($post_id) !== 'es') {
return;
}
mi_sitio_reconstruir_indice_json_completo();
}
/**
* Función que recorre todas las entradas publicadas y crea el JSON
*/
function mi_sitio_reconstruir_indice_json_completo() {
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'ASC',
'fields' => 'ids', // Solo IDs para optimizar memoria
);
if (function_exists('pll_current_language')) {
$args['lang'] = 'es';
}
$all_post_ids = get_posts($args);
$indice = array();
foreach ($all_post_ids as $id) {
$titulo = get_the_title($id);
$contenido = get_post_field('post_content', $id);
$indice[] = array(
'i' => (int)$id,
't' => (string)$titulo,
'k' => (string)wp_strip_all_tags($contenido),
);
}
$upload_dir = wp_upload_dir();
$json_path = $upload_dir['basedir'] . '/search-es.json';
file_put_contents($json_path, json_encode($indice, JSON_UNESCAPED_UNICODE));
}
초기 생성: 이 스니펫을 처음 설치할 때는, 일시적으로 my_site_rebuild_full_json_index(); 를 실행하거나, 기존 게시물을 저장하거나 업데이트하기만 해도 search-es.json 파일이 처음 생성됩니다. 이 작업이 완료되면 wp-content/uploads/ 경로를 방문하여 파일이 생성되었는지 확인하고 파일 크기를 파악하세요.
이 search-es.json 파일의 대략적인 용량을 감을 잡으시려면, 제 경우 6,040개의 항목으로 3.41 MB 크기의 파일이 생성되었는데, 15~20 MB(약 30,000~40,000개의 분량 많은 항목에 해당)을 넘지 않는 한 걱정할 필요가 없습니다. 정적 JSON 파일은 서버나 PHP(file_get_contents)의 캐시에 한 번에 직접 로드되며, 몇 메가바이트 정도의 크기는 1천분의 1초도 채 걸리지 않아 읽히기 때문입니다. 하지만 파일 크기가 지나치게 커지면, 검색을 실행할 때마다 PHP는 거대한 JSON을 로드하기 위해 불필요한 RAM 메모리를 추가로 소비하게 됩니다. 이 경우 전체 콘텐츠 대신 제목과 요약문만 색인화하거나, 전용 데이터베이스 기반 색인 검색 솔루션으로 전환하는 것을 고려하는 것이 좋습니다.
3단계: 검색을 표준화하기 위한 보조 기능 (악센트 제외)
사용자가 어떻게 입력하든(악센트 유무, 대소문자 구분 없이) 검색 결과가 제대로 표시되도록 하기 위해, 정제 함수를 정의합니다:
PHP
/**
* Función auxiliar para eliminar acentos y pasar a minúsculas
*/
if (!function_exists('mi_sitio_limpiar_acentos')) {
function mi_sitio_limpiar_acentos($cadena) {
$cadena = mb_strtolower($cadena, 'UTF-8');
$string = array(
'á'=>'a', 'à'=>'a', 'ä'=>'a', 'â'=>'a', 'ª'=>'a', 'Á'=>'a', 'À'=>'a', 'Ä'=>'a', 'Â'=>'a',
'é'=>'e', 'è'=>'e', 'ë'=>'e', 'ê'=>'e', 'É'=>'e', 'È'=>'e', 'Ë'=>'e', 'Ê'=>'e',
'í'=>'i', 'ì'=>'i', 'ï'=>'i', 'î'=>'i', 'Í'=>'i', 'Ì'=>'i', 'Ï'=>'i', 'Î'=>'i',
'ó'=>'o', 'ò'=>'o', 'ö'=>'o', 'ô'=>'o', 'Ó'=>'o', 'Ò'=>'o', 'Ö'=>'o', 'Ô'=>'o',
'ú'=>'u', 'ù'=>'u', 'ü'=>'u', 'û'=>'u', 'Ú'=>'u', 'Ù'=>'u', 'Ü'=>'u', 'Û'=>'u',
'ñ'=>'n', 'Ñ'=>'n'
);
return strtr($cadena, $string);
}
}
4단계: posts_pre_query를 사용하여 메인 쿼리를 가로채기
posts_pre_query 필터를 통해, WordPress의 검색 쿼리가 데이터베이스에 도달하기 전에 이를 가로챕니다. JSON을 읽어들이고, 검색어와 일치하는 ID를 찾아낸 뒤, 해당 페이지 매김을 지정하여 오직 해당 게시물들만 WordPress로 반환합니다.
PHP
/**
* Interceptación de la búsqueda nativa leyendo el archivo JSON
*/
add_filter('posts_pre_query', function($posts, $wp_query) {
if (!is_admin() && $wp_query->is_search() && $wp_query->is_main_query()) {
if (function_exists('pll_current_language')) {
if (pll_current_language() !== 'es') return $posts;
}
$s = $wp_query->get('s');
if (empty($s)) return array();
$upload_dir = wp_upload_dir();
$json_path = $upload_dir['basedir'] . '/search-es.json';
if (!file_exists($json_path)) return $posts;
$indice = json_decode(file_get_contents($json_path), true);
if (!is_array($indice)) return $posts;
$s_clean = mi_sitio_limpiar_acentos(wp_strip_all_tags($s));
$palabras_buscadas = array_filter(explode(' ', $s_clean));
if (empty($palabras_buscadas)) return array();
$matched_ids = array();
// Recorrer el índice acumulando coincidencias
foreach ($indice as $item) {
$titulo_clean = mi_sitio_limpiar_acentos($item['t']);
$texto_clean = mi_sitio_limpiar_acentos($item['k']);
$coinciden = true;
foreach ($palabras_buscadas as $palabra) {
if (mb_strlen($palabra, 'UTF-8') < 2) continue; // Ignorar palabras de 1 letra
if (mb_strpos($titulo_clean, $palabra) === false && mb_strpos($texto_clean, $palabra) === false) {
$coinciden = false;
break;
}
}
if ($coinciden) {
$matched_ids[] = (int)$item['i'];
}
}
// Si no hay resultados, devolver array vacío
if (empty($matched_ids)) {
$wp_query->found_posts = 0;
$wp_query->max_num_pages = 0;
return array();
}
// Paginación
$posts_per_page = (int)get_option('posts_per_page', 10);
$paged = max(1, (int)$wp_query->get('paged'));
$total_encontrados = count($matched_ids);
$wp_query->found_posts = $total_encontrados;
$wp_query->max_num_pages = ceil($total_encontrados / $posts_per_page);
// Recuperar solo los objetos de los posts encontrados, ordenados por fecha
return get_posts(array(
'post__in' => $matched_ids,
'orderby' => 'date',
'order' => 'DESC',
'posts_per_page' => $posts_per_page,
'paged' => $paged,
'post_type' => 'post',
'no_found_rows' => true,
'cache_results' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'suppress_filters' => true,
));
}
return $posts;
}, 10, 2);
5단계: 결과 확인
위의 단계를 모두 완료한 후, 검색 테스트를 수행하고 Query Monitor를 엽니다:
- 데이터베이스 총 처리 시간: 이제 처리 시간이 몇 초에서 불과 몇 밀리초(보통 0.03초에서 0.2초 사이)로 단축된 것을 확인할 수 있습니다.
- SQL 쿼리: LIKE %검색어% 절은 완전히 사라지게 됩니다.
- RAM: 메모리 사용량이 최고치일 때도 매우 낮은 수준(약 30MB)을 유지합니다.
- 정렬: 게시물은 워드프레스의 기본 페이지 분할 방식을 따르며, 엄격한 시간순 내림차순(가장 최근 게시물부터 가장 오래된 게시물 순)으로 표시됩니다.
결과에 정말 만족합니다. 수년 동안 앓아오던 문제를 해결했을 뿐만 아니라, 이제 검색 결과도 그야말로 놀라울 정도입니다. “viñeta”라는 검색어에 대해 4,887개의 결과가 나오는 것처럼, 가장 까다로운 검색 상황에서도 검색 엔진은 매우 빠르게 응답합니다. 결과가 적은 검색의 경우, 결과가 즉시 표시된다고 할 수 있습니다. 다른 어떤 단어를 검색해 보시면 직접 확인해 보실 수 있습니다.







