본문 바로가기
디자인원엑스커뮤니티의 새로운 기본
홈 고객센터
Latest Version
DXCMS v10.3.02026.06.15 배포 D+125일 (4개월)
  • 155다운로드
  • 0테마
  • 59플러그인
  • 7스킨

아직도 짤리는지 체크해주세요.

D DX관리자
2026.10.18 01:10(수정됨) 3 0 5

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, 폼 name bf_{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. 관리자가 설정하는 순서

  1. 관리자 → 게시판 관리 → 해당 게시판의 여분 필드 아이콘 → 필드 추가/수정
  2. 목록에 보일 필드는 "목록 노출" 체크
  3. 필터로 쓸 필드는 "검색 필터" 체크
  4. 저장 → 해당 게시판 목록 화면에서 즉시 반영 (스킨에 2·3단계가 적용되어 있을 때)
  5. 칸 순서는 정렬 순서(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. 롤백

  1. extend/top/60_board_fields_helpers.php 삭제
  2. boards/handler.php 를 백업본으로 복원 (또는 BF-PATCH 3곳 제거)
  3. 스킨 수정분 복원 (복사본 스킨을 썼다면 게시판의 스킨을 원래대로 선택)

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 (수정 불필요)

댓글5

디
디엑스AI 2026.10.18 01:10

관리자님, 네, 이번 글도 중간에서 끊겨 전달됐습니다. 지난번보다는 조금 더 왔습니다. 본문은 3장 헬퍼 코드의 dx_bf_meta_map 함수 안, 반복문이 시작되는 "for" 부분에서 "(이하 생략)"으로 끝났습니다.

받은 범위는 0장 에이전트 지침, 1장 사실 8가지, 2장 구현 방식 선택, 3장의 함수 목록 표와 dx_bf_list_fields 함수 전체입니다.

못 받은 범위는 dx_bf_meta_map의 나머지, dx_bf_display, dx_bf_build_filter, dx_bf_render_filter_form 함수, 2단계 목록 칸, 3단계 handler.php 패치와 필터 폼, 4단계 체크리스트, 부록 A입니다. 제가 받은 공식 매뉴얼 자료에도 중간중간 생략된 곳이 있어서 빈 부분을 메울 수 없습니다. 못 받은 코드와 패치 위치는 짐작해서 말하지 않겠습니다.

글 길이가 길면 코드 구간에서 잘리는 것으로 보이지만, 정확한 기준은 제 자료에서 확인되지 않습니다. 함수 두세 개 단위로 나눠 올리시면 더 멀리 전달될 가능성이 있습니다.

※ AI가 자동으로 작성한 댓글입니다. 참고용으로만 봐주세요.

D
DX관리자 2026.10.18 01:11
이유는 다음과 같은 것 같아요.
코드 블럭 때문인듯 합니다.
소스 길이가 길면 인식을 못하는 것 같아요.

@디엑스
디
디엑스AI 2026.10.18 01:11

관리자님, 말씀하신 가설이 제가 받은 자료와 잘 맞습니다. 이번 글은 3장 코드 블록 안 dx_bf_meta_map 함수의 for 지점에서 끊겼고, 앞선 두 글도 모두 같은 헬퍼 코드 구간에서 끊겼습니다. 표와 일반 문장은 끝까지 왔는데 코드가 시작되면 멀리 못 가는 모습입니다. 공식 매뉴얼로 전달된 자료에서도 코드 앞뒤가 (…)로 생략된 곳이 많았습니다.

다만 코드 블록 때문인지 전체 길이 때문인지는 제 쪽에서 구분할 수 없고, 전달 과정에서 어떤 제한이 걸리는지도 제 자료에는 없습니다. 그래서 확정은 못 하고 가능성이 높다고만 말씀드립니다.

원인을 가리려면 두 가지를 해 보시면 됩니다. 하나는 코드 없이 같은 길이의 긴 일반 텍스트를 올려서 끝까지 오는지 보는 것입니다. 다른 하나는 짧은 코드 블록 하나만 올려서 온전히 오는지 보는 것입니다. 결과를 보면 코드 블록 문제인지 길이 문제인지 좁혀질 것 같습니다. 코드를 함수 하나씩 나눠 올리시는 방법도 있습니다.

※ AI가 자동으로 작성한 댓글입니다. 참고용으로만 봐주세요.

D
DX관리자 2026.10.18 01:14
좋은 제안이네요. 
다만, 그렇다고 모든 것을 그렇게 할 수는 없겠죠.
시스템 개발 부분은 내가 알아서 처리하도록 하겠습니다.
피곤함이 좀 가라앉으면 하도록 할께요.
제안을 받아서 문서를 만들겠습니다.
디
디엑스AI 2026.10.18 01:14

관리자님, 제안을 좋게 봐 주셔서 감사합니다. 말씀대로 모든 글을 그렇게 쪼갤 수는 없으니, 시스템 개발 쪽은 관리자님 판단대로 처리하시는 게 맞습니다. 저는 전달 방식에 대해 더 말씀드리지 않겠습니다.

문서는 피곤함이 가라앉으신 뒤에 천천히 만드셔도 됩니다. 급할 것이 없으니 컨디션부터 챙기세요. 나중에 짧은 코드 블록이나 긴 일반 텍스트 글을 올리시고 @디엑스로 불러 주시면, 받은 범위만 읽고 어디까지 왔는지 있는 그대로 알려드리겠습니다.

지금은 근무 중이시니 퇴근 시간인 새벽 2시까지 중간중간 숨 돌리면서 무리하지 마세요.

※ AI가 자동으로 작성한 댓글입니다. 참고용으로만 봐주세요.

로그인 후 댓글을 작성할 수 있습니다.
NO TITLE AUTHOR DATE VIEW
공지
D DX관리자
05.22 295
54
DX관리자
10.18 2
DX관리자 · 2
53
DX관리자
10.18 2
DX관리자 · 2
47
D DX러브맨
10.16 8
D DX러브맨 · 8
46
DX관리자
10.16 7
DX관리자 · 7
44
DX관리자
10.16 6
DX관리자 · 6
내 플레이리스트
플레이리스트가 비어있습니다
스튜디오 게시판에서
플레이리스트에 담기 버튼을
눌러보세요
목록 로그인
TODAY 가입0 게시글8 댓글56 방문18
TOTAL 회원56 게시글1,633 댓글3,532 방문56,039
현재 접속1
목록