DXCMS 여분 필드 — 목록 노출 · 검색 필터 구현 가이드
대상: DXCMS v10.3.0 / AI 에이전트 작업 지침서 범위: 여분 필드의
is_list(목록 노출)와is_search(검색 필터) 두 설정을 실제로 동작하게 만드는 방법 근거: 소스 직접 분석(boards/handler.php,core/BoardFields.php, 번들 gallery 스킨) + 이 문서의 코드를 MariaDB 10.11 / PHP 8.3 에서 실행 검증 (부록 A)
0. AI 에이전트 사용 지침
- 사용자가 "여분 필드를 목록에 보이게 해줘", "필드 값으로 검색/필터하고 싶다"고 요청하면 이 문서대로 작업한다.
- 기본 구조(정의
dx_board_fields, 값dx_post_meta, 폼 namebf_{key})는 이미 알고 있다고 가정한다. - 추측하지 말 것.
is_list,is_search체크박스는 DB에 값만 저장하며, 코어는 이 값을 읽는 코드가 없다. 이 문서의 코드를 설치해야 비로소 동작한다. - 작업 순서는 항상 아래와 같다.
[1단계] 헬퍼 파일 설치 (코어 수정 없음) ← 필수
[2단계] 스킨에 목록 칸 추가 ← "목록 노출" 요청일 때
[3단계] handler.php 패치 3곳 + 스킨에 필터 폼 ← "검색 필터" 요청일 때
[4단계] 검증 체크리스트 수행 ← 항상
- 수정 전에 대상 파일을 백업하고, 수정 후에는
php -l 파일로 문법을 확인한다. - 사용자가 "목록 노출만" 원하면 3단계는 하지 않는다. (코어 파일 수정이 필요한 것은 3단계뿐)
1. 소스에서 확인된 사실 (이 가이드의 근거)
| # | 사실 | 의미 |
|---|---|---|
| 1 | is_list, is_search 를 읽는 코드는 코어 어디에도 없다 (관리자 저장 화면에서만 사용) |
스킨/핸들러에서 직접 구현해야 함 |
| 2 | 목록 쿼리는 boards/handler.php 의 case 'list': case 'search': 안에서 $where 문자열과 $params 배열로 조립된다. 조건을 끼워 넣는 훅이 없다 |
필터는 handler.php 에 소량 패치가 필요 |
| 3 | COUNT(*) 쿼리와 목록 쿼리가 같은 $where/$params 를 쓴다. 글 별칭은 p |
조건을 한 번만 붙이면 총 개수·페이지 수가 함께 맞는다 |
| 4 | 목록 캐시(DxCache, 60초, 키=게시판+페이지)는 !$search && !$cat && !$tag && 비로그인 && $action!=='search' 일 때만 사용된다 |
필터 조건을 캐시 조건에 넣지 않으면 필터가 무시된 캐시 목록이 나온다 → 패치에 반드시 포함 |
| 5 | dx_board_list_context 훅은 쿼리 이후에 실행되지만, 캐시 경로에서는 실행되지 않는다 |
훅으로 글에 meta를 붙이는 방식은 비로그인 방문자에게 누락될 수 있음 → 스킨에서 직접 조회 |
| 6 | 목록 파라미터는 s, sf, cat, parent, tag, page. 스킨의 페이지네이션은 이 값들만 링크에 이어붙인다 |
필터 파라미터(bf_*)를 페이지네이션 링크에 직접 추가해야 2페이지에서 필터가 풀리지 않음 |
| 7 | gallery 스킨은 _list_rows.php(카드 행 partial), list-live.js(새 글 실시간 삽입, /api/post_row 호출)를 쓴다 |
실시간으로 삽입된 새 행에는 여분 필드 칸이 없을 수 있음 (새로고침하면 정상) |
| 8 | 글 상세 하단의 "목록"은 별도 쿼리(view 액션)이다 |
필터는 목록 화면에만 적용된다 |
2. 구현 방식 선택
| 요구 | 권장 방법 | 코어 수정 | 비고 |
|---|---|---|---|
| 목록에 값 표시 | 스킨에서 일괄 조회 후 출력 | 없음 | 2단계 |
| 필드 값으로 검색/필터 | handler.php 3곳 패치 + 헬퍼 |
소량 (3곳) | 3단계, 권장 |
(대안) 스킨 독립 핸들러 list/handler.php |
목록 액션 전체를 스킨이 처리 | 없음 | 공지·카테고리·캐시·SEO·소켓을 전부 재구현해야 하므로 비권장 |
| (비권장) 현재 페이지 결과를 JS/PHP로 걸러내기 | — | — | 페이지 단위로만 걸러져 총 개수·페이지네이션이 어긋남 |
(비권장) dx_board_list_context 훅으로 meta 부착 |
— | — | 캐시 경로에서 실행 안 됨 (사실 #5) |
3. 1단계 — 헬퍼 파일 설치 (코어 수정 없음)
extend/top/60_board_fields_helpers.php 를 새로 만든다.
- 파일명은
50_board_fields.php보다 뒤(60_이상)여야 한다. (extend/top/은 파일명 오름차순으로 자동 실행) - 이 파일은 DB 구조를 바꾸지 않는다. 삭제하면 원상복구된다.
- 아래 코드는 PHP 5.6 호환 문법(
array(),isset())만 사용한다. (실행 테스트는 PHP 8.3)
| 함수 | 용도 |
|---|---|
dx_bf_list_fields($boardId) |
is_list=1 이고 활성인 필드 정의 |
dx_bf_meta_map($posts) |
글 배열의 값을 쿼리 1회로 조회 → [글ID][key]=값 |
dx_bf_display($field, $value) |
값 → 목록용 HTML (이스케이프·타입별 표시) |
dx_bf_build_filter($boardId, $_GET) |
is_search=1 필드의 GET 파라미터 → SQL 조건 |
dx_bf_render_filter_form(...) |
필터 폼 HTML 생성 |
<?php
/**
* DXCMS — 여분 필드 보조 헬퍼 (목록 노출 · 검색 필터)
* extend/top/60_board_fields_helpers.php
*
* - 파일명은 반드시 50_board_fields.php 보다 뒤(60_ 이상)여야 합니다. (오름차순 로드)
* - PHP 5.6 호환 문법만 사용합니다.
* - 이 파일은 DB 구조를 바꾸지 않습니다. 삭제하면 원상복구됩니다.
*/
if (!defined('DX_CMS')) exit('Direct access not allowed.');
/* ══ 1. 목록 표시용 ═════════════════════════════════════════ */
/** 목록에 노출할 필드 정의 (활성 + is_list=1, 정렬순) */
if (!function_exists('dx_bf_list_fields')) {
function dx_bf_list_fields($boardId)
{
if (!function_exists('dx_board_fields')) return array();
$out = array();
foreach (dx_board_fields()->getFields((int)$boardId) as $f) {
if ((int)$f['is_list'] === 1) $out[] = $f;
}
return $out;
}
}
/**
* 글 배열의 여분 필드 값을 한 번에 조회 (N+1 방지)
* 반환: array('글ID' => array('field_key' => 'value', ...), ...)
* 사용: $metaMap = dx_bf_meta_map(array_merge($notices, $posts));
*/
if (!function_exists('dx_bf_meta_map')) {
function dx_bf_meta_map($posts)
{
$map = array();
if (empty($posts)) return $map;
$ids = array();
foreach ($posts as $p) {
if (isset($p['id'])) $ids[] = (string)$p['id'];
}
$ids = array_values(array_unique($ids));
if (!$ids) return $map;
$db = Database::getInstance();
$tbl = $db->table('post_meta');
foreach (array_chunk($ids, 200) as $chunk) {
$in = implode(',', array_fill(0, count($chunk), '?'));
$rows = $db->rows(
"SELECT post_id, field_key, value FROM `{$tbl}` WHERE post_id IN ($in)",
$chunk
);
foreach ($rows as $r) {
$map[(string)$r['post_id']][$r['field_key']] = $r['value'];
}
}
return $map;
}
}
/** 값 1개를 목록용 HTML 로 변환 (항상 이스케이프됨) */
if (!function_exists('dx_bf_display')) {
function dx_bf_display($field, $value)
{
if ($value === null || $value === '') return '';
$e = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
switch ($field['field_type']) {
case 'checkbox':
$o = '';
foreach (explode(',', $value) as $v) {
$v = trim($v);
if ($v === '') continue;
$o .= '<span class="dx-bf-chip" style="display:inline-block;margin:0 4px 2px 0;padding:1px 8px;'
. 'border-radius:999px;background:var(--bg-hover,#f1f5f9);font-size:.72rem">'
. htmlspecialchars($v, ENT_QUOTES, 'UTF-8') . '</span>';
}
return $o;
case 'number':
return (is_numeric($value) && strpos($value, '.') === false && strlen($value) <= 15)
? number_format((float)$value) : $e;
case 'url':
return preg_match('#^https?://#i', $value)
? '<a href="' . $e . '" target="_blank" rel="noopener">링크</a>' : $e;
case 'datetime':
return htmlspecialchars(str_replace('T', ' ', $value), ENT_QUOTES, 'UTF-8');
default:
return $e;
}
}
}
/* ══ 2. 검색 필터 — SQL 조건 생성 ═══════════════════════════
*
* URL 규격 (is_search=1 인 활성 필드만 허용 — 그 외 파라미터는 무시)
* bf_{key}=값 정확히 일치 (select/radio/text/date ...)
* bf_{key}[]=A&bf_{key}[]=B 체크박스: 선택한 항목을 모두 포함(AND)
* bfmin_{key}, bfmax_{key} number/date/datetime 범위 (이상/이하)
* bfq_{key}=검색어 text/textarea/tel/url 부분 일치(LIKE)
*
* 반환:
* sql → ' AND EXISTS (...) ...' ($where 뒤에 이어붙임, 별칭 p = dx_posts)
* params → sql 의 ? 순서대로의 값 (필드 key 도 바인딩됨)
* active → 하나라도 적용되면 true
* applied → array(array('key','label','display'), ...) (칩 표시용)
* qs → '&bf_...=...' 페이지네이션/링크에 붙일 쿼리스트링
*/
if (!function_exists('dx_bf_build_filter')) {
function dx_bf_build_filter($boardId, $src = null)
{
$res = array('sql' => '', 'params' => array(), 'active' => false, 'applied' => array(), 'qs' => '');
if ($src === null) $src = $_GET;
if (!is_array($src) || !function_exists('dx_board_fields')) return $res;
$db = Database::getInstance();
$tPm = $db->table('post_meta');
$q = array();
foreach (dx_board_fields()->getFields((int)$boardId) as $f) {
if ((int)$f['is_search'] !== 1) continue;
$k = $f['field_key'];
$type = $f['field_type'];
$cond = '';
$cp = array();
$show = '';
/* ① 정확 일치 / 체크박스 포함 */
$n = 'bf_' . $k;
if (isset($src[$n])) {
$vals = is_array($src[$n]) ? $src[$n] : array($src[$n]);
$clean = array();
foreach ($vals as $v) {
if (!is_scalar($v)) continue;
$v = trim((string)$v);
if ($v === '' || mb_strlen($v, 'UTF-8') > 255) continue;
$clean[] = $v;
if (count($clean) >= 20) break;
}
if ($clean) {
if ($type === 'checkbox') {
foreach ($clean as $v) { $cond .= ' AND FIND_IN_SET(?, pm.`value`)'; $cp[] = $v; }
$q[$n] = $clean;
} else {
$cond .= ' AND pm.`value` = ?';
$cp[] = $clean[0];
$q[$n] = $clean[0];
$clean = array($clean[0]);
}
$show = implode(', ', $clean);
}
}
/* ② 범위 (number / date / datetime) */
if (in_array($type, array('number', 'date', 'datetime'), true)) {
$ops = array('min' => '>=', 'max' => '<=');
foreach ($ops as $kind => $op) {
$rn = 'bf' . $kind . '_' . $k;
if (!isset($src[$rn]) || !is_scalar($src[$rn])) continue;
$rv = trim((string)$src[$rn]);
if ($rv === '') continue;
if ($type === 'number') {
if (!is_numeric($rv)) continue;
$cond .= " AND pm.`value` <> '' AND CAST(pm.`value` AS DECIMAL(20,4)) $op ?";
$cp[] = $rv;
} else {
if (!preg_match('/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/', $rv)) continue;
$cmp = $rv;
if ($type === 'datetime' && strlen($rv) === 10) {
// 날짜만 입력하면 그 날의 시작/끝으로 보정
$cmp = $rv . ($kind === 'min' ? 'T00:00' : 'T23:59');
}
$cond .= " AND pm.`value` <> '' AND pm.`value` $op ?";
$cp[] = $cmp;
}
$q[$rn] = $rv;
$show .= ($show !== '' ? ' / ' : '') . ($kind === 'min' ? '≥ ' : '≤ ') . $rv;
}
}
/* ③ 부분 일치 (text / textarea / tel / url) */
if (in_array($type, array('text', 'textarea', 'tel', 'url'), true)) {
$ln = 'bfq_' . $k;
if (isset($src[$ln]) && is_scalar($src[$ln])) {
$lv = trim((string)$src[$ln]);
if ($lv !== '' && mb_strlen($lv, 'UTF-8') <= 100) {
$cond .= ' AND pm.`value` LIKE ?';
$cp[] = '%' . addcslashes($lv, '%_\\') . '%';
$q[$ln] = $lv;
$show .= ($show !== '' ? ' / ' : '') . '"' . $lv . '"';
}
}
}
if ($cond !== '') {
$res['sql'] .= " AND EXISTS (SELECT 1 FROM `{$tPm}` pm"
. " WHERE pm.`post_id` = p.`id` AND pm.`field_key` = ?" . $cond . ")";
$res['params'][] = $k;
foreach ($cp as $v) { $res['params'][] = $v; }
$res['applied'][] = array('key' => $k, 'label' => $f['field_label'], 'display' => $show);
$res['active'] = true;
}
}
if ($q) $res['qs'] = '&' . http_build_query($q);
return $res;
}
}
/* ══ 3. 검색 필터 — 폼 HTML 생성 ════════════════════════════ */
/**
* is_search=1 필드의 필터 폼 HTML
* $action : 폼 action URL (목록 URL)
* $keep : 유지할 GET 값 예) array('cat'=>$cat, 'parent'=>$p, 's'=>$search, 'sf'=>$sf)
* $src : 현재 선택값을 읽을 배열 (기본 $_GET)
* 검색 가능 필드가 없으면 빈 문자열.
*/
if (!function_exists('dx_bf_render_filter_form')) {
function dx_bf_render_filter_form($boardId, $action, array $keep = array(), $src = null)
{
if ($src === null) $src = $_GET;
if (!function_exists('dx_board_fields')) return '';
$fields = array();
foreach (dx_board_fields()->getFields((int)$boardId) as $f) {
if ((int)$f['is_search'] === 1) $fields[] = $f;
}
if (!$fields) return '';
$h = function ($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); };
$css = 'padding:7px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font-size:.82rem;'
. 'background:var(--bg-body,#fff);color:var(--text-main,#111);font-family:inherit;box-sizing:border-box';
$opts = function ($raw) {
$o = array();
foreach (explode("\n", str_replace("\r", '', (string)$raw)) as $line) {
$line = trim($line);
if ($line !== '') $o[] = $line;
}
return $o;
};
$html = '<form method="get" action="' . $h($action) . '" class="dx-bf-filter" '
. 'style="display:flex;flex-wrap:wrap;gap:10px 14px;align-items:flex-end;margin:0 0 16px">';
foreach ($keep as $name => $val) {
if ($val === '' || $val === null) continue;
$html .= '<input type="hidden" name="' . $h($name) . '" value="' . $h($val) . '">';
}
foreach ($fields as $f) {
$k = $f['field_key'];
$type = $f['field_type'];
$lab = '<span style="display:block;font-size:.72rem;font-weight:700;color:var(--text-muted,#888);margin-bottom:4px">'
. $h($f['field_label']) . '</span>';
$html .= '<label style="display:block">' . $lab;
if ($type === 'select' || $type === 'radio') {
$cur = isset($src['bf_' . $k]) && is_scalar($src['bf_' . $k]) ? (string)$src['bf_' . $k] : '';
$html .= '<select name="bf_' . $h($k) . '" style="' . $css . '"><option value="">전체</option>';
foreach ($opts($f['field_options']) as $o) {
$html .= '<option value="' . $h($o) . '"' . ($cur === $o ? ' selected' : '') . '>' . $h($o) . '</option>';
}
$html .= '</select>';
} elseif ($type === 'checkbox') {
$cur = array();
if (isset($src['bf_' . $k])) {
$cur = is_array($src['bf_' . $k]) ? $src['bf_' . $k] : array($src['bf_' . $k]);
}
$html .= '<span style="display:flex;flex-wrap:wrap;gap:4px 12px;font-size:.82rem">';
foreach ($opts($f['field_options']) as $o) {
$html .= '<span style="display:inline-flex;align-items:center;gap:4px">'
. '<input type="checkbox" name="bf_' . $h($k) . '[]" value="' . $h($o) . '"'
. (in_array($o, $cur, true) ? ' checked' : '') . '>' . $h($o) . '</span>';
}
$html .= '</span>';
} elseif ($type === 'number' || $type === 'date' || $type === 'datetime') {
$it = ($type === 'number') ? 'number' : 'date';
$vmin = isset($src['bfmin_' . $k]) && is_scalar($src['bfmin_' . $k]) ? substr((string)$src['bfmin_' . $k], 0, 10) : '';
$vmax = isset($src['bfmax_' . $k]) && is_scalar($src['bfmax_' . $k]) ? substr((string)$src['bfmax_' . $k], 0, 10) : '';
if ($type === 'number') {
$vmin = isset($src['bfmin_' . $k]) && is_scalar($src['bfmin_' . $k]) ? (string)$src['bfmin_' . $k] : '';
$vmax = isset($src['bfmax_' . $k]) && is_scalar($src['bfmax_' . $k]) ? (string)$src['bfmax_' . $k] : '';
}
$html .= '<span style="display:inline-flex;align-items:center;gap:6px">'
. '<input type="' . $it . '" name="bfmin_' . $h($k) . '" value="' . $h($vmin) . '" placeholder="최소" style="' . $css . ';width:120px">'
. '~'
. '<input type="' . $it . '" name="bfmax_' . $h($k) . '" value="' . $h($vmax) . '" placeholder="최대" style="' . $css . ';width:120px">'
. '</span>';
} else { // text / textarea / tel / url → 부분 일치
$cur = isset($src['bfq_' . $k]) && is_scalar($src['bfq_' . $k]) ? (string)$src['bfq_' . $k] : '';
$html .= '<input type="text" name="bfq_' . $h($k) . '" value="' . $h($cur) . '" style="' . $css . '">';
}
$html .= '</label>';
}
$reset = $action;
$rq = array();
foreach ($keep as $name => $val) { if ($val !== '' && $val !== null) $rq[$name] = $val; }
if ($rq) $reset .= (strpos($action, '?') === false ? '?' : '&') . http_build_query($rq);
$html .= '<span style="display:inline-flex;gap:6px">'
. '<button type="submit" style="' . $css . ';background:var(--p,#3b82f6);color:#fff;border-color:transparent;font-weight:700;cursor:pointer">필터 적용</button>'
. '<a href="' . $h($reset) . '" style="' . $css . ';text-decoration:none;display:inline-block">초기화</a>'
. '</span></form>';
return $html;
}
}
설치 후 확인: 아무 페이지나 열어 오류가 없으면 정상이다. (50_board_fields.php 가 없으면 dx_board_fields() 가 없어 모든 함수가 빈 결과를 반환하도록 방어되어 있다.)
4. 2단계 — 목록 노출 (is_list)
4-1. 동작 원리
칸(열)은 필드 정의를 읽어 자동 생성된다. 스킨을 한 번만 고쳐 두면, 이후 관리자가 필드에서 "목록 노출"을 체크/해제하는 것만으로 목록에 칸이 생기고 사라진다. (코드 재수정 불필요)
4-2. 공통: 루프 시작 전에 준비
스킨 list.php 의 글 루프 앞에 넣는다. ($posts, $notices, $board 는 모든 스킨의 list.php 에서 사용 가능한 변수)
<?php
// 목록 노출 대상 필드 + 현재 페이지 글들의 값을 한 번에 조회
$_bfCols = function_exists('dx_bf_list_fields') ? dx_bf_list_fields($board['id']) : array();
$_bfMeta = $_bfCols
? dx_bf_meta_map(array_merge(isset($notices) && is_array($notices) ? $notices : array(), $posts))
: array();
?>
글마다
getMeta()/dx_get_post_meta()를 호출하면 글 수만큼 쿼리가 실행된다(N+1). 반드시dx_bf_meta_map()로 일괄 조회한다.
4-3. 표(테이블)형 스킨
<!-- thead 안, 제목 th 뒤 -->
<?php foreach ($_bfCols as $_f): ?>
<th><?php echo htmlspecialchars($_f['field_label'], ENT_QUOTES, 'UTF-8'); ?></th>
<?php endforeach; ?>
<!-- 각 행 tr 안, 제목 td 뒤 ($p = 현재 글 변수. 스킨의 루프 변수명에 맞출 것) -->
<?php $_m = isset($_bfMeta[(string)$p['id']]) ? $_bfMeta[(string)$p['id']] : array(); ?>
<?php foreach ($_bfCols as $_f): ?>
<td><?php echo dx_bf_display($_f, isset($_m[$_f['field_key']]) ? $_m[$_f['field_key']] : ''); ?></td>
<?php endforeach; ?>
공지 행($notices)이 별도 루프라면 같은 방식으로 칸을 추가하거나, 칸 수를 맞추기 위해 빈 <td> 를 넣는다. (colspan 도 기본열수 + count($_bfCols) 로 수정)
4-4. 카드/갤러리형 스킨
번들 gallery 는 list.php(약 652행 foreach($posts as $_i=>$_p))에서 카드를 그린다. 카드 내부 원하는 위치에 넣는다.
<?php
$_m = isset($_bfMeta[(string)$_p['id']]) ? $_bfMeta[(string)$_p['id']] : array();
if ($_bfCols):
?>
<div class="dx-bf-meta" style="display:flex;flex-wrap:wrap;gap:2px 10px;font-size:.75rem;color:var(--text-muted,#888)">
<?php foreach ($_bfCols as $_f):
$_v = isset($_m[$_f['field_key']]) ? $_m[$_f['field_key']] : '';
if ($_v === '') continue; ?>
<span><b><?php echo htmlspecialchars($_f['field_label'], ENT_QUOTES, 'UTF-8'); ?></b> <?php echo dx_bf_display($_f, $_v); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
- gallery 는 행 렌더링이
_list_rows.php로 분리되어 있고view.php하단 목록과 공유한다. 양쪽에 모두 보이게 하려면_list_rows.php에 넣고, 그 partial 을 include 하기 전에 4-2 의 준비 코드가 실행되도록 한다. - 스킨 파일은 업데이트 시 덮어써질 수 있으므로, 번들 스킨을 직접 고치기보다 복사본 스킨(예:
gallery-bf)을 만들어 게시판에 적용하는 것을 권장한다.
4-5. 알려진 한계
list-live.js가 새 글을 실시간으로 끼워 넣을 때는/api/post_row가 만든 행을 쓰므로 여분 필드 칸이 없다. 새로고침하면 정상 표시된다. (필요하면api/post_row를 별도로 확장해야 하며, 이 문서의 범위 밖)- textarea 값은 한 줄로 출력된다. 긴 값은 목록 노출에 부적합하다.
- 목록 노출 필드는 4~5개 이내를 권장한다. (모바일 레이아웃)
5. 3단계 — 검색 필터 (is_search)
5-1. URL 규격 (헬퍼가 해석)
is_search=1 이고 활성인 필드만 허용한다. 정의되지 않았거나 검색 설정이 꺼진 key 는 무시된다.
| 파라미터 | 대상 타입 | 의미 | 예 |
|---|---|---|---|
bf_{key}=값 |
select, radio, text, date … | 정확히 일치 | ?bf_region=서울 |
bf_{key}[]=A&bf_{key}[]=B |
checkbox | 선택한 항목을 모두 포함(AND) | ?bf_facilities[]=주차&bf_facilities[]=에어컨 |
bfmin_{key} / bfmax_{key} |
number, date, datetime | 이상 / 이하 | ?bfmin_price=1000&bfmax_price=5000 |
bfq_{key}=검색어 |
text, textarea, tel, url | 부분 일치(LIKE) | ?bfq_memo=역세권 |
- 여러 필드·여러 파라미터는 AND 로 결합된다.
- 기본 검색(
s,sf), 카테고리(cat)와도 함께 쓸 수 있다. - 예:
/board/list?cat=아파트&bf_region=서울&bfmax_price=5000&page=2
5-2. boards/handler.php 패치 (3곳)
먼저 백업하고, 아래 3곳을 수정한다. 각 앵커 문자열은 파일 안에서 1회만 나타난다(v10.3.0 기준, 수정 전 grep -c 로 재확인).
패치 1 — $tag = dx_get('tag', ''); 바로 아래에 추가 (캐시 판정보다 앞이어야 함)
/* BF-PATCH-1: 여분 필드 검색 필터 (extend/top/60_board_fields_helpers.php) */
$_bfFilter = function_exists('dx_bf_build_filter')
? dx_bf_build_filter($board['id'], $_GET)
: array('sql'=>'', 'params'=>array(), 'active'=>false);
패치 2 — 캐시 조건에 필터 여부 추가
// 변경 전
!$search && !$cat && !$tag &&
// 변경 후
!$search && !$cat && !$tag && empty($_bfFilter['active']) && /* BF-PATCH-2 */
패치 3 — if ($tag) { $where.=" AND p.tags LIKE ?"; ... } 줄 바로 아래에 추가
/* BF-PATCH-3: 여분 필드 검색 조건 (COUNT 와 목록 쿼리가 같은 $where 를 쓰므로 한 번만 추가) */
if (!empty($_bfFilter['active'])) {
$where .= $_bfFilter['sql'];
foreach ($_bfFilter['params'] as $_bfp) { $params[] = $_bfp; }
}
적용 후 확인:
php -l boards/handler.php # No syntax errors detected
grep -n BF-PATCH boards/handler.php # 3줄 (BF-PATCH-1, -2, -3)
grep -n _bfFilter boards/handler.php # 5줄
- 이 패치는 v10.3.0 의 실제
handler.php에 적용해php -l문법 검사를 통과한 것이다. - DXCMS 업데이트로
handler.php가 교체되면 패치가 사라진다.BF-PATCH마커로 검색해 재적용 여부를 점검한다. - 헬퍼 파일(
60_...)이 없어도function_exists가드 때문에 패치 코드는 무해하다. (필터가 비활성 상태로 동작)
5-3. 스킨에 필터 폼·적용 표시·페이지네이션 연결
변수명은 스킨마다 다르다. 대상 스킨의 list.php 상단에서 목록 URL, 현재 카테고리, 검색어/검색필드 변수를 확인해 맞춘다. (gallery: $_base, $currentCategory, $_catParentSlug, $search, $searchField)
(1) 필터 계산 + 폼 출력 — 목록 위 원하는 위치
<?php
$_bfF = function_exists('dx_bf_build_filter')
? dx_bf_build_filter($board['id'], $_GET)
: array('active'=>false, 'applied'=>array(), 'qs'=>'');
if (function_exists('dx_bf_render_filter_form')) {
echo dx_bf_render_filter_form($board['id'], $_base, array(
'cat' => $currentCategory,
'parent' => $_catParentSlug,
's' => $search,
'sf' => $search ? $searchField : '',
));
}
?>
검색 가능 필드가 없으면 빈 문자열이 반환되어 아무것도 출력되지 않는다. 폼 컨트롤은 타입별로 자동 결정된다.
| 필드 타입 | 폼 컨트롤 |
|---|---|
| select, radio | 드롭다운 ("전체" 포함) |
| checkbox | 체크박스 묶음 |
| number, date, datetime | 최소 ~ 최대 입력 2칸 |
| text, textarea, tel, url | 부분 검색 입력 1칸 |
(2) 적용 중인 필터 표시 (선택)
<?php if (!empty($_bfF['active'])): ?>
<div class="dx-bf-applied" style="margin:0 0 12px;font-size:.8rem;display:flex;flex-wrap:wrap;gap:6px;align-items:center">
<?php foreach ($_bfF['applied'] as $_a): ?>
<span style="padding:2px 10px;border-radius:999px;background:var(--bg-hover,#f1f5f9)">
<?php echo htmlspecialchars($_a['label'] . ': ' . $_a['display'], ENT_QUOTES, 'UTF-8'); ?>
</span>
<?php endforeach; ?>
<span style="color:var(--text-muted,#888)">총 <?php echo (int)$total; ?>건</span>
</div>
<?php endif; ?>
(3) 페이지네이션 링크에 필터 유지 (필수) — gallery 의 $__pBase 예 (약 815행)
// 기존 식 끝에 아래 조각을 이어붙인다
.(!empty($_bfF['qs']) ? ltrim($_bfF['qs'], '&') . '&' : '')
$__pBase = $_base.'?' ... .($_catParentSlug?'parent='.urlencode($_catParentSlug).'&':'') . (!empty($_bfF['qs']) ? ltrim($_bfF['qs'],'&').'&' : '');
dx_pagination($total,$perPage,$current,$urlPattern) 를 쓰는 스킨이면 $urlPattern 에 같은 조각을 포함시킨다. 이것을 빠뜨리면 2페이지로 넘어가는 순간 필터가 풀린다.
(4) 키워드 검색 폼이 필터를 지우지 않게 (선택) — 기존 검색 <form> 안에 추가
<?php
if (!empty($_bfF['qs'])) {
parse_str(ltrim($_bfF['qs'], '&'), $_bfQ);
foreach ($_bfQ as $_n => $_v) {
foreach ((array)$_v as $_vv) {
echo '<input type="hidden" name="' . htmlspecialchars($_n . (is_array($_v) ? '[]' : ''), ENT_QUOTES, 'UTF-8')
. '" value="' . htmlspecialchars($_vv, ENT_QUOTES, 'UTF-8') . '">';
}
}
}
?>
6. 운영자·방문자 사용법 (완성 후)
6-1. 관리자가 설정하는 순서
- 관리자 → 게시판 관리 → 해당 게시판의 여분 필드 아이콘 → 필드 추가/수정
- 목록에 보일 필드는 "목록 노출" 체크
- 필터로 쓸 필드는 "검색 필터" 체크
- 저장 → 해당 게시판 목록 화면에서 즉시 반영 (스킨에 2·3단계가 적용되어 있을 때)
- 칸 순서는 정렬 순서(
sort_order) 숫자로 조절 (작을수록 앞)
6-2. 타입별 권장 설정
| 타입 | 목록 노출 | 검색 필터 | 비고 |
|---|---|---|---|
| select / radio | 권장 | 권장 | 가장 쓰기 좋은 필터 (드롭다운) |
| checkbox | 선택 | 권장 | 선택 항목을 모두 포함하는 글만 표시 (AND). 옵션에 쉼표(,) 금지 |
| number | 권장 | 권장 | 최소~최대 범위. 숫자만 입력하도록 안내 (쉼표 금지, 부록 B 참고) |
| date / datetime | 선택 | 권장 | 기간 범위 |
| text / tel / url | 선택 | 선택 | 필터는 부분 일치. url 은 목록에서 "링크"로 표시 |
| textarea | 비권장 | 비권장 | 길이가 길어 목록·검색에 부적합 |
6-3. 방문자(사용자) 사용법
- 목록 위 필터 폼에서 조건을 고르고 "필터 적용" 을 누른다. 여러 조건은 모두 만족하는 글만 나온다(AND).
- 적용된 조건은 칩으로 표시되고, "초기화" 를 누르면 필터가 해제된다.
- 필터 결과 주소(URL)를 그대로 복사해 공유·북마크할 수 있다.
- 카테고리와 키워드 검색도 함께 사용할 수 있다.
7. 한계와 주의사항 (테스트로 확인된 것 포함)
| # | 내용 | 대응 |
|---|---|---|
| 1 | number 값에 쉼표가 있으면(12,000) 범위 필터에서 12 로 계산됨 (실제 테스트에서 재현) |
필드 안내문에 "숫자만 입력" 표기. 기존 데이터는 UPDATE dx_post_meta SET value=REPLACE(value, ',', '') WHERE field_key='price' 로 정리 (실행 전 SELECT 로 대상 확인·백업) |
| 2 | number 값이 비어 있는 글은 범위 필터에서 제외됨 (의도된 동작) | — |
| 3 | 정확 일치·체크박스 비교는 대소문자를 구분하지 않음 (utf8mb4_unicode_ci) |
— |
| 4 | 체크박스 필터는 AND. OR 가 필요하면 dx_bf_build_filter 의 checkbox 분기에서 FIND_IN_SET 조건들을 OR 로 묶도록 수정 |
요구가 있을 때만 |
| 5 | 필터를 쓰는 요청은 목록 캐시를 우회한다 | 필터 사용 빈도가 높고 트래픽이 크면 부하 증가 가능 |
| 6 | 값 컬럼은 TEXT 라 값에 인덱스가 없다. 글마다 필드당 EXISTS 1회 (post_id + field_key 유니크 인덱스를 사용) |
글 수 수만 건, 동시 필터 3~4개 이하 권장. 그 이상이면 별도 정규화 컬럼/검색 테이블을 검토 |
| 7 | 옵션 이름을 바꾸면 기존 글의 저장값과 불일치 → 필터에 안 걸림 | 옵션 변경 시 dx_post_meta 값도 함께 UPDATE |
| 8 | 필드를 비활성/삭제하면 필터 대상에서 제외 | — |
| 9 | 글 상세 하단 목록, list-live.js 실시간 삽입 행에는 적용되지 않음 |
사실 #7, #8 |
| 10 | handler.php 업데이트 시 패치 소실 |
grep -n BF-PATCH boards/handler.php 로 점검 |
| 11 | 여분 필드는 글 작성 시점에 저장되는 값만 검색 대상. API/별도 저장 경로로 만든 글은 meta가 없을 수 있음 | dx_save_post_meta() 로 보완 |
8. 검증 체크리스트 (AI가 작업 후 반드시 수행)
목록 노출
- 관리자에서 필드의 "목록 노출" 체크 → 목록 화면에 칸/라벨이 나온다
- 값이 있는 글/없는 글 모두 오류 없이 표시된다 (빈 칸)
- 체크박스 값이 칩으로, url 이 "링크"로, number 가 천 단위 구분으로 표시된다
- "목록 노출"을 해제하면 칸이 사라진다 (스킨 재수정 불필요)
- 비로그인 상태(캐시 경로)에서도 칸이 보인다
- 목록 쿼리 수가 글 수에 비례해 늘지 않는다 (meta 조회는 페이지당 1회)
검색 필터
-
php -l boards/handler.php통과,BF-PATCH마커 확인 - 필터 폼이 나타나고, 선택한 값이 적용 후에도 유지된다
-
?bf_{key}=값결과가 정확하고, 총 개수/페이지 수가 맞다 - 2페이지로 이동해도 필터가 유지된다 (페이지네이션 연결)
- 비로그인 상태에서도 필터가 적용된다 (캐시 우회 확인)
-
is_search=0인 필드, 정의되지 않은 key 를 URL 에 넣어도 무시된다 - 카테고리·키워드 검색과 함께 써도 정상이다
- 이상한 값(
' OR '1'='1, 배열 등)을 넣어도 오류 없이 빈 결과/무시 처리된다 - 필터 초기화 링크가
cat/검색어를 유지하면서bf_*만 지운다
SQL 직접 확인 (대조용)
-- 게시판 3, 지역=서울 인 글 수 (화면의 총 개수와 같아야 함)
SELECT COUNT(*) FROM dx_posts p
WHERE p.board_id=3 AND p.status=1 AND p.is_notice=0
AND EXISTS (SELECT 1 FROM dx_post_meta pm
WHERE pm.post_id=p.id AND pm.field_key='region' AND pm.value='서울');
9. 롤백
extend/top/60_board_fields_helpers.php삭제boards/handler.php를 백업본으로 복원 (또는BF-PATCH3곳 제거)- 스킨 수정분 복원 (복사본 스킨을 썼다면 게시판의 스킨을 원래대로 선택)
DB 구조·데이터는 이 작업이 전혀 건드리지 않으므로 롤백 시 데이터 손실이 없다.
10. AI 작업 요약 (한눈에)
| 사용자 요청 | 할 일 |
|---|---|
| "목록에 {필드}가 보이게" | 1단계 + 2단계 + 관리자에서 해당 필드 "목록 노출" 체크 |
| "{필드}로 필터/검색" | 1단계 + 3단계(패치 3곳 + 폼 + 페이지네이션) + 관리자에서 "검색 필터" 체크 |
| "필터가 2페이지에서 풀려요" | 5-3 (3) 페이지네이션 연결 누락 |
| "비로그인에서만 필터가 안 먹어요" | 패치 2(캐시 조건) 누락 |
| "필터 결과 개수가 이상해요" | 패치 3 위치($where 조립 이후, COUNT 이전)·$params 순서 확인 |
| "가격 범위가 이상해요" | 값에 쉼표/문자가 섞여 있는지 확인 (7장 #1) |
| "글 상세 아래 목록에는 안 나와요" | 정상 (사실 #8). 필요 시 _list_rows.php 에 2단계 코드 추가 |
부록 A. 실행 검증 결과
환경: MariaDB 10.11 / PHP 8.3, 샘플 글 5건(게시판 3) + 타 게시판 1건, 위 헬퍼 코드를 그대로 실행. 각 케이스에서 ? 개수 = 바인딩 값 개수임을 확인했다.
| 케이스 | 결과 (글 ID) | 판정 |
|---|---|---|
| 필터 없음 | 1001~1005 (5건) | 정상 |
bf_region=서울 |
1001, 1003, 1005 | 정상 (타 게시판 글 2001 제외) |
| 서울 + 주차 | 1001 | 정상 |
| 체크박스 주차+에어컨 (AND) | 1001, 1002 | 정상 |
| 체크박스 엘리베이터 | 1002, 1005 | 정상 |
bfmin_price=1000 |
1001, 1002 | 정상 (빈 값 1003 제외) |
bfmax_price=1000 |
1001, 1004, 1005 | 1004 는 값이 12,000 → 쉼표 문제(7장 #1) |
bfmin 500 ~ bfmax 6000 |
1001, 1002 | 정상 |
bfmin_price=abc |
조건 무시 (5건) | 정상 |
bfq_memo=100%_ |
1001 | 정상 (%, _ 이스케이프) |
bfq_memo=1_0 |
0건 | 정상 (_ 가 와일드카드로 동작하지 않음) |
bfq_memo=조용 |
1002 | 정상 (한글) |
date bfmin_open_date=2026-10-15 / bfmax=2026-10-01 |
1002 / 1001 | 정상 |
datetime bfmax_start_at=2026-10-17 |
1001 | 정상 (그날 23:59 까지 포함) |
datetime bfmin_start_at=2026-10-18 |
1005 | 정상 |
is_search=0 필드 / 미정의 key |
조건 무시 | 정상 |
bf_region=서울' OR '1'='1 |
0건 | 정상 (바인딩으로 인젝션 무효) |
| 배열/이상값 | 조건 무시 | 정상 |
| 필터 폼 | 선택값 유지, hidden 유지, 검색 비대상 필드 미노출 | 정상 |
dx_bf_display |
url 은 http(s):// 만 링크, blocked: 는 텍스트, HTML 이스케이프 |
정상 |
패치된 handler.php |
php -l 문법 통과 |
정상 |
실제 DXCMS 전체를 구동한 통합 테스트는 아니다. 헬퍼 SQL/폼/표시 함수는 실제 MariaDB 로,
handler.php패치는 문법 검사로 검증했다. 최종 확인은 8장 체크리스트를 실제 사이트에서 수행해야 한다.
부록 B. 관련 파일
extend/top/50_board_fields.php ← 여분 필드 로더 (기존)
extend/top/60_board_fields_helpers.php ← 이 문서 3장 (신규)
boards/handler.php ← 5-2 패치 3곳 (case 'list' / 'search')
boards/skins/{스킨}/list.php ← 4장 목록 칸, 5-3 필터 폼·페이지네이션
boards/skins/gallery/_list_rows.php ← 카드 행 partial (view 하단 목록과 공유)
core/BoardFields.php ← getFields / getMeta (수정 불필요)