운영 요령
WP-CLI가 부팅되지 않을 때 워드프레스 직렬화 데이터를 안전하게 치환하기
wp-config.php의 add_filter 때문에 WP-CLI가 뜨지 않는 사이트 7곳에서, 직렬화 길이를 깨지 않고 이메일을 43행 치환한 PHP 스크립트의 핵심과 백업·검증 방법입니다.
워드프레스 사이트 7곳의 관리자 이메일을 한 주소에서 다른 주소로 바꿔야 했습니다. 설정 화면에서 바꾸면 되지 않나 싶지만, 이메일은 admin_email 한 곳에만 있는 게 아닙니다. 사용자 테이블, 댓글 작성자, 플러그인이 저장해 둔 알림과 통계 데이터 안에도 들어 있습니다.
이런 일에는 보통 WP-CLI의 wp search-replace를 씁니다. 그런데 이 NAS에서는 WP-CLI가 부팅조차 안 됐습니다.
WP-CLI가 못 뜬 이유
WP-CLI 2.12.0을 받아 돌렸더니 7곳 중 6곳에서 워드프레스를 불러오다가 멈췄습니다. 원인은 각 사이트의 wp-config.php였습니다. 설정 파일 안에서 add_filter()를 부르고 있었는데, WP-CLI가 설정 파일을 읽는 시점에는 아직 워드프레스 함수가 정의되지 않아 거기서 죽습니다.
wp-config.php를 고치면 WP-CLI는 돌겠지만, 지금 잘 돌고 있는 사이트 6곳의 설정 파일을 이 작업 하나 때문에 건드리고 싶지 않았습니다. 그래서 치환만 하는 작은 PHP 스크립트를 따로 만들었습니다.
그냥 문자열 치환을 하면 안 되는 이유
워드프레스와 플러그인은 배열이나 객체를 PHP 직렬화 형식으로 DB에 저장합니다. 이 형식은 문자열마다 길이를 같이 적습니다.
s:17:"old@example.com.x";
여기서 SQL REPLACE()로 주소만 바꾸면 글자 수가 달라져도 앞의 17은 그대로 남습니다. 그러면 PHP가 이 값을 풀지 못하고, 해당 옵션 전체가 빈 값처럼 동작합니다. 위젯이나 플러그인 설정이 통째로 사라진 것처럼 보이는 전형적인 사고입니다.
풀어서 바꾸고 다시 묶습니다
해결 방법은 단순합니다. 직렬화된 값이면 unserialize()로 풀고, 안쪽 값을 재귀로 돌며 바꾼 뒤 serialize()로 다시 묶습니다. 다시 묶을 때 길이는 PHP가 새로 계산합니다. 스크립트의 핵심 부분입니다(DB 접속 정보는 뺐습니다).
ini_set("unserialize_callback_func", "stub_class");
function stub_class($c) {
// 플러그인 클래스가 로드되지 않은 CLI에서도 객체를 풀 수 있게 빈 클래스를 만든다
if (!class_exists($c, false)) eval("#[AllowDynamicProperties] class $c {}");
}
function repl($v, $from, $to) {
if (is_string($v)) {
$u = @unserialize($v, ['allowed_classes' => true]);
if ($u !== false || $v === 'b:0;') {
return serialize(repl($u, $from, $to)); // 풀어서 바꾸고 다시 직렬화 → 길이 자동 재계산
}
return str_replace($from, $to, $v); // 직렬화가 아닌 평문
}
if (is_array($v)) { foreach ($v as $k => $x) $v[$k] = repl($x, $from, $to); return $v; }
if (is_object($v)) { foreach ($v as $k => $x) $v->$k = repl($x, $from, $to); return $v; }
return $v;
}
까다로운 부분이 두 개 있었습니다.
- 없는 클래스. 일부 값은
WP_User같은 객체를 통째로 직렬화해 두고 있었습니다. CLI에서는 그 클래스가 로드돼 있지 않으니 그냥 풀면__PHP_Incomplete_Class가 되고, 속성을 바꿀 수 없습니다.unserialize_callback_func로 모르는 클래스가 나올 때마다 빈 클래스를 만들어 주는 방식으로 넘어갔습니다. b:0;. 직렬화된false는 풀어도false라서, 실패와 구분이 안 됩니다. 이 경우만 따로 처리했습니다.
나머지는 반복입니다. 테이블마다 기본 키와 텍스트 계열 컬럼(char, text, blob, json)을 찾고, LIKE로 바꿀 문자열이 들어 있는 행만 가져와 위 함수를 거친 뒤, 값이 달라진 행만 기본 키로 UPDATE합니다.
기본 동작은 미리 보기입니다. 어떤 테이블의 어떤 행이 바뀌는지, 직렬화 값인지 평문인지만 출력하고, 마지막 인자에 apply를 줘야 실제로 씁니다.
php sr.php <db> old@example.com new@example.com # 미리 보기
php sr.php <db> old@example.com new@example.com apply # 실제 반영
실행 전 백업, 실행 후 검증
실행 전에 DB마다 options, users, usermeta, comments 테이블을 덤프해 뒀습니다. 이메일이 들어 있을 만한 테이블은 이 네 개였습니다.
7개 DB에서 바뀐 행은 모두 43개였습니다.
| 위치 | 형식 |
|---|---|
admin_email, new_admin_email, 번역 플러그인의 옵트인 이메일 | 평문 |
통계 플러그인 설정, auto_core_update_notified, SEO 플러그인 알림(안에 WP_User 객체 포함) | 직렬화 |
wp_users.user_email | 평문 |
| 한 사이트의 댓글 작성자 이메일 18건 | 평문 |
검증은 두 가지로 했습니다. 같은 스크립트로 옛 주소를 다시 검색해 0행인지 봤고, 직렬화 행 10개를 전부 다시 unserialize()해서 하나도 깨지지 않았는지 확인했습니다. 7곳 모두 관리자 이메일과 사용자 이메일이 새 주소로 바뀌어 있었습니다. 이미 새 주소를 쓰던 2곳은 0행이었습니다.
쓸 때 주의할 점
allowed_classes => true로 임의의 객체를 푸는 건 외부 입력에 쓰면 위험한 방식입니다. 이 스크립트는 제 DB의 값만 CLI에서 다루니 괜찮다고 판단했지만, 웹에서 호출되는 곳에는 이 코드를 두지 않습니다. 스크립트에 DB 접속 정보를 넣는다면 웹 루트 밖에 두고, 작업이 끝나면 접속 정보는 지우는 게 맞습니다.
WP-CLI가 돈다면 wp search-replace가 같은 일을 더 안전하게 합니다. 이 스크립트는 WP-CLI를 쓸 수 없는 상황에서 쓴 대안입니다.
확인한 공식 자료
구현 과정에서 판단 기준을 교차 확인한 공식 문서입니다. 글의 사례와 결론은 운영자가 직접 겪은 작업을 바탕으로 작성했습니다.