홈시리즈멘토링

© 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월 6일

지난 네 편은 전부 화면에 보이지 않는 층에 대한 이야기였습니다. 이번 편에서 다룰 워드프레스 숏코드는 처음으로 눈에 보이는 자리입니다. 앞의 네 편은 코드를 어디에 둘 것인가, 워드프레스가 그 코드를 언제 부르고 언제 부르지 않는가, 훅 이름이 실제로 무엇을 보장하는가, 플러그인이 자기 테이블을 어떻게 만들고 지우는가에 대한 것이었습니다. 방문자 입장에서는 아무것도 달라 보이지 않는 이야기들입니다.

그런데 정작 하고 싶은 이야기는 화면에 무엇을 그렸는가가 아닙니다. 숏코드 한 줄이 만들어 준 성질이 나중에 전혀 다른 문제를 푸는 열쇠가 됐다는 쪽입니다. 그 성질을 처음 손에 넣었을 때 저는 그게 성능 이야기인 줄로만 알았습니다.

숏코드를 어느 페이지에 붙일지는 제가 정하지 않았습니다

검색 화면을 만들었으니 사이트 어딘가에 붙여야 합니다. 워드프레스에는 그 자리가 여러 개 있습니다. 위젯 영역에 넣을 수도 있고, 블록으로 만들 수도 있고, 테마의 템플릿 훅에 끼워 넣거나, 아예 독립된 주소를 하나 만들 수도 있습니다.

제가 고른 것은 숏코드였습니다.

add_shortcode('catalog_search', array('Catalog_Search', 'shortcode'));

add_shortcode 로 이렇게 등록해두면 운영자가 페이지 본문에 [catalog_search] 라고 적는 것만으로 그 자리에 검색 화면이 들어갑니다. 개념적으로 중요한 부분은 여기입니다. 어느 페이지에 붙일지를 개발자가 아니라 운영자가 정합니다. 페이지 ID 를 코드 어딘가에 박아둘 필요가 없고, 나중에 그 페이지가 다른 자리로 옮겨가도 제 코드를 고칠 일이 없습니다.

다른 진입점을 두고 숏코드를 고른 이유도 여기에 있었습니다. 숏코드는 기존 페이지 편집 화면만으로 배치가 끝납니다. 운영자가 새로 배워야 하는 개념이 없습니다. 1편에서 플러그인을 고른 결정적인 이유가 운영자가 직접 누를 수 있는 스위치였던 것과 같은 사정입니다. 인수인계를 받을 사람이 개발자가 아닐 때, 그 사람이 코드를 열지 않고 할 수 있는 일의 범위가 곧 그 기능의 수명이 된다는 생각이 들었습니다.

다만 콜백을 쓸 때 걸리기 쉬운 지점이 하나 있습니다. 숏코드 콜백은 결과를 출력하지 말고 반환해야 합니다.

// 화면 맨 위로 튀어 오릅니다
public static function shortcode($atts) {
    echo $html;
}

// 문자열로 반환합니다
public static function shortcode($atts) {
    return $html;
}

숏코드가 치환되는 시점은 콘텐츠 필터가 도는 중입니다. 그래서 콜백 안에서 echo 를 하면 그 출력이 본문이 조립되기 전에 먼저 나가버리고, 결과적으로 글 맨 위에 덩그러니 붙습니다. 숏코드 콜백에서 가장 흔한 실수로 꼽히는 것이 이 부분인데, 문법 오류가 아니라 위치만 이상해지는 종류라 원인을 엉뚱한 곳에서 찾기 쉽습니다.

wp_enqueue_script — 등록과 적재를 나눕니다

화면을 그리려면 CSS 와 자바스크립트가 필요합니다. 워드프레스에서 자산을 다루는 함수는 두 종류로 나뉘어 있습니다. wp_register_style 과 wp_register_script 는 "이런 자산이 있다"는 선언이고, wp_enqueue_style 과 wp_enqueue_script 가 실제로 페이지에 싣습니다.

