1. 전체 실행 흐름 조감도
브라우저에서 요청이 들어와 응답이 나갈 때까지 DXCMS가 거치는 모든 단계를 순서대로 정리합니다. Extend 구조(extend/ 파일, Hook, Plugin)가 각각 어느 지점에서 개입하는지를 먼저 파악하면 이후 세부 내용을 훨씬 쉽게 이해할 수 있습니다.
1.1 10단계 처리 파이프라인
|
브라우저 요청 (예: GET /notice/view/42)
|
| [STEP 0] index.php 진입 • ob_start() — 출력 버퍼 시작 • PHP 버전 체크 (5.6 미만 거부) • DX_CMS / DX_ROOT / DX_EXTEND 등 핵심 상수 정의 |
| [STEP 1] 클래스/함수 로드 (실행 없이 정의만) • functions.php / DxCache / Secure / Database / HookManager • PluginRegistry / Auth / DxSite / DxTheme / DxExtend / Router … |
| [STEP 2] 보안 초기화 (Secure.php 전담) • 세션 설정 → 세션 시작 (읽기전용 GET 요청은 스킵) • 보안 헤더 발행, CSRF 토큰 선제 발급 |
| [STEP 3] DB 연결 + 설정 로드 (data/config.php) • DB 연결, $dx_config 전역 배열 채우기 • secret_key 시크릿 키 주입 |
| [STEP 4] 초기화 (DB 연결 완료 후) HookManager → PluginRegistry → load_plugins() └─ plugins/*/plugin.php 자동 로드 & 훅 등록 DxSite (멀티사이트) → DxTheme → Auth → DxContainer DxExtend::ensureDirs() — extend/ 폴더 자동 생성 extend/top/ 실행 (모든 초기화 완료 직후) |
| [STEP 5] 라우팅 Router::resolve() — URI 파싱 → 라우트 타입 확정 $GLOBALS['dx_route'] 세팅 extend/middle/ 실행 (라우트 확정 직후) |
| [STEP 6] 디스패치 (Dispatcher) 라우트 타입별 핸들러 실행 home / page / board / admin / auth / api / search / 404 └─ 테마 레이아웃(layout/main.php)으로 래핑하여 렌더링 └─ dx_hook_top / dx_hook_middle / dx_hook_bottom 발화 |
| [STEP 7] 렌더링 완료 extend/bottom/ 실행 (ob_end_flush() 직전) |
| [STEP 8] 출력 버퍼 최종 Flush → 브라우저 응답 전송 |
1.2 Extend 개입 지점 요약
| 개입 지점 | 발화 파일/함수 | index.php 위치 | 직전 상태 | 직후 상태 |
|---|---|---|---|---|
| extend/top/ | DxExtend::runTop() | STEP 4 끝 | 모든 초기화 완료 | 라우팅 전 |
| extend/middle/ | DxExtend::runMiddle() | Dispatcher::dispatch() | 라우트 확정 | 핸들러 실행 전 |
| extend/bottom/ | DxExtend::runBottom() | STEP 7 끝 | 렌더링 완료 | ob_end_flush() 전 |
| Hook(dx_top 등) | dx_run_hook() | 테마 layout/main.php | HTML <body> 직후 | 콘텐츠 출력 전 |
| Plugin(훅 기반) | dx_run_hook() 경유 | STEP 4: load_plugins() | 훅 등록 후 | 해당 훅 발화 시 |
2. 각 단계 상세 분석
2.1 STEP 0~1: 진입 및 클래스 로드
index.php가 PHP에 의해 실행되는 즉시 두 가지 작업이 시작됩니다. 출력 버퍼를 열고, 클래스와 함수 정의 파일들을 require_once로 메모리에 올립니다. 이 단계에서는 실제 실행 로직이 없고 정의만 이루어집니다.
실행 코드 (index.php 발췌)
// ── 출력 버퍼 시작 ─────────────────────────────────────
// IIS/CGI에서 header()가 "headers already sent" 없이 동작하도록 보장
ob_start();
// ── 핵심 상수 정의 ─────────────────────────────────────
define('DX_CMS', true);
define('DX_VERSION', '8.1.0');
define('DX_ROOT', str_replace('\\', '/', dirname(__FILE__)));
define('DX_CORE', DX_ROOT . '/core');
define('DX_EXTEND', DX_ROOT . '/extend'); // ← Extend 루트
define('DX_START', microtime(true)); // ← 성능 측정 기준점
// ── 클래스/함수 정의 파일 로드 (실행 없이 정의만) ──────
require_once DX_CORE . '/functions.php'; // dx_config, dx_log 등
require_once DX_CORE . '/DxCache.php'; // 캐시 (config.php 캐싱에 필요)
require_once DX_CORE . '/hook/HookManager.php'; // dx_add_hook, dx_run_hook
require_once DX_CORE . '/PluginRegistry.php'; // dx_register_plugin
require_once DX_CORE . '/DxExtend.php'; // ← Extend 엔진
require_once DX_CORE . '/router/Router.php';
require_once DX_CORE . '/router/Dispatcher.php';
// ... (Auth, DxSite, DxTheme 등 약 20개 파일)
💡 포인트: DX_CMS 상수가 정의되는 것이 바로 이 시점입니다.
extend/ 파일 첫 줄의 if (!defined('DX_CMS')) exit; 가 이 상수를 검증합니다.
이 체크가 없으면 extend/ 파일을 웹에서 직접 URL로 호출할 수 있게 되어 보안 취약점이 생깁니다.
2.2 STEP 2~3: 보안 초기화 및 DB 연결
Secure.php가 세션 설정, 보안 헤더, CSRF 토큰 발급을 전담합니다. 이후 data/config.php가 로드되어 DB 연결이 이루어집니다. 이 두 단계가 끝나야 비로소 CMS의 모든 기능을 사용할 수 있는 상태가 됩니다.
// ── STEP 2: 보안 초기화 ─────────────────────────────────
$_dxSecure = Secure::getInstance();
$_dxSecure->initSession($_dxIsHttps); // 세션 옵션 설정
// GET + 세션쿠키 없는 경우 세션 시작 스킵 (성능 최적화)
// /admin, /auth, /view/, /api/ 등은 세션 필수 → 항상 시작
if ($_dxNeedSession) {
$_dxSecure->startSession();
}
$_dxSecure->sendSecurityHeaders(); // X-Frame-Options 등 보안 헤더
$_dxSecure->csrfToken(); // CSRF 토큰 선제 발급
// ── STEP 3: DB 연결 + 설정 로드 ────────────────────────
require_once DX_ROOT . '/data/config.php';
// config.php 내부: Database::getInstance()->connect(...)
// $dx_config 전역 배열이 DB의 dx_settings 테이블에서 채워짐
2.3 STEP 4: 초기화 완료 → extend/top/ 실행
가장 많은 일이 일어나는 단계입니다. 플러그인이 로드되고, 멀티사이트•테마•인증이 초기화된 후, extend/top/ 폴더의 PHP 파일들이 자동 실행됩니다.
2.3.1 플러그인 로드: load_plugins()
load_plugins()는 plugins/ 하위의 모든 폴더를 순회하며 plugin.php를 require_once로 로드합니다. 각 plugin.php는 이 시점에 dx_register_plugin()과 dx_add_hook()을 호출하여 자신을 등록합니다.
// core/functions.php 내 load_plugins() 구현
function load_plugins() {
$dirs = glob(DX_PLUGINS . '/*', GLOB_ONLYDIR);
foreach ($dirs as $dir) {
$f = $dir . '/plugin.php';
if (file_exists($f)) {
require_once $f; // ← 플러그인 코드 실행
// plugin.php 내부에서 이루어지는 일:
// dx_register_plugin(...) → PluginRegistry 등록
// dx_add_hook('dx_editor_render', ...) → 훅 등록
// dx_add_hook('dx_bottom', ...) → 하단 JS 주입 등록
}
}
}
2.3.2 핵심 서비스 초기화 순서
| 순서 | 호출 | 역할 | 완료 후 가용 기능 |
|---|---|---|---|
| ① | HookManager::getInstance() | 훅 시스템 초기화 | dx_add_hook() / dx_run_hook() 사용 가능 |
| ② | PluginRegistry::getInstance() | 플러그인 등록소 초기화 | dx_register_plugin() 사용 가능 |
| ③ | load_plugins() | 플러그인 코드 로드 및 훅 등록 | plugins/ 하위 훅 모두 활성화 |
| ④ | DxSite::getInstance() | 멀티사이트 도메인 감지 | 도메인별 테마•설정 오버라이드 |
| ⑤ | DxTheme::getInstance() | 테마 엔진 초기화 | 테마 폴백 체인 활성화 |
| ⑥ | Auth::getInstance() | 인증 세션 검증 | isLoggedIn() / isAdmin() 사용 가능 |
| ⑦ | DxContainer::registerCoreServices() | DI 컨테이너 서비스 등록 | dx_app()->make() 사용 가능 |
| ⑧ | DxExtend::ensureDirs() | extend/ 폴더 자동 생성 | top•middle•bottom 폴더 보장 |
2.3.3 extend/top/ 실행
위 8단계가 모두 완료된 직후 DxExtend::runTop()이 호출됩니다. 이 시점은 모든 CMS 기능이 준비된 상태이므로 extend/top/ 파일 안에서 DB 쿼리, 세션 조작, 인증 확인 등 어떤 작업도 가능합니다.
// index.php — STEP 4 끝 부분 (실제 소스)
DxExtend::getInstance()->runTop(array(
'version' => DX_VERSION, // '8.1.0'
'path' => dx_request_uri(),// '/notice/view/42'
));
// DxExtend::runTop() 내부 동작:
// 1. extend/top/ 폴더의 *.php 파일을 파일명 오름차순으로 수집
// 2. 각 파일을 safeExec()로 실행 (에러 격리)
// 3. dx_run_hook('dx_extend_top', $context) 발화
⚠️ 주의: extend/top/ 에서 exit()를 호출하면 이후 모든 처리가 중단됩니다.
점검 모드처럼 의도적인 경우에만 사용하고, 일반 로직에서는 return을 사용하세요.
return은 현재 파일 실행만 중단하고 다음 파일로 넘어갑니다.
2.3.4 실제 내장 예제: extend/top/01_darkmode_early.php
DXCMS에 내장된 다크모드 FOUC(화면 깜빡임) 방지 코드입니다. ob_start 콜백을 이용해 최종 HTML에 인라인 스크립트를 삽입합니다. top/ 슬롯이기 때문에 렌더링 전에 출력 버퍼 가로채기를 설정할 수 있습니다.
// extend/top/01_darkmode_early.php 핵심 부분
if (!defined('DX_CMS')) exit; // 보안 필수
// ob_start 콜백으로 최종 HTML 버퍼를 가로챔
ob_start(function($buffer) {
// <head> 바로 뒤에 인라인 스크립트 삽입
$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";'
. '}'
. '}catch(e){}'
. '})();'
. '</script>';
// <head> 바로 뒤에 삽입
$buffer = preg_replace('/<head([^>]*)>/i',
'<head$1>' . $earlyScript, $buffer, 1);
return $buffer;
});
// ↑ 이 ob_start 콜백은 렌더링이 완료되고 ob_end_flush()가
// 호출될 때 비로소 실행됩니다.
// 즉, top/에서 등록 → 전체 HTML이 완성된 후 처리됩니다.
2.4 STEP 5: 라우팅 → extend/middle/ 실행
Router가 요청 URI를 분석하여 어떤 타입의 페이지인지 확정합니다. 확정 결과는 $GLOBALS['dx_route']에 저장되고, 이 시점에 extend/middle/ 파일들이 실행됩니다.
2.4.1 Router::resolve() — URI 파싱 과정
/notice/view/42 같은 URI가 들어올 때 Router가 어떻게 라우트를 확정하는지 추적합니다.
// Router::resolve() 흐름 (예: GET /notice/view/42)
// 1. URI 정규화
$uri = '/notice/view/42';
// 2. 세그먼트 분리
$segments = ['notice', 'view', '42'];
// 3. 첫 세그먼트가 'admin' / 'auth' / 'api' 인지 확인
// → 아님 → 게시판 액션인지 확인
// 4. $second('view')가 게시판 액션 목록에 있는지 확인
// boardActions = ['list','view','write','edit','delete','search','reply','bulk']
// 'view' → 있음 → boards 테이블에서 board_key='notice' 조회
// 5. 게시판 존재 → TYPE_BOARD로 확정
$GLOBALS['dx_route'] = array(
'type' => 'board',
'slug' => 'notice',
'action' => 'view',
'id' => '42',
'board' => ['board_key'=>'notice', 'name'=>'공지사항', ...],
);
2.4.2 라우트 타입별 처리 분기
| TYPE | 예시 URL | 핸들러 | 특이사항 |
|---|---|---|---|
| home | / | 테마 page/home.php 또는 DB is_home=1 페이지 | 테마 > DB > pages/home.php 순으로 폴백 |
| page | /about | DX_PAGES/about.php | 접근 레벨(0=공개,1=로그인,9=관리자) 체크 |
| board | /notice/view/42... | boards/handler.php | 읽기/쓰기 레벨 체크 후 핸들러 실행 |
| admin | /admin/dashboard | admin/index.php | 관리자 권한 없으면 로그인 페이지로 리다이렉트 |
| auth | /auth/login | core/auth/login.php | 소셜 callback은 ob 버퍼 초기화 후 실행 |
| api | /api/comment | core/api/comment.php | JSON 헤더 자동 설정 |
| search | /search | core/search/handler.php | 통합 검색 |
| 404 | 존재하지 않는 경로 | 테마 page/404.php | HTTP 404 응답 |
2.4.3 extend/middle/ 실행 — Dispatcher::dispatch() 내부
Dispatcher::dispatch()가 라우트를 받아 $GLOBALS['dx_route']에 저장한 직후, 핸들러를 실행하기 전에 extend/middle/을 실행합니다.
// core/router/Dispatcher.php — dispatch() 실제 소스
public function dispatch() {
$this->route = $this->router->resolve();
$GLOBALS['dx_route'] = $this->route; // ← 라우트 확정
// ┌─────────────────────────────────────────────────┐
// │ extend/middle/ 실행 │
// │ 라우트 확정 직후, 핸들러 실행 전 │
// └─────────────────────────────────────────────────┘
DxExtend::getInstance()->runMiddle(array(
'type' => $this->route['type'],
'route' => $this->route,
));
// ← 이 시점부터 핸들러 실행
switch ($this->route['type']) {
case 'home': $this->dispatchHome(); break;
case 'page': $this->dispatchPage(); break;
case 'board': $this->dispatchBoard(); break;
// ...
}
}
2.4.4 실제 내장 예제: extend/middle/01_visit_tracker.php
방문자 통계를 수집하는 내장 파일입니다. middle/ 슬롯이기 때문에 $GLOBALS['dx_route']를 읽어 admin•api 경로를 제외하고, 봇을 필터링하며, DB 기록은 register_shutdown_function으로 응답 후 처리합니다.
// extend/middle/01_visit_tracker.php 핵심 흐름
if (!defined('DX_CMS')) exit;
// ① 라우트 타입 확인 (middle에서만 가능)
$_vt_route = isset($GLOBALS['dx_route']) ? $GLOBALS['dx_route'] : array();
$_vt_type = isset($_vt_route['type']) ? $_vt_route['type'] : '';
if (in_array($_vt_type, array('admin', 'api'), true)) return;
// ② 봇 판별 (봇은 전혀 기록하지 않음)
$ua_lower = strtolower($_vt_ua);
foreach ($bot_patterns as $b) {
if (strpos($ua_lower, $b) !== false) { return; }
}
// ③ 순방문자 판단: DxCache 사용 (DB 조회 없음)
$cacheKey = 'vt_u:' . date('Y-m-d') . ':' . substr(md5($ip), 0, 12);
if (!DxCache::get($cacheKey, false)) {
DxCache::set($cacheKey, 1, $ttl);
$_vt_isUnique = true;
}
// ④ 응답 후 DB 기록 (register_shutdown_function으로 지연)
register_shutdown_function(function() use ($_vt_data) {
// fastcgi_finish_request()로 응답 먼저 전송
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// 이후 DB INSERT — 사용자 체감 속도에 영향 없음
$pdo->prepare("INSERT INTO visits ...")->execute(...);
});
2.5 STEP 6: 디스패치 — 렌더링과 Hook 발화
Dispatcher가 핸들러를 실행하면 실제 HTML이 생성됩니다. 이 과정에서 테마 레이아웃이 로드되고, 레이아웃 안에서 훅(dx_hook_top / dx_hook_middle / dx_hook_bottom)이 발화됩니다.
2.5.1 테마 렌더링 흐름
// Dispatcher::renderPageWithLayout() 흐름
// 1. 테마 레이아웃 파일 결정
$layoutFile = DxTheme::getInstance()->resolve('layout/main.php');
// 폴백 체인: themes/{현재테마}/layout/main.php
// → themes/default/layout/main.php
// 2. 콘텐츠 파일을 버퍼에 렌더링
ob_start();
extract($context, EXTR_SKIP);
include $contentFile; // pages/about.php 등
$dx_content = ob_get_clean(); // 콘텐츠 HTML 캡처
// 3. 레이아웃에 콘텐츠 주입
require $layoutFile;
// layout/main.php 내부에서 $dx_content를 출력하고
// dx_hook_top / dx_hook_middle / dx_hook_bottom 을 호출
2.5.2 테마 layout/main.php에서의 Hook 발화
테마 개발자는 layout/main.php 안에서 아래와 같이 훅 함수를 호출합니다. 이 함수들이 호출되는 순간, dx_add_hook()으로 등록된 모든 콜백이 우선순위 순으로 실행됩니다.
<!-- themes/default/layout/main.php 구조 (개념) -->
<!DOCTYPE html>
<html>
<head>
<?php dx_head_assets(); ?> <!-- CSS/JS 자동 주입 -->
</head>
<body>
<?php dx_hook_top($context); ?> <!-- ← Hook 발화 지점 1 -->
<!--
내부: dx_run_hook('dx_top', $context) // 모든 페이지 공통
dx_run_hook('dx_board_top', $context) // 게시판이면
dx_run_hook('dx_page_notice_top', $ctx) // slug='notice'이면
-->
<main>
<?php echo $dx_content; ?> <!-- 페이지/게시판 콘텐츠 -->
</main>
<?php dx_hook_bottom($context); ?> <!-- ← Hook 발화 지점 2 -->
<!--
내부: dx_run_hook('dx_bottom', $context)
dx_run_hook('dx_board_bottom', $context)
-->
</body>
</html>
2.5.3 dx_hook_top/bottom 내부 로직 (HookManager)
dx_hook_top()이 호출되면 세 개의 훅이 순서대로 발화됩니다. 이 중 type•slug 기반 훅은 현재 라우트에 맞는 것만 발화됩니다.
// core/hook/HookManager.php 실제 소스
function dx_hook_top($context = array()) {
dx_run_hook('dx_top', $context); // 모든 페이지
if (isset($context['type'])) {
// 예: $context['type'] = 'board'
dx_run_hook('dx_board_top', $context); // 게시판 페이지만
}
if (isset($context['slug'])) {
// 예: $context['slug'] = 'notice'
dx_run_hook('dx_page_notice_top', $context); // notice 게시판만
}
}
// ─────────────────────────────────────────────────────
// 예: extend/top/에서 이렇게 등록했다면
dx_add_hook('dx_board_top', function($ctx) {
echo '<div class="board-banner">게시판 전용 배너</div>';
}, 10);
// 이 콜백은 type='board'인 페이지에서만 실행됨
// type='page'나 type='home'에서는 실행되지 않음
2.6 STEP 7: 렌더링 완료 → extend/bottom/ 실행
Dispatcher가 핸들러 실행을 마치면 index.php의 마지막 부분에서 extend/bottom/이 실행됩니다. 이 시점은 HTML이 이미 완성된 후이므로 헤더 변경은 불가하지만, ob_start가 활성화된 상태라면 추가 출력이 가능합니다.
// index.php 마지막 부분 (실제 소스)
// DxRouter 또는 Dispatcher가 핸들러 실행 완료
// extend/bottom/ 실행 (렌더링 완료 후)
DxExtend::getInstance()->runBottom(array(
'elapsed' => round((microtime(true) - DX_START) * 1000, 2),
// elapsed: 현재까지 경과 시간(ms) - 성능 로그에 활용 가능
));
// 출력 버퍼 최종 flush
if (ob_get_level() > 0) {
ob_end_flush();
// ← 여기서 extend/top/01_darkmode_early.php가 등록한
// ob_start 콜백이 실행되어 HTML을 가공합니다.
}
실제 내장 예제: extend/bottom/02_darkmode_engine.php
다크모드 JS 파일을 모든 페이지 하단에 자동 주입합니다. bottom/ 슬롯이므로 렌더링 완료 후 JS 삽입이 보장됩니다.
// 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('') : '', '/');
// dx-darkmode-engine.js 로드 스크립트 출력
echo '<script src="' . $_dmBase . '/assets/js/dx-darkmode-engine.js'
. '?v=' . htmlspecialchars($_dmVer, ENT_QUOTES, 'UTF-8') . '"></script>' . "\n";
3. ob_start 콜백의 실행 타이밍 완전 해부
다크모드 방지처럼 ob_start 콜백을 사용하는 패턴은 처음 보면 언제 실행되는지 헷갈릴 수 있습니다. 실행 시점을 정확히 짚어 봅니다.
3.1 ob_start 중첩 구조
index.php가 시작될 때 이미 ob_start()가 한 번 호출됩니다. 이후 extend/top/01_darkmode_early.php가 ob_start(콜백)을 추가로 호출하면 중첩된 버퍼가 쌓입니다.
시간 흐름 →
[index.php 시작]
ob_start() ← 버퍼 레벨 1 (콜백 없음)
[extend/top/ 실행]
ob_start(callback) ← 버퍼 레벨 2 (다크모드 콜백)
[렌더링]
echo "<!DOCTYPE html>..." ← 레벨 2 버퍼에 쌓임
[index.php 끝]
ob_end_flush() ← 레벨 2 버퍼 → 콜백 실행 → HTML 가공
가공된 HTML이 레벨 1 버퍼로 넘어감
ob_end_flush() ← 레벨 1 버퍼 → 브라우저로 전송
결과: <head> 바로 뒤에 인라인 스크립트가 삽입된 HTML이 전송됨
💡 이 패턴의 장점:
• 테마 파일을 전혀 수정하지 않아도 됩니다.
• 플러그인/extend 파일이 자신을 등록하고, HTML이 완성되는 시점에 자동으로 처리됩니다.
• CMS 업데이트 후에도 동작이 유지됩니다.
4. 실전 시나리오 — 요청 하나를 끝까지 추적
사용자가 /notice/view/42에 접속할 때 Extend 구조가 어떻게 개입하는지 처음부터 끝까지 추적합니다.
4.1 시나리오 설정
| 항목 | 값 |
|---|---|
| 요청 URL | GET /notice/view/42 |
| 사용자 상태 | 일반 회원 (로그인) |
| 설치된 extend 파일 | top/01_darkmode_early.php, middle/01_visit_tracker.php, bottom/02_darkmode_engine.php |
| 설치된 플러그인 | ckeditor4-editor (에디터), dx-socket (소켓) |
| 테마 | default |
4.2 ms 단위 타임라인
|
T+0ms
|
index.php 진입 ob_start() 호출. DX_CMS=true, DX_START=microtime(true) 정의. |
|
T+1ms
|
STEP 1: 클래스 로드 20여 개 core/*.php 파일을 require_once. 실행 없이 클래스/함수 정의만 메모리에 올림. |
|
T+2ms
|
STEP 2: 보안 초기화 Secure::initSession() → startSession() → sendSecurityHeaders() → csrfToken(). /notice/view/42는 view/ 포함이라 세션 필요 → 세션 시작. |
|
T+3ms
|
STEP 3: DB 연결 data/config.php 로드. MySQL 연결. $dx_config 배열에 사이트 설정 로드. |
|
T+4ms
|
load_plugins() plugins/ckeditor4-editor/plugin.php → dx_register_plugin + dx_add_hook('dx_editor_render'...) 등록. plugins/dx-socket/plugin.php → WebSocket 훅 등록. |
|
T+5ms
|
DxSite + DxTheme + Auth 도메인 감지 → 테마 = 'default' 확정. Auth: 세션에서 회원 정보 복원 → isLoggedIn() = true. |
|
T+6ms
|
extend/top/ 실행 01_darkmode_early.php: ob_start(콜백) 등록. (콜백은 아직 실행 안 됨, ob_end_flush 때 실행됨) |
|
T+7ms
|
Router::resolve() URI /notice/view/42 파싱. segments=['notice','view','42']. boards 테이블에서 board_key='notice' 조회. TYPE_BOARD 확정. $GLOBALS['dx_route'] 세팅. |
|
T+8ms
|
extend/middle/ 실행 01_visit_tracker.php: type='board' → 기록 대상. 봇 아님. DxCache로 순방문자 확인. register_shutdown_function으로 DB INSERT 예약. |
|
T+9ms
|
Dispatcher::dispatchBoard() 게시판 접근 레벨 체크 (read_level=0 → 통과). boards/handler.php 실행. DB에서 post_id=42 조회. $dx_content 생성. |
|
T+10ms
|
layout/main.php 렌더링 themes/default/layout/main.php 로드. dx_hook_top() → dx_run_hook('dx_top'), dx_run_hook('dx_board_top'). $dx_content 출력. dx_hook_bottom() → dx_run_hook('dx_bottom'), dx_run_hook('dx_board_bottom'). |
|
T+11ms
|
extend/bottom/ 실행 02_darkmode_engine.php: echo '<script src=".../dx-darkmode-engine.js">'. elapsed 값 컨텍스트로 받아 성능 로그 가능. |
|
T+12ms
|
ob_end_flush() — 콜백 실행 01_darkmode_early.php의 ob_start 콜백 실행. <head> 뒤에 인라인 스크립트 삽입. HTML 가공 완료 → 브라우저로 전송. |
|
T+13ms
|
register_shutdown_function 실행 fastcgi_finish_request() → 사용자에게 응답 먼저 전송. 이후 방문자 DB INSERT (visits, visit_logs 테이블). |
4.3 타임라인 요약 다이어그램
T+0ms ├── ob_start() [index.php]
T+1ms ├── require 20개 클래스 파일 [STEP 1]
T+2ms ├── 세션 시작 / 보안 헤더 [STEP 2]
T+3ms ├── DB 연결 / config 로드 [STEP 3]
T+4ms ├── load_plugins() → 훅 등록 [STEP 4]
T+5ms ├── DxSite/DxTheme/Auth 초기화 [STEP 4]
T+6ms ├── ████ extend/top/ 실행 ████ [Extend top]
│ └─ ob_start(darkmode_callback) 등록
T+7ms ├── Router::resolve() → TYPE_BOARD 확정 [STEP 5]
T+8ms ├── ████ extend/middle/ 실행 ████ [Extend middle]
│ └─ 방문자 통계 shutdown 예약
T+9ms ├── boards/handler.php → post#42 조회 [STEP 6]
T+10ms ├── layout/main.php 렌더링 [STEP 6]
│ ├─ dx_hook_top() → 플러그인 실행
│ ├─ $dx_content 출력
│ └─ dx_hook_bottom() → 플러그인 실행
T+11ms ├── ████ extend/bottom/ 실행 ████ [Extend bottom]
│ └─ darkmode-engine.js script 태그 출력
T+12ms ├── ob_end_flush() [STEP 8]
│ └─ darkmode_callback() → <head> 뒤 스크립트 삽입
│ → 브라우저 전송
T+13ms └── shutdown: DB INSERT (방문자 로그) [비동기]
5. 파일 로드 순서 결정 메커니즘
extend/ 폴더에 여러 파일이 있을 때 어떤 순서로 실행되는지, 내부적으로 어떻게 결정되는지를 설명합니다.
5.1 DxExtend::collectFiles() 동작 원리
// core/DxExtend.php — collectFiles() 실제 소스
private function collectFiles($dir) {
$files = array();
// 1단계: 해당 폴더의 *.php 파일 수집
$found = glob($dir . '/*.php');
if ($found) { sort($found); $files = $found; }
// ↑ sort()가 파일명 오름차순 보장
// 2단계: 하위 폴더 탐색 (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;
}
5.2 파일명 기반 순서 제어
| 파일명 | 실행 순서 | 권장 역할 |
|---|---|---|
| 01_maintenance.php | 1번 | 서비스 점검 모드 (가장 먼저 실행되어야 함) |
| 02_ip_block.php | 2번 | IP 차단 (점검 모드 다음) |
| 03_global_vars.php | 3번 | 전역 변수 주입 |
| 10_my_feature.php | 4번 | 일반 기능 (앞 번호 확보) |
| 99_debug_panel.php | 5번 | 디버그 패널 (가장 나중) |
| security/01_block.php | 6번 | 하위 폴더: 상위 파일 다음에 실행 |
⚠️ 파일명 정렬은 사전식(lexicographic) 정렬입니다.
9_file.php는 10_file.php보다 나중에 실행됩니다. ('9' > '1' 사전순)
반드시 01_, 02_, 10_, 11_ 처럼 자릿수를 맞추세요.
예) 01_, 02_, 03_ ... 09_, 10_, 11_ ... 99_
5.3 safeExec() — 에러 격리 메커니즘
DxExtend는 각 파일을 safeExec()로 실행합니다. 한 파일에서 에러가 발생해도 다음 파일이 계속 실행되도록 보장합니다.
// DxExtend::safeExec() 핵심 로직
private function safeExec($file, $context, $slot) {
// 보안: extend/ 경계 검증 (path traversal 방지)
$realFile = realpath($file);
$realExtend = realpath($this->extendRoot);
if (strpos($realFile, $realExtend . '/') !== 0) {
dx_log('[DxExtend] 보안 차단: ' . $file, 'warning');
return;
}
// 에러 핸들러 설정 (이 파일의 에러만 로그 기록)
set_error_handler(function($errno, $errstr, ...) {
dx_log('[DxExtend] ' . $errstr, 'error');
return true; // PHP 기본 핸들러 억제
});
try {
extract($context, EXTR_SKIP); // 컨텍스트 변수 주입
$dx_extend_slot = $slot; // 현재 슬롯 정보 주입
include $file;
} catch (Exception $e) {
dx_log('[DxExtend] Exception: ' . $e->getMessage(), 'error');
// 예외가 발생해도 다음 파일로 계속 진행
}
restore_error_handler();
}
6. Hook 발화 시점 상세
훅이 등록되는 시점과 발화되는 시점은 다릅니다. 이 차이를 이해하는 것이 올바른 훅 사용의 핵심입니다.
6.1 등록 vs 발화 시점
| 훅 등록 시점 (dx_add_hook 호출) | 훅 발화 시점 (dx_run_hook 호출) |
|---|---|
| plugins/*/plugin.php 로드 시 (STEP 4) | 테마 layout/main.php에서 dx_hook_top() 호출 시 |
| extend/top/ 파일 실행 시 (STEP 4) | 테마 layout/main.php에서 dx_hook_bottom() 호출 시 |
| extend/middle/ 파일 실행 시 (STEP 5) | extend/top/ 완료 후 dx_run_hook('dx_extend_top') |
| 어디서나 가능 | extend/middle/ 완료 후 dx_run_hook('dx_extend_middle') |
6.2 발화 순서도 — 전체 훅 발화 흐름
요청 처리 순서 발화되는 훅
─────────────────────────────────────────────────────────
load_plugins() (훅 등록만, 발화 없음)
│
extend/top/ 실행
│ └─ runTop() 내부 → dx_extend_top 발화
│
Router::resolve() (발화 없음)
│
extend/middle/ 실행
│ └─ runMiddle() 내부 → dx_extend_middle 발화
│
boards/handler.php or pages/xx.php (발화 없음)
│
layout/main.php: dx_hook_top() → dx_top 발화
→ dx_{type}_top 발화
→ dx_page_{slug}_top 발화
│
$dx_content 출력 (발화 없음)
│
layout/main.php: dx_hook_bottom() → dx_bottom 발화
→ dx_{type}_bottom 발화
→ dx_page_{slug}_bottom 발화
│
extend/bottom/ 실행
│ └─ runBottom() 내부 → dx_extend_bottom 발화
│
ob_end_flush() → ob_start 콜백 실행 (훅 아님, PHP 내부)
│
shutdown functions (훅 아님, PHP 내부)
6.3 훅 컨텍스트 변수
각 훅이 발화될 때 전달되는 $context 배열의 내용입니다. extend/ 파일과 훅 콜백 모두 이 정보를 활용할 수 있습니다.| 훅 | 컨텍스트 키 | 예시 값 | 설명 |
|---|---|---|---|
| dx_extend_top | version | 8.1.0 | CMS 버전 |
| dx_extend_top | path | /notice/view/42 | 요청 URI |
| dx_extend_middle | type | board | 라우트 타입 |
| dx_extend_middle | route | {type,slug,action,id,board} | 전체 라우트 정보 |
| dx_extend_bottom | elapsed | 12.34 | 경과 시간(ms) |
| dx_top | type | board | 페이지 타입 |
| dx_top | slug | notice | 슬러그 |
| dx_board_top | board | {board_key,name,...} | 게시판 정보 |
| dx_page_notice_top | page | {slug,title,...} | 페이지 정보 |
7. Plugin이 실행 흐름에 개입하는 방식
Plugin은 독립적인 실행 주체가 아니라 Hook을 통해 적절한 시점에 실행됩니다. Plugin 자체는 load_plugins() 시점에 훅을 등록하고, 해당 훅이 발화될 때 비로소 동작합니다.
7.1 에디터 플러그인(ckeditor4-editor) 흐름 추적
// ─── STEP 4: load_plugins() ───────────────────────────
// plugins/ckeditor4-editor/plugin.php 실행
dx_register_plugin(array(
'id' => 'ckeditor4',
'type' => 'editor',
'name' => 'CKEditor 4',
));
// dx_editor_render 훅 등록
dx_add_hook('dx_editor_render', function($args) {
if ($args['editor'] !== 'ckeditor4') return; // 다른 에디터면 무시
$name = $args['name'];
$value = $args['value'];
echo '<textarea id="cke_' . $name . '" name="' . $name . '">' . $value . '</textarea>';
echo '<script>CKEDITOR.replace("' . $name . '");</script>';
}, 10);
// ─── STEP 6: 게시판 글쓰기 페이지 렌더링 ──────────────
// boards/write.php (스킨 파일)
dx_run_hook('dx_editor_init', array(
'name' => 'content',
'value' => '',
'board' => $board,
));
// ↑ dx_editor_init 훅 발화 → load_plugins()에서 등록된 브릿지 실행
// 브릿지 내부: dx_render_editor() 호출
// dx_render_editor() → dx_run_hook('dx_editor_render', ...)
// → ckeditor4-editor의 콜백이 실행되어 에디터 HTML 출력
7.2 소켓 플러그인(dx-socket) 흐름 추적
// ─── STEP 4: dx-socket/plugin.php ──────────────────────
dx_register_plugin(array('id'=>'dx-socket','type'=>'socket',...));
// 1. 모든 페이지 하단에 소켓 클라이언트 JS 주입
dx_add_hook('dx_bottom', function($ctx) {
if (dx_config('socket_enabled', '0') !== '1') return;
echo '<script src="/plugins/dx-socket/socket-core.js.php"></script>';
}, 100);
// 2. 관리자 대시보드에 실시간 접속자 위젯 추가
dx_add_hook('dx_admin_dashboard_widget', function() {
require __DIR__ . '/admin/widget.php';
}, 10);
// ─── STEP 6: layout/main.php ────────────────────────────
// dx_hook_bottom() 호출 → dx_run_hook('dx_bottom')
// → dx-socket의 콜백 실행 → 소켓 JS 태그 출력
8. 실전 패턴 모음
8.1 패턴 1: 점검 모드 (top/)
모든 초기화가 완료된 직후 실행되므로 Auth, dx_is_admin() 등을 사용할 수 있습니다.
// extend/top/01_maintenance.php
if (!defined('DX_CMS')) exit;
$maintenance = array('enabled'=>true, 'end_time'=>'2026-05-01 03:00');
if (!$maintenance['enabled']) return;
// Auth는 이미 초기화되어 있으므로 isAdmin() 사용 가능
if (function_exists('dx_is_admin') && dx_is_admin()) return;
http_response_code(503);
header('Retry-After: 3600');
echo '<h1>🔧 점검중 — 종료 예정: ' . $maintenance['end_time'] . '</h1>';
exit;
// exit 이후 extend/top/ 나머지 파일, Router, Dispatcher 전혀 실행 안 됨
8.2 패턴 2: 라우트 기반 리다이렉트 (middle/)
라우트가 확정된 후 실행되므로 $GLOBALS['dx_route']를 읽어 조건부 리다이렉트를 구현합니다.
// extend/middle/02_redirect_old_url.php
if (!defined('DX_CMS')) exit;
$route = isset($GLOBALS['dx_route']) ? $GLOBALS['dx_route'] : array();
// 구 URL /board/notice → 새 URL /notice 리다이렉트
if (isset($route['type']) && $route['type'] === 'board'
&& isset($route['slug']) && $route['slug'] === 'board'
&& isset($route['action']) && $route['action'] === 'notice') {
dx_redirect(dx_base_url('notice'), 301);
exit;
}
// 비회원 접근 시 특정 게시판 차단
if (isset($route['slug']) && $route['slug'] === 'premium') {
if (!Auth::getInstance()->isLoggedIn()) {
dx_redirect(dx_base_url('auth/login') . '?redirect=' . urlencode(dx_current_url()));
exit;
}
}
8.3 패턴 3: 응답 후 비동기 처리 (middle/)
DB 집중 작업이나 외부 API 호출은 register_shutdown_function으로 응답 후 처리합니다. 사용자 체감 속도가 크게 향상됩니다.
// extend/middle/03_async_analytics.php
if (!defined('DX_CMS')) exit;
$route = isset($GLOBALS['dx_route']) ? $GLOBALS['dx_route'] : array();
if (isset($route['type']) && $route['type'] === 'api') return;
$data = array(
'url' => dx_request_uri(),
'ip' => dx_ip(),
'referer' => isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '',
'timestamp' => date('Y-m-d H:i:s'),
);
// ← 응답 먼저 보내고 나서 처리
register_shutdown_function(function() use ($data) {
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // PHP-FPM: 즉시 응답 완료
}
// 여기서 외부 API 호출, 무거운 DB 작업 등 수행
// 사용자는 이미 페이지를 받아 브라우저에서 렌더링 중
$db = Database::getInstance();
$db->query("INSERT INTO analytics ...", $data);
});
8.4 패턴 4: HTML 전체 가공 (top/ + bottom/ 조합)
ob_start 콜백으로 전체 HTML을 가공합니다. top/에서 콜백을 등록하고, 실제 가공은 ob_end_flush() 시점(bottom/ 이후)에 자동 실행됩니다.
// extend/top/05_html_processor.php
if (!defined('DX_CMS')) exit;
ob_start(function($buffer) {
// 예1: 특정 텍스트 자동 링크화
$buffer = preg_replace('/\bDXCMS\b/', '<a href="/">DXCMS</a>', $buffer);
// 예2: 성능 측정 주석 삽입
$elapsed = round((microtime(true) - DX_START) * 1000, 2);
$buffer = str_replace('</body>',
'<!-- DX: ' . $elapsed . 'ms --></body>', $buffer);
return $buffer;
});
// ↑ 이 콜백은 index.php 마지막의 ob_end_flush() 시점에 실행됨
// 즉, bottom/ 실행이 끝난 후에 전체 HTML을 받아 가공
8.5 패턴 5: 슬롯 정보 활용
$dx_extend_slot 변수를 통해 현재 어느 슬롯(top/middle/bottom)에서 실행 중인지 알 수 있습니다.
// extend/top/99_multi_slot.php (실제로는 슬롯별로 분리 권장)
if (!defined('DX_CMS')) exit;
// $dx_extend_slot은 DxExtend::safeExec()가 자동 주입
// 'top', 'middle', 'bottom' 중 하나
if ($dx_extend_slot === 'top') {
// top에서만 실행할 코드
} elseif ($dx_extend_slot === 'middle') {
// middle에서만 실행할 코드
}
// context 변수도 extract()로 자동 주입
// top에서는 $version, $path 사용 가능
// middle에서는 $type, $route 사용 가능
// bottom에서는 $elapsed 사용 가능
9. 실행 흐름 디버깅 방법
Extend 구조에서 문제가 생겼을 때 어떻게 원인을 추적하는지 설명합니다.
9.1 DX_DEBUG 모드 활성화
// data/config.php 에서 설정
define('DX_DEBUG', true);
// DX_DEBUG = true 시:
// • 에러가 화면에 표시됩니다.
// • dx_log()의 info 레벨도 기록됩니다.
// • 에디터/페이지 에러가 노란 박스로 표시됩니다.
9.2 실행된 extend 파일 목록 확인
// extend/bottom/99_debug_extend.php (개발 환경에서만 사용)
if (!defined('DX_CMS')) exit;
if (!defined('DX_DEBUG') || !DX_DEBUG) return;
$executed = DxExtend::getInstance()->getExecuted();
echo '<pre style="position:fixed;bottom:0;left:0;background:#000;color:#0f0;
font-size:11px;padding:10px;z-index:9999;max-height:200px;overflow:auto">';
echo 'Extend 실행 파일: ';
foreach ($executed as $item) {
echo $item['slot'] . '/' . $item['file'] . " ";
}
echo '</pre>';
9.3 훅 등록 현황 확인
// extend/bottom/99_debug_hooks.php
if (!defined('DX_CMS')) exit;
if (!defined('DX_DEBUG') || !DX_DEBUG) return;
$allHooks = HookManager::getInstance()->getAll();
$executed = HookManager::getInstance()->getExecuted();
echo '<pre style="...">';
echo '등록된 훅: ' . implode(', ', $allHooks) . " ";
echo '발화된 훅: ' . implode(', ', $executed);
echo '</pre>';
9.4 에러 로그 확인
extend/ 파일에서 발생하는 에러는 DxExtend::safeExec()가 가로채어 data/error.log에 기록합니다.
// data/error.log 로그 형식 예시
[2026-05-01 12:00:00] [extend/top/01_maintenance.php:25]
[DxExtend] Undefined variable: maintenance (line 25)
[2026-05-01 12:00:01] [extend/middle/02_custom.php:10]
[DxExtend] Exception: Database connection failed
// → 파일명과 줄 번호가 함께 기록되어 원인 파악이 쉬움
9.5 성능 측정 패턴
// extend/bottom/98_perf_log.php (DX_DEBUG 모드 전용)
if (!defined('DX_CMS')) exit;
if (!defined('DX_DEBUG') || !DX_DEBUG) return;
// bottom 컨텍스트에서 $elapsed가 자동 주입됨
// (DxExtend::runBottom(['elapsed' => round(..., 2)]))
$elapsed = isset($elapsed) ? $elapsed : round((microtime(true) - DX_START) * 1000, 2);
$queries = Database::getInstance()->getQueryCount();
$route = isset($GLOBALS['dx_route']) ? json_encode($GLOBALS['dx_route']) : '-';
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';
echo '<br>📍 ' . htmlspecialchars($route, ENT_QUOTES);
echo '</div>';
10. 빠른 참조 — 슬롯 선택 체크리스트
10.1 어느 슬롯에 넣어야 하나?
| 하고 싶은 일 | 슬롯 | 이유 |
|---|---|---|
| 점검 모드 (exit로 중단) | top/ | 인증•세션 사용 가능, 라우팅 전 차단 |
| IP 차단 | top/ | 가장 빠른 시점, DB 조회도 가능 |
| ob_start 콜백 등록 | top/ | 렌더링 전에 버퍼 설정해야 함 |
| 전역 PHP 변수 주입 | top/ | 모든 파일이 참조할 수 있도록 먼저 정의 |
| 훅 등록(dx_add_hook) | top/ 또는 plugin.php | 발화 전에 등록되어 있으면 됨 |
| 방문자 통계 기록 | middle/ | 라우트 확인 후 관리자•API 제외 가능 |
| 라우트 기반 리다이렉트 | middle/ | $GLOBALS['dx_route'] 사용 필요 |
| A/B 테스트 분기 | middle/ | 라우트 확정 후 핸들러 전 처리 가능 |
| 캐시 저장 | bottom/ | 렌더링 완료 후 저장 |
| 성능 로그 출력 | bottom/ | 경과 시간($elapsed) 컨텍스트 제공 |
| 임시 파일 정리 | bottom/ | 요청 처리 완료 후 정리 |
| JS 파일 script 태그 추가 | bottom/ | DOM 완성 후 추가 (또는 훅 사용) |
10.2 가용 변수 체크리스트
| 변수/함수 | top/ | middle/ | bottom/ |
|---|---|---|---|
| DB::query(), db_*() | ✅ | ✅ | ✅ |
| Auth::isLoggedIn(), dx_is_admin() | ✅ | ✅ | ✅ |
| dx_config(), dx_log() | ✅ | ✅ | ✅ |
| $GLOBALS['dx_route'] | ❌ (미확정) | ✅ | ✅ |
| $GLOBALS['dx_route']['type'] | ❌ | ✅ | ✅ |
| $elapsed (경과 시간ms) | ❌ | ❌ | ✅ (컨텍스트) |
| $version (CMS 버전) | ✅ (컨텍스트) | ❌ | ❌ |
| $path (요청 URI) | ✅ (컨텍스트) | ❌ | ❌ |
| $type (라우트 타입) | ❌ | ✅ (컨텍스트) | ❌ |
| 헤더 변경 (header()) | ✅ | ✅ | ❌ (이미 출력) |
| ob_start() 호출 | ✅ | ⚠️ 가능하나 주의 | ❌ (너무 늦음) |
10.3 최종 요약
DXCMS Extend 구조 — 핵심 정리
1. index.php가 단일 진입점. ob_start()로 시작, ob_end_flush()로 마무리.
2. STEP 1~3: 클래스 로드 → 보안 → DB. Extend 개입 없음.
3. STEP 4: load_plugins() → 훅 등록 완료 → extend/top/ 실행.
이 시점부터 모든 CMS 기능 사용 가능.
4. STEP 5: Router가 라우트 확정 → Dispatcher가 extend/middle/ 실행.
이 시점부터 $GLOBALS['dx_route'] 사용 가능.
5. STEP 6: 핸들러 + 테마 렌더링. 레이아웃에서 Hook 발화.
6. STEP 7: extend/bottom/ 실행 → ob_end_flush() → 브라우저 전송.
7. 훅 콜백은 등록(dx_add_hook)과 발화(dx_run_hook)가 분리되어 있음.
8. Plugin은 load_plugins() 시 훅을 등록하고, 훅 발화 시 동작함.
9. 무거운 작업은 register_shutdown_function으로 응답 후 처리.
10. 코어 파일은 절대 수정하지 않는다. 모든 확장은 extend/를 통해.