1. 실제 적용 흐름 전체 조감
이 챕터는 index.php와 Dispatcher.php의 실제 소스를 기반으로 Extend가 어떤 순서로, 어떤 상태에서 실행되는지를 단계별로 상세히 설명합니다.
1.1 index.php 전체 실행 순서 (STEP 1~7)
STEP 1 — 클래스•함수 정의 파일 로드
functions.php, DxCache, Secure, Database, HookManager, PluginRegistry, Auth, DxPoint, DxSite, DxTheme, DxExtend, Router, Dispatcher 순서로 require_once. 이 단계에서는 실행이 없고 정의만 합니다.
STEP 2 — 보안 초기화Secure::getInstance(). 세션 설정 → (조건부) 세션 시작 → 보안 헤더 발행 → CSRF 토큰 선제 발급.
STEP 3 — DB 연결 + 설정 로드data/config.php require. 이 안에서 Database::getInstance()->connect() 실행. DX_DEBUG, DX_SECRET_KEY 등 상수 확정.
STEP 4 — CMS 객체 초기화HookManager → PluginRegistry → load_plugins() → DxSite → DxTheme → Auth → DxContainer.
모든 플러그인의 dx_add_hook() 등록이 이 단계에서 완료됩니다.
DxExtend::ensureDirs()로 extend/ 폴더 자동 생성.
extend/top/ 실행 ← STEP 4 완료 직후DxExtend::getInstance()->runTop(["version"=>DX_VERSION, "path"=>dx_request_uri()]).
모든 초기화가 완료된 상태이므로 Database, Auth, DxSite, DxCache, HookManager 전부 사용 가능.
라우트 정보($GLOBALS["dx_route"])는 아직 미설정.
STEP 5 — 라우팅 + 디스패치routes/ 폴더 자동 로드 → DxRouter::dispatch() 시도 → 미매칭 시 Dispatcher 폴백. Dispatcher::dispatch() 내부:
① Router::resolve()로 라우트 확정 → $GLOBALS["dx_route"] 설정 🟡 extend/middle/ 실행
② switch($type) → 핸들러 실행 + 렌더링
extend/bottom/ 실행 ← 렌더링 완료 후
DxExtend::getInstance()->runBottom(["elapsed"=>경과ms]).HTML이 ob 버퍼에 완성된 상태. 헤더 변경 불가. ob_end_flush() 직전.
🔍 핵심 타임라인 요약
① top 실행 시점: Auth•DB•플러그인 전부 준비 완료. 라우트 정보 없음. 헤더•리다이렉트 가능.
② middle 실행 시점: $GLOBALS["dx_route"]["type"] 확정. 어떤 페이지인지 알고 처리 가능.
③ bottom 실행 시점: HTML 버퍼 완성. echo로 HTML 말단에 추가 가능. 헤더 변경 불가.
1.2 $GLOBALS["dx_route"] 구조 상세
middle/bottom 파일에서 사용할 수 있는 $GLOBALS["dx_route"] (또는 inject된 $route ) 배열의 실제 구조입니다. Router::resolve()가 URL을 분석하여 확정합니다.
📄 dx_route 배열 구조
// $GLOBALS["dx_route"] 전체 구조 (Dispatcher::dispatch() 직후)
// ── 공통 키 (모든 라우트 타입) ──────────────────────────
$route["type"] // "home" | "page" | "board" | "admin" | "auth" | "api" | "search" | "404"
$route["slug"] // 페이지 슬러그 ("about", "home", "free" 등)
// ── board 타입 전용 ────────────────────────────────────
$route["board_key"] // 게시판 키 (예: "free", "notice", "gallery")
$route["board"] // 게시판 전체 설정 배열 (DB에서 로드된 행)
$route["action"] // "list" | "view" | "write" | "edit" | "delete"
$route["id"] // 글 ID (view/edit/delete 시)
// ── page 타입 전용 ────────────────────────────────────
$route["page"] // 페이지 전체 설정 배열
// ── admin 타입 전용 ───────────────────────────────────
$route["admin_action"] // 관리자 액션 문자열
// middle context에서 inject된 변수 (extract()로 주입)
$type // $route["type"]과 동일
$route // 위 배열 전체
// 사용 예시:
if ($type === "board" && isset($route["board_key"])) {
$boardKey = $route["board_key"]; // "free", "notice" 등
}
1.3 context 변수 inject 상세
DxExtend::safeExec() 는 extract($context, EXTR_SKIP) 를 호출하여 컨텍스트 배열의 키를 파일 로컬 변수로 자동 주입합니다. 슬롯별로 주입되는 변수가 다릅니다.| 슬롯 | 주입되는 변수 | 타입 | 설명 |
|---|---|---|---|
| top | $version | string | DX_VERSION ("8.1.0") |
| top | $path | string | dx_request_uri() — 현재 요청 경로 |
| top | $dx_extend_slot | string | "top" — 항상 주입 |
| middle | $type | string | 라우트 타입 (board/page/home/…) |
| middle | $route | array | 전체 라우트 배열 (위 1.2 참조) |
| middle | $dx_extend_slot | string | "middle" — 항상 주입 |
| bottom | $elapsed | float | 경과 시간(ms) — microtime 기반 |
| bottom | $dx_extend_slot | string | "bottom" — 항상 주입 |
💡 EXTR_SKIP 안전 모드
extract($context, EXTR_SKIP)은 파일 내에 이미 같은 이름의 변수가 있으면 덮어쓰지 않습니다.
예: extend 파일 첫 줄에 $type = "custom"; 을 정의하면 context의 $type이 주입되지 않습니다.
반대로, context에 없는 변수는 주입되지 않으므로 isset() 확인이 필요합니다.
1.4 파일 수집 알고리즘과 실행 순서 결정
DxExtend::collectFiles($dir) 가 실제로 파일을 수집하는 방식과, 그 결과 실행 순서가 어떻게 결정되는지 구체적인 예시로 설명합니다.
📄 파일 수집 알고리즘 예시
// DxExtend::collectFiles() 단계별 동작
// ① 슬롯 폴더 직접 *.php 수집
$found = glob("extend/top/*.php");
// 결과: ["extend/top/01_darkmode_early.php", "extend/top/02_maintenance.php", ...]
// .disabled 파일은 *.php 패턴에 불일치 → 자동 제외
sort($found); // 파일명 오름차순 정렬 (문자열 비교)
// ② 하위 폴더 1단계 재귀
$subDirs = glob("extend/top/*", GLOB_ONLYDIR);
sort($subDirs); // 폴더명도 오름차순
// 예시 디렉터리:
// extend/top/
// 01_darkmode_early.php ← 직접 파일
// 05_maintenance.php ← 직접 파일
// security/ ← 하위 폴더
// 01_ip_block.php
// 02_rate_limit.php
// utils/ ← 하위 폴더
// 01_globals.php
// 최종 실행 순서:
// 1. extend/top/01_darkmode_early.php
// 2. extend/top/05_maintenance.php
// 3. extend/top/security/01_ip_block.php
// 4. extend/top/security/02_rate_limit.php
// 5. extend/top/utils/01_globals.php
2. 내장 Extend 파일 완전 분석
DXCMS v8.1.0에 기본 포함된 3개 파일의 실제 소스 코드를 전체 로직 흐름, 설계 의도, 주의사항까지 완전히 분석합니다.extend/top/ 01_darkmode_early.php
다크모드 FOUC(Flash of Unstyled Content) 완전 제거
▸ 동작 원리
이 파일의 핵심은 ob_start(callback) 입니다. 파일 자체는 HTML을 출력하지 않고, 출력 버퍼가 flush될 때 실행될 콜백 함수만 등록합니다. 콜백은 완성된 HTML 버퍼를 받아 두 곳을 수정합니다.
| 수정 위치 | 삽입 내용 | 목적 |
|---|---|---|
| <head> 직후 | 인라인 JS + 인라인 CSS | localStorage를 즉시 읽어 다크 클래스 적용 + 흰 화면 숨김 |
| </body> 직전 | 마이크로 스크립트 (3줄) | dx-dark-early 클래스 → body.dark 교체 (engine.js 로드 전 보정) |
단계별 실행 흐름
1. ob_start(callback) 등록
extend/top/ 실행 시 이 파일이 실행되어 ob_start()만 등록하고 종료. 실제 HTML 수정은 ob_end_flush() 시점에 발생.
2. 브라우저 요청 처리 & 렌더링
CMS가 정상적으로 라우팅, 핸들러 실행, HTML 생성을 모두 완료. ob 버퍼에 전체 HTML이 쌓임.
3. ob_end_flush() 호출 → 콜백 실행
콜백이 완성된 HTML을 받아 preg_replace로 <head> 다음과 </body> 앞에 스크립트•스타일 삽입.
4. 브라우저 수신 & 렌더링
<head> 안의 인라인 JS가 즉시 실행 → localStorage 확인 → 다크모드면 html에 dx-dark-early 클래스 + visibility:hidden 적용.
5. bottom/02_darkmode_engine.js 로드 완료
dx-darkmode-engine.js가 body.dark 클래스를 정식 적용하고 visibility:visible 복원 → 깜빡임 0.
실제 소스 전체 분석
📄 extend/top/01_darkmode_early.php 전체
<?php
// extend/top/01_darkmode_early.php
if (!defined("DX_CMS")) exit;
ob_start(function($buffer) {
/* ① FOUC 방지 인라인 스크립트 */
$earlyScript = "<script>"
. "(function(){"
. "try{"
. " var t=localStorage.getItem(\"dx-theme\");"
. " var sys=window.matchMedia&&window.matchMedia(\"(prefers-color-scheme: dark)\").matches;"
. " if(t===\"dark\"||( t===null&&sys)){"
. " document.documentElement.classList.add(\"dx-dark-early\");"
. " document.documentElement.style.visibility=\"hidden\";", // ← FOUC 방지
. " }"
. "}catch(e){}"
. "})();"
. "</script>";
/* ② FOUC 방지 인라인 CSS: 다크 배경 즉시 적용 */
$earlyStyle = "<style>"
. ".dx-dark-early body,.dx-dark-early{background-color:#0f172a!important;color:#f1f5f9!important}"
. ".dx-dark-early #adm_navi_sidebar,.dx-dark-early #adm_navi_top,"
. ".dx-dark-early #adm_navi_wrap{background-color:#0f172a!important}"
. "</style>";
/* ③ <head> 바로 뒤에 삽입 (preg_replace, 1회) */
$buffer = preg_replace(
"/<head([^>]*)>/i",
"<head$1>" . $earlyScript . $earlyStyle,
$buffer, 1
);
/* ④ </body> 직전 — early 클래스 → body.dark 교체 */
$lateScript = "<script>"
. "(function(){"
. " var html=document.documentElement;"
. " if(html.classList.contains(\"dx-dark-early\")){"
. " html.classList.remove(\"dx-dark-early\");"
. " document.body.classList.add(\"dark\");"
. " }"
. "})();"
. "</script>";
$buffer = preg_replace("/<\/body>/i", $lateScript . "</body>", $buffer, 1);
return $buffer; // ← 수정된 HTML 반환
});
// → 이 파일 실행 완료. 나머지는 ob 콜백이 처리.
⚠️ 설계 포인트
ob_start()를 top에서 등록하는 이유: 이미 출력이 시작된 후에는 ob_start()를 등록해도 그 이전 출력을 캡처할 수 없습니다.
EXTR_SKIP이 아닌 이유: 이 파일은 context 변수를 아예 사용하지 않으므로 extract 결과와 무관합니다.
preg_replace의 마지막 인자 1: <head>가 여러 번 등장하는 잘못된 HTML에서도 첫 번째만 처리합니다.
extend/middle/ 01_visit_tracker.php
방문자 통계 자동 기록 — 봇 완전 차단 + 비동기 DB 기록
설계 목표
이 파일의 핵심 설계 목표는 두 가지입니다.
• 응답 속도 보호: register_shutdown_function으로 DB INSERT를 응답 후에 처리합니다. 사용자는 통계 기록 시간을 기다리지 않습니다.
단계별 실행 흐름
1. 조기 탈출 — admin/api/정적파일 제외
$GLOBALS["dx_route"]["type"]가 "admin" 또는 "api"이면 즉시 return. /assets/, /favicon, .js/.css 등 정적 경로도 즉시 return.
2. 봇 판별 (40+ 패턴)
User-Agent를 소문자 변환 후 googlebot, bingbot, curl, python, selenium, puppeteer 등 40개 이상 패턴 순차 검사.
빈 UA도 봇으로 처리. 봇이면 즉시 return → DB 기록 없음.
3. IP 추출 (Cloudflare 우선)
HTTP_CF_CONNECTING_IP → HTTP_X_FORWARDED_FOR → HTTP_CLIENT_IP → REMOTE_ADDR 순서. 공인 IP만 허용(FILTER_FLAG_NO_PRIV_RANGE).
4. 브라우저 파싱
User-Agent에서 Edge/Opera/Samsung/Chrome/Firefox/Safari/IE/Whale 판별. DB 저장용.
5. 순방문자 판단 (DxCache — DB 조회 없음)
"vt_u:날짜:IP+브라우저 해시(12자)" 키로 자정까지 TTL 캐시. 캐시 히트 = 재방문, 미스 = 순방문. DB 쿼리 0.
6. 📦 shutdown 클로저에 데이터 캡처
date, ip, browser, is_unique, page_url, referer, ref_domain, ua, member_id, created_at 를 배열로 패킹하여 클로저에 전달.
7. ✅ 사용자에게 응답 전송 (fastcgi_finish_request)
register_shutdown_function 내에서 fastcgi_finish_request() 호출 → PHP-FPM 환경에서 즉시 응답 완료.
사용자는 이 시점에 페이지를 수신.
8. DB INSERT (응답 후 백그라운드)
① dx_visits UPSERT (INSERT ON DUPLICATE KEY): visit_count+1, unique_count 조건부 +1.
② visit_logs INSERT: ip, page_url, referer, referer_domain, ua, browser, member_id.
③ 1/200 확률로 90일 초과 로그 삭제.
실제 소스 핵심 로직 분석
📄 extend/middle/01_visit_tracker.php 핵심 로직
// extend/middle/01_visit_tracker.php 핵심 발췌
/* ─ 1단계: 조기 탈출 ─────────────────────────────── */
$_vt_route = $GLOBALS["dx_route"] ?? [];
$_vt_type = $_vt_route["type"] ?? "";
if (in_array($_vt_type, ["admin", "api"], true)) return;
$_vt_excl_prefixes = ["/ws-", "/api/", "/admin/", "/assets/", "/data/", "/favicon"];
foreach ($_vt_excl_prefixes as $_vt_ep) {
if (strpos($_vt_path, $_vt_ep) === 0) return;
}
/* ─ 2단계: 봇 판별 (40+ 패턴) ──────────────────── */
$_vt_bot_patterns = [
"googlebot","bingbot","curl","wget","python-requests",
"selenium","puppeteer","playwright","headlesschrome",
// ... (총 40+ 패턴)
];
if ($_vt_ua === "") { $_vt_isBot = true; }
else {
$ua_lower = strtolower($_vt_ua);
foreach ($_vt_bot_patterns as $_b) {
if (strpos($ua_lower, $_b) !== false) { $_vt_isBot = true; break; }
}
}
if ($_vt_isBot) return; // ← 봇: 기록 없이 종료
/* ─ 4단계: 순방문자 판단 (DxCache) ─────────────── */
$_vt_cacheKey = "vt_u:" . date("Y-m-d") . ":" . substr(md5($_vt_ip . $_vt_browser), 0, 12);
if (!DxCache::get($_vt_cacheKey, false)) {
DxCache::set($_vt_cacheKey, 1, strtotime("tomorrow") - time()); // 자정까지 TTL
$_vt_isUnique = true; // 순방문자
}
/* ─ 5단계: 응답 후 DB 기록 ─────────────────────── */
register_shutdown_function(function() use ($_vt_data) {
// PHP-FPM: 즉시 응답 완료 (사용자는 이미 페이지 수신)
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request();
}
$uniqueInc = $_vt_data["is_unique"] ? 1 : 0;
// ① dx_visits UPSERT (쿼리 1회)
$pdo->prepare(
"INSERT INTO `{$vtbl}` (visit_date, visit_count, unique_count)",
"VALUES (?, 1, ?)",
"ON DUPLICATE KEY UPDATE",
" visit_count = visit_count + 1,",
" unique_count = unique_count + {$uniqueInc}"
)->execute([$_vt_data["date"], $uniqueInc]);
// ② visit_logs INSERT
$pdo->prepare(
"INSERT INTO `{$ltbl}`",
"(visit_date,ip,page_url,referer,referer_domain,user_agent,browser,is_bot,member_id,created_at)",
"VALUES (?,?,?,?,?,?,?,0,?,?)"
)->execute([...]); // $_vt_data 값들
// ③ 1/200 확률 자동 정리 (90일 초과 삭제)
if (mt_rand(1, 200) === 1) {
$pdo->prepare("DELETE FROM `{$ltbl}` WHERE visit_date < ?")
->execute([date("Y-m-d", strtotime("-90 days"))]);
}
});
/* ─ 변수 정리 (전역 오염 방지) ─────────────────── */
unset($_vt_route, $_vt_type, $_vt_ua, $_vt_isBot, ...);
🔍 변수명 접두사 $_vt_ 의 의미
이 파일은 전역 스코프에서 실행됩니다. $type, $ip, $browser 같은 짧은 변수명은 다른 파일의 변수와 충돌할 수 있습니다.
$_vt_*(visit tracker) 접두사를 붙여 네임스페이스 충돌을 방지합니다. 파일 끝에서 unset()으로 모두 정리합니다.
이것이 extend 파일 작성의 베스트 프랙티스입니다: 고유 접두사 + 파일 끝 unset.
extend/bottom/ 02_darkmode_engine.php
다크모드 엔진 JS 주입 — 모든 페이지 </body> 직전
가장 단순한 내장 파일입니다. 모든 페이지(사이트 + 관리자)의 HTML 버퍼 말단에 다크모드 엔진 JS를 echo합니다.
📄 extend/bottom/02_darkmode_engine.php 전체
<?php
// extend/bottom/02_darkmode_engine.php
if (!defined("DX_CMS")) exit;
$_dmVer = defined("DX_VERSION") ? DX_VERSION : "1.0";
$_dmBase = rtrim(function_exists("dx_base_url") ? dx_base_url("") : "", "/");
// 다크모드 엔진 JS 로드 (버전 쿼리로 캐시 무효화)
echo '<script src="' . $_dmBase . '/assets/js/dx-darkmode-engine.js'
. '?v=' . htmlspecialchars($_dmVer, ENT_QUOTES, "UTF-8") . '"></script>' . "\n";
| 포인트 | 설명 |
|---|---|
| 슬롯 선택 이유 | bottom은 HTML 버퍼 말단 → </body> 직전에 JS가 위치 |
| 파일명 02_ | 내장 파일임을 표시. 사용자 파일을 03_이후로 배치하면 됨 |
| dx_base_url() | CMS 설치 경로가 서브디렉터리여도 올바른 URL 생성 |
| 버전 쿼리 ?v= | 브라우저 캐시 무효화. 업데이트 시 자동 갱신 |
| htmlspecialchars() | XSS 방지. 버전 문자열이 의도치 않게 특수문자를 포함할 경우 대비 |
3. 다양한 활용 사례
top / middle / bottom 3개 슬롯별로 실전에서 바로 사용할 수 있는 12가지 사례를 실제 코드와 함께 상세히 설명합니다.
extend/top/ 활용 사례
top 슬롯은 모든 초기화 완료 직후, 라우팅 전에 실행됩니다. 라우트 정보 없이 실행되므로 "모든 URL에 공통 적용되어야 하는" 처리에 적합합니다.
extend/top/ 01_maintenance.php
사례 T-1 — 서비스 점검 모드
핵심 기능: 관리자 IP는 정상 접근, 그 외 모든 요청에 503 페이지 반환. 비활성화: 파일명을 01_maintenance.php.disabled 로 변경하면 즉시 비활성화됩니다.
📄 extend/top/01_maintenance.php
<?php
// extend/top/01_maintenance.php
if (!defined("DX_CMS")) exit;
// ── 설정 ──────────────────────────────────────────
$_mt_active = true; // false → 이 파일 자체를 .disabled 로 관리
$_mt_adminIps = ["127.0.0.1", "123.45.67.89"]; // 통과시킬 관리자 IP
$_mt_endTime = "2026-05-01 06:00:00"; // 점검 완료 예정 시각
if (!$_mt_active) return;
// ── IP 추출 (Cloudflare / 프록시 지원) ────────────
$_mt_ip = trim(explode(",", (
$_SERVER["HTTP_CF_CONNECTING_IP"] ??
$_SERVER["HTTP_X_FORWARDED_FOR"] ??
$_SERVER["REMOTE_ADDR"] ?? ""))[0]);
if (in_array($_mt_ip, $_mt_adminIps, true)) return; // 관리자 → 통과
// ── 503 점검 페이지 출력 ──────────────────────────
http_response_code(503);
header("Retry-After: 3600");
header("Content-Type: text/html; charset=UTF-8");
echo '<!DOCTYPE html><html lang="ko"><head>
<meta charset="UTF-8"><title>서비스 점검 중</title>
<style>
body{margin:0;background:#0f172a;color:#f1f5f9;font-family:sans-serif;
display:flex;align-items:center;justify-content:center;min-height:100vh}
.box{text-align:center;padding:2rem}
h1{font-size:2.5rem;margin-bottom:1rem}
p{font-size:1.1rem;color:#94a3b8;line-height:1.8}
.time{margin-top:1.5rem;color:#38bdf8;font-weight:bold}
</style></head><body>
<div class="box">
<h1>🔧 서비스 점검 중</h1>
<p>더 나은 서비스를 위해 점검 중입니다.<br>이용에 불편을 드려 죄송합니다.</p>
<p class="time">예정 완료: ' . $_mt_endTime . '</p>
</div></body></html>';
exit; // ← 이후 모든 처리 중단
extend/top/ 02_ip_block.php
사례 T-2 — 동적 IP 차단 (DB + 하드코딩 혼합)
핵심 기능: 하드코딩 차단 목록과 DB 동적 차단 목록을 합산하여 처리. 캐시로 DB 조회를 최소화합니다.
📄 extend/top/02_ip_block.php
<?php
// extend/top/02_ip_block.php
if (!defined("DX_CMS")) exit;
// ── 하드코딩 차단 목록 (긴급 차단용) ──────────────
$_ib_staticList = [
"185.220.101.", // Tor 출구 노드 대역
"45.155.205.", // 알려진 스캐너
];
// ── DB 동적 차단 목록 (캐시 5분) ──────────────────
$_ib_cacheKey = "ip_block_list_v1";
$_ib_dbList = DxCache::get($_ib_cacheKey, []);
if (empty($_ib_dbList)) {
$db = Database::getInstance();
$rows = $db->query(
"SELECT ip FROM " . $db->table("ip_blocks") .
" WHERE (expires_at IS NULL OR expires_at > NOW())"
);
$_ib_dbList = array_column($rows, "ip");
DxCache::set($_ib_cacheKey, $_ib_dbList, 300);
}
$_ib_allList = array_merge($_ib_staticList, $_ib_dbList);
// ── IP 추출 ────────────────────────────────────────
$_ib_ip = trim(explode(",", (
$_SERVER["HTTP_CF_CONNECTING_IP"] ??
$_SERVER["HTTP_X_FORWARDED_FOR"] ??
$_SERVER["REMOTE_ADDR"] ?? ""))[0]);
// ── 차단 검사 ──────────────────────────────────────
foreach ($_ib_allList as $_ib_pattern) {
if (strpos($_ib_ip, $_ib_pattern) === 0 || $_ib_ip === $_ib_pattern) {
// 차단 이력 기록 (비동기)
register_shutdown_function(function() use ($_ib_ip) {
$db = Database::getInstance();
$db->query(
"INSERT INTO " . $db->table("ip_block_logs") .
" (ip, blocked_at) VALUES (?, NOW())",
[$_ib_ip]
);
});
http_response_code(403);
echo "403 Forbidden";
exit;
}
}
unset($_ib_staticList, $_ib_dbList, $_ib_allList, $_ib_ip, $_ib_cacheKey, $_ib_pattern);
extend/top/ 05_global_vars.php
사례 T-3 — 전역 변수 사전 주입 (캐시 활용)
핵심 기능: 모든 페이지에서 공통으로 사용하는 데이터(공지사항, 인기 태그, 사이트 통계 등)를 DB에서 한 번만 조회하여 $GLOBALS["dx_shared"] 에 캐싱합니다. 각 페이지에서 중복 쿼리를 방지합니다.
📄 extend/top/05_global_vars.php
<?php
// extend/top/05_global_vars.php
if (!defined("DX_CMS")) exit;
define("DX_SHARED_CACHE_TTL", 300); // 5분
$_gv_cacheKey = "dx_shared_v2";
$_gv_data = DxCache::get($_gv_cacheKey, null);
if ($_gv_data === null) {
$db = Database::getInstance();
// 최신 공지사항 3개
$_gv_notices = $db->query(
"SELECT id, title, created_at FROM " . $db->table("posts") .
" WHERE board_key='notice' AND status='published' ORDER BY id DESC LIMIT 3"
);
// 인기 태그 20개
$_gv_tags = $db->query(
"SELECT tag, COUNT(*) as cnt FROM " . $db->table("post_tags") .
" GROUP BY tag ORDER BY cnt DESC LIMIT 20"
);
// 사이트 통계 (회원수, 게시글수)
$_gv_stats = [
"members" => (int)($db->value("SELECT COUNT(*) FROM " . $db->table("members")) ?? 0),
"posts" => (int)($db->value("SELECT COUNT(*) FROM " . $db->table("posts")) ?? 0),
];
$_gv_data = [
"notices" => $_gv_notices,
"popular_tags"=> $_gv_tags,
"stats" => $_gv_stats,
"cached_at" => time(),
];
DxCache::set($_gv_cacheKey, $_gv_data, DX_SHARED_CACHE_TTL);
}
// 전역 주입 → 모든 스킨/페이지 파일에서 접근 가능
$GLOBALS["dx_shared"] = $_gv_data;
// 스킨에서 사용 예시:
// $notices = $GLOBALS["dx_shared"]["notices"] ?? [];
// foreach ($notices as $n) { echo $n["title"]; }
unset($_gv_cacheKey, $_gv_data, $_gv_notices, $_gv_tags, $_gv_stats);
extend/top/ 10_rate_limiter.php
사례 T-4 — Rate Limiter (분당 요청 수 제한)
핵심 기능: IP별 분당 요청 수를 캐시로 추적하여 임계값 초과 시 429 Too Many Requests 반환. DDoS 경감에 유효합니다.
📄 extend/top/10_rate_limiter.php
<?php
// extend/top/10_rate_limiter.php
if (!defined("DX_CMS")) exit;
// ── 설정 ──────────────────────────────────────────
$_rl_limit = 120; // 분당 최대 요청 수
$_rl_window = 60; // 윈도우 (초)
// API•admin은 별도 제한 (더 엄격하게)
$_rl_type = $GLOBALS["dx_route"]["type"] ?? ""; // top이므로 dx_route 없을 수도 있음
// → top에서는 dx_route 미확정. 대신 REQUEST_URI로 판단
$_rl_uri = $_SERVER["REQUEST_URI"] ?? "/";
if (strpos($_rl_uri, "/api/") !== false) { $_rl_limit = 30; }
// ── IP 추출 ────────────────────────────────────────
$_rl_ip = trim(explode(",", (
$_SERVER["HTTP_CF_CONNECTING_IP"] ??
$_SERVER["HTTP_X_FORWARDED_FOR"] ??
$_SERVER["REMOTE_ADDR"] ?? "127.0.0.1"))[0]);
// ── 요청 카운트 ────────────────────────────────────
$_rl_key = "rl:" . $_rl_ip . ":" . floor(time() / $_rl_window);
$_rl_count = (int)DxCache::get($_rl_key, 0);
$_rl_count++;
DxCache::set($_rl_key, $_rl_count, $_rl_window + 5);
// ── 한도 초과 ──────────────────────────────────────
if ($_rl_count > $_rl_limit) {
http_response_code(429);
header("Retry-After: " . $_rl_window);
header("X-RateLimit-Limit: " . $_rl_limit);
header("X-RateLimit-Remaining: 0");
echo json_encode(["error" => "Too many requests. Retry after " . $_rl_window . "s."]);
exit;
}
header("X-RateLimit-Limit: " . $_rl_limit);
header("X-RateLimit-Remaining: " . max(0, $_rl_limit - $_rl_count));
unset($_rl_limit, $_rl_window, $_rl_ip, $_rl_key, $_rl_count, $_rl_type, $_rl_uri);
extend/middle/ 활용 사례
middle 슬롯은 라우트 확정 직후, 핸들러 실행 전에 실행됩니다. $route["type"] 로 어떤 페이지인지 알 수 있어 "라우트 타입별 처리"에 적합합니다.
extend/middle/ 02_access_log.php
사례 M-1 — 상세 접근 로그 (파일 기록)
핵심 기능: 관리자 페이지 및 인증 행위를 파일 로그에 기록합니다. 보안 감사(audit trail)에 활용합니다. 봇 자동 제외, 비동기 기록.
📄 extend/middle/02_access_log.php
<?php
// extend/middle/02_access_log.php
if (!defined("DX_CMS")) exit;
// ── 기록 대상 라우트만 처리 ───────────────────────
$_al_type = $route["type"] ?? ""; // context에서 inject됨
$_al_targets = ["admin", "auth", "api"];
if (!in_array($_al_type, $_al_targets, true)) return;
// ── 봇 제외 ────────────────────────────────────────
$_al_ua = $_SERVER["HTTP_USER_AGENT"] ?? "";
if (preg_match("/bot|crawl|spider|curl|wget/i", $_al_ua)) return;
// ── 로그 데이터 수집 ───────────────────────────────
$_al_user = class_exists("Auth") && Auth::getInstance()->isLoggedIn()
? Auth::getInstance()->get("nickname", "guest") : "guest";
$_al_method = $_SERVER["REQUEST_METHOD"] ?? "GET";
$_al_ip = function_exists("dx_ip") ? dx_ip() : ($_SERVER["REMOTE_ADDR"] ?? "?");
$_al_uri = substr($_SERVER["REQUEST_URI"] ?? "/", 0, 300);
$_al_action = $route["admin_action"] ?? ($route["slug"] ?? "");
// ── 비동기 파일 기록 ────────────────────────────────
register_shutdown_function(function() use (
$_al_type, $_al_action, $_al_user, $_al_method, $_al_ip, $_al_uri, $_al_ua
) {
if (function_exists("fastcgi_finish_request")) fastcgi_finish_request();
$line = implode(" | ", [
date("Y-m-d H:i:s"),
strtoupper($_al_type),
$_al_action,
$_al_user,
$_al_method,
$_al_ip,
$_al_uri,
]) . PHP_EOL;
$logFile = DX_ROOT . "/data/logs/access_" . date("Y-m") . ".log";
$logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
});
unset($_al_type, $_al_targets, $_al_ua, $_al_user, $_al_method, $_al_ip, $_al_uri, $_al_action);
extend/middle/ 10_ab_test.php
사례 M-2 — A/B 테스트 (홈페이지 변형 테스트)
핵심 기능: 세션 기반으로 A/B 그룹을 결정하고, $GLOBALS["ab_variant"] 에 변형 데이터를 주입합니다. 홈 스킨 파일에서 이 변수를 읽어 다른 UI를 렌더링합니다.
📄 extend/middle/10_ab_test.php
<?php
// extend/middle/10_ab_test.php
if (!defined("DX_CMS")) exit;
// home 라우트에서만 적용 ($type은 context에서 inject)
if (($type ?? "") !== "home") return;
// ── 세션 기반 그룹 결정 (재방문 시 유지) ─────────
if (!isset($_SESSION["ab_group"])) {
$_SESSION["ab_group"] = mt_rand(0, 99) < 50 ? "A" : "B";
}
$_ab_group = $_SESSION["ab_group"];
// ── 변형 데이터 정의 ───────────────────────────────
$_ab_variants = [
"A" => [
"hero_title" => "최고의 커뮤니티에 오신 것을 환영합니다",
"hero_cta" => "지금 가입하기",
"cta_color" => "#2563EB",
"layout" => "hero_centered",
],
"B" => [
"hero_title" => "함께 성장하는 공간, 지금 시작하세요",
"hero_cta" => "무료로 시작하기",
"cta_color" => "#16A34A",
"layout" => "hero_left",
],
];
// ── 전역 주입 ──────────────────────────────────────
$GLOBALS["ab_group"] = $_ab_group;
$GLOBALS["ab_variant"] = $_ab_variants[$_ab_group];
// 스킨 파일(home.php)에서 사용 예시:
// $variant = $GLOBALS["ab_variant"] ?? [];
// echo '<h1>' . ($variant["hero_title"] ?? "") . '</h1>';
// ── 전환 이벤트 비동기 기록 ────────────────────────
register_shutdown_function(function() use ($_ab_group) {
if (function_exists("fastcgi_finish_request")) fastcgi_finish_request();
$db = Database::getInstance();
$db->query(
"INSERT INTO " . $db->table("ab_impressions") . " (group_name, page, created_at) VALUES (?, ?, NOW())",
[$_ab_group, "home"]
);
});
unset($_ab_group, $_ab_variants);
extend/middle/ 20_board_access.php
사례 M-3 — 게시판별 비회원 접근 제어
핵심 기능: 특정 게시판을 비회원에게 차단합니다. 플러그인 코드 없이 extend 파일 하나로 처리합니다.
📄 extend/middle/20_board_access.php
<?php
// extend/middle/20_board_access.php
if (!defined("DX_CMS")) exit;
// board 라우트에서만 처리
if (($type ?? "") !== "board") return;
// ── 회원 전용 게시판 목록 ─────────────────────────
$_ba_membersOnly = ["secret", "staff", "vip", "premium"];
$_ba_boardKey = $route["board_key"] ?? "";
if (!in_array($_ba_boardKey, $_ba_membersOnly, true)) return; // 비대상 게시판
// ── 로그인 여부 확인 ──────────────────────────────
if (!Auth::getInstance()->isLoggedIn()) {
// 로그인 후 원래 페이지로 복귀하도록 return URL 포함
$returnUrl = urlencode(dx_request_uri());
dx_redirect(dx_base_url("auth/login?return=" . $returnUrl));
exit;
}
// ── VIP 게시판: 레벨 제한 ─────────────────────────
if ($_ba_boardKey === "vip") {
$_ba_userLevel = (int)Auth::getInstance()->get("level", 0);
if ($_ba_userLevel < 10) {
http_response_code(403);
dx_redirect(dx_base_url("?msg=level_required"));
exit;
}
}
unset($_ba_membersOnly, $_ba_boardKey);
extend/middle/ 30_feature_flags.php
사례 M-4 — 기능 플래그 (Feature Flags)
핵심 기능: DB에서 기능 플래그를 읽어 특정 사용자/IP/라우트에만 새 기능을 점진적으로 배포합니다.
📄 extend/middle/30_feature_flags.php
<?php
// extend/middle/30_feature_flags.php
if (!defined("DX_CMS")) exit;
// ── 플래그 로드 (캐시 10분) ───────────────────────
$_ff_key = "feature_flags_v1";
$_ff_flags = DxCache::get($_ff_key, null);
if ($_ff_flags === null) {
$db = Database::getInstance();
$rows = $db->query(
"SELECT flag_key, enabled, target_group FROM " . $db->table("feature_flags") .
" WHERE enabled = 1"
);
$_ff_flags = [];
foreach ($rows as $row) {
$_ff_flags[$row["flag_key"]] = $row["target_group"];
}
DxCache::set($_ff_key, $_ff_flags, 600);
}
// ── 현재 사용자 그룹 판별 ─────────────────────────
$_ff_userId = Auth::getInstance()->isLoggedIn() ? (int)Auth::getInstance()->get("id", 0) : 0;
$_ff_isBeta = $_ff_userId > 0 && $_ff_userId % 10 < 2; // 20% 베타 사용자
$_ff_isStaff = Auth::getInstance()->isLoggedIn() && Auth::getInstance()->get("is_staff", 0);
// ── 활성 플래그 계산 ──────────────────────────────
$_ff_active = [];
foreach ($_ff_flags as $flagKey => $targetGroup) {
switch ($targetGroup) {
case "all": $_ff_active[$flagKey] = true; break;
case "beta": $_ff_active[$flagKey] = $_ff_isBeta; break;
case "staff": $_ff_active[$flagKey] = (bool)$_ff_isStaff; break;
}
}
// ── 전역 주입 ──────────────────────────────────────
$GLOBALS["feature_flags"] = $_ff_active;
// 스킨/핸들러에서 사용 예시:
// if ($GLOBALS["feature_flags"]["new_editor"] ?? false) {
// // 새 에디터 렌더링
// }
unset($_ff_key, $_ff_flags, $_ff_active, $_ff_userId, $_ff_isBeta, $_ff_isStaff);
extend/bottom/ 활용 사례
bottom 슬롯은 렌더링 완료 후, ob_end_flush() 직전에 실행됩니다. HTML이 이미 버퍼에 완성된 상태이므로 헤더 변경은 불가하지만 echo로 HTML에 추가가 가능합니다.
extend/bottom/ 01_page_cache.php
사례 B-1 — HTML 페이지 캐시 저장
핵심 기능: 렌더링 완료된 HTML을 파일에 저장합니다. Nginx/Apache에서 이 파일을 직접 서빙하면 PHP 실행 없이 응답 가능합니다.
📄 extend/bottom/01_page_cache.php
<?php
// extend/bottom/01_page_cache.php
if (!defined("DX_CMS")) exit;
// ── 캐시 대상 조건 ────────────────────────────────
$_pc_route = $GLOBALS["dx_route"] ?? [];
if ($_SERVER["REQUEST_METHOD"] !== "GET") return;
if (Auth::getInstance()->isLoggedIn()) return; // 로그인 사용자 제외
if (!empty($_GET)) return; // 쿼리스트링 제외
if (!in_array($_pc_route["type"] ?? "", ["home", "page"], true)) return;
// ── ob 버퍼 읽기 (flush 전이므로 가능) ───────────
$_pc_html = ob_get_contents();
if (!$_pc_html || strlen($_pc_html) < 200) return;
// ── 캐시 파일 경로 ────────────────────────────────
$_pc_uri = preg_replace("/[^a-zA-Z0-9\/\-_]/", "_", $_SERVER["REQUEST_URI"] ?? "/");
$_pc_file = DX_ROOT . "/data/cache/pages" . $_pc_uri . "/index.html";
$_pc_dir = dirname($_pc_file);
if (!is_dir($_pc_dir)) mkdir($_pc_dir, 0755, true);
// ── 저장 (만료 메타 주석 포함) ───────────────────
$_pc_expires = time() + 300; // 5분
$_pc_content = "<!-- dx-cache:expires=" . $_pc_expires . " -->\n" . $_pc_html;
file_put_contents($_pc_file, $_pc_content, LOCK_EX);
// ── 만료 파일 정리 (5% 확률) ────────────────────
if (mt_rand(1, 20) === 1) {
$cacheBase = DX_ROOT . "/data/cache/pages";
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cacheBase));
foreach ($iterator as $_pc_f) {
if ($_pc_f->isFile() && $_pc_f->getExtension() === "html") {
if ($_pc_f->getMTime() + 300 < time()) @unlink($_pc_f->getPathname());
}
}
}
unset($_pc_route, $_pc_html, $_pc_uri, $_pc_file, $_pc_dir, $_pc_expires, $_pc_content);
extend/bottom/ 05_slow_query_alert.php
사례 B-2 — 느린 페이지 Slack 알림
핵심 기능: 응답 시간이 임계값을 초과한 페이지를 감지하여 Slack Webhook으로 실시간 알림을 발송합니다.
📄 extend/bottom/05_slow_query_alert.php
<?php
// extend/bottom/05_slow_query_alert.php
if (!defined("DX_CMS")) exit;
// ── 임계값 설정 ───────────────────────────────────
$_sq_warnMs = 1000; // 1초 초과 시 경고
$_sq_critMs = 3000; // 3초 초과 시 위험
// context에서 inject된 $elapsed 사용
$_sq_elapsed = $elapsed ?? round((microtime(true) - DX_START) * 1000, 2);
$_sq_queries = Database::getInstance()->getQueryCount();
if ($_sq_elapsed < $_sq_warnMs && $_sq_queries < 50) return; // 정상
// ── 알림 데이터 구성 ──────────────────────────────
$_sq_route = $GLOBALS["dx_route"] ?? [];
$_sq_url = $_SERVER["REQUEST_URI"] ?? "/";
$_sq_level = $_sq_elapsed >= $_sq_critMs ? "🔴 위험" : "🟡 경고";
$_sq_message = implode("\n", [
$_sq_level . " *느린 페이지 감지*",
"• URL: `" . $_sq_url . "`",
"• 응답시간: `" . $_sq_elapsed . "ms`",
"• DB쿼리: `" . $_sq_queries . "회`",
"• 라우트: `" . ($route["type"] ?? "?") . "`",
"• 시각: `" . date("Y-m-d H:i:s") . "`",
]);
// ── 비동기 Slack 발송 ────────────────────────────
register_shutdown_function(function() use ($_sq_message) {
if (function_exists("fastcgi_finish_request")) fastcgi_finish_request();
$webhookUrl = dx_config("slack_perf_webhook", "");
if (!$webhookUrl) return;
$payload = json_encode(["text" => $_sq_message]);
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
]);
curl_exec($ch);
curl_close($ch);
});
unset($_sq_warnMs, $_sq_critMs, $_sq_elapsed, $_sq_queries, $_sq_route, $_sq_url, $_sq_level, $_sq_message);
extend/bottom/ 10_html_minify.php
사례 B-3 — HTML Minify (출력 버퍼 가로채기)
핵심 기능: ob_get_clean() + ob_start()를 사용하여 완성된 HTML에서 불필요한 공백과 주석을 제거합니다. 페이지 전송 크기를 10~20% 절감합니다.
📄 extend/bottom/10_html_minify.php
<?php
// extend/bottom/10_html_minify.php
if (!defined("DX_CMS")) exit;
// 개발 모드에서는 비활성화 (가독성 유지)
if (defined("DX_DEBUG") && DX_DEBUG) return;
// JSON/API 응답에는 적용 안 함
$_hm_ct = $_SERVER["HTTP_ACCEPT"] ?? "";
if (strpos($_hm_ct, "application/json") !== false) return;
// ── 현재 버퍼를 가져와서 교체 ───────────────────
$_hm_html = ob_get_clean(); // 버퍼 내용 읽고 종료
ob_start(); // 새 버퍼 시작
// ── Minify 처리 ──────────────────────────────────
$_hm_html = preg_replace([
'/<!--(?!\[if|<!)[\s\S]*?-->/', // 조건부 주석 제외한 HTML 주석 제거
'/\s+/', // 연속 공백 → 단일 공백
'/>\s+</', // 태그 사이 공백 제거
], [
'',
' ',
'><',
], $_hm_html);
// <script>, <pre>, <textarea> 안 내용은 공백 보존 필요
// → 완전한 Minify는 전용 라이브러리 사용 권장
echo $_hm_html; // 새 버퍼에 minify된 HTML 출력
unset($_hm_ct, $_hm_html);
// ob_end_flush()는 index.php가 처리
⚠️ HTML Minify 주의사항
<script>, <pre>, <textarea>, <code> 내부 내용은 공백이 의미 있을 수 있습니다. 위 코드는 단순화 버전이며, 프로덕션에서는 전용 라이브러리 사용을 권장합니다.
ob_get_clean() + ob_start() 패턴을 사용할 때 이후에 ob_end_flush()를 호출하는 주체(index.php)가 있는지 확인하세요. 없으면 버퍼가 버려집니다.
top에서 01_darkmode_early.php가 ob_start(callback)을 등록한 경우, bottom에서 ob_get_clean()을 호출하면 그 콜백이 실행됩니다. 순서에 주의하세요.
4. 슬롯 간 협력 패턴
하나의 기능을 여러 슬롯의 파일이 협력하여 구현하는 패턴입니다. 내장 파일(다크모드)이 이 패턴을 사용하며, 실전에서도 자주 활용됩니다.
4.1 top → bottom 협력 (데이터 전달)
패턴: top에서 $GLOBALS 에 데이터를 설정하고, bottom에서 해당 데이터를 읽어 HTML에 추가합니다.📄 extend/top/05_performance_start.php
// extend/top/05_performance_start.php
// ─ top: 측정 시작점 기록 ───────────────────────────────
$GLOBALS["_perf"] = [
"start" => microtime(true),
"start_mem" => memory_get_usage(true),
];
📄 extend/bottom/95_performance_overlay.php
// extend/bottom/95_performance_overlay.php
// ─ bottom: 수집 완료 후 오버레이 출력 ───────────────────
if (!defined("DX_CMS")) exit;
if (!defined("DX_DEBUG") || !DX_DEBUG) return;
$_po_perf = $GLOBALS["_perf"] ?? [];
$_po_elapsed = round((microtime(true) - ($_po_perf["start"] ?? microtime(true))) * 1000, 2);
$_po_memDiff = round((memory_get_usage(true) - ($_po_perf["start_mem"] ?? 0)) / 1024, 1);
$_po_queries = Database::getInstance()->getQueryCount();
echo '<div style="position:fixed;bottom:0;left:0;right:0;background:#0F172A;color:#E2E8F0;'
. 'padding:8px 16px;font-family:monospace;font-size:12px;z-index:99999;'
. 'display:flex;gap:24px;align-items:center">'
. '<span>⚡ ' . $_po_elapsed . 'ms</span>'
. '<span>🗄 ' . $_po_queries . ' queries</span>'
. '<span>💾 ' . $_po_memDiff . ' KB</span>'
. '<span>📍 ' . htmlspecialchars($GLOBALS["dx_route"]["type"] ?? "?") . '</span>'
. '</div>';
unset($_po_perf, $_po_elapsed, $_po_memDiff, $_po_queries);
💡 top→bottom 협력 실전 사용 사례
다크모드: top에서 ob_start 등록 → bottom에서 engine.js 로드
성능 측정: top에서 측정 시작 → bottom에서 완료 후 오버레이 출력
보안 헤더 커스터마이징: top에서 nonce 생성 → bottom에서 CSP 헤더 출력 (실제론 헤더 변경 불가하므로 meta http-equiv 삽입)
A/B 테스트: middle에서 그룹 설정 → bottom에서 그룹별 분석 스크립트 삽입
4.2 middle → bottom 협력 (라우트 컨텍스트 활용)
📄 extend/middle/40_breadcrumb.php
// extend/middle/40_breadcrumb.php
// ─ middle: 라우트 기반 브레드크럼 데이터 생성 ───────────
if (!defined("DX_CMS")) exit;
$_bc_type = $type ?? "";
$_bc_route = $route ?? [];
$_bc_items = [["label" => "홈", "url" => dx_base_url()]];
switch ($_bc_type) {
case "board":
$boardName = $_bc_route["board"]["board_name"] ?? "게시판";
$_bc_items[] = ["label" => $boardName,
"url" => dx_base_url("board/" . ($_bc_route["board_key"] ?? ""))];
if (($_bc_route["action"] ?? "") === "view") {
$_bc_items[] = ["label" => "글 상세", "url" => ""];
}
break;
case "page":
$_bc_items[] = ["label" => $_bc_route["page"]["title"] ?? "페이지", "url" => ""];
break;
}
$GLOBALS["breadcrumb"] = $_bc_items;
unset($_bc_type, $_bc_route, $_bc_items);
📄 extend/bottom/40_breadcrumb_schema.php
// extend/bottom/40_breadcrumb_schema.php
// ─ bottom: breadcrumb JSON-LD 스키마 삽입 ──────────────
if (!defined("DX_CMS")) exit;
$_bcs_items = $GLOBALS["breadcrumb"] ?? [];
if (count($_bcs_items) < 2) return; // 홈만 있으면 스키마 불필요
$_bcs_list = [];
foreach ($_bcs_items as $i => $item) {
$_bcs_list[] = [
"@type" => "ListItem",
"position" => $i + 1,
"name" => $item["label"],
"item" => $item["url"] ?: dx_current_url(),
];
}
$_bcs_schema = json_encode([
"@context" => "https://schema.org",
"@type" => "BreadcrumbList",
"itemListElement" => $_bcs_list,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo '<script type="application/ld+json">' . $_bcs_schema . '</script>' . "\n";
unset($_bcs_items, $_bcs_list, $_bcs_schema);
5. 트러블슈팅 & 디버깅
5.1 실행된 파일 목록 확인
DxExtend::getExecuted() 는 지금까지 실행된 모든 extend 파일의 슬롯 + 파일명 배열을 반환합니다. 어떤 파일이 실행됐는지 확인하는 데 사용합니다.📄 extend/bottom/99_debug_extend.php
// extend/bottom/99_debug_extend.php (DX_DEBUG 모드에서만 활성)
if (!defined("DX_CMS")) exit;
if (!defined("DX_DEBUG") || !DX_DEBUG) return;
$executed = DxExtend::getInstance()->getExecuted();
// 반환 형식: [["slot"=>"top","file"=>"01_darkmode_early.php"], ...]
echo '<div style="position:fixed;bottom:50px;right:10px;background:#1E293B;color:#E2E8F0;'
. 'padding:12px;border-radius:8px;font-family:monospace;font-size:11px;z-index:9999;max-width:300px">'
. '<strong>Extend 실행 목록</strong><br>';
foreach ($executed as $ex) {
$slotColors = ["top"=>"#EF4444","middle"=>"#F59E0B","bottom"=>"#22C55E"];
$color = $slotColors[$ex["slot"]] ?? "#94A3B8";
echo '<span style="color:' . $color . '">['.$ex["slot"].']</span> '
. htmlspecialchars($ex["file"]) . '<br>';
}
echo '</div>';
5.2 자주 발생하는 문제와 해결
| 증상 | 원인 | 해결책 |
|---|---|---|
| 파일이 실행되지 않음 | .php 확장자가 없음 (.disabled 상태) 또는 파일이 2단계 이상 하위 폴더에 있음 | 파일명 확인. 하위 폴더는 1단계만 지원. |
| 변수 undefined 오류 | top 파일에서 $type, $route 사용 시도 (middle 컨텍스트 변수) | top에서는 $GLOBALS["dx_route"]도 미설정. REQUEST_URI로 판단 |
| header() 경고 | bottom에서 헤더 변경 시도 (출력 이미 완료) | bottom에서는 헤더 변경 불가. echo로 HTML만 추가 |
| 다른 파일 실행 중단 | exit/die 호출 | exit는 명확한 의도(점검모드 등)에만 사용. 단순 스킵은 return 사용 |
| $GLOBALS 오염 | 짧은 변수명($type, $ip 등) 사용 | 고유 접두사 사용 ($_vt_, $_mt_ 등) + 파일 끝 unset() |
| ob_start 중복 등록 | top 파일 여러 개가 모두 ob_start 등록 | 같은 파일에서 ob_start를 한 번만 등록. 또는 ob_get_level() 확인 |
Extend 실제 소스 코드 완전 분석 • 12가지 실전 사례