회원가입 | 고객센터 |
DESIGNONEX
디자인원엑스
About
Service
Q&A
PR리그
자유게시판N
갤러리
포인트게임
공지사항N
통계
로그인 회원가입
고객센터
3.8 Extend 구조

Extend 개념

A Administrator
2026.04.21 01:00(수정됨) 95 0

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  보안 고려사항

1. 반드시 DX_CMS 상수 확인  — 파일 첫 줄에  if (!defined("DX_CMS")) exit;  를 포함하세요. 직접 URL 접근을 차단합니다.
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  성능 고려사항

6. 무거운 작업은 register_shutdown_function으로  — DB INSERT, 외부 API 호출 등은 응답 후 처리하세요. 사용자 체감 속도를 보호합니다.
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의 관용적 패턴입니다.

댓글0

로그인 후 댓글을 작성할 수 있습니다.
3.8 Extend 구조 Extend 실제 소스 코드 완전 분석 • 12가지 실전 사례 2026.05.02 3.8 Extend 구조 코어 수정 없이 CMS를 확장하는 방법 2026.05.02 3.8 Extend 구조 실제 적용 흐름 2026.04.21 3.8 Extend 구조 Extend 개념 2026.04.21
30
전체 회원
269
전체 게시글
144
전체 댓글
181
오늘 방문
28,530
전체 방문
1
현재 접속
인기글 7일 이내
최신글
최신댓글
목록