처음에는 이 둘이 왜 나뉘어 있는지 잘 이해하지 못했습니다. wp_enqueue_scripts 훅에서 곧장 enqueue 하면 되는 것 아닌가 싶었습니다. 그런데 그렇게 하면 사이트의 모든 페이지에 제 CSS 와 JS 가 실립니다. 검색 페이지에서만 쓰는 자산이 홈에도 실리고 글 상세에도 실립니다.

그래서 두 단계로 나눴습니다. 훅에서는 등록만 하고, 숏코드가 실제로 렌더될 때 적재합니다.

public static function init() {
    add_shortcode('catalog_search', array(__CLASS__, 'shortcode'));
    add_action('wp_enqueue_scripts', array(__CLASS__, 'register_assets'));
}

// 1) 훅에서는 '등록'만 합니다 — 아직 아무 페이지에도 실리지 않습니다
public static function register_assets() {
    wp_register_style('catalog-search', CATALOG_URL . 'assets/search.css', array(), self::asset_ver('assets/search.css'));
    wp_register_script('catalog-search', CATALOG_URL . 'assets/search.js', array(), self::asset_ver('assets/search.js'), true);
}

// 2) 숏코드가 실제로 렌더될 때만 '적재'합니다
public static function shortcode($atts) {
    wp_enqueue_style('catalog-search');
    wp_enqueue_script('catalog-search');
    // ... 화면을 그립니다 ...
}

등록은 wp_enqueue_scripts 훅에서 일어나지만, 그 순간에는 아직 아무 페이지에도 실리지 않습니다. 실제로 싣는 것은 숏코드 콜백입니다. 즉 [catalog_search] 가 없는 페이지에서는 이 자산들이 애초에 등장하지 않습니다. 흔히 조건부 로딩이라고 부르는 방식입니다. wp_register_script 의 마지막 인자 true 는 스크립트를 푸터에 넣으라는 뜻인데, 문서 파싱을 막지 않게 하려는 것입니다.

그때 저는 이 조건부 로딩을 성능 이야기로만 이해했습니다. 필요 없는 페이지에 불필요한 요청을 만들지 않는다는, 흔한 최적화 원칙 정도로 받아들였습니다. 그런데 이 분리에는 부수 효과가 하나 더 있었습니다. 그건 한참 뒤에야 알게 됩니다.

배포했는데 반영이 안 됐습니다 — 캐시 버스터를 filemtime 으로

워드프레스에서 자산을 등록할 때 넘기는 값 중에 버전이 있습니다. 이 값은 그대로 URL 뒤에 ?ver= 로 붙고, 브라우저는 이 URL 이 바뀌어야 파일을 새로 받아옵니다. 캐시 버스터라고 부르는 것입니다.

처음에는 흔한 방식대로 플러그인 버전 상수를 붙였습니다. ?ver=0.7.0 같은 형태입니다. 그런데 이 방식에는 문제가 두 가지 있었습니다.

첫째, 그 상수는 사람이 올려야 바뀝니다. 파일을 고쳤다는 사실과 상수를 올리는 행위 사이에는 아무 연결도 없습니다. 둘째, 플러그인 헤더 주석의 Version: 과 같은 값이 두 군데에 적혀 있는 상태라 한쪽만 올리기가 쉽습니다. 같은 사실이 두 곳에 적혀 있으면 언젠가는 어긋난다는, 흔히 단일 정의 원칙이라고 부르는 이야기입니다.

실제로 CSS 를 두 번 고치는 동안 ?ver= 값이 그대로였습니다. 파일은 바뀌었는데 URL 이 안 바뀌니 재방문자에게는 옛 CSS 가 그대로 서빙됐습니다. 화면상으로는 "배포했는데 반영이 안 됐다"로 보입니다. 개인적으로는 이게 가장 시간을 많이 잡아먹는 종류의 증상이라고 생각합니다. 코드는 분명히 올라갔고 서버에도 새 파일이 있는데 화면만 옛것이니, 어디부터 의심해야 하는지가 애매합니다.

