1. 전체 모듈 로딩 흐름 — 5단계 파이프라인
브라우저 요청이 들어온 순간부터 플러그인과 확장이 로드되어 실행되기까지의 전 과정을 5단계 파이프라인으로 설명합니다. 각 단계가 완전히 완료된 후에야 다음 단계로 진행됩니다.
1.1 전체 흐름 다이어그램
[ 브라우저 요청 ] ──────────────────────────────────────────────
┌─── STEP 0: 진입 및 전처리 ─────────────────────────────────┐
│ ob_start() ← 출력 버퍼 시작 │
│ 이중 슬래시 URI 감지 → 301 리다이렉트 │
│ PHP 5.6 미만 → 즉시 종료 │
│ 핵심 상수 정의: DX_CMS, DX_ROOT, DX_PLUGINS, DX_EXTEND … │
│ DX_START = microtime(true) ← 성능 측정 기준점 │
└────────────────────────────────────────────────────────────┘
↓
┌─── STEP 1: 클래스/함수 로드 (실행 없이 정의만) ────────────┐
│ functions.php → DxCache → Secure → DxSanitizer │
│ Database → HookManager → PluginRegistry → Auth → DxPoint │
│ DxFriend → DxShop → DxSite → DxTheme → DxSeo │
│ DxCategory → DxThumb → DxNotification → DxMemberMonitor │
│ DxPopup → DxBoardSkin → DxMarket → DxSocialAuth(옵션) │
│ DxExtend → Router → Dispatcher → QueryBuilder → DxContainer│
│ DxRouter │
└────────────────────────────────────────────────────────────┘
↓
┌─── STEP 2: 보안 초기화 ────────────────────────────────────┐
│ Secure::getInstance() → initSession() → startSession() │
│ sendSecurityHeaders() → csrfToken() │
└────────────────────────────────────────────────────────────┘
↓
┌─── STEP 3: DB 연결 + 설정 로드 ────────────────────────────┐
│ require data/config.php → DB 연결 → $dx_config 배열 채우기│
│ Secure::initSecretKeys() │
└────────────────────────────────────────────────────────────┘
↓
┌─── STEP 4: 서비스 초기화 + 플러그인/확장 로드 ─────────────┐
│ │
│ ① HookManager::getInstance() 훅 시스템 준비 │
│ ② PluginRegistry::getInstance() 등록소 준비 │
│ ③ load_plugins() ◀◀ 플러그인 로딩 핵심 ◀◀ │
│ ④ _dx_register_point_hooks() 포인트 훅 등록 │
│ ⑤ DxSite::getInstance() 멀티사이트 초기화 │
│ ⑥ DxTheme::getInstance() 테마 엔진 초기화 │
│ ⑦ Auth::getInstance() 인증 세션 복원 │
│ ⑧ DxContainer::registerCoreServices() DI 컨테이너 │
│ ⑨ DxExtend::ensureDirs() extend/ 폴더 자동 생성 │
│ ⑩ DxPopup•DxMemberMonitor 훅 등록 │
│ │
│ ██ extend/top/ 실행 ◀◀ 확장 로딩 핵심 1 ██ │
│ │
└────────────────────────────────────────────────────────────┘
↓
┌─── STEP 5: 라우팅 + 디스패치 ──────────────────────────────┐
│ routes/*.php 자동 로드 ← 클래스 기반 라우트 정의 │
│ DxRouter::dispatch() ← 클래스 기반 라우터 우선 시도 │
│ └─ 매칭 없으면 Dispatcher(Router) 폴백 │
│ └─ Router::resolve() → $GLOBALS['dx_route'] 세팅 │
│ │
│ ██ extend/middle/ 실행 ◀◀ 확장 로딩 핵심 2 ██ │
│ │
│ 핸들러 실행 (home/page/board/admin/auth/api/search/404) │
│ 테마 layout/main.php 렌더링 │
│ └─ dx_hook_top() / dx_hook_bottom() 발화 │
│ │
│ ██ extend/bottom/ 실행 ◀◀ 확장 로딩 핵심 3 ██ │
│ │
│ ob_end_flush() → 브라우저 전송 │
│ register_shutdown_function 실행 (비동기 후처리) │
└────────────────────────────────────────────────────────────┘
1.2 플러그인•확장 로딩 핵심 3지점
| 지점 | 호출 코드 | 위치 | 직전 상태 | 역할 |
| ① load_plugins() | load_plugins() | STEP 4 ③ | HookManager·PluginRegistry 초기화 후 | plugins/*/plugin.php 전체 로드 및 훅 등록 |
| ② extend/top/ | DxExtend::runTop() | STEP 4 끝 | 모든 초기화 완료 | 파일 기반 확장 실행 (초기화 완료 시점) |
| ③ extend/middle/ | DxExtend::runMiddle() | Dispatcher::dispatch() | 라우트 확정 | 파일 기반 확장 실행 (라우트 확정 시점) |
| ④ extend/bottom/ | DxExtend::runBottom() | STEP 5 끝 | 렌더링 완료 | 파일 기반 확장 실행 (렌더링 완료 시점) |
| ⑤ routes/*.php | glob + require_once | STEP 5 시작 | DxRouter 실행 전 | 클래스 기반 라우트 정의 자동 로드 |
2. 플러그인•확장 관련 핵심 상수
index.php STEP 0에서 정의되는 상수들입니다. 플러그인과 확장 파일 안에서 이 상수들을 사용하여 경로를 안전하게 참조합니다.
// index.php — STEP 0 핵심 상수 정의 (실제 소스)
define('DX_CMS', true); // 직접 접근 차단용: if (!defined('DX_CMS')) exit;
define('DX_VERSION', '8.1.0');
define('DX_ROOT', str_replace('\\', '/', dirname(__FILE__)));
define('DX_CORE', DX_ROOT . '/core');
define('DX_DATA', DX_ROOT . '/data');
define('DX_THEMES', DX_ROOT . '/themes');
define('DX_PLUGINS', DX_ROOT . '/plugins'); // ← 플러그인 루트
define('DX_PAGES', DX_ROOT . '/pages');
define('DX_BOARDS', DX_ROOT . '/boards');
define('DX_EXTEND', DX_ROOT . '/extend'); // ← 확장 루트
define('DX_START', microtime(true)); // 성능 측정 기준점
| 상수 | 경로 예시 | 플러그인·확장에서 활용 |
| DX_CMS | true | 보안: if (!defined('DX_CMS')) exit; |
| DX_ROOT | /var/www/html | DX_ROOT . '/my-config.php' |
| DX_PLUGINS | /var/www/html/plugins | DX_PLUGINS . '/my-plugin/assets/' |
| DX_EXTEND | /var/www/html/extend | DxExtend::getInstance()->getRoot() |
| DX_DATA | /var/www/html/data | DX_DATA . '/uploads/2026/' |
| DX_THEMES | /var/www/html/themes | DxTheme 폴백 체인 기준 경로 |
| DX_VERSION | 8.1.0 | 플러그인 최소 버전 요구사항 비교 |
| DX_START | 1746345600.123 | bottom/: round((microtime(true)-DX_START)*1000,2) |
3. STEP 1 — 클래스/함수 정의 파일 로드
STEP 1은 "실행 없이 정의만" 하는 단계입니다. require_once로 클래스 정의와 함수 정의를 메모리에 올리지만, 실제 동작(DB 연결, 세션 시작 등)은 전혀 일어나지 않습니다. 플러그인 로딩은 이보다 훨씬 후인 STEP 4에서 이루어집니다.
3.1 로드 순서와 이유
| 순서 | 파일 | 이유 (왜 이 순서인가) |
| 1 | functions.php | 전역 함수 정의. 다른 클래스 생성자에서 dx_log(), dx_config() 호출 가능하도록 |
| 2 | DxCache.php | config.php 캐싱에 필요. 가장 빨리 로드해야 함 |
| 3 | Secure.php | 보안 클래스. 고유 경로(security/{hash}/)에서 로드. 없으면 core/Secure.php 폴백 |
| 4 | DxSanitizer.php | Secure가 사용하는 입력 정제 클래스 |
| 5 | Database.php | DB 연결 클래스. 이후 모든 클래스가 의존 |
| 6 | HookManager.php | 훅 시스템. PluginRegistry보다 먼저 (플러그인이 훅을 등록하므로) |
| 7 | PluginRegistry.php | 플러그인 등록소. HookManager 다음 |
| 8 | Auth.php | 인증. Database 완료 후 |
| 9~N | 나머지 core/*.php | 기능 클래스들. 순서 의존성 낮음 |
| N-2 | DxExtend.php | extend 엔진. 라우터보다 먼저 |
| N-1 | Router.php / Dispatcher.php | 라우터·디스패처 |
| N | DxContainer.php / DxRouter.php | DI 컨테이너 + 클래스 기반 라우터 |
3.2 Secure.php 특수 로딩 (보안 경로)
Secure.php는 설치 시 data/config.php에 저장된 16자리 해시 경로에서 로드됩니다. 이 경로를 모르면 파일을 직접 수정할 수 없어 보안성이 높아집니다.
// index.php 실제 소스
// DX_SECURITY_PATH = 설치 시 생성된 16자리 해시 (예: a3f8c1d2e4b5f6a7)
// config.php에서 선취 파싱 (STEP 3보다 먼저)
if (defined('DX_SECURITY_PATH')) {
$_dxSecurePath = DX_CORE . '/security/' . DX_SECURITY_PATH . '/Secure.php';
if (!file_exists($_dxSecurePath)) {
$_dxSecurePath = DX_CORE . '/Secure.php'; // 파일 손실 시 폴백
}
} else {
$_dxSecurePath = DX_CORE . '/Secure.php'; // 구버전 호환
}
require_once $_dxSecurePath;
// 실제 파일 구조
// core/security/a3f8c1d2e4b5f6a7/Secure.php ← 운영 환경
// core/Secure.php ← 폴백
4. STEP 4 — 서비스 초기화 및 플러그인 로딩 (핵심)
STEP 4는 가장 많은 작업이 일어나는 단계입니다. 특히 load_plugins()가 plugins/ 디렉토리의 모든 plugin.php를 로드하고 훅을 등록합니다. 이 단계가 끝나야 모든 CMS 기능과 플러그인 기능을 사용할 수 있습니다.
4.1 STEP 4 세부 실행 순서
| ① | HookManager::getInstance() 훅 시스템 초기화. 이후 dx_add_hook()·dx_run_hook() 사용 가능. |
| ② | PluginRegistry::getInstance() 플러그인 등록소 초기화. 이후 dx_register_plugin() 사용 가능. |
| ③ | load_plugins() DX_PLUGINS 하위 모든 폴더의 plugin.php 로드. 훅 등록. ← 핵심 |
| ④ | _dx_register_point_hooks() 포인트·경험치 훅(dx_after_write 등) 5개 등록. |
| ⑤ | DxSite::getInstance() 도메인 감지 → $dx_config 멀티사이트 오버라이드. |
| ⑥ | DxTheme::getInstance() 테마 엔진 초기화. DxSite 이후 (테마 확정 뒤). |
| ⑦ | Auth::getInstance() 세션/Remember Me 쿠키로 로그인 상태 복원. |
| ⑧ | DxContainer::registerCoreServices() db·auth·cache·hook·site·theme 서비스를 DI 컨테이너에 등록. |
| ⑨ | DxExtend::ensureDirs() extend/top·middle·bottom/ 폴더 없으면 자동 생성. |
| ⑩ | 팝업·모니터 훅 등록 DxPopup·DxMemberMonitor 훅 등록 (dx_body_bottom·dx_after_login 등). |
| ⑪ | extend/top/ 실행 DxExtend::runTop(). 모든 초기화 완료 후 첫 번째 확장 실행 지점. |
4.2 load_plugins() 상세 분석
load_plugins()는 STEP 4의 핵심입니다. plugins/ 하위의 모든 폴더를 glob()으로 탐색하여 plugin.php를 순서대로 로드합니다.
// core/functions.php — load_plugins() 실제 소스
function load_plugins()
{
if (!defined('DX_PLUGINS') || !is_dir(DX_PLUGINS)) return;
$dirs = glob(DX_PLUGINS . '/*', GLOB_ONLYDIR); // 하위 폴더 전체 목록
if (!$dirs) return;
foreach ($dirs as $dir) {
$f = $dir . '/plugin.php';
if (file_exists($f)) {
require_once $f; // ← 플러그인 코드 실행 (훅 등록 등)
}
// plugin.php 없는 폴더는 조용히 건너뜀
}
// DXB CSS 엔진 자동 주입 (테마 파일 수정 불필요)
_dx_inject_dxb_css();
// 에디터 훅 브릿지 등록
// dx_editor_init → dx_render_editor() → dx_editor_render 훅 발화
dx_add_hook('dx_editor_init', function($args) {
$name = isset($args['name']) ? $args['name'] : 'content';
$value = isset($args['value']) ? $args['value'] : '';
$options = isset($args['options']) ? $args['options'] : array();
if (isset($args['board'])) $options['board'] = $args['board'];
dx_render_editor($name, $value, $options);
}, 10);
}
4.3 load_plugins() 실행 순서 규칙
glob()이 반환하는 디렉토리 목록의 순서가 플러그인 로드 순서입니다. 운영체제와 파일시스템에 따라 다르지만, 일반적으로 알파벳 오름차순입니다.
plugins/
├── ckeditor4-editor/ ← ① 첫 번째 로드 (알파벳 순)
│ ├── plugin.php ← require_once로 로드
│ └── manifest.php
├── dx-socket/ ← ② 두 번째
│ └── plugin.php
├── example-plugin/ ← ③ 세 번째
│ └── plugin.php
├── kakao-pay/ ← ④ 네 번째
│ └── plugin.php
└── tinymce/ ← ⑤ 다섯 번째
└── plugin.php
// 폴더에 plugin.php가 없으면 건너뜀
// manifest.php만 있는 폴더 → 건너뜀 (정보 파일만)
// plugin.php 있는 폴더만 로드됨
플러그인 로드 순서 의존성 주의
플러그인 A가 플러그인 B의 훅에 의존하는 경우, 폴더명으로 순서를 제어합니다.
예: 플러그인 B가 먼저 로드되어야 하면 폴더명을 '01_b-plugin'처럼 숫자 접두어로 지정합니다.
그러나 대부분의 경우 플러그인은 상호 독립적으로 설계해야 합니다.
4.4 plugin.php 내부에서 일어나는 일
각 plugin.php가 로드될 때 실행되는 전형적인 코드 패턴입니다. 이 시점에서 ①등록과 ②훅 연결이 동시에 이루어집니다.
// plugins/example-plugin/plugin.php (내장 예제 전체)
if (!defined('DX_CMS')) exit('Direct access not allowed.');
// DX_DEBUG 모드에서만 실행 시간 + 쿼리 수 표시
if (defined('DX_DEBUG') && DX_DEBUG) {
dx_add_hook('dx_bottom', function($context) {
$time = round((microtime(true) - DX_START_TIME) * 1000, 2);
$db = Database::getInstance();
echo '<div style="position:fixed;bottom:10px;right:10px;
background:rgba(0,0,0,.7);color:#fff;
padding:6px 12px;border-radius:8px;
font-size:11px;z-index:9999">';
echo '⚡ ' . $time . 'ms | DB: ' . $db->getQueryCount() . '쿼리';
echo '</div>';
}, 999);
}
// 이 플러그인의 특징:
// • dx_register_plugin() 없음 → PluginRegistry에 등록 안 함
// • 단순히 dx_add_hook()으로 동작만 추가
// • 조건부 실행 (DX_DEBUG 모드에서만)
5. DxExtend 엔진 — 확장 파일 자동 실행
DxExtend는 extend/ 폴더의 PHP 파일을 자동으로 탐색하고 안전하게 실행하는 엔진입니다. v1.3.0 기준으로 보안 경계 검증, 에러 격리, 컨텍스트 주입, 디버그 로그 기능을 제공합니다.
5.1 DxExtend 클래스 구조
class DxExtend {
private static $instance = null; // 싱글턴
private $extendRoot; // DX_ROOT . '/extend'
private $executed = array(); // 실행된 파일 로그
// ─── 3개 공개 실행 메서드 ───
public function runTop($context) → runSlot('top', $context)
public function runMiddle($context) → runSlot('middle', $context)
public function runBottom($context) → runSlot('bottom', $context)
// ─── 내부 실행 로직 ───
private function runSlot($slot, $context)
→ collectFiles($dir)
→ safeExec($file, $context, $slot)
// ─── 유틸 ───
public function ensureDirs() // top•middle•bottom 폴더 자동 생성
public function getExecuted() // 실행 로그 반환 (디버그용)
public function getRoot() // extend/ 루트 경로 반환
}
// 전역 헬퍼 함수
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)
5.2 collectFiles() — 파일 수집 알고리즘
extend/top/ 같은 슬롯 폴더에서 PHP 파일을 탐색합니다. 파일명 오름차순으로 정렬되므로 파일명의 숫자 접두어가 실행 순서를 결정합니다.
// DxExtend::collectFiles() 실제 소스
private function collectFiles($dir)
{
$files = array();
// 1단계: 슬롯 폴더 직접 하위의 *.php 수집
$found = glob($dir . '/*.php');
if ($found) {
sort($found); // 파일명 오름차순 정렬
$files = $found;
}
// 2단계: 1단계 하위 폴더 탐색 (1단계 재귀만)
$subDirs = glob($dir . '/*', GLOB_ONLYDIR);
if ($subDirs) {
sort($subDirs); // 폴더도 이름순 정렬
foreach ($subDirs as $sub) {
$sub = glob($sub . '/*.php');
if ($sub) {
sort($sub);
$files = array_merge($files, $sub);
}
}
}
return $files;
}
// 실행 순서 예시: extend/top/ 폴더가 아래와 같을 때
// extend/top/
// 01_maintenance.php ← 1번째
// 02_ip_block.php ← 2번째
// 10_global_vars.php ← 3번째
// security/ ← 4번째 이후 (하위 폴더)
// 01_waf.php ← 4번째
// 02_rate_limit.php ← 5번째
파일명 정렬은 사전식(lexicographic) 정렬입니다.
9_file.php는 10_file.php보다 나중에 실행됩니다. ('9' > '1' 사전순)
반드시 01_, 02_, 09_, 10_, 11_ 처럼 자릿수를 맞추세요.
잘못된 예: 1_, 2_, 10_ → 10_이 1_보다 먼저 실행됨
5.3 safeExec() — 파일 안전 실행 메커니즘
각 파일을 실행할 때 4가지 보호 장치가 작동합니다. 하나의 파일에서 오류가 발생해도 다른 파일은 정상 실행됩니다.
// DxExtend::safeExec() 실제 소스 (핵심 부분)
private function safeExec($file, $context, $slot)
{
// ─── 보호 1: 보안 경계 검증 ───────────────────────
$realFile = @realpath($file);
$realExtend = @realpath($this->extendRoot);
if (!$realFile || !$realExtend) return;
// 백슬래시 정규화 (Windows 호환)
$realFile = str_replace('\\\\', '/', $realFile);
$realExtend = str_replace('\\\\', '/', $realExtend);
// extend/ 경계 밖의 파일 실행 차단 (path traversal 방어)
if (strpos($realFile, $realExtend . '/') !== 0) {
dx_log('[DxExtend] 보안 차단: ' . $file, 'warning');
return;
}
$this->executed[] = array('slot' => $slot, 'file' => basename($file));
// ─── 보호 2: 에러 핸들러 (에러 격리) ──────────────
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 {
// ─── 보호 3: 컨텍스트 변수 주입 ─────────────
extract($context, EXTR_SKIP); // 기존 변수 덮어쓰지 않음
$dx_extend_slot = $slot; // 현재 슬롯 정보 주입
include $file;
} catch (Exception $e) {
// ─── 보호 4: 예외 격리 ──────────────────────
dx_log('[DxExtend] Exception: ' . $e->getMessage(), 'error');
// 예외가 발생해도 catch로 잡혀 다음 파일 계속 실행
}
restore_error_handler();
}
5.4 컨텍스트 변수 — 슬롯별 주입 변수
safeExec()가 extract($context, EXTR_SKIP)을 호출하여 각 슬롯별 컨텍스트 변수를 파일 스코프에 자동 주입합니다. EXTR_SKIP이므로 파일 내 기존 변수를 덮어쓰지 않습니다.| 슬롯 | 자동 주입 변수 | 출처 | 예시 값 |
| top/ | $version | DX_VERSION 상수 | '8.1.0' |
| top/ | $path | dx_request_uri() | '/notice/view/42' |
| top/ | $dx_extend_slot | DxExtend 내부 | 'top' |
| middle/ | $type | $GLOBALS['dx_route']['type'] | 'board' |
| middle/ | $route | $GLOBALS['dx_route'] 전체 | array(type,slug,action,id,…) |
| middle/ | $dx_extend_slot | DxExtend 내부 | 'middle' |
| bottom/ | $elapsed | round((microtime(true)-DX_START)*1000,2) | 12.34 (ms) |
| bottom/ | $dx_extend_slot | DxExtend 내부 | 'bottom' |
5.5 runTop() / runMiddle() / runBottom() 발화 시점
// ─── runTop() 발화 위치: index.php ────────────────────────
DxExtend::getInstance()->runTop(array(
'version' => DX_VERSION,
'path' => dx_request_uri(),
));
// 직전: Auth, DxContainer, DxPopup 훅 등록 완료
// 직후: STEP 5 routes/*.php 로드
// ─── runMiddle() 발화 위치: Dispatcher::dispatch() ────────
DxExtend::getInstance()->runMiddle(array(
'type' => $this->route['type'],
'route' => $this->route,
));
// 직전: Router::resolve() → $GLOBALS['dx_route'] 세팅 완료
// 직후: switch($route['type']) 핸들러 실행
// ─── runBottom() 발화 위치: index.php 끝 ──────────────────
DxExtend::getInstance()->runBottom(array(
'elapsed' => round((microtime(true) - DX_START) * 1000, 2),
));
// 직전: DxRouter::dispatch() 또는 Dispatcher::dispatch() 완료
// 직후: ob_end_flush() → 브라우저 전송
6. 플러그인 파일 구조 및 로딩 규칙
6.1 플러그인 디렉토리 구조
plugins/
├── my-plugin/ ← 플러그인 ID = 폴더명
│ ├── plugin.php ← 필수: 로딩 엔진이 이 파일을 실행
│ ├── manifest.php ← 선택: 마켓 메타 정보 (배열 return)
│ ├── admin/ ← 선택: 관리자 설정 페이지
│ │ └── settings.php
│ ├── assets/ ← 선택: CSS/JS/이미지
│ │ ├── style.css
│ │ └── editor.js
│ └── controllers/ ← 선택: DI 컨테이너 자동 로드 컨트롤러
│ └── MyController.php
│
├── example-plugin/ ← 내장 예제 (복사해서 시작)
│ ├── plugin.php
│ └── manifest.php
│
└── dx-payment-helper.php ← 단일 파일 형식 (폴더 불필요)
// ※ 단일 파일은 폴더가 없으므로 load_plugins()가 로드 안 함
// ※ 단일 파일은 별도 require_once 필요
6.2 plugin.php 작성 규칙
| 규칙 | 내용 | 이유 |
| 보안 체크 필수 | if (!defined('DX_CMS')) exit; 첫 줄에 | URL 직접 접근 시 코드 노출 방지 |
| require_once로 로드 | 중복 로드 방지 | load_plugins()가 require_once 사용 |
| 훅 등록만 수행 | 실행 코드 최소화 | load_plugins 시점에는 라우트 미확정 |
| $GLOBALS 접근 주의 | dx_route 미확정 시점 | 라우트 정보는 middle/ 이후에만 |
| exit() 금지 | return 사용 | exit는 나머지 플러그인 로드 중단 |
| PHP 5.6 호환 | 화살표 함수·match 사용 금지 | 최소 PHP 5.6 지원 요구 |
6.3 manifest.php 구조
// plugins/my-plugin/manifest.php (내장 예제 전체)
return array(
'name' => 'Example Plugin',
'version' => '1.0.0',
'description' => '플러그인 개발 예제. 복사해서 만들어보세요.',
'author' => 'DesignOneX',
'author_url' => 'https://designonex.com',
'type' => 'example', // editor|payment|socket|captcha|커스텀
'min_version' => '1.8.0', // 최소 CMS 버전
'tags' => '예제,샘플',
'category' => 'utility',
'homepage' => 'https://designonex.com',
'license' => 'MIT',
);
// manifest.php는 마켓 등록 및 관리자 플러그인 목록에서 사용
// plugin.php와 독립적 — manifest.php만 있어도 마켓 등록 가능
// plugin.php가 없으면 로직 실행 없음 (정보 파일만)
6.4 plugin.php 완성 예제 — 에디터 플러그인
// plugins/my-editor/plugin.php
if (!defined('DX_CMS')) exit('Direct access not allowed.');
// ① PluginRegistry에 등록
dx_register_plugin(array(
'id' => 'my-editor',
'type' => 'editor',
'name' => 'My Custom Editor',
'version' => '1.0.0',
'description' => '마크다운 기반 커스텀 에디터',
'author' => '홍길동',
'priority' => 10,
'settings' => array(
'height' => array('label'=>'에디터 높이(px)', 'type'=>'number'),
),
));
// ② 에디터 렌더링 훅 등록
dx_add_hook('dx_editor_render', function($args) {
if ($args['editor'] !== 'my-editor') return; // 내 에디터만
$name = htmlspecialchars($args['name'], ENT_QUOTES);
$value = htmlspecialchars($args['value'], ENT_QUOTES, 'UTF-8');
$height = dx_config('plugin_my-editor_height', 400);
echo '<div class="my-editor-wrap">';
echo '<textarea id="my-ed-' . $name . '" name="' . $name . '" style="height:' . $height . 'px">' . $value . '</textarea>';
echo '<script src="' . dx_base_url('plugins/my-editor/assets/editor.js') . '"></script>';
echo '</div>';
}, 10);
// ③ 모든 페이지 하단에 CSS 로드 (선택)
dx_add_hook('dx_bottom', function($ctx) {
// 활성 에디터가 my-editor일 때만 CSS 로드
if (dx_active_plugin('editor') !== 'my-editor') return;
echo '<link rel="stylesheet" href="' . dx_base_url('plugins/my-editor/assets/style.css') . '">';
}, 10);
7. extend/ 파일 구조 및 작성 규칙
7.1 3개 슬롯 전체 규칙
| 슬롯 | 실행 시점 | 가용 변수 | 주의 사항 | 권장 용도 |
| top/ | 모든 초기화 완료 직후, 라우팅 전 | DB·세션·Auth·dx_config() 모두 사용 가능. $version, $path | $GLOBALS['dx_route'] 미확정. 헤더 변경 가능 | 점검 모드, IP 차단, ob_start 등록, 전역 변수 주입 |
| middle/ | 라우트 확정 직후, 핸들러 실행 전 | $type, $route (dx_route 전체) | 핸들러 실행 전이므로 페이지 출력 불가 | 방문자 통계, 라우트별 분기, A/B 테스트 |
| bottom/ | 렌더링 완료 후, ob_end_flush 전 | $elapsed (ms). 기타 CMS 변수 | 헤더 변경 불가 (출력 완료). ob_start 등록 늦음 | 캐시 저장, 성능 로그, JS 태그 추가, 정리 |
7.2 extend 파일 작성 필수 규칙
<?php
// ─── 필수 보안 헤더 (첫 번째 줄) ──────────────────────────
if (!defined('DX_CMS')) exit;
// ─── 슬롯별 주입 변수 확인 ─────────────────────────────────
// top/: $version, $path, $dx_extend_slot
// middle/: $type, $route, $dx_extend_slot
// bottom/: $elapsed, $dx_extend_slot
// ─── 안전한 변수 접근 (isset 사용) ─────────────────────────
$type = isset($type) ? $type : '';
$route = isset($route) ? $route : array();
// ─── exit 대신 return 사용 ──────────────────────────────────
if ($type === 'admin') return; // ← 이 파일만 건너뜀
// exit; ← 절대 금지: 이후 파일 전체가 실행 안 됨
// ─── 무거운 작업은 응답 후 처리 ────────────────────────────
register_shutdown_function(function() use ($route) {
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
$db = Database::getInstance();
$db->query("INSERT INTO my_log...", array(...));
});
7.3 파일명 네이밍 규칙
| 패턴 | 예시 | 실행 순서 | 설명 |
| NN_설명.php | 01_maintenance.php | 1번째 | 숫자 2자리 필수. 자릿수 맞춰야 정렬 올바름 |
| NN_설명.php | 02_ip_block.php | 2번째 | 기능별로 파일 분리 권장 |
| NN_설명.php | 10_my_feature.php | 3번째 | 앞 번호 여유 확보 (나중에 삽입 가능) |
| 비활성화 | 99_old.php.disabled | 실행 안 됨 | .disabled 추가 시 *.php 패턴 불일치 → 제외 |
| 하위 폴더 | security/01_waf.php | 상위 파일 후 | security/ 폴더 하위 → 상위 폴더 파일 실행 후 |
8. routes/ 폴더 자동 로드 (v6.2.0+)
v6.2.0부터 routes/ 폴더를 지원합니다. 이 폴더의 PHP 파일을 파일명 오름차순으로 자동 로드하여 DxRouter에 클래스 기반 라우트를 정의할 수 있습니다.
8.1 routes/ 로드 소스
// index.php STEP 5 — routes/*.php 자동 로드 (실제 소스)
$_dxRouteDir = DX_ROOT . '/routes';
if (is_dir($_dxRouteDir)) {
$_dxRouteFiles = glob($_dxRouteDir . '/*.php');
if ($_dxRouteFiles) {
sort($_dxRouteFiles); // 파일명 오름차순
foreach ($_dxRouteFiles as $_dxRouteFile) {
require_once $_dxRouteFile; // 라우트 정의 로드
}
}
}
unset($_dxRouteDir, $_dxRouteFiles, $_dxRouteFile);
// 우선순위: DxRouter → 기존 Dispatcher 폴백
if (!DxRouter::dispatch()) {
$_dxDispatcher = new Dispatcher(new Router());
$_dxDispatcher->dispatch();
}
8.2 routes/ 파일 예시
// routes/web.php — 클래스 기반 라우트 정의
if (!defined('DX_CMS')) exit;
DxRouter::get('/my-page', 'MyPageController@index');
DxRouter::post('/my-api', 'MyApiController@handle');
DxRouter::get('/my-page/{id}', 'MyPageController@show');
// routes/api.php — API 전용 라우트
DxRouter::post('/api/my-endpoint', function() {
dx_json(array('status' => 'ok'));
});
routes/ vs extend/middle/ 차이
routes/*.php: DxRouter에 클래스 기반 라우트를 정의하는 용도. 라우팅 전에 로드.
extend/middle/: 라우트 확정 후 실행. 방문자 통계, 접근 제어 등 미들웨어 역할.
두 방식은 목적이 다릅니다: routes/는 새 URL 경로를 만들고, extend/middle/은 기존 경로에 동작을 추가합니다.
9. 플러그인•확장 로딩 후 실행 흐름
모든 플러그인과 확장이 로드된 후, 실제 요청을 처리하는 흐름에서 이들이 어떻게 동작하는지 추적합니다.
9.1 훅 등록 vs 발화 시점 분리
시간 흐름 →
[STEP 4: load_plugins()]
plugins/my-plugin/plugin.php 로드
→ dx_add_hook('dx_bottom', myCallback, 10) ← 등록만, 발화 안 함
→ dx_add_hook('dx_editor_render', ...) ← 등록만
→ dx_register_plugin(...) ← 등록소에 기록
[STEP 4: extend/top/ 실행]
01_darkmode_early.php
→ ob_start(callback) ← 콜백 등록만, 실행 안 함
[STEP 5: Dispatcher → 테마 layout/main.php]
dx_hook_bottom($context)
→ dx_run_hook('dx_bottom', $context) ← 발화!
→ myCallback($context) 실행 ← 등록된 콜백 실행
[STEP 5: 렌더링 완료 → ob_end_flush()]
→ ob_start 콜백 실행 ← 등록된 콜백 실행!
→ <head> 뒤에 다크모드 스크립트 삽입
핵심: 등록(STEP 4)과 발화(STEP 5)는 완전히 분리됩니다.
9.2 에디터 플러그인 전체 실행 추적
[STEP 4: load_plugins()]
plugins/my-editor/plugin.php
→ dx_register_plugin({id:'my-editor', type:'editor', ...})
→ PluginRegistry: plugins['editor']['my-editor'] = {...}
→ dx_add_hook('dx_editor_render', editorCallback, 10)
→ HookManager: hooks['dx_editor_render'] = [{callback, priority:10}]
[관리자: 설정에서 my-editor 선택]
→ dx_settings.active_editor = 'my-editor' DB 저장
[STEP 5: boards/write.php 스킨 실행]
dx_render_editor('content', $value, $options)
→ dx_active_plugin('editor')
→ dx_config('active_editor') = 'my-editor'
→ dx_has_hook('dx_editor_render') = true
→ dx_run_hook('dx_editor_render', {name,value,editor:'my-editor'})
→ HookManager::run()
→ editorCallback({name,value,editor:'my-editor'}) 실행
→ 에디터 HTML 출력
9.3 extend/top/ ob_start 콜백 실행 시점
실행 흐름 ob 버퍼 상태
───────────────────────────────────────────────────────────
[index.php 시작]
ob_start() 레벨 1 버퍼 시작
[STEP 4: extend/top/ 실행]
01_darkmode_early.php:
ob_start(darkmode_callback) 레벨 2 버퍼 시작
// 콜백은 등록만, 실행 안 됨
[STEP 5: 렌더링]
echo "<!DOCTYPE html>..." 레벨 2 버퍼에 쌓임
[index.php 끝: ob_end_flush()]
ob_end_flush() 호출
→ 레벨 2 버퍼 → darkmode_callback 실행 콜백 실행!
→ <head> 뒤에 스크립트 삽입 HTML 가공
→ 가공된 HTML → 레벨 1 버퍼로 이동
ob_end_flush() 다시 호출
→ 레벨 1 버퍼 → 브라우저 전송 응답!
결론: extend/top/에서 ob_start를 등록하면
전체 HTML이 완성된 후 자동으로 가공됩니다.
10. 플러그인•확장 로딩 실전 패턴
10.1 플러그인에서 DB 기반 설정 읽기
plugin.php 로드 시점(STEP 4)은 이미 DB 연결이 완료된 후이므로 설정을 DB에서 읽을 수 있습니다. DxCache로 캐싱하면 성능 영향이 없습니다.
// plugins/my-plugin/plugin.php
if (!defined('DX_CMS')) exit;
// ① dx_config()로 관리자 설정 읽기 (DB 기반, 캐싱됨)
$apiKey = dx_config('my_plugin_api_key', '');
$isEnabled = dx_config('my_plugin_enabled', '0') === '1';
if (!$isEnabled || !$apiKey) return; // 비활성 시 건너뜀
// ② 싱글턴 서비스 등록
dx_app()->singleton('my_api', function() use ($apiKey) {
return new MyApiClient($apiKey);
});
// ③ 훅 등록
dx_add_hook('dx_bottom', function($ctx) {
// 등록된 서비스 꺼내 사용
$api = dx_make('my_api');
echo '<script>window.__MY_PLUGIN_ID__ = "' . dx_esc($api->getTrackId()) . '";</script>';
}, 10);
10.2 extend/middle/ — 라우트 기반 조건부 처리
// extend/middle/02_premium_check.php
if (!defined('DX_CMS')) exit;
// middle: $type, $route가 자동 주입됨
$type = isset($type) ? $type : '';
$slug = isset($route['slug']) ? $route['slug'] : '';
// premium 게시판에 비회원 접근 차단
if ($type === 'board' && $slug === 'premium') {
if (!Auth::getInstance()->isLoggedIn()) {
dx_set_flash('프리미엄 회원 전용 게시판입니다.', 'error');
dx_redirect(dx_base_url('auth/login') . '?redirect=' . urlencode(dx_current_url()));
exit;
}
// 구독 만료 체크
$userId = Auth::getInstance()->get('id', 0);
$cacheKey = 'premium_' . $userId;
$isPremium = DxCache::get($cacheKey, null);
if ($isPremium === null) {
$sub = Database::getInstance()->row(
'SELECT * FROM subscriptions WHERE user_id=? AND expires_at > NOW() LIMIT 1',
array($userId)
);
$isPremium = $sub ? true : false;
DxCache::set($cacheKey, $isPremium, 300);
}
if (!$isPremium) {
dx_set_flash('구독이 필요합니다.', 'warning');
dx_redirect(dx_base_url('subscribe'));
exit;
}
}
10.3 extend/bottom/ — 성능 로그
// extend/bottom/99_perf_log.php
if (!defined('DX_CMS')) exit;
if (!defined('DX_DEBUG') || !DX_DEBUG) return;
// bottom: $elapsed가 자동 주입됨
$elapsed = isset($elapsed) ? $elapsed : round((microtime(true)-DX_START)*1000,2);
$queries = Database::getInstance()->getQueryCount();
$route = json_encode(isset($GLOBALS['dx_route']) ? $GLOBALS['dx_route'] : array());
$memory = round(memory_get_peak_usage(true) / 1024 / 1024, 2);
echo '<div style="position:fixed;bottom:10px;right:10px;
background:rgba(15,23,42,.9);color:#94a3b8;
padding:8px 14px;border-radius:8px;font-size:11px;
font-family:monospace;z-index:9999;line-height:1.9">';
echo '⚡ ' . $elapsed . 'ms 🗄 ' . $queries . ' queries 💾 ' . $memory . 'MB';
echo '<br>📍 ' . htmlspecialchars($route, ENT_QUOTES);
echo '</div>';
10.4 디버그: 실행된 플러그인•확장 파일 목록 확인
// extend/bottom/98_debug_loader.php (개발 환경 전용)
if (!defined('DX_CMS')) exit;
if (!defined('DX_DEBUG') || !DX_DEBUG) return;
// 실행된 extend 파일 목록
$executed = DxExtend::getInstance()->getExecuted();
// 등록된 훅 목록
$allHooks = HookManager::getInstance()->getAll();
$executedHooks = HookManager::getInstance()->getExecuted();
// 등록된 플러그인 목록
$types = PluginRegistry::getInstance()->getAllTypes();
$plugins = array();
foreach ($types as $type) {
$plugins[$type] = PluginRegistry::getInstance()->getByType($type);
}
echo '<pre style="position:fixed;top:10px;right:10px;
background:#0f172a;color:#94a3b8;
padding:12px;border-radius:8px;font-size:10px;z-index:9999;max-height:400px;overflow:auto">';
echo '=== Extend 실행 파일 === ';
foreach ($executed as $item) {
echo '[' . $item['slot'] . '] ' . $item['file'] . " ";
}
echo ' === 등록된 훅 === ' . implode(', ', $allHooks);
echo ' === 발화된 훅 === ' . implode(', ', $executedHooks);
echo '</pre>';
플러그인•확장 로딩 핵심 원칙 12가지
1. 모든 로딩은 index.php 단일 진입점을 통해 순서대로 진행된다.2. STEP 1: 클래스 로드(정의만). STEP 4: 서비스 초기화 + 플러그인 로드. STEP 5: 라우팅.
3. load_plugins()는 plugins/*/plugin.php를 폴더명 오름차순으로 자동 로드한다.
4. plugin.php 없는 폴더는 건너뜀. manifest.php만 있어도 마켓 등록은 가능.
5. plugin.php 첫 줄: if (!defined('DX_CMS')) exit; 필수. exit() 대신 return 사용.
6. 훅 등록(STEP 4)과 발화(STEP 5)는 완전히 분리된다. 등록 시 실행 안 됨.
7. extend/ 파일은 파일명 오름차순 실행. 01_, 02_, 10_ 자릿수를 맞춰야 한다.
8. safeExec(): 경계 검증 → 에러 핸들러 → extract(EXTR_SKIP) → include → restore.
9. top/: 초기화 완료 후. middle/: 라우트 확정 후. bottom/: 렌더링 완료 후.
10. ob_start 콜백은 top/에 등록하고 ob_end_flush() 시점에 자동 실행된다.
11. routes/*.php는 STEP 5 시작에 로드. DxRouter에 클래스 기반 라우트 정의.
12. 무거운 작업은 register_shutdown_function으로 응답 후 처리한다.