홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

워드프레스 커스텀 테이블 — 쿼리가 성공해도 스키마는 안 바뀝니다

정기창·2026년 9월 5일

워드프레스로 무언가를 만들 때 가장 먼저 걸리는 질문은 대개 데이터를 어디에 둘 것인가입니다. 그리고 대부분은 커스텀 포스트 타입 선에서 해결됩니다. 그런데 제가 맡았던 부품 카탈로그는 그 선에서 멈추지 않아, 결국 커스텀 테이블을 직접 만들게 되었습니다.

부품 한 건에 교차참조 번호가 여러 체계로 여러 개 딸리고, 그 부품이 들어가는 적용 차량 조합이 또 여러 행 붙는 구조였습니다. 명백히 관계형이었습니다.

그런데 테이블을 만드는 순간, 예상하지 못한 일을 떠맡게 되었습니다. 워드프레스에는 다른 프레임워크에서 당연하게 쓰던 스키마 마이그레이션 도구가 없습니다. 스키마를 바꾸는 절차도, 바뀌었는지 판단하는 절차도 직접 만들어야 했습니다. 그리고 제가 가장 많이 틀린 곳은 스키마를 바꾸는 코드가 아니라, 바뀌었다고 판단하는 코드였습니다.

데이터를 어디에 둘 것인가

데이터를 넣을 자리는 크게 셋입니다. 포스트 메타, 커스텀 포스트 타입, 그리고 워드프레스 커스텀 테이블입니다. 각각이 무엇을 공짜로 주고 무엇을 대가로 요구하는지가 다릅니다.

자리 맞는 경우 안 맞는 경우
포스트 메타 (wp_postmeta) 글에 딸린 소수의 부가 정보 행이 수만 개인 경우. 값이 전부 문자열이라 정렬·범위 질의가 느립니다
커스텀 포스트 타입 관리 화면·검색·권한을 공짜로 쓰고 싶을 때 관계형 데이터. 조인이 전부 메타 테이블 자기조인이 됩니다
커스텀 테이블 정규화가 필요한 관계형 데이터, 인덱스가 성능을 좌우할 때 관리 화면·권한·리비전을 전부 직접 만들어야 합니다

포스트 메타로 갔다면 검색 한 번이 메타 테이블 자기조인 여러 겹이 되었을 것입니다. 그래서 테이블 세 개를 만들었습니다. 돌이켜보면 판단 기준은 성능 수치가 아니라 훨씬 단순한 질문 하나였습니다.

이 데이터를 워드프레스가 글처럼 다뤄줘야 하는가? 아니라면 커스텀 테이블이 정직합니다.

커스텀 테이블 이름은 상수가 될 수 없습니다

테이블을 만들기로 했으면 이름부터 정해야 하는데, 여기서 바로 한 번 걸립니다. 워드프레스에서 테이블 이름은 고정값이 아닙니다.

public static function table($name) {
    global $wpdb;
    return $wpdb->prefix . 'catalog_' . $name;
}

$wpdb->prefix 는 설치마다 다릅니다. wp_ 가 기본이지만 보안을 이유로 바꿔 쓰는 경우가 흔합니다. 더 중요한 것은 멀티사이트에서는 같은 설치 안에서도 사이트마다 다르다는 점입니다. 1번 사이트는 wp_, 4번 사이트는 wp_4_ 를 씁니다. 같은 플러그인 코드가 사이트마다 다른 테이블을 봐야 합니다.

그래서 이름을 상수로 박으면, 다른 설치에서 조용히 엉뚱한 곳을 보거나 아무것도 못 찾습니다. 오류가 나면 차라리 낫습니다. 빈 결과가 돌아올 뿐이라 한참 뒤에야 알아차리게 됩니다. 헬퍼 하나를 만들어 두고 이름을 직접 쓰는 길을 막아 두는 편이 안전했습니다.

커스텀 테이블은 언제 만드나 — 활성화 훅의 구멍

다음 질문은 시점입니다. 워드프레스는 플러그인 활성화 시점에 훅을 하나 내어줍니다.

register_activation_hook(__FILE__, array('Catalog_Schema', 'maybe_upgrade'));

이걸로 끝일 것 같았지만 아니었습니다. 활성화 훅은 활성화한 그 사이트 컨텍스트에서 한 번만 돕니다. 멀티사이트에서 네트워크 활성화를 하면 사이트마다 돌지 않습니다. 4번 사이트는 테이블이 없는 채로 플러그인이 켜져 있게 됩니다.

그래서 활성화 훅과 별개로 지연 보장이 필요해졌습니다. 관리 화면에 진입할 때 현재 사이트의 스키마를 확인하고, 없으면 그때 만드는 배선입니다. 다만 그 배선을 어디에 걸어야 하는지, 그리고 제가 고른 자리가 왜 위험했는지는 앞 편에서 따로 다룬 이야기라 여기서는 넘어가겠습니다.

버전 옵션이 만든 영구 고착

지연 보장을 붙이면 새로운 문제가 생깁니다. 관리 화면에 들어올 때마다 스키마를 확인한다면, 매 요청 CREATE TABLE 을 날릴 수는 없습니다. 그래서 흔한 방법대로 버전 옵션으로 가드를 걸었습니다.

const SCHEMA_VERSION = '3';
const OPT_KEY        = 'catalog_schema_version';

public static function maybe_upgrade() {
    if (get_option(self::OPT_KEY) === self::SCHEMA_VERSION) {
        return null;              // 최신이면 아무것도 안 합니다
    }
    // ... 생성/업그레이드 ...
    update_option(self::OPT_KEY, self::SCHEMA_VERSION);
    return null;
}

읽어보면 문제가 없어 보입니다. 저도 그렇게 생각했습니다. 그런데 여기에 함정이 두 겹 있었습니다.

함정 하나 — 성공한 쿼리가 아무 일도 하지 않을 때

테이블 생성은 이미 있는 테이블을 건드리지 않는 형태로 씁니다. 그래야 반복 실행이 안전하기 때문입니다. 그런데 이 안전장치가 정확히 문제의 원인이 되었습니다.

스키마 정의를 고쳐 컬럼을 하나 추가한 상황을 생각해보겠습니다. 신규 설치에서는 잘 반영됩니다. 그러나 테이블이 이미 있는 기존 설치에서는 그 쿼리가 성공하고 아무 일도 하지 않습니다. 오류가 나지 않았으니 코드는 성공했다고 판단하고, 다음 줄에서 버전 옵션을 3으로 올립니다.

실제 DB      : 구 스키마 (row_no 컬럼 없음)
버전 옵션    : '3'  (최신)
다음 요청    : get_option() === SCHEMA_VERSION  →  즉시 return

이제 가드가 재시도까지 막습니다. 다음 요청에서도 코드는 "이미 최신"이라고 판단하고 아무것도 하지 않습니다. 되돌릴 방법도 마땅치 않습니다. 플러그인을 비활성화해도 옵션은 지워지지 않기 때문입니다. 영구 고착입니다.

가장 나빴던 점은 어디에도 오류가 남지 않았다는 것입니다. 쿼리는 성공했고, 로그는 깨끗했고, 버전 옵션은 최신이었습니다. 실제로 없는 것은 컬럼 하나뿐인데 그 사실을 아무도 기록하지 않았습니다.

곰곰이 생각해보니 제가 잘못 믿은 것은 워드프레스의 동작이 아니었습니다. "오류가 없었다"를 "의도한 일이 일어났다"로 읽은 제 습관이었습니다. 두 문장은 전혀 다른 말인데, 코드를 쓰는 동안에는 늘 같은 뜻처럼 느껴집니다.

"쿼리가 실패하지 않았다"는 "의도한 스키마가 생겼다"가 아닙니다.

함정 둘 — 실패한 채로 버전을 기록하면

고착의 구조를 정리해보니 원인은 두 가지가 겹친 것이었습니다. 하나는 실패했는데도 버전을 기록한 것이고, 다른 하나는 성공 여부를 쿼리 반환값으로 판정한 것입니다. 그래서 양쪽을 다 고쳤습니다.

먼저 버전 옵션은 성공이 확인되었을 때만 기록하도록 순서를 바꿨습니다. 그리고 그 "성공"의 판정을 쿼리 반환값이 아니라 실물 확인으로 교체했습니다.

