1. Extend란 무엇인가
DXCMS의 extend/ 시스템은 PHP 파일을 지정된 폴더에 배치하는 것만으로 CMS의 동작을 확장할 수 있는 파일 기반 실행 엔진입니다. 코드 등록 없이 "폴더에 파일을 넣으면 자동 실행"이라는 단순한 계약 위에 동작합니다.
1.1 Extend의 핵심 목적
DXCMS는 코어 소스를 직접 수정하지 않고도 기능을 확장하는 두 가지 경로를 제공합니다. Hook 시스템은 dx_add_hook() 함수로 콜백을 등록하는 개발자 친화적 방식이고, Extend 시스템은 파일을 폴더에 넣기만 하면 되는 누구나 사용 가능한 방식입니다.
🎯 Extend의 3가지 핵심 가치
① 비침습성(Non-Invasive) — 코어 파일을 전혀 수정하지 않습니다. 업데이트해도 커스텀 코드가 그대로 유지됩니다.
② 격리성(Isolation) — 각 파일은 독립 실행됩니다. 한 파일에 치명적 오류가 발생해도 다른 파일과 CMS 전체는 영향받지 않습니다.
③ 가시성(Visibility) — 어떤 확장이 적용되어 있는지 extend/ 폴더만 보면 파악할 수 있습니다. 코드를 추적할 필요 없습니다.
1.2 Hook과의 근본적 차이
두 시스템은 상호 배타적이지 않으며, 함께 사용할 수 있습니다. 아래 비교를 통해 각각의 쓰임새를 이해하세요| Hook (dx_add_hook) | Extend (extend/ 폴더) |
|---|---|
| 등록 방식 | 파일을 폴더에 배치 (코드 불필요) |
| 주 사용 대상 | 개발자 & 비개발자 모두 |
| 주요 특징 | PHP 파일 존재 여부로 ON/OFF |
| 실행 시점 | 3개 슬롯: top / middle / bottom |
| 우선순위 제어 | 파일명 오름차순으로 순서 결정 |
| 비활성화 방법 | 파일명 끝에 .disabled 추가 |
| 디버그 방법 | DxExtend::getExecuted() 로 목록 조회 |
| 적합한 용도 | 점검모드, 로그, 미들웨어 성격 코드 |
1.3 동작 원리 한눈에 보기
아래 흐름은 index.php와 Dispatcher.php에서 실제로 extend가 호출되는 지점입니다.| ob_start() — 출력 버퍼 시작 index.php 최상단 |
| [STEP 1~4] 클래스 로드 → 보안 초기화 → DB 연결 → 플러그인 로드 Auth, DxSite, DxTheme, PluginRegistry 완료 |
| extend/top/ 실행 DxExtend::getInstance()->runTop() ← 모든 초기화 완료 직후 |
| Router::resolve() — URL → route 결정 $GLOBALS["dx_route"] 확정 |
| extend/middle/ 실행 DxExtend::getInstance()->runMiddle() ← 라우트 확정 직후, 핸들러 실행 전 |
| Dispatcher::dispatch() — 핸들러 실행 + 렌더링 페이지/게시판/관리자 등 실제 처리 |
| extend/bottom/ 실행 DxExtend::getInstance()->runBottom() ← 렌더링 완료 후, ob_end_flush 직전 |
| ob_end_flush() — 최종 출력 브라우저로 HTML 전송 |
📌 핵심 포인트
top은 라우팅 전이므로 URL이 무엇이든 실행됩니다. 전역 점검 모드, IP 차단처럼 "모든 요청에 앞서" 처리해야 할 로직에 적합합니다.
middle은 $GLOBALS["dx_route"]가 확정된 후입니다. 어떤 페이지인지 알고 처리할 수 있어 라우트별 로깅, A/B 테스트 등에 유용합니다.
bottom은 HTML이 이미 생성된 후입니다. 헤더 변경은 불가하지만 캐시 저장, 성능 로그 등 렌더링 후 정리 작업에 적합합니다.
2. 폴더 구조와 실행 위치
2.1 전체 디렉터리 구조
extend/ 폴더는 CMS 루트에 위치하며, DxExtend::ensureDirs()가 최초 실행 시 자동으로 생성합니다.
디렉터리 구조
CMS 루트/
├── index.php ← 단일 진입점 (top/middle/bottom 호출)
├── core/
│ └── DxExtend.php ← 실행 엔진 (싱글턴)
│
└── extend/ ← 여기에 커스텀 파일을 배치
├── README.txt ← 사용 안내 (자동 생성)
│
├── top/ ← 슬롯 1: CMS 초기화 직후
│ ├── README.txt
│ ├── 01_darkmode_early.php ← 내장 파일 (다크모드 FOUC 방지)
│ ├── 02_maintenance.php ← 사용자 추가 파일
│ └── 99_example.php.disabled ← 비활성화 예제
│
├── middle/ ← 슬롯 2: 라우트 확정 직후
│ ├── README.txt
│ ├── 01_visit_tracker.php ← 내장 파일 (방문자 통계)
│ ├── 02_access_log.php ← 사용자 추가 파일
│ └── 99_example.php.disabled
│
└── bottom/ ← 슬롯 3: 렌더링 완료 후
├── README.txt
├── 02_darkmode_engine.php ← 내장 파일 (다크모드 엔진 JS)
├── 03_perf_log.php ← 사용자 추가 파일
└── 99_example.php.disabled
2.2 extend/top/ — CMS 초기화 직후
| 항목 | 내용 |
|---|---|
| 실행 시점 | index.php — 모든 초기화 완료 직후, 라우팅 전 |
| 호출 코드 | DxExtend::getInstance()->runTop($context) |
| 전달 컨텍스트 | ['version' => DX_VERSION, 'path' => dx_request_uri()] |
| 사용 가능 객체 | Database, Auth, DxSite, DxTheme, HookManager, PluginRegistry 모두 준비됨 |
| 헤더 변경 | 가능 (출력 버퍼 안이므로 header() 호출 가능) |
| 리다이렉트 | 가능 (exit 포함) |
| 라우트 정보 | 불가 ($GLOBALS["dx_route"] 아직 미설정) |
전형적인 사용 사례:
- 서비스 점검 모드 — 관리자 외 모든 접근 차단 + 503 응답
- IP 차단 — 특정 IP/대역 접근 즉시 차단
- 커스텀 인증 — JWT 기반 외부 인증, SSO 처리
- 전역 변수 주입 — $GLOBALS에 공통 데이터 미리 준비
- ob_start 콜백 — 출력 버퍼 가로채기 (HTML 후처리)
- 다크모드 FOUC 방지 — 내장 파일 01_darkmode_early.php
2.3 extend/middle/ — 라우트 확정 직후
| 항목 | 내용 |
|---|---|
| 실행 시점 | Dispatcher::dispatch() — 라우트 확정 직후, 핸들러 실행 전 |
| 호출 코드 | DxExtend::getInstance()->runMiddle(['type'=>$route['type'], 'route'=>$route]) |
| 전달 컨텍스트 | ['type' => 라우트타입, 'route' => 전체 라우트 배열] |
| $GLOBALS["dx_route"] | 사용 가능 — type, slug, board_key 등 포함 |
| 헤더 변경 | 가능 |
| 리다이렉트 | 가능 (라우트별 접근 제어에 유용) |
| 라우트 타입 | home / page / board / admin / auth / api / search |
전형적인 사용 사례:
- 방문자 통계 — 내장 파일 01_visit_tracker.php (봇 필터링, 비동기 기록)
- 접근 로그 — URL, IP, User-Agent, 라우트 정보 기록
- A/B 테스트 — 라우트 타입별 분기 처리
- 라우트별 권한 검사 — 특정 게시판 비회원 접근 차단
- 캐시 확인 — 정적 페이지 캐시 히트 시 핸들러 건너뛰기
2.4 extend/bottom/ — 렌더링 완료 후
| 항목 | 내용 |
|---|---|
| 실행 시점 | index.php — ob_end_flush() 직전 (HTML 생성 완료 후) |
| 호출 코드 | DxExtend::getInstance()->runBottom(['elapsed' => 경과ms]) |
| 전달 컨텍스트 | ['elapsed' => round((microtime(true) - DX_START) * 1000, 2)] |
| 헤더 변경 | 불가 (출력이 이미 버퍼에 쌓임) |
| echo 출력 | 가능 — 버퍼에 추가됨 (HTML 맨 끝에 붙음) |
| 비동기 처리 | register_shutdown_function 권장 (응답 후 처리) |
| 경과 시간 | $context["elapsed"] 또는 microtime(true) - DX_START 로 계산 |
전형적인 사용 사례:
- 페이지 캐시 저장 — 완성된 HTML을 파일/Redis에 저장
- 성능 로그 — 실행 시간, DB 쿼리 수 기록
- 임시 파일 정리 — 업로드 임시 파일, 세션 만료 파일 삭제
- JS 엔진 주입 — 내장 파일 02_darkmode_engine.php
- 에러 집계 — 페이지별 오류 발생 현황 수집
3. 파일 실행 규칙
3.1 파일명 오름차순 자동 실행
DxExtend는 glob($dir . "/*.php") 후 sort() 를 적용합니다. 파일명의 문자열 오름차순 순서로 실행되므로, 앞에 숫자 접두사를 붙여 순서를 명확하게 제어합니다.
파일명 순서 예시
extend/top/
├── 01_maintenance.php ← 가장 먼저 (점검 모드 — 다른 파일보다 우선)
├── 02_ip_block.php ← 두 번째 (IP 차단)
├── 03_darkmode_early.php ← 세 번째
├── 10_custom_auth.php ← 네 번째 (10번대 = 인증 관련)
├── 50_global_vars.php ← 다섯 번째 (50번대 = 변수 준비)
└── 99_debug.php ← 마지막 (99번대 = 디버그/로그)
💡 숫자 접두사 레이어 전략 (권장)
01~09 : 최우선 처리 (점검 모드, 강제 리다이렉트, 보안 차단)
10~19 : 인증•세션 관련 (커스텀 인증, SSO, 세션 강화)
20~49 : 기능 확장 (전역 변수, 설정 오버라이드)
50~89 : 분석•로깅 (방문자 추적, A/B 테스트)
90~98 : 유지보수 (클린업, 알림)
99 : 개발•디버그 전용 (프로덕션 배포 전 .disabled 처리)
3.2 하위 폴더 재귀 탐색 (1단계)
DxExtend는 슬롯 폴더 바로 아래의 하위 폴더 1단계까지 재귀 탐색합니다. 먼저 슬롯 폴더의 *.php 를 수집하고, 이후 하위 폴더별로 각각 *.php 를 오름차순 수집합니다.
하위 폴더 실행 순서
extend/middle/
├── 01_visit_tracker.php ← 1순위: 슬롯 폴더 직접 파일
├── 02_access_log.php ← 2순위: 슬롯 폴더 직접 파일
│
├── analytics/ ← 하위 폴더 (알파벳 순 탐색)
│ ├── 01_pageview.php ← analytics/ 내에서 오름차순
│ └── 02_event.php
│
└── security/ ← 하위 폴더
└── 01_ratelimit.php
실행 순서:
01_visit_tracker.php
02_access_log.php
analytics/01_pageview.php
analytics/02_event.php
security/01_ratelimit.php
⚠️ 주의
하위 폴더는 1단계만 탐색됩니다. analytics/sub/ 같은 2단계 폴더는 탐색하지 않습니다.
하위 폴더명도 알파벳 오름차순으로 탐색합니다. 폴더명에도 숫자 접두사를 사용하면 순서를 명확히 제어할 수 있습니다.
3.3 .disabled 파일 무시 메커니즘
DxExtend는 *.php 패턴으로만 파일을 수집합니다. .disabled 확장자가 붙은 파일은 PHP 파일이 아니므로 자동으로 제외됩니다. 파일 삭제 없이 비활성화할 수 있는 안전한 메커니즘입니다.
활성화/비활성화
// 활성화 상태:
extend/top/01_maintenance.php ← 실행됨
// 비활성화 (파일명만 변경):
extend/top/01_maintenance.php.disabled ← 무시됨 (*.php 패턴에 불일치)
// Shell 명령으로 ON/OFF:
$ mv extend/top/01_maintenance.php extend/top/01_maintenance.php.disabled
$ mv extend/top/01_maintenance.php.disabled extend/top/01_maintenance.php
// PHP 코드로 ON/OFF (관리자 기능 구현 시):
rename(",/extend/top/01_maintenance.php",
"./extend/top/01_maintenance.php.disabled");
3.4 에러 격리 — 한 파일이 망가져도 CMS는 살아있다
이것이 Extend 시스템의 가장 강력한 특성입니다. safeExec() 는 set_error_handler() 와 try/catch 로 각 파일을 완전히 격리합니다. PHP 치명적 오류(Fatal Error), 예외(Exception), 경고(Warning) 어느 것도 CMS 전체를 멈추지 못합니다.
DxExtend::safeExec() 핵심 구현
// DxExtend::safeExec() 에러 격리 흐름
private function safeExec($file, $context, $slot) {
// ① 보안 검증: extend/ 경로 밖의 파일 실행 차단
$realFile = @realpath($file);
$realExtend = @realpath($this->extendRoot);
if (strpos($realFile, $realExtend . "/") !== 0) {
dx_log("[DxExtend] 보안 차단: " . $file, "warning");
return; // ← 실행 안 함
}
// ② 실행 기록 (디버그용)
$this->executed[] = ["slot" => $slot, "file" => basename($file)];
// ③ 에러 핸들러: 이 파일의 오류를 로그에만 기록, CMS는 계속 진행
set_error_handler(function($errno, $errstr, $errfile, $errline) {
if (error_reporting() === 0) return true; // @ 억제 에러 무시
dx_log("[DxExtend] " . $errstr . " (line " . $errline . ")", "error");
return true; // ← PHP 기본 에러 처리 막음
});
try {
extract($context, EXTR_SKIP); // context 변수 파일 스코프에 주입
$dx_extend_slot = $slot; // 현재 슬롯명 사용 가능
include $file; // ← 파일 실행
} catch (Exception $e) {
dx_log("[DxExtend][" . basename($file) . "] Exception: " . $e->getMessage(), "error");
// ← 예외 잡고 계속 진행. CMS 중단 없음.
}
restore_error_handler(); // ← 이 파일용 핸들러 해제
}
// → 다음 파일 실행 계속됨
| 상황 | 처리 방식 | 영향 |
|---|---|---|
| PHP Warning/Notice | 에러 핸들러가 잡아 로그 기록 | CMS 계속 실행, 다음 파일도 실행 |
| PHP Fatal Error (구) | 에러 핸들러가 잡아 로그 기록 | CMS 계속 실행, 다음 파일도 실행 |
| Exception 발생 | try/catch가 잡아 로그 기록 | CMS 계속 실행, 다음 파일도 실행 |
| TypeError (PHP 7+) | 에러 핸들러가 잡아 로그 기록 | CMS 계속 실행, 다음 파일도 실행 |
| ParseError (구문 오류) | 에러 핸들러가 잡아 로그 기록 | CMS 계속 실행, 다음 파일도 실행 |
| 보안 위반 (경로 탈출) | realpath 검증으로 즉시 차단 | 해당 파일 스킵, 경고 로그 기록 |
보안 검증 상세
safeExec()는 include 전에 realpath()로 실제 경로를 확인합니다. 심볼릭 링크나 "../" 경로 조작으로 extend/ 밖의 파일을 실행하려는 시도는 차단됩니다.
예: extend/top/../../core/secret.php 같은 경로 조작은 realpath 후 extend/ 접두사 검증에서 걸립니다.
차단 시 dx_log()로 warning 레벨 로그가 기록됩니다.
4. 내부 구현 분석 (DxExtend 클래스)
4.1 클래스 구조와 싱글턴 패턴
DxExtend 는 싱글턴 패턴으로 구현됩니다. DxExtend::getInstance() 를 여러 번 호출해도 동일한 인스턴스가 반환되며, $executed 배열에 실행 이력이 누적됩니다.
DxExtend 클래스 구조
class DxExtend {
private static $instance = null; // 싱글턴 인스턴스
private $extendRoot; // extend/ 폴더 절대 경로
private $executed = array(); // 실행된 파일 이력
// ── 공개 API ─────────────────────────────────────────
public function runTop($context = []) { ... } // index.php에서 호출
public function runMiddle($context = []) { ... } // Dispatcher에서 호출
public function runBottom($context = []) { ... } // index.php에서 호출
public function ensureDirs() { ... } // 폴더 자동 생성
public function getExecuted() { ... } // 실행 이력 반환
public function getRoot() { ... } // extend/ 경로 반환
// ── 내부 메서드 ───────────────────────────────────────
private function runSlot($slot, $context) { ... } // 슬롯 실행
private function safeExec($file, $ctx, $slot) { ... } // 격리 실행
private function collectFiles($dir) { ... } // 파일 수집
}
// 전역 헬퍼 (index.php에서 사용)
function dx_extend_top($ctx = []) { DxExtend::getInstance()->runTop($ctx); }
function dx_extend_middle($ctx = []) { DxExtend::getInstance()->runMiddle($ctx); }
function dx_extend_bottom($ctx = []) { DxExtend::getInstance()->runBottom($ctx); }
4.2 safeExec — 보안 검증 + 에러 격리
3.4절에서 상세히 다뤘으므로, 여기서는 실용적인 관점의 포인트만 짚습니다.| 검증 단계 | 구현 방법 | 목적 |
|---|---|---|
| 경로 정규화 | realpath() 사용 | 심볼릭 링크, ../ 경로 조작 방지 |
| 경로 접두사 | strpos($real, $extendRoot) | extend/ 폴더 외부 파일 실행 차단 |
| 슬래시 정규화 | str_replace("\\", "/") | Windows/Linux 경로 통일 |
| 실행 기록 | $this->executed[] 추가 | 디버그용 이력 저장 |
| 에러 핸들러 | set_error_handler() | PHP 에러를 로그로 전환 |
| 예외 처리 | try/catch(Exception) | 예외를 로그로 전환 |
| 핸들러 복원 | restore_error_handler() | 다음 파일에 영향 없도록 |
4.3 collectFiles — 파일 수집 알고리즘
DxExtend::collectFiles() 구현
private function collectFiles($dir) {
$files = array();
// ① 슬롯 폴더 직접 파일 수집
$found = glob($dir . "/*.php"); // .disabled는 .php 아니므로 제외됨
if ($found) {
sort($found); // 파일명 오름차순 정렬
$files = $found;
}
// ② 하위 폴더 1단계 재귀
$subDirs = glob($dir . "/*", GLOB_ONLYDIR);
if ($subDirs) {
sort($subDirs); // 폴더명도 오름차순
foreach ($subDirs as $sub) {
$subFiles = glob($sub . "/*.php");
if ($subFiles) {
sort($subFiles);
$files = array_merge($files, $subFiles);
}
}
}
return $files; // 순서가 결정된 파일 목록
}
🔍 알고리즘 포인트
glob("*.php")는 파일 시스템 레벨에서 .disabled 파일을 자동 제외합니다. PHP 코드에서 별도 필터링이 필요하지 않습니다.
sort()는 로케일에 영향받는 natsort()와 달리 순수 문자열 비교입니다. 파일명 앞에 두 자리 숫자를 붙이면 정확한 순서 제어가 가능합니다.
슬롯 폴더 직접 파일 → 하위 폴더 파일 순서입니다. 하위 폴더 내 파일이 슬롯 직접 파일보다 나중에 실행됩니다.
4.4 context 변수 주입 메커니즘
safeExec() 내부에서 extract($context, EXTR_SKIP) 를 호출합니다. 이로 인해 $context 배열의 키가 extend 파일의 로컬 변수로 자동 바인딩됩니다. EXTR_SKIP 는 이미 같은 이름의 변수가 있으면 덮어쓰지 않는 안전 모드입니다.
context 변수 주입 예시
// top 컨텍스트: ["version" => "8.1.0", "path" => "/board/free"]
// → extract() 후 extend 파일에서 아래 변수 사용 가능:
$version // "8.1.0"
$path // "/board/free"
$dx_extend_slot // "top" (항상 주입)
// middle 컨텍스트: ["type" => "board", "route" => [...]]
// → extend 파일에서:
$type // "board"
$route // 전체 라우트 배열
$dx_extend_slot // "middle"
// bottom 컨텍스트: ["elapsed" => 45.23]
// → extend 파일에서:
$elapsed // 45.23 (ms)
$dx_extend_slot // "bottom"
// 또한 $GLOBALS["dx_route"]로 라우트에 항상 접근 가능 (middle/bottom)
4.5 Hook과의 연동 — dx_extend_* 훅
runTop() , runMiddle() , runBottom() 각각의 끝에서 대응하는 훅을 실행합니다. 파일 기반 Extend와 코드 기반 Hook이 동일한 타이밍에 실행될 수 있습니다.
Hook과 Extend 연동
public function runTop($context = []) {
$this->runSlot("top", $context); // ① extend/top/*.php 파일들 실행
dx_run_hook("dx_extend_top", $context); // ② dx_extend_top 훅 실행
}
public function runMiddle($context = []) {
$this->runSlot("middle", $context);
dx_run_hook("dx_extend_middle", $context); // dx_extend_middle 훅 실행
}
public function runBottom($context = []) {
$this->runSlot("bottom", $context);
dx_run_hook("dx_extend_bottom", $context); // dx_extend_bottom 훅 실행
}
// 플러그인에서 extend 타이밍에 반응하는 예시:
dx_add_hook("dx_extend_top", function($context) {
// extend/top/ 파일들이 모두 실행된 직후에 실행됨
// 예: 점검 모드가 설정됐는지 확인 후 추가 처리
});
5. 내장 Extend 파일 분석
DXCMS v8.1.0에 포함된 실제 내장 파일 3개를 상세히 분석합니다. 이 파일들은 Extend 파일 작성의 훌륭한 참고 예제입니다.
5.1 extend/top/01_darkmode_early.php
목적: 다크모드 FOUC(Flash of Unstyled Content) 완전 차단. 페이지가 처음 렌더링될 때 흰 화면이 잠깐 깜빡이는 현상을 막습니다.핵심 기법: ob_start() 콜백 등록. CMS가 top 슬롯에서 이 파일을 실행하면, 파일은 출력 버퍼 콜백을 등록합니다. 이후 모든 HTML 생성이 완료된 후 ob_end_flush() 시점에 콜백이 실행되어 <head> 바로 다음에 인라인 스크립트를 삽입합니다.
extend/top/01_darkmode_early.php (핵심 발췌)
// extend/top/ 에서 실행 → ob_start 콜백만 등록하고 종료
// 실제 HTML 삽입은 ob_end_flush() 시점에 콜백이 담당
ob_start(function($buffer) {
// ─ 인라인 스크립트: localStorage를 즉시 읽어 다크 클래스 적용 ─
$earlyScript = '<script>(function(){'
. 'try{'
. ' var t=localStorage.getItem("dx-theme");'
. ' var sys=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>';
// ─ 인라인 CSS: 다크 배경을 즉시 적용 (흰 화면 방지) ─
$earlyStyle = '<style>'
. '.dx-dark-early body{background-color:#0f172a!important}'
. '</style>';
// ─ <head> 바로 뒤에 삽입 (정규식 치환) ─
return preg_replace('/<head([^>]*)>/i',
'<head$1>' . $earlyScript . $earlyStyle, $buffer, 1);
});
// → 이 파일 실행 완료. 실제 삽입은 렌더링 완료 후 ob 콜백이 처리
| 포인트 | 설명 |
|---|---|
| 슬롯 선택 이유 | ob_start는 출력 시작 전에 등록되어야 함. top이 가장 적합 |
| ob_start 활용 | 이 파일 자체는 HTML을 출력하지 않고 콜백만 등록 |
| 파일명 01_ | 최우선 실행 보장. 다른 파일보다 먼저 ob_start 등록 |
| visibility:hidden | 스크립트 로드 전 흰 화면을 숨겨 깜빡임 방지 |
| bottom 파일 연동 | 02_darkmode_engine.php가 JS 엔진을 로드, visibility 복원 |
5.2 extend/middle/01_visit_tracker.php
목적: 실제 사용자의 방문 통계를 기록. 봇 트래픽을 완전 차단하고, 사용자 응답 후 DB 기록으로 체감 속도를 보호합니다.
extend/middle/01_visit_tracker.php (핵심 발췌)
// middle 슬롯 → $GLOBALS["dx_route"] 사용 가능
// ① 관리자•API 요청 및 정적 파일 제외
if (in_array($type, ["admin", "api"], true)) return;
if (preg_match('/\.(js|css|png|jpg|...)$/i', $path)) return;
// ② 봇 판별 (40+ 패턴 매칭) → 봇이면 즉시 return
$botPatterns = ["googlebot", "bingbot", ..., "curl", "python-requests", ...];
foreach ($botPatterns as $b) {
if (strpos(strtolower($ua), $b) !== false) return; // 봇 차단
}
// ③ DxCache로 순방문자 판단 (DB 조회 없음)
$cacheKey = "vt_u:" . date("Y-m-d") . ":" . substr(md5($ip . $browser), 0, 12);
if (!DxCache::get($cacheKey)) {
DxCache::set($cacheKey, 1, $ttlToMidnight);
$isUnique = true;
}
// ④ ★ 응답 후 DB 기록 (register_shutdown_function)
register_shutdown_function(function() use ($data) {
// PHP-FPM: 사용자에게 먼저 응답 완료 전송
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request();
}
// DB INSERT (사용자는 이미 응답 받음)
$pdo->prepare("INSERT INTO dx_visits ... ON DUPLICATE KEY UPDATE ...")
->execute([...]);
});
| 포인트 | 설명 |
|---|---|
| 슬롯 선택 이유 | middle은 $GLOBALS["dx_route"]에서 라우트 타입 확인 가능 |
| DxCache 활용 | 순방문자 판단을 DB 없이 캐시로 처리 → 빠름 |
| shutdown 비동기 | 사용자 응답 후 DB 기록 → 체감 속도 영향 없음 |
| fastcgi_finish_request | PHP-FPM 환경에서 즉시 응답 완료 후 백그라운드 처리 |
| PDO 직접 사용 | Database::query()의 에러 시 exit을 우회하기 위함 |
5.3 extend/bottom/02_darkmode_engine.php
목적: 다크모드 엔진 JS를 모든 페이지(사이트 + 관리자)의 </body> 직전에 로드합니다. top 파일이 설정한 visibility:hidden 을 해제하는 역할도 합니다.
extend/bottom/02_darkmode_engine.php
// extend/bottom/ → HTML 버퍼에 추가됨 (</body> 직전 위치)
$ver = defined("DX_VERSION") ? DX_VERSION : "1.0";
$base = rtrim(dx_base_url(""), "/");
// 다크모드 엔진 JS 로드 (캐시 무효화 버전 쿼리 포함)
echo '<script src="' . $base . '/assets/js/dx-darkmode-engine.js'
. '?v=' . htmlspecialchars($ver, ENT_QUOTES, "UTF-8") . '"></script>' . "\n";
// dx-darkmode-engine.js가 하는 일:
// 1) localStorage 읽어 body.dark 클래스 적용/제거
// 2) .dx-dark-early 클래스 → body.dark 로 교체
// 3) visibility:hidden → visibility:visible 복원 (FOUC 해소)
💡 top → bottom 연동 패턴
01_darkmode_early.php (top): ob_start 콜백으로 <head> 직후에 "즉시 다크 적용 + visibility:hidden" 삽입
02_darkmode_engine.php (bottom): </body> 직전에 엔진 JS를 로드해 "정식 다크 클래스 적용 + visibility:visible 복원"
이 패턴은 서로 다른 슬롯의 파일이 협력하여 하나의 기능을 구현하는 좋은 예시입니다.
6. 실전 활용 사례
6.1 서비스 점검 모드
슬롯: extend/top/ | 파일명 제안: 01_maintenance.php 관리자 IP는 정상 접근을 허용하고, 나머지 모든 요청에는 503 페이지를 반환합니다.
extend/top/01_maintenance.php
<?php
if (!defined("DX_CMS")) exit;
// 점검 모드 활성화 플래그 (false → true로 변경 시 점검 시작)
$maintenance = true;
if (!$maintenance) return; // 비활성화 시 즉시 종료
// 관리자 IP는 통과
$adminIps = ["127.0.0.1", "123.45.67.89"];
$clientIp = $_SERVER["HTTP_CF_CONNECTING_IP"] // Cloudflare
?? $_SERVER["HTTP_X_FORWARDED_FOR"] // 프록시
?? $_SERVER["REMOTE_ADDR"] // 직접
?? "0.0.0.0";
if (in_array(trim(explode(",", $clientIp)[0]), $adminIps, true)) return;
// 503 점검 페이지 출력 후 종료
http_response_code(503);
header("Retry-After: 3600");
echo '<!DOCTYPE html><html lang="ko"><head>',
'<meta charset="UTF-8"><title>점검 중</title>',
'<style>body{font-family:sans-serif;text-align:center;padding:10vh}
h1{font-size:2em;color:#1B3A6B}</style></head><body>',
'<h1>🔧 서비스 점검 중</h1>',
'<p>더 나은 서비스를 위해 점검 중입니다.<br>
예정 완료: 2026-05-01 06:00 (KST)</p>',
'</body></html>';
exit;
⚠️ 점검 모드 ON/OFF 방법
빠른 전환: 파일명을 01_maintenance.php.disabled 로 변경하면 즉시 비활성화됩니다.
코드 내 플래그: $maintenance = false 로 변경해도 됩니다.
관리자 DB 설정 연동: dx_config("maintenance_mode", false) 로 DB에서 읽을 수도 있습니다.
6.2 IP 차단 시스템
슬롯: extend/top/ | 파일명 제안: 02_ip_block.php
extend/top/02_ip_block.php
<?php
if (!defined("DX_CMS")) exit;
// 차단할 IP 목록 (단일 IP, CIDR 대역 지원)
$blockedList = [
"192.168.1.100", // 단일 IP
"10.0.0.", // /24 대역 (단순 접두사 매칭)
"203.0.113.", // 공격 IP 대역
];
// DB에서 동적으로 로드 (선택적)
if (class_exists("Database")) {
$db = Database::getInstance();
$dbBlocked = $db->query("SELECT ip FROM " . $db->table("ip_blocks") .
" WHERE expires_at > NOW() OR expires_at IS NULL");
foreach ($dbBlocked as $row) {
$blockedList[] = $row["ip"];
}
}
$ip = trim(explode(",", (
$_SERVER["HTTP_CF_CONNECTING_IP"] ??
$_SERVER["HTTP_X_FORWARDED_FOR"] ??
$_SERVER["REMOTE_ADDR"] ?? ""))[0]);
foreach ($blockedList as $blocked) {
if (strpos($ip, $blocked) === 0 || $ip === $blocked) {
http_response_code(403);
echo "403 Forbidden";
exit;
}
}
6.3 전역 변수 주입
슬롯: extend/top/ | 파일명 제안: 05_global_vars.php 모든 페이지에서 공통으로 필요한 데이터를 DB에서 한 번만 조회하여 $GLOBALS에 저장합니다. 각 페이지에서 중복 쿼리를 방지합니다.
extend/top/05_global_vars.php
<?php
if (!defined("DX_CMS")) exit;
// 캐시 키
$cacheKey = "global_data_v1";
// 캐시에서 먼저 시도
$globalData = DxCache::get($cacheKey);
if (!$globalData) {
$db = Database::getInstance();
// 공지사항 (최신 3개)
$notices = $db->query(
"SELECT id, title FROM " . $db->table("posts") .
" WHERE board_key = 'notice' AND status = 'published'" .
" ORDER BY id DESC LIMIT 3"
);
// 인기 태그
$popularTags = $db->query(
"SELECT tag, COUNT(*) as cnt FROM " . $db->table("post_tags") .
" GROUP BY tag ORDER BY cnt DESC LIMIT 20"
);
$globalData = [
"notices" => $notices,
"popularTags" => $popularTags,
"siteStats" => ["members" => 1234, "posts" => 5678],
];
DxCache::set($cacheKey, $globalData, 300); // 5분 캐시
}
// 전역 변수로 주입 → 모든 페이지/스킨에서 $GLOBALS["dx_global"]로 접근
$GLOBALS["dx_global"] = $globalData;
// 스킨 파일에서 사용 예:
// $notices = $GLOBALS["dx_global"]["notices"] ?? [];
6.4 A/B 테스트 라우팅
슬롯: extend/middle/ | 파일명 제안: 10_ab_test.php middle 슬롯에서는 $GLOBALS["dx_route"] (또는 inject된 $route 변수)로 현재 라우트를 확인하고, 특정 페이지에 대해 A/B 테스트를 수행할 수 있습니다.
extend/middle/10_ab_test.php
<?php
if (!defined("DX_CMS")) exit;
// context에서 route 정보 사용 ($route 변수는 extract()로 주입됨)
$routeType = $route["type"] ?? "";
$routeSlug = $route["slug"] ?? "";
// 홈페이지에서만 A/B 테스트 적용
if ($routeType !== "home") return;
// 사용자 세션 기반으로 A/B 그룹 결정 (재방문 시 동일 그룹 유지)
if (!isset($_SESSION["ab_group"])) {
$_SESSION["ab_group"] = (mt_rand(0, 1) === 0) ? "A" : "B";
}
$abGroup = $_SESSION["ab_group"];
// 그룹별 전역 변수 설정
$GLOBALS["ab_group"] = $abGroup;
$GLOBALS["ab_variant"] = [
"A" => ["hero_text" => "최고의 커뮤니티", "cta_color" => "#2563EB"],
"B" => ["hero_text" => "함께 성장하는 공간", "cta_color" => "#DC2626"],
][$abGroup];
// 스킨 파일에서: $GLOBALS["ab_variant"]["hero_text"] 로 사용
// A/B 전환률 기록 (비동기)
register_shutdown_function(function() use ($abGroup) {
$db = Database::getInstance();
$db->query("INSERT INTO dx_ab_log (group_name, page, logged_at)",
" VALUES (?, ?, NOW())", [$abGroup, "home"]);
});
6.5 페이지 캐시 저장
슬롯: extend/bottom/ | 파일명 제안: 01_page_cache.php 렌더링이 완료된 HTML을 파일 캐시에 저장합니다. 다음 방문자는 PHP 실행 없이 캐시 파일을 직접 서빙합니다. 주의: 헤더 변경은 불가하지만 echo로 버퍼에 추가는 가능합니다.
extend/bottom/01_page_cache.php
<?php
if (!defined("DX_CMS")) exit;
// 캐시 대상: GET 요청, 비로그인, 특정 라우트 타입만
$route = $GLOBALS["dx_route"] ?? [];
if ($_SERVER["REQUEST_METHOD"] !== "GET") return;
if (Auth::getInstance()->isLoggedIn()) return; // 로그인 사용자 캐시 제외
if (!in_array($route["type"] ?? "", ["home", "page", "board"], true)) return;
// 캐시 키 (URL 기반)
$cacheKey = "page:" . md5($_SERVER["REQUEST_URI"] ?? "/");
$cacheFile = DX_ROOT . "/data/cache/pages/" . $cacheKey . ".html";
// 현재 ob 버퍼 내용 가져오기 (flush 전이므로 가능)
$html = ob_get_contents();
if ($html && strlen($html) > 100) {
$dir = dirname($cacheFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
// 만료 시각 주석 + HTML 저장
$expires = time() + 300; // 5분
file_put_contents($cacheFile,
"<!-- cached:" . time() . " expires:" . $expires . " -->\n" . $html,
LOCK_EX
);
}
// 만료된 캐시 파일 정리 (1% 확률로 실행)
if (mt_rand(1, 100) === 1) {
foreach (glob($dir . "/*.html") as $f) {
if (filemtime($f) + 300 < time()) @unlink($f);
}
}
6.6 성능 로그 기록
슬롯: extend/bottom/ | 파일명 제안: 90_perf_log.php
extend/bottom/90_perf_log.php
<?php
if (!defined("DX_CMS")) exit;
// context에서 elapsed 사용 (extract()로 주입된 변수)
$elapsed = $elapsed ?? round((microtime(true) - DX_START) * 1000, 2);
$db = Database::getInstance();
$queryCount = $db->getQueryCount();
$route = $GLOBALS["dx_route"] ?? [];
// 임계값 초과 시만 기록 (느린 페이지 감지)
if ($elapsed < 500 && $queryCount < 30) return; // 정상 범위는 기록 안 함
// 비동기 로그 (응답 속도 영향 없음)
register_shutdown_function(function() use ($elapsed, $queryCount, $route) {
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request();
}
$line = implode(" | ", [
date("Y-m-d H:i:s"),
$elapsed . "ms",
"Q:" . $queryCount,
($route["type"] ?? "?") . "/" . ($route["slug"] ?? ""),
$_SERVER["REQUEST_URI"] ?? "/",
$_SERVER["REMOTE_ADDR"] ?? "0.0.0.0",
]);
error_log("[SLOW] " . $line, 3, DX_ROOT . "/data/slow.log");
});
// DX_DEBUG 모드에서는 화면에도 표시
if (defined("DX_DEBUG") && DX_DEBUG) {
echo '<div style="position:fixed;bottom:0;right:0;background:#0F172A;color:#E2E8F0;'
. 'padding:6px 12px;font-family:monospace;font-size:11px;z-index:9999">'
. '⚡ ' . $elapsed . 'ms 🗄 ' . $queryCount . ' queries'
. '</div>';
}
7. 베스트 프랙티스 & 주의사항
7.1 파일명 네이밍 전략
| 접두사 | 용도 | 예시 |
|---|---|---|
| 01~05 | 최우선 처리 (차단, 점검, 리다이렉트) | 01_maintenance.php, 02_ip_block.php |
| 10~19 | 인증•세션 강화 | 10_custom_auth.php, 15_sso.php |
| 20~49 | 기능 확장 (변수 주입, 설정) | 20_global_vars.php, 30_feature_flags.php |
| 50~89 | 분석•로깅 | 50_analytics.php, 60_ab_test.php |
| 90~98 | 정리•후처리 | 90_perf_log.php, 95_cleanup.php |
| 99 | 개발•디버그 전용 | 99_debug.php (배포 전 .disabled 필수) |
💡 네이밍 팁
같은 팀에서 작업한다면 10번대, 50번대처럼 범위를 나눠 충돌을 방지하세요.
플러그인과 함께 사용할 경우, 플러그인 파일명은 20번대 이후로 잡아 extend/top/01_이 항상 먼저 실행되도록 합니다.
파일명에 간단한 설명을 포함하면 폴더만 봐도 어떤 확장이 있는지 알 수 있습니다: 01_maintenance.php, 02_ip_block.php
7.2 보안 고려사항
2. .disabled 파일 관리 — 디버그 파일은 프로덕션 배포 전 반드시 .disabled 처리하세요.
3. 외부 입력 신뢰 금지 — $context로 주입된 변수라도 사용자 입력이 포함될 수 있습니다. DB 쿼리에는 반드시 준비된 문(prepared statement)을 사용하세요.
4. 민감 정보 하드코딩 금지 — API 키, 비밀번호 등은 dx_config() 또는 환경 변수에서 읽으세요.
5. exit 사용 시 주의 — top/middle에서 exit() 호출 시 bottom 파일도 실행되지 않습니다. 점검 모드처럼 명확한 의도가 있을 때만 사용하세요.
보안 패턴
// ✅ 올바른 파일 시작 패턴
<?php
if (!defined("DX_CMS")) exit; // 직접 접근 차단
// ✅ 외부 입력 사용 시 준비된 문 사용
$db->query("SELECT * FROM dx_table WHERE id = ?", [$userId]);
// ❌ 절대 하지 말 것
$db->query("SELECT * FROM dx_table WHERE id = " . $_GET["id"]); // SQL 인젝션
7.3 성능 고려사항
7. DxCache 적극 활용 — DB 조회가 필요한 경우 캐시를 먼저 확인하세요. visit_tracker가 순방문자 판단을 캐시로 처리하는 것이 좋은 예입니다.
8. 불필요한 슬롯 선택 피하기 — 라우트 정보가 필요 없다면 top, 필요하다면 middle을 선택하세요. bottom은 렌더링 후 정리 작업에만 사용하세요.
9. 조기 return 패턴 — 조건에 맞지 않는 요청은 최대한 빨리 return 처리하세요 (봇 차단, API 경로 제외 등).
10. 슬롯 파일 수 최소화 — 파일 하나당 I/O + 실행 오버헤드가 발생합니다. 관련 기능은 하나의 파일에 묶는 것을 고려하세요.
7.4 Hook과 Extend 선택 가이드
아래 질문으로 어떤 방법을 사용할지 결정하세요.| 이 경우 Extend 선택 | 이 경우 Hook 선택 |
|---|---|
| 특정 이벤트(로그인, 글 저장)가 아닌 "항상 실행"이 필요한가? | 로그인 후, 댓글 작성 후처럼 특정 이벤트에만 반응해야 하는가? |
| 파일 ON/OFF로 간단히 기능을 켜고 끌 필요가 있는가? | 플러그인 활성화 여부와 연동되어야 하는가? |
| 미들웨어 성격 (점검 모드, IP 차단, 로그, 방문 통계)? | 게시판 훅, 회원 훅처럼 특정 기능과 밀접한가? |
| ob_start로 HTML 전체를 가로채야 하는가? | dx_apply_filter로 특정 값을 변환해야 하는가? |
| 팀 내 비개발자도 파일을 배치/삭제하여 기능을 조작해야 하는가? | 코드에서 동적으로 훅을 등록/해제해야 하는가? |
✅ 결론
Extend와 Hook은 상호 보완적입니다. 하나를 선택해야 한다는 제약이 없습니다.
실제 내장 파일(방문자 통계)처럼 Extend 파일 안에서 register_shutdown_function을 사용하거나,
runTop() 내부에서 dx_run_hook("dx_extend_top")이 실행되는 것처럼 두 시스템이 자연스럽게 연동됩니다.
extend/ 폴더에서는 전역 미들웨어 성격의 코드를, 플러그인/plugin.php에서는 Hook 기반 이벤트 처리를 하는 것이 DXCMS의 관용적 패턴입니다.