워드프레스 훅이 불리지 않는 순간 — add_action 을 걸었는데 아무 일도 없었습니다
지난 글에서는 워드프레스에 기능을 더할 자리를 고르는 이야기를 했습니다. 결론은 남의 코드를 고치지 않고 끼어든다는 것이었고, 그 끼어들기를 담는 그릇으로 독립 플러그인을 골랐습니다. 그런데 자리를 정하고 나니 더 기본적인 질문이 남았습니다. 끼어든다는 것이 정확히 무슨 뜻인가입니다.
플러그인은 실행되는 프로그램이 아니었습니다. 워드프레스 훅이라는 정해진 지점에서 불려가는 콜백 묶음이었습니다. 이 구조 자체를 이해하는 데는 반나절이면 충분했습니다. 정작 시간을 쓴 곳은 그 훅이 불리지 않는 경우를 만났을 때였습니다.
플러그인이라는 선언은 주석 한 덩어리입니다
워드프레스는 wp-content/plugins/ 아래를 훑어 PHP 파일의 첫 주석 블록을 파싱합니다. 거기에 Plugin Name: 이 있으면 그 파일은 플러그인입니다. 별도의 등록 절차도, 매니페스트 파일도, 빌드 단계도 없습니다.
<?php
/**
* Plugin Name: 카탈로그 통합검색
* Description: 부품번호 교차참조 통합검색.
* Version: 0.10.1
* Requires PHP: 7.3
* Network: false
* Text Domain: catalog-search
* Domain Path: /languages
*/
세 줄만 짚어두겠습니다.
Version은 관리 화면의 업데이트 표시와 에셋 캐시 무효화에 쓰입니다.Network: false는 멀티사이트에서 네트워크 전체가 아니라 사이트마다 개별로 활성화한다는 선언입니다. 뒤에 나올 스키마 이야기가 전부 이 한 줄에서 갈라집니다.Text Domain은 번역 파일을 찾는 키입니다.
주석이 곧 설정 파일이라는 게 처음에는 낯설었습니다. 다만 덕분에 플러그인의 최소 형태가 파일 한 개가 됩니다. 기능 하나를 위해 플러그인을 새로 만드는 비용이 거의 없다는 이야기이기도 합니다.
거의 모든 워드프레스 플러그인이 ABSPATH 검사로 시작하는 이유
플러그인 파일을 열어보면 대부분 이렇게 시작합니다.
if (!defined('ABSPATH')) {
exit;
}
처음에는 관습이려니 했습니다. 곰곰이 생각해보니 이건 취향이 아니라 배포 구조가 강제하는 방어였습니다.
플러그인 파일은 웹 루트 안에 있습니다. 즉 https://사이트/wp-content/plugins/catalog-search/includes/class-catalog-schema.php 같은 주소로 브라우저가 그 파일을 직접 요청할 수 있습니다. 그러면 워드프레스가 부팅되지 않은 상태에서 그 파일만 단독으로 실행됩니다. 정의되지 않은 함수와 전역 때문에 치명 오류가 나고, 오류 메시지에 절대 경로와 코드 일부가 노출됩니다.
ABSPATH 는 워드프레스가 부팅될 때만 정의되는 상수입니다. 그래서 이 상수가 없다는 것은 정상 경로로 들어온 요청이 아니라는 뜻이고, 그때는 아무것도 하지 않고 즉시 종료하는 편이 맞습니다. 애플리케이션 코드가 문서 루트 바깥에 놓이는 스택에서는 애초에 필요 없는 줄입니다. 같은 언어를 쓰더라도 배포 형태가 다르면 방어선을 그어야 할 위치도 달라진다는 사실이 인상적이었습니다.
워드프레스 훅은 액션과 필터 두 종류뿐입니다
워드프레스가 내 코드를 불러주는 통로는 딱 두 가지였습니다.
| 구분 | 액션 (action) | 필터 (filter) |
|---|---|---|
| 하는 일 | 특정 시점에 무언가를 실행 | 값을 받아 바꿔서 반환 |
| 반환값 | 무시됨 | 반드시 반환해야 함 |
| 예 | add_action('init', ...) |
add_filter('the_content', ...) |
// 액션 — 번역 파일 로드
add_action('init', function () {
load_plugin_textdomain('catalog-search', false, dirname(plugin_basename(__FILE__)) . '/languages');
});
// 필터 — 스크립트 태그에 defer 를 붙인다
add_filter('script_loader_tag', function ($tag, $handle) {
if ($handle !== 'catalog-search') {
return $tag;
}
return str_replace(' src=', ' defer src=', $tag);
}, 10, 2);
필터에서 return 을 빠뜨리면 값이 그대로 null 이 됩니다. 화면이 통째로 비는데 오류는 나지 않습니다. 이 글의 후반부에서 다시 다룰 무음 실패의 가장 작은 형태입니다.
세 번째 인자인 우선순위는 기본값이 10이고, 같은 훅에 걸린 콜백들의 실행 순서를 정합니다. 네 번째 인자는 콜백이 받을 인자 개수인데, 선언하지 않으면 두 번째 인자부터 넘어오지 않습니다. 위 필터에서 10, 2 를 빼면 $handle 이 조용히 사라집니다.
플러그인 본문이 실행될 때는 아직 아무것도 없습니다
이 구조가 왜 이렇게 생겼는지는 워드프레스 부팅 순서를 보고 나서야 납득이 되었습니다.
wp-load.php
→ mu-plugins
→ 활성 플러그인들 (여기서 우리 파일이 통째로 실행된다)
→ 테마 functions.php
→ 'init' 액션
→ 'wp' → 템플릿 렌더 → 'wp_enqueue_scripts' → ...
플러그인 파일 본문이 실행되는 시점에는 아직 아무것도 준비돼 있지 않습니다. 현재 사용자도, 질의도, 테마도 없습니다. 그래서 플러그인 파일 본문에서 하는 일은 거의 전부 add_action 과 add_filter 로 등록하는 것뿐이고, 실제 작업은 전부 나중에 불려갑니다.
이건 제어의 역전(Inversion of Control) 이라는 이름이 붙은 오래된 구조입니다. 흔히 헐리우드 원칙이라고도 부릅니다. "부르지 마세요, 저희가 부르겠습니다." 내 코드가 프레임워크를 부르는 게 아니라, 프레임워크가 내 코드를 부릅니다. 플러그인이 남의 코드를 고치지 않고도 기능을 얹을 수 있는 이유가 정확히 여기에 있습니다.
처음 플러그인을 만드는 사람이 가장 많이 겪는 일도 여기서 나옵니다. 파일 본문에서 get_current_user_id() 나 번역 함수를 곧바로 부르고 "왜 0이지" 하는 것입니다. 그 시점에는 아직 현재 사용자라는 개념이 없습니다.
그런데 add_action 으로 건 훅이 불리지 않았습니다
여기까지 정리하고 나서 저는 이 구조를 다 이해했다고 생각했습니다. 그 생각이 깨진 건 캐시 하나 때문이었습니다.
게시판 플러그인이 만든 게시판이 어느 페이지에 붙어 있는지를 매 요청마다 조회하는 건 비쌌습니다. 그래서 결과를 12시간 캐시했습니다. 캐시는 무효화가 있어야 의미가 있으니, 워드프레스의 표준 훅 네 개에 무효화 콜백을 걸었습니다.
foreach (array('save_post_page', 'deleted_post', 'trashed_post', 'untrashed_post') as $hook) {
add_action($hook, array('Catalog_Board', 'flush_page_cache'));
}
그런데 게시판을 다른 페이지로 옮겨도 캐시가 지워지지 않았습니다.
원인을 따라가 보니, 운영자가 게시판을 옮기는 가장 자연스러운 경로는 게시판 플러그인 설정 화면의 드롭다운이었습니다. 그 핸들러는 이렇게 끝나고 있었습니다.
$wpdb->update(...); // DB 를 직접 쓴다
wp_redirect($url); // 그리고 리다이렉트
exit;
즉 워드프레스의 글 라이프사이클을 전혀 타지 않습니다. save_post 도 deleted_post 도 발화하지 않습니다. 제가 건 훅 네 개는 하나도 돌지 않았습니다. 코드에 문법 오류가 있던 것도 아니고 훅 이름을 틀린 것도 아니었습니다. 다만 그 순간이 오지 않았을 뿐이었습니다.
훅 기반 통합의 전제는 "상대도 워드프레스를 통과한다"는 것입니다. 상대가
$wpdb로 직접 쓰면, 워드프레스는 그 변화를 모르고 따라서 저에게 알려줄 수도 없습니다.
제어의 역전은 제어권이 프레임워크를 통과할 때만 성립하는 약속이었습니다. 프레임워크가 관측하지 못한 변화는 프레임워크가 통지할 수도 없습니다. 저는 그동안 훅을 "이 사건이 일어나면 불린다"로 읽고 있었는데, 정확히는 "이 사건이 워드프레스를 거쳐 일어나면 불린다"였습니다.
후순위로 끼어드는 우회도 막혀 있었습니다
처음 떠올린 우회는 단순했습니다. 그 핸들러가 걸린 액션에 우선순위를 99로 해서 제 콜백을 뒤에 붙이면, 앞사람 일이 끝난 다음에 캐시를 지울 수 있겠다고 생각했습니다. 그런데 앞선 콜백이 wp_redirect 와 exit 로 끝나기 때문에 후순위는 영영 실행되지 않습니다.
우선순위는 "나중에 실행된다"를 보장하지, "실행된다"를 보장하지 않습니다. 한 줄로 적으면 당연해 보이는데, 막상 문제 앞에서는 우선순위 숫자를 올려보는 쪽으로 손이 먼저 갔습니다.
결국 해결은 그 플러그인이 자기 훅을 쏘는 지점을 찾는 것이었습니다. 리다이렉트 직전에 자체 액션을 하나 발화하고 있었고, 거기에 걸어서 해결했습니다.
add_action('board_setting_updated', array('Catalog_Board', 'flush_page_cache'));
이 훅은 문서에 없었습니다. 벤더 플러그인 소스를 직접 읽어야만 알 수 있었습니다. 확장 지점의 목록이 문서가 아니라 소스에 있다는 것은 유쾌한 사실은 아닙니다. 다만 남의 코드 위에 얹혀 사는 이상, 상대가 어디에서 자기 훅을 쏘는지는 결국 제가 확인해야 할 몫이라는 생각이 들었습니다.
더 불편한 사실은 아무 일도 일어나지 않았다는 것입니다
이 일에서 오래 마음에 남은 건 원인이 아니라 증상이었습니다.
캐시가 지워지지 않으면 화면은 어떻게 보일까요. 게시판이 옮겨간 뒤에도 링크는 옛 페이지를 가리키는데, 그 페이지도 200 을 반환합니다. 깨진 티가 나지 않습니다. 페이지는 열리고, 서버 로그는 조용하고, 관리 화면에는 아무 표시도 없습니다.
훅이 걸리지 않은 것은 오류가 아닙니다. 아무 일도 안 일어나는 것이고, 아무 일도 안 일어난 것은 로그에 남지 않습니다. 예외는 스택 트레이스라도 남기지만, 호출되지 않은 콜백은 흔적 자체가 없습니다. 그래서 이런 종류의 결함은 대개 사용자가 이상하다고 말해줄 때까지 발견되지 않습니다.
훅이 실제로 발화하는지부터 확인합니다
그래서 훅을 걸 때는 그것이 실제로 발화하는지를 한 번은 눈으로 확인해야 한다는 결론에 이르렀습니다. 거창한 도구가 필요한 일도 아닙니다. 임시로 한 줄이면 됩니다.
add_action('board_setting_updated', function () {
error_log('[catalog-search] board_setting_updated fired');
});
이 한 줄을 넣고 운영자가 하듯 화면에서 한 번 조작해 보면, 내가 건 훅이 살아 있는지 아닌지가 로그 한 줄로 판가름 납니다. 확인이 끝나면 지우면 됩니다. 이걸 안 했기 때문에 저는 동작한다고 믿는 코드를 한동안 그대로 두었습니다.
워드프레스 훅이라는 약속에는 유효 범위가 있습니다
훅은 워드프레스가 "여기서 당신을 부르겠다"고 약속한 지점입니다. 그 약속 덕분에 남의 파일을 한 줄도 고치지 않고 기능을 얹을 수 있습니다. 다만 이번 일로 배운 것은 그 약속의 유효 범위였습니다. 약속은 워드프레스를 통과하는 코드에 대해서만 유효합니다.
그래서 이제 훅을 걸 때 저는 두 가지를 먼저 확인합니다. 이 변화가 정말 워드프레스를 통과하는가, 그리고 이 훅이 실제로 발화하는가입니다. 둘 중 하나만 어긋나도 코드는 오류 없이, 로그도 없이, 조용히 아무 일도 하지 않습니다.
다음 편에서는 조금 더 불편한 이야기를 다루려고 합니다. 훅이 제때 발화하더라도, 약속의 이름이 그 순간의 문맥까지 보장해 주지는 않는다는 사실입니다. 이름만 보고 문맥을 짐작했다가 인증 순서를 잘못 읽은 경험이 있는데, 그 이야기는 다음 편에서 이어 쓰겠습니다.
관련 글
워드프레스 플러그인 개발, 왜 functions.php 가 아니었나 — 남의 코드 위에 기능을 얹는 경계
워드프레스에 기능을 넣을 자리는 네 군데였고, 선택 기준은 기술 취향이 아니라 남의 코드가 업데이트될 때 내 코드가 살아남는가였습니다. 플러그인으로 PHP 는 분리했지만 CSS 는 격리되지 않았고, 경계는 제가 그은 곳이 아니라 언어마다 다르게 그어져 있었습니다.
운영 워드프레스를 건드리지 않고 로컬 Docker로 클론해 개발하기
운영 중인 워드프레스를 직접 수정하는 건 위험합니다. 운영에는 읽기만 하면서 DB와 파일을 로컬 Docker로 복제해 안전한 개발 환경을 만든 과정과, 멀티사이트 도메인·PHP 버전·문자 인코딩에서 마주친 함정을 정리했습니다.
사람 손 없이 PR→머지→배포: AI 에이전트 자동 연쇄를 설계하며 배운 것
코드 리뷰·머지 게이트·배포는 다 자동화했는데, 사슬의 진입점만 수동 호출로 남아 있었습니다. PR 생성 직후 자동 트리거, 보고가 아닌 GitHub를 직접 조회하는 머지 게이트, 배포 보류 게이팅까지 — 자동 연쇄를 직접 설계하며 겪은 시행착오를 정리했습니다.