// update_option 직전에 실제 DB 상태를 확인합니다
$cols = $wpdb->get_col("SHOW COLUMNS FROM `{$table}`");
if (!in_array('row_no', $cols, true)) {
    return '테이블 정의가 반영되지 않았습니다.';   // 버전 기록 없이 실패 반환
}
$idx = $wpdb->get_results("SHOW INDEX FROM `{$table}`");
// ... 기대한 인덱스가 실제로 있는지 확인 ...

여기서 의도적으로 지킨 원칙이 하나 있습니다. 스키마를 만드는 방법과 만들어졌는지 확인하는 방법을 일부러 다르게 했다는 점입니다. 만드는 쪽은 CREATE TABLE 이고, 확인하는 쪽은 SHOW COLUMNS 와 SHOW INDEX 로 컬럼과 인덱스가 실제로 있는지를 DB에 직접 묻습니다.

같은 방법으로 확인하면 그 방법의 맹점은 영영 보이지 않습니다. 애초에 이 사고가 그랬습니다. 쿼리의 성공 여부로 쿼리의 효과를 검증했기 때문에, 쿼리가 아무 일도 하지 않는 경우가 시야에서 통째로 빠져 있었습니다. 검증은 다른 축에서 들어와야 검증입니다.

이 원칙은 스키마에만 해당하지 않는다는 생각이 들었습니다. 무언가를 실행한 도구로 그 실행의 결과를 확인하는 구조라면, 그 도구가 애초에 못 보는 실패는 확인 단계에서도 똑같이 안 보입니다. 확인이 통과했다는 사실이 아무것도 보장하지 않게 되는 것입니다.

실패를 화면까지 실어나르기

실패를 감지하게 되었으니 이제 그것을 사람에게 전달해야 했습니다. 그래서 반환 규약을 bool 에서 null|string 으로 바꿨습니다.

/** @return string|null  최신이 보장되면 null, 아니면 사용자에게 보여줄 실패 사유 */

그런데 이 변경에는 호출부 함정이 딸려 왔습니다. if (!maybe_upgrade()) 라고 써 두었던 곳이 이제 성공에서 참이 됩니다. null 은 falsy 이기 때문입니다. 실패 문자열은 truthy 라서 정확히 반대로 동작합니다.

반드시 === null 로 판정해야 했습니다. 반환 타입을 바꿀 때는 호출부의 진리값 해석까지 같이 바뀐다는 것을, 이 코드에서 다시 배웠습니다.

실패 사유는 임시 저장(transient)에 담아 관리 화면 상단 배너로 띄웠습니다. 그러지 않으면 운영자는 검색창이 왜 안 나오는지를 영영 모릅니다. 실패가 조용하면 없는 것과 같습니다.

삭제할 때 — 같은 고착이 재설치에서 되살아납니다

마지막은 정리하는 쪽입니다. 워드프레스에서 삭제 처리는 uninstall.php 라는 별도 파일에서 이루어지고, 시작 지점에 가드가 필요합니다.

// 이 파일은 '삭제' 할 때만 실행됩니다. '비활성화' 로는 실행되지 않습니다.
if (!defined('WP_UNINSTALL_PLUGIN')) {
    exit;
}

비활성화는 삭제가 아닙니다. 비활성화는 훅이 안 걸릴 뿐이고 테이블도 옵션도 그대로 남습니다. 그래서 정리는 삭제 경로에서만 일어납니다.

여기서 앞의 함정이 되돌아옵니다. 테이블만 지우고 버전 옵션을 안 지우면, 재설치했을 때 가드가 "이미 최신"이라고 판단해 테이블을 다시 만들지 않습니다. 실제 DB에는 테이블이 없고 옵션만 최신인 상태 — 앞서 본 것과 똑같은 고착이 재설치 경로에서 그대로 재현됩니다. 원인이 하나면 증상도 하나일 것 같지만, 같은 구조는 다른 경로에서 다시 나타납니다.

정리하면서 두 가지가 더 걸렸습니다. 하나는 임시 저장이 delete_option() 으로 지워지지 않는다는 점입니다. 실제 행 이름이 _transient_<키> 와 _transient_timeout_<키> 두 개라서, delete_transient() 를 써야 합니다.

다른 하나는 삭제 파일 역시 삭제를 실행한 그 사이트에서 한 번만 돈다는 점입니다. 멀티사이트에서는 활성화 훅과 똑같은 구멍입니다. 사이트를 옮길 때마다 $wpdb 의 prefix 도 함께 바뀝니다.

$blog_ids = is_multisite() ? get_sites(array('fields' => 'ids', 'number' => 0)) : array(null);
foreach ($blog_ids as $blog_id) {
    if ($blog_id !== null) { switch_to_blog($blog_id); }   // $wpdb->prefix 가 바뀝니다
    // ... 이 사이트의 테이블/옵션 삭제 ...
    if ($blog_id !== null) { restore_current_blog(); }
}

마지막으로 하나 더 있었습니다. 삭제 파일은 플러그인이 부트되지 않은 상태에서 실행됩니다. 그래서 클래스 상수를 그냥 가져다 쓸 수가 없습니다. 필요한 클래스 파일을 직접 require 하되, 그마저 못 읽는 경우를 대비해 옵션 키를 리터럴로 한 번 더 적어 두었습니다.

단일 정의 원칙을 깬 것이라 마음이 편하지는 않았습니다. 다만 두 값이 어긋날 위험보다 옵션이 남아 다음 설치를 고착시킬 위험이 더 나쁘다고 판단했습니다.

정리하며

워드프레스 커스텀 테이블을 쓰기로 한 결정보다 어려웠던 것은 그 다음이었습니다. 플러그인이 자기 테이블을 갖는 순간, 프레임워크가 대신 해주던 것들이 전부 내 일이 됩니다. 스키마 마이그레이션 이력, 롤백, 상태 검증 같은 것들입니다. 그것들이 원래 얼마나 많은 일을 하고 있었는지는 직접 만들어보고서야 알았습니다.

그중에서도 제가 가장 많이 틀린 곳은 스키마를 바꾸는 코드가 아니었습니다. 바뀌었다고 판단하는 코드였습니다. 만드는 코드는 틀리면 오류를 내지만, 판단하는 코드는 틀려도 아무 소리를 내지 않습니다. 그래서 조용히 오래 갑니다.

그래서 지금은 스키마를 손볼 때 두 가지를 먼저 확인합니다. 성공을 무엇으로 판정하는지, 그리고 그 판정이 실패했을 때 어디에 남는지입니다. 두 질문 모두 스키마 정의와는 아무 상관이 없는데, 실제로 오래 갈 문제는 대체로 이쪽에서 나왔습니다.

다음 편에서는 이렇게 만든 데이터를 실제 페이지에 꺼내 놓는 이야기 — 숏코드 하나가 페이지에 붙기까지 에셋 로딩 범위를 어떻게 잡아야 했는지를 이어 쓰겠습니다.

워드프레스플러그인 개발커스텀 테이블스키마 마이그레이션데이터베이스PHP

관련 글

admin_init 은 관리자를 뜻하지 않았습니다 — 워드프레스 훅 이름을 권한으로 읽은 대가

훅 이름이 admin_init 이니 관리자만 도달한다고 믿었습니다. 그런데 워드프레스 코어는 인증을 확인하기 전에 그 훅을 발화하고 있었고, 그 안에는 제가 방금 넣은 DROP TABLE 이 있었습니다. 권한 검사를 경로가 아니라 호출 지점으로 옮긴 이야기입니다.

관련도 95%

워드프레스 플러그인 개발, 왜 functions.php 가 아니었나 — 남의 코드 위에 기능을 얹는 경계

워드프레스에 기능을 넣을 자리는 네 군데였고, 선택 기준은 기술 취향이 아니라 남의 코드가 업데이트될 때 내 코드가 살아남는가였습니다. 플러그인으로 PHP 는 분리했지만 CSS 는 격리되지 않았고, 경계는 제가 그은 곳이 아니라 언어마다 다르게 그어져 있었습니다.

관련도 94%

워드프레스 훅이 불리지 않는 순간 — add_action 을 걸었는데 아무 일도 없었습니다

워드프레스 훅은 add_action 으로 걸어두면 알아서 불린다고 생각했습니다. 그런데 표준 훅 네 개가 하나도 발화하지 않았습니다. 훅 기반 통합의 전제는 상대도 워드프레스를 통과한다는 것이고, 그 전제가 깨지면 오류가 아니라 무음 실패로 나타났습니다.

관련도 94%