그래서 방식을 바꿨습니다.

private static function asset_ver($rel) {
    $mtime = @filemtime(CATALOG_DIR . $rel);
    return $mtime ? (string) $mtime : CATALOG_VER;   // 못 읽으면 버전 상수로 폴백
}

파일의 수정 시각을 그대로 버전으로 씁니다. 파일이 바뀌는 순간 URL 이 자동으로 바뀝니다. 상수를 올리는 걸 잊어도, 인수받은 사람이 CSS 를 직접 손봐도, 압축 파일을 통째로 재배포해도 항상 맞습니다.

비용은 요청당 파일 상태 조회 한 번입니다. 그 정도면 싸다고 판단했습니다.

폴백을 둔 이유는 조금 다른 데 있습니다. 권한이나 스트림 래퍼 문제로 filemtime 이 실패할 수 있는데, 그때 캐시 버스터가 아예 없는 상태로 떨어지지 않게 하려는 것입니다. 실패했을 때 어디로 내려앉는지를 정해두지 않으면, 폴백은 조용히 더 나쁜 상태로 데려갑니다.

사람이 갱신해야 하는 값과 파일의 실제 상태 중, 캐시 키는 후자여야 합니다.

"플러그인 범위 밖"이라고 판단했습니다

모바일에서 표를 손가락으로 확대할 수 없다는 문제가 있었습니다. 부품 스펙 표는 열이 많아서 작은 화면에서는 글자가 작아질 수밖에 없는데, 확대가 막혀 있으면 읽을 방법이 없습니다.

원인을 찾는 것 자체는 어렵지 않았습니다. 테마가 <head> 에 뷰포트 메타 태그를 출력하면서 user-scalable=0 을 넣어두고 있었습니다. 사이트 전역이었습니다.

그리고 저는 여기서 이건 플러그인이 손댈 수 있는 범위가 아니라고 판단했습니다. 이유는 세 가지였고, 하나하나는 다 그럴듯했습니다. 테마 파일을 고쳐야 하는 일이고, 그건 1편에서 정한 원칙에 정면으로 어긋나며, 사이트 전역 설정을 바꾸는 일이라 제 판단만으로 결정할 것도 아니었습니다.

그 판단이 틀렸다는 것을 나중에 알았습니다. 세 가지 이유가 전부 같은 하나의 전제 위에 서 있었습니다. 뷰포트 설정을 바꾸려면 전역을 바꿔야 한다는 전제입니다.

못 바꾸는 것은 "전역"이었고, 필요한 것은 "이 페이지 하나"였습니다.

표를 읽을 수 있어야 하는 곳은 검색 결과 페이지였습니다. 홈이나 소개 페이지에서 확대가 되든 안 되든 제가 풀려던 문제와는 상관이 없었습니다. 그런데 저는 그 둘을 한 덩어리로 놓고 "전역 설정 변경"이라는 이름을 붙여버렸고, 그 이름이 붙는 순간 자동으로 범위 밖이 되어 있었습니다.

조건부 로딩이라는 성질은 이미 손에 있었습니다

앞에서 등록과 적재를 나눠둔 이야기를 했습니다. 그때는 성능 최적화라고만 생각했던 그 분리 덕분에, 이 플러그인의 스크립트는 숏코드가 있는 페이지에서만 로드됩니다.

그렇다면 그 스크립트 안에서 뷰포트 설정을 다시 쓰면 어떻게 되는가. 그 재작성은 정확히 그 페이지에서만 일어납니다. 테마 파일은 한 글자도 건드리지 않습니다.

// 이 스크립트는 숏코드가 있는 페이지에서만 로드된다 → 이 페이지에만 적용된다
var meta = document.querySelector('meta[name="viewport"]');
if (meta) {
    meta.setAttribute('content', 'width=device-width, initial-scale=1');
}

세 줄입니다. 그리고 이 세 줄이 "테마 전역 설정을 고쳐야 하는 일"을 "플러그인이 자기 페이지에서 하는 일"로 바꿔놓았습니다.

운영에서 확인한 결과는 이렇습니다. 안드로이드 크롬에서 검색 페이지는 확대가 가능해졌고, 홈에서는 user-scalable=0 이 그대로 유지됐습니다. 제가 바꾼 범위가 의도한 범위와 일치했습니다.

여기서 두 가지를 배웠습니다.

첫 번째는 조건부 로딩에 대한 것입니다. 자산을 필요한 페이지에만 싣는 이야기는 대개 성능 최적화로 소개됩니다. 그런데 실은 "적용 범위"라는 도구이기도 했습니다. 어디에 실리는지가 결정되면, 그 코드가 무엇을 바꿔도 되는지의 범위도 함께 결정됩니다. 전역에서는 허락되지 않는 변경이 한 페이지 안에서는 허락될 수 있고, 그 경계를 만들어 주는 것이 로딩 조건이었습니다.

두 번째는 조금 더 불편한 쪽입니다. "범위 밖"이라는 판단은 대개 문제를 너무 크게 잡았기 때문에 나옵니다. 저는 전역이 필요한 줄 알았지만 필요했던 것은 한 페이지였습니다. 그날 제 판단을 그대로 밀고 나갔다면 이 문제는 협의 안건이 되어 한참을 기다렸을 것이고, 어쩌면 지금도 그대로 남아 있었을 것입니다. 기술적으로 막혀 있던 게 아니라 문제의 크기를 잘못 재서 막혀 있었습니다.

자바스크립트가 실패해도 정보는 남습니다

뷰포트 이야기를 하고 나니 자바스크립트에 기대는 부분이 많아 보일 수 있는데, 실제 구조는 그 반대에 가깝습니다. 검색 결과는 서버에서 렌더합니다. 자바스크립트가 맡는 것은 접기와 펼치기, 확대 모달 같은 편의 기능입니다.

그래서 자바스크립트가 어떤 이유로든 실패해도 정보 자체는 사라지지 않습니다. 표는 그대로 있고, 조금 불편해질 뿐입니다. 점진적 향상이라고 부르는 방식인데, 여기서는 부수적인 이점도 하나 있었습니다. 브라우저 인쇄로 검색 결과를 PDF 로 저장하는 것이 깔끔하게 동작합니다. 화면에 그려진 것이 곧 문서이기 때문입니다.

스크립트 태그에는 워드프레스의 script_loader_tag 필터로 defer 를 붙여 파서를 막지 않게 했습니다. 2편에서 필터를 설명하며 예로 들었던 코드가 바로 이것이었습니다.

그릴 수 없을 때 무엇을 보여줄 것인가

지난 편에 쓴 것처럼 이 플러그인은 자기 테이블을 직접 관리합니다. 그래서 테이블이 아직 최신 스키마가 아닌 상태가 존재할 수 있고, 그 상태에서 새 스키마를 전제한 질의를 날리면 500 이 납니다. 하필 그 500 이 나는 자리는 운영자가 방문자에게 보여주려고 만들어 둔 페이지입니다.

그래서 숏코드를 등록하는 자리에 분기를 하나 뒀습니다.

if (Catalog_Schema::is_current()) {
    Catalog_Search::init();
} else {
    // 안전 폴백 — 방문자에게는 아무것도 출력하지 않습니다
    add_shortcode('catalog_search', 'catalog_search_pending');
}

폴백 숏코드가 하는 일은 조금 특이합니다. 방문자에게는 아무것도 출력하지 않습니다. 검색창이 잠시 보이지 않는 편이 500 페이지보다 낫다고 판단했습니다. 검색창이 없으면 방문자는 다른 경로로 돌아가지만, 500 을 보면 사이트 자체를 의심하게 됩니다.

다만 이걸 조용한 실패로 두면 아무도 모르는 채 방치됩니다. 그래서 관리자에게만 원인과 조치를 보여줍니다. 진단 정보를 HTML 주석으로 남길 때도 같은 조건을 걸었습니다. 비로그인 방문자에게 "이 사이트에 이 플러그인이 있고, 지금 스키마가 2인데 3을 기대한다"는 문장이 나가면, 그건 어떤 버전의 어떤 결함을 노려야 하는지 알려주는 셈이 됩니다.

안내 문구에서는 한 문장을 뺐습니다. 처음에는 "자동으로 업그레이드됩니다"라고 적어뒀는데 지웠습니다. 관리 화면을 백 번 열어도 고쳐지지 않는 실패가 실제로 있었기 때문입니다. 고쳐진다고 적어두면 운영자는 그 화면을 계속 새로고침하며 시간을 버립니다. 안내 문구도 코드와 같아서, 지킬 수 없는 약속을 적어두면 그것 자체가 결함입니다.

다섯 편 동안 같은 질문을 다시 했습니다

여기까지가 이 플러그인을 만들면서 정리하게 된 것들입니다. 시리즈를 닫으면서 돌이켜 생각해보면, 다섯 편은 서로 다른 다섯 가지 주제가 아니었습니다. 하나의 질문이 층을 바꿔가며 계속 다시 나타난 기록이었습니다.

내 코드는 어디까지가 내 것인가.

PHP 에서는 플러그인 디렉토리가 그 경계였습니다. 남의 파일을 건드리지 않으면 남도 제 파일을 건드리지 않았습니다. 그런데 CSS 에서는 같은 경계가 통하지 않았습니다. 파일은 나뉘어 있어도 브라우저 안에서는 하나의 캐스케이드였고, 제 스타일이 이기고 있던 것은 규칙이 아니라 로드 순서라는 우연이었습니다.

훅에서는 경계가 또 다른 모양이었습니다. 아무리 정확한 자리에 코드를 걸어둬도 상대가 워드프레스를 통과하지 않으면 그 코드는 불리지 않았고, 반대로 훅 이름을 잘못 읽으면 제가 상정하지 않은 사람이 제 코드를 부를 수 있었습니다. 데이터베이스에서는 경계를 스스로 만들고 스스로 지워야 했는데, 그 시작과 끝을 판단하는 코드에서 가장 많이 틀렸습니다.

그리고 마지막에는 그 경계가 제약이 아니라 도구가 되어 돌아왔습니다. 자산이 이 페이지에만 실린다는 사실이, 테마 전역 설정 앞에서 막혀 있던 문제를 푸는 열쇠였습니다.

다섯 편을 쓰고 나서 남은 생각은 이렇습니다. 플러그인이라는 형식이 저를 위해 해준 일은 경계를 선언하게 만든 것이었습니다. 어디부터가 제 것인지 매번 적어두게 만들었을 뿐, 그 경계를 대신 지켜준 적은 한 번도 없었습니다. 지키는 일은 층마다 새로 해야 했고, 층이 바뀔 때마다 저는 같은 질문을 처음부터 다시 해야 했습니다. 아마 이 감각이 이번 일에서 가장 오래 남을 것 같습니다.

워드프레스플러그인 개발숏코드wp_enqueue_script캐시 버스터PHP

관련 글

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

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

관련도 95%

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

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

관련도 95%

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

부품 카탈로그가 커스텀 포스트 타입으로 감당되지 않아 커스텀 테이블을 직접 만들었습니다. 그러자 스키마 마이그레이션과 상태 검증까지 제 몫이 되었고, 재실행을 막으려 걸어둔 버전 옵션 가드가 구 스키마를 영구 고착시켰습니다. 쿼리는 성공했고 로그는 깨끗했지만 컬럼은 없었습니다.

관련도 93%