1. Hook 시스템 개요
DXCMS의 Hook 시스템은 코어 소스를 직접 수정하지 않고도 CMS의 동작을 확장하거나 변경할 수 있는 이벤트 기반 확장 메커니즘입니다. WordPress의 훅 시스템에서 영감을 받아 DXCMS 환경에 맞게 설계되었으며, PHP 5.6+를 지원합니다.
1.1 핵심 개념
HookManager는 싱글턴 패턴으로 구현된 중앙 관리 클래스로, 모든 훅의 등록•실행•제거를 담당합니다. 전역 헬퍼 함수를 통해 코드 어디서나 간편하게 사용할 수 있습니다.| Action 훅 (dx_run_hook) | Filter 훅 (dx_apply_filter) |
|---|---|
| 부수 효과 실행 목적 | 값을 변환하여 반환하는 목적 |
| 반환값 없음 (void) | 변환된 값을 반환 (return) |
| HTML 출력, DB 기록, 이메일 발송 등 | 컨텐츠 필터링, 데이터 가공 등 |
| dx_add_hook() + dx_run_hook() | dx_add_filter() + dx_apply_filter() |
1.2 전역 헬퍼 함수 레퍼런스
DXCMS는 HookManager 클래스를 직접 사용하는 대신 전역 함수를 통해 더 간결하게 훅을 사용할 수 있습니다.
// ── Action 훅 등록 ──────────────────────────────
dx_add_hook($name, $callback, $priority = 10);
// ── Action 훅 실행 ──────────────────────────────
dx_run_hook($name, $args = array());
// ── Filter 훅 등록 ──────────────────────────────
dx_add_filter($name, $callback, $priority = 10);
// ── Filter 훅 실행 (변환된 값 반환) ──────────────
$result = dx_apply_filter($name, $value, $args = array());
// ── 훅 제거 ────────────────────────────────────
dx_remove_hook($name, $callback = null); // null이면 전체 제거
// ── 훅 등록 여부 확인 ──────────────────────────
$exists = dx_has_hook($name);
1.3 우선순위(Priority) 동작 방식
우선순위는 정수값이며 낮을수록 먼저 실행됩니다. 기본값은 10입니다.
// 우선순위 1 → 가장 먼저 실행
dx_add_hook("dx_bottom", function($ctx) {
echo "<!-- 첫 번째 실행 -->";
}, 1);
// 우선순위 10 (기본값)
dx_add_hook("dx_bottom", function($ctx) {
echo "<!-- 두 번째 실행 -->";
}, 10);
// 우선순위 999 → 가장 나중에 실행
dx_add_hook("dx_bottom", function($ctx) {
echo "<!-- 마지막 실행 -->";
}, 999);
2. 표준 훅 포인트 전체 목록
DXCMS가 기본 제공하는 모든 훅 포인트입니다. 플러그인과 extend 파일에서 이 훅들을 구독하여 기능을 확장합니다.
2.1 레이아웃 렌더링 훅
| 훅 이름 | 실행 위치 | 전달 인자 | 주요 용도 | 타입 |
|---|---|---|---|---|
| dx_head | <head> 태그 내부 | $context 배열 | CSS/JS 추가, 메타 태그 | Action |
| dx_top | 모든 페이지 최상단 (body 시작) | $context 배열 | 공지사항, 배너, 전역 UI | Action |
| dx_middle | 컨텐츠 영역 내부 | $context 배열 | 컨텐츠 삽입, 광고 | Action |
| dx_bottom | 모든 페이지 최하단 (body 끝) | $context 배열 | JS 삽입, 추적 코드 | Action |
| dx_body_bottom | </body> 직전 | 없음 | 채팅 위젯, 팝업, 모달 | Action |
| dx_footer_scripts | 푸터 스크립트 영역 | 빈 배열 | 추가 스크립트 파일 로드 | Action |
2.2 페이지 타입별 훅
컨텍스트 내 type 값에 따라 자동으로 dx_{type}_top , dx_{type}_middle , dx_{type}_bottom 훅이 실행됩니다.
// 페이지 타입별 훅 자동 생성 예시
// type = "board" 인 경우:
// dx_board_top ← 게시판 페이지 상단
// dx_board_middle ← 게시판 페이지 중간
// dx_board_bottom ← 게시판 페이지 하단
// type = "page" 이고 slug = "about" 인 경우:
// dx_page_about_top ← about 페이지 상단만
// dx_page_about_middle ← about 페이지 중간만
// dx_page_about_bottom ← about 페이지 하단만
2.3 게시판(Board) 훅
| 훅 이름 | 실행 위치 | 전달 인자 | 주요 용도 | 타입 |
|---|---|---|---|---|
| dx_board_before | 게시판 핸들러 진입 직전 | board, action, skin, id | 접근 제어, 사전 처리 | Action |
| dx_board_after | 게시판 핸들러 완료 직후 | board, action, skin | 후처리, 로그 | Action |
| dx_board_list_context | 목록 컨텍스트 확정 직후 | &$context, board | 목록 데이터 가공 | Action |
| dx_board_view_context | 뷰 컨텍스트 확정 직후 | &$context, board, post | 뷰 데이터 가공 | Action |
| dx_board_write_context | 쓰기 컨텍스트 확정 직후 | &$context, board | 쓰기 폼 데이터 가공 | Action |
| dx_board_before_save | 글 저장 직전 (쓰기/수정) | &$data, board, action | 데이터 검증•변환 | Action |
| dx_board_after_save | 글 저장 완료 직후 | post_id, board, &$redirect | 알림 발송, 포인트 지급 | Action |
| dx_board_before_delete | 글 삭제 직전 | post, board_key | 삭제 전 백업, 검증 | Action |
| dx_board_after_delete | 글 삭제 완료 직후 | post_id, board_key | 관련 데이터 정리 | Action |
2.4 회원 인증 훅
| 훅 이름 | 실행 위치 | 전달 인자 | 주요 용도 | 타입 |
|---|---|---|---|---|
| dx_after_login | 로그인 성공 직후 | user (회원 정보 배열) | 마지막 접속 기록, 알림 | Action |
| dx_after_logout | 로그아웃 처리 직후 | user (회원 정보 배열) | 세션 정리, 로그 | Action |
| dx_after_register | 회원가입 완료 직후 | user_id, data (가입 데이터) | 환영 이메일, 포인트 지급 | Action |
2.5 댓글•좋아요•포인트 훅
| 훅 이름 | 실행 위치 | 전달 인자 | 주요 용도 | 타입 |
|---|---|---|---|---|
| dx_after_comment | 댓글 작성 완료 직후 | post_id, comment_id, member_id | 댓글 알림, 포인트 | Action |
| dx_after_like | 좋아요 처리 직후 | post_id, owner_id | 좋아요 알림 | Action |
| dx_after_point | 포인트 증감 직후 | member_id, type, point, balance | 포인트 이력 로그 | Action |
| dx_levelup | 레벨업 발생 직후 | member_id, old_level, new_level | 레벨업 축하 알림 | Action |
| dx_add_friend | 친구 추가 직후 | member_id, target_id | 친구 알림 | Action |
2.6 결제•에디터•기타 훅
| 훅 이름 | 실행 위치 | 전달 인자 | 주요 용도 | 타입 |
|---|---|---|---|---|
| dx_payment_request | 결제창 렌더링 시 | amount, product_name, order_id… | PG사별 결제창 처리 | Action |
| dx_shop_after_purchase | 구매 완료 직후 | payment 관련 데이터 | 구매 이력 기록 | Action |
| dx_editor_render | 에디터 HTML 생성 시 | name, value, options | 커스텀 에디터 렌더 | Action |
| dx_editor_init | 에디터 초기화 시 | config 배열 | 에디터 옵션 주입 | Action |
| dx_mailer_drivers | 메일 드라이버 등록 시 | 없음 | 커스텀 메일 드라이버 | Action |
| dx_sms_drivers | SMS 드라이버 등록 시 | 빈 배열 | 커스텀 SMS 드라이버 | Action |
| dx_captcha_drivers | Captcha 드라이버 등록 시 | 없음 | 커스텀 Captcha 추가 | Action |
| dx_admin_top | 관리자 페이지 상단 | action (현재 관리 액션) | 관리자 UI 확장 | Action |
| dx_admin_bottom | 관리자 페이지 하단 | action | 관리자 스크립트 | Action |
| dx_admin_dashboard_widgets | 대시보드 위젯 영역 | 없음 | 커스텀 대시보드 위젯 | Action |
3. 활용 사례
사례 1 — 전역 스크립트•스타일 삽입
모든 페이지에 공통 CSS나 JS를 삽입할 때 dx_head / dx_body_bottom 훅을 사용합니다. Google Tag Manager, 채팅 위젯, 공통 CSS 오버라이드 등에 활용합니다.
예제 A — Google Tag Manager 삽입
// plugins/my-analytics/plugin.php
// <head> 안에 GTM 스크립트 삽입
dx_add_hook("dx_head", function($context) {
$gtmId = dx_config("gtm_id", "");
if (!$gtmId) return;
echo '<script>(function(w,d,s,l,i){w[l]=w[l]||[];',
'w[l].push({\'gtm.start\':',
'new Date().getTime(),event:\'gtm.js\'});',
'var f=d.getElementsByTagName(s)[0],',
'j=d.createElement(s),dl=l!=\'dataLayer\'?\'&l=\'+l:\'\';',
'j.async=true;j.src=\'',
'https://www.googletagmanager.com/gtm.js?id=\'+i+dl;',
'f.parentNode.insertBefore(j,f);',
'})(window,document,\'script\',\'dataLayer\',\'' . $gtmId . '\');</script>';
}, 1); // 우선순위 1 → head 안에서 가장 먼저
// <body> 최상단에 GTM noscript 삽입
dx_add_hook("dx_top", function($context) {
$gtmId = dx_config("gtm_id", "");
if (!$gtmId) return;
echo '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=',
$gtmId, '" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>';
}, 1)
예제 B — 채팅 위젯 (Floating Button) 삽입
// extend/bottom/05_chat_widget.php
// </body> 직전에 채팅 위젯 삽입
dx_add_hook("dx_body_bottom", function() {
// 로그인 회원에게만 채팅 위젯 표시
if (!Auth::getInstance()->isLoggedIn()) return;
echo '<div id="chat-widget" style="position:fixed;bottom:20px;right:20px;z-index:9999;">';
echo ' <button style="background:#1E3A5F;color:#fff;';
echo ' border:none;border-radius:50%;width:56px;height:56px;';
echo ' font-size:24px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.3)">';
echo ' 💬</button>';
echo '</div>';
echo '<script src="' . dx_base_url("assets/js/chat-widget.js") . '"></script>';
});
사례 2 — 로그인•로그아웃 후처리
Auth 클래스가 로그인/로그아웃 처리 완료 직후 dx_after_login / dx_after_logout 훅을 실행합니다. 접속 기록, 알림, 리워드 지급 등에 활용합니다.
예제 A — 마지막 접속 일시 업데이트
// extend/top/02_login_tracker.php
dx_add_hook("dx_after_login", function($args) {
$userId = isset($args["user"]["id"]) ? (int)$args["user"]["id"] : 0;
if (!$userId) return;
$db = Database::getInstance();
$db->query(
"UPDATE " . $db->table("members") . " SET last_login_at = NOW() WHERE id = ?",
[$userId]
);
// 접속 로그 기록
$ip = $_SERVER["REMOTE_ADDR"] ?? "unknown";
$db->query(
"INSERT INTO " . $db->table("login_logs") .
" (member_id, ip, logged_in_at) VALUES (?, ?, NOW())",
[$userId, $ip]
);
});
예제 B — 연속 로그인 포인트 지급
// plugins/attendance/plugin.php
dx_add_hook("dx_after_login", function($args) {
$user = $args["user"] ?? [];
$userId = (int)($user["id"] ?? 0);
if (!$userId) return;
$db = Database::getInstance();
$cache = DxCache::get("login_today:" . $userId);
// 오늘 이미 출석했으면 스킵
if ($cache) return;
// 오늘 처음 로그인 → 출석 포인트 지급
DxCache::set("login_today:" . $userId, 1, 86400);
$point = new DxPoint();
$point->add($userId, "attendance", 10, "일일 출석 포인트");
// 알림 발송
$notify = new DxNotification();
$notify->send($userId, "출석 체크 완료! +10 포인트 지급되었습니다.", "/mypage");
});
예제 C — 로그아웃 시 세션 토큰 무효화
// plugins/security-plus/plugin.php
dx_add_hook("dx_after_logout", function($args) {
$user = $args["user"] ?? [];
$userId = (int)($user["id"] ?? 0);
if (!$userId) return;
// 해당 회원의 모든 "기억하기" 토큰 삭제
$db = Database::getInstance();
$db->query(
"DELETE FROM " . $db->table("remember_tokens") . " WHERE member_id = ?",
[$userId]
);
// Redis 캐시에서도 세션 삭제
DxCache::delete("session:" . $userId);
DxCache::delete("login_today:" . $userId);
});
사례 3 — 회원가입 후처리 (환영 이메일 + 포인트)
dx_after_register 훅은 회원가입 절차가 모두 완료된 후 실행됩니다. user_id와 data(sanitize된 가입 데이터 배열)가 전달됩니다.
예제 — 환영 이메일 + 가입 포인트 지급 통합
// plugins/welcome/plugin.php
dx_add_hook("dx_after_register", function($args) {
$userId = (int)($args["user_id"] ?? 0);
$data = $args["data"] ?? [];
if (!$userId || empty($data["email"])) return;
// 1) 환영 이메일 발송
$mailer = new DxMailer();
$mailer->to($data["email"])
->subject("[" . dx_site_name() . "] 회원가입을 환영합니다!")
->html(
"<h2>안녕하세요, " . htmlspecialchars($data["nickname"] ?? "회원") . "님!</h2>"
. "<p>가입해 주셔서 감사합니다.</p>"
. "<p>가입 기념으로 <strong>100 포인트</strong>를 드립니다.</p>"
. "<p><a href=\"" . dx_base_url() . "\">사이트 바로가기</a></p>"
)
->send();
// 2) 가입 기념 포인트 지급
$point = new DxPoint();
$point->add($userId, "register", 100, "회원가입 기념 포인트");
// 3) 관리자에게 새 회원 알림 (Slack Webhook 예시)
$webhookUrl = dx_config("slack_webhook_url", "");
if ($webhookUrl) {
$payload = json_encode(["text" =>
"✅ 새 회원 가입: " . ($data["nickname"] ?? "미정") .
" (" . $data["email"] . ") — " . date("Y-m-d H:i:s")
]);
// 비동기 처리 (응답 속도 영향 없음)
register_shutdown_function(function() use ($webhookUrl, $payload) {
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
});
}
});
사례 4 — 게시판 글 저장 전•후 처리
dx_board_before_save는 참조(&$data)로 데이터를 전달하므로 실제로 저장될 데이터를 수정할 수 있습니다. dx_board_after_save / dx_after_write는 저장 완료 후 알림, 포인트 지급 등 후처리에 사용합니다.
예제 A — 저장 전 금지어 필터 (before_save)
// plugins/content-filter/plugin.php
dx_add_hook("dx_board_before_save", function($args) {
// $args["data"]는 참조(&)로 전달됨 → 직접 수정 가능
$data = &$args["data"];
$board = $args["board"] ?? [];
// 특정 게시판에만 필터 적용
if (($board["board_key"] ?? "") !== "free") return;
// 금지어 목록
$blocked = ["욕설1", "욕설2", "스팸키워드"];
// 제목•내용 금지어 치환
foreach ($blocked as $word) {
if (isset($data["title"])) {
$data["title"] = str_replace($word, str_repeat("*", mb_strlen($word)), $data["title"]);
}
if (isset($data["content"])) {
$data["content"] = str_replace($word, str_repeat("*", mb_strlen($word)), $data["content"]);
}
}
// 수정된 $data는 자동으로 저장 로직에 반영됨
});
예제 B — 글 작성 후 알림•포인트 지급 (after_save / after_write)
// plugins/board-rewards/plugin.php
dx_add_hook("dx_after_write", function($args) {
$postId = (int)($args["post_id"] ?? 0);
$board = $args["board"] ?? [];
$data = $args["data"] ?? [];
if (!$postId) return;
$authorId = (int)($data["member_id"] ?? 0);
// 1) 글 작성 포인트 지급
if ($authorId) {
$point = new DxPoint();
$point->add($authorId, "write", 5, "글 작성 포인트");
}
// 2) 공지 게시판 글 → 모든 회원에게 알림
if (($board["board_key"] ?? "") === "notice") {
$db = Database::getInstance();
$members = $db->query(
"SELECT id FROM " . $db->table("members") .
" WHERE push_notice = 1 LIMIT 200"
);
$notify = new DxNotification();
$title = $data["title"] ?? "새 공지사항";
foreach ($members as $m) {
$notify->send((int)$m["id"], "📢 " . $title, "/board/notice/view/" . $postId);
}
}
});
사례 5 — 댓글 작성 후 원글 작성자 알림
dx_after_comment 훅은 댓글이 성공적으로 저장된 직후 실행됩니다. post_id, comment_id, member_id 등 댓글 관련 정보가 전달됩니다.
예제 — 댓글 작성 시 원글 작성자에게 알림 + 이메일
// plugins/comment-notify/plugin.php
dx_add_hook("dx_after_comment", function($args) {
$postId = (int)($args["post_id"] ?? 0);
$commentId = (int)($args["comment_id"] ?? 0);
$writerId = (int)($args["member_id"] ?? 0); // 댓글 작성자
if (!$postId || !$writerId) return;
$db = Database::getInstance();
// 원글 정보 조회
$post = $db->query(
"SELECT p.id, p.title, p.member_id, m.email, m.nickname" .
" FROM " . $db->table("posts") . " p" .
" JOIN " . $db->table("members") . " m ON m.id = p.member_id" .
" WHERE p.id = ? LIMIT 1",
[$postId]
);
if (empty($post)) return;
$post = $post[0];
$postOwnerId = (int)$post["member_id"];
// 자신의 글에 단 댓글은 알림 불필요
if ($postOwnerId === $writerId) return;
// 1) 사이트 내 알림
$notify = new DxNotification();
$notify->send(
$postOwnerId,
"내 글 \"" . mb_substr($post["title"], 0, 20) . "\"에 댓글이 달렸습니다.",
"/board/view/" . $postId . "?c=" . $commentId
);
// 2) 이메일 알림 (수신 설정 확인)
$setting = $db->query(
"SELECT email_comment FROM " . $db->table("member_settings") .
" WHERE member_id = ? LIMIT 1",
[$postOwnerId]
);
$emailEnabled = empty($setting) ? 1 : (int)($setting[0]["email_comment"] ?? 1);
if ($emailEnabled && $post["email"]) {
$mailer = new DxMailer();
$mailer->to($post["email"])
->subject("[" . dx_site_name() . "] 댓글 알림")
->html("<p><strong>" . htmlspecialchars($post["nickname"]) . "</strong>님의 글에 댓글이 작성되었습니다.</p>",
"<p><a href=\"" . dx_base_url("board/view/" . $postId) . "\">댓글 보러가기</a></p>")
->send();
}
});
사례 6 — 포인트 변동 & 레벨업 처리
DxPoint 클래스가 포인트 증감 후 dx_after_point 을, 레벨이 올랐을 때 dx_levelup 훅을 실행합니다.
예제 A — 포인트 이력 외부 DB 동기화
// plugins/point-sync/plugin.php
dx_add_hook("dx_after_point", function($args) {
$memberId = (int)($args["member_id"] ?? 0);
$type = $args["type"] ?? "";
$point = (int)($args["point"] ?? 0);
$balance = (int)($args["balance"] ?? 0);
if (!$memberId) return;
// 외부 분석 DB에 이력 기록 (비동기)
register_shutdown_function(function() use ($memberId, $type, $point, $balance) {
$db = Database::getInstance();
$db->query(
"INSERT INTO " . $db->table("point_history_ext") .
" (member_id, type, delta, balance, recorded_at) VALUES (?, ?, ?, ?, NOW())",
[$memberId, $type, $point, $balance]
);
});
});
예제 B — 레벨업 축하 알림 + 등급별 혜택 부여
// plugins/level-rewards/plugin.php
dx_add_hook("dx_levelup", function($args) {
$memberId = (int)($args["member_id"] ?? 0);
$oldLevel = (int)($args["old_level"] ?? 0);
$newLevel = (int)($args["new_level"] ?? 0);
if (!$memberId || $newLevel <= $oldLevel) return;
// 레벨별 혜택 포인트 정의
$rewards = [5 => 500, 10 => 2000, 20 => 10000, 30 => 50000];
$bonusPoint = $rewards[$newLevel] ?? 50;
// 보너스 포인트 지급
$point = new DxPoint();
$point->add($memberId, "levelup_bonus", $bonusPoint, "레벨 {$newLevel} 달성 보너스");
// 사이트 내 축하 알림
$notify = new DxNotification();
$notify->send(
$memberId,
"🎉 레벨 {$newLevel}으로 레벨업! 보너스 {$bonusPoint}P 지급됨",
"/mypage/point"
);
// 레벨 30 이상 → 특별 배지 부여
if ($newLevel >= 30) {
$db = Database::getInstance();
$db->query(
"INSERT IGNORE INTO " . $db->table("member_badges") .
" (member_id, badge_key, awarded_at) VALUES (?, ?, NOW())",
[$memberId, "expert"]
);
}
});
사례 7 — 커스텀 에디터 등록 및 렌더링
dx_editor_render 훅은 에디터 HTML을 생성하는 시점에 실행됩니다. CKEditor4 플러그인이 이 훅을 사용하여 에디터를 렌더링합니다. 동일한 방식으로 다른 에디터(SimpleMDE, Toast UI Editor 등)를 등록할 수 있습니다.
예제 — SimpleMDE 마크다운 에디터 등록
// plugins/simplemde-editor/plugin.php
dx_register_plugin([
"id" => "simplemde-editor",
"type" => "editor",
"name" => "SimpleMDE (마크다운)",
"version" => "1.0.0",
"description" => "마크다운 기반 심플 에디터",
]);
if (dx_config("active_editor", "") !== "simplemde-editor") return;
// 에디터 렌더 훅 등록
dx_add_hook("dx_editor_render", function($args) {
$name = $args["name"] ?? "content";
$value = $args["value"] ?? "";
$opts = $args["options"] ?? [];
$editorId = "simplemde_" . preg_replace("/[^a-zA-Z0-9_]/", "_", $name);
echo '<link rel="stylesheet" href="' . dx_base_url("assets/simplemde/simplemde.min.css") . '">';
echo '<textarea id="' . $editorId . '"
name="' . htmlspecialchars($name, ENT_QUOTES) . '">';
echo htmlspecialchars($value, ENT_QUOTES);
echo '</textarea>';
echo '<script src="' . dx_base_url("assets/simplemde/simplemde.min.js") . '"></script>';
echo '<script>';
echo 'var simplemde = new SimpleMDE({
element: document.getElementById("' . $editorId . '"),
spellChecker: false,
placeholder: "마크다운으로 작성하세요...",
});';
echo '</script>';
});
사례 8 — 결제 PG사 플러그인 등록
DXCMS는 dx_payment_request 훅을 통해 PG사별 결제창 처리를 분리합니다. 다날(DPAY) 플러그인의 실제 코드를 기반으로 설명합니다.
예제 — 다날 DPAY 결제창 연동 (실제 소스 기반)
// plugins/danal-payment/plugin.php (간략화)
// 플러그인 등록
dx_register_plugin([
"id" => "danal-payment",
"type" => "payment",
"name" => "다날 DPAY",
"settings" => [
"cpid" => ["label" => "CPID", "type" => "text"],
"cpkey" => ["label" => "CP KEY", "type" => "password"],
"mode" => ["label" => "운영 모드", "type" => "select",
"options" => ["test" => "테스트", "live" => "실서비스"]],
"pay_method" => ["label" => "결제 수단", "type" => "select",
"options" => ["CARD" => "신용카드", "MOBILE" => "휴대폰"]],
],
]);
// 이 플러그인이 활성화된 경우에만 훅 등록
if (dx_active_plugin("payment") !== "danal-payment") return;
// 결제창 렌더 훅
dx_add_hook("dx_payment_request", function($data) {
$cpid = dx_pay_cfg("danal-payment", "cpid");
$cpkey = dx_pay_cfg("danal-payment", "cpkey");
$mode = dx_pay_cfg("danal-payment", "mode", "test");
if (!$cpid || !$cpkey) {
dx_payment_error_html("다날 CPID/KEY가 설정되지 않았습니다.");
return;
}
$orderId = $data["order_id"] ?? dx_payment_order_id("DNL");
$amount = (int)($data["amount"] ?? 0);
$timestamp = time();
$signature = hash("sha256", $cpid . $orderId . $amount . $timestamp . $cpkey);
$apiBase = $mode === "live"
? "https://api.danalpay.com/v2/payment"
: "https://testapi.danalpay.com/v2/payment";
// API 요청 및 결제창 URL 리다이렉트
$body = json_encode([
"CPID" => $cpid, "ORDERID" => $orderId, "AMOUNT" => $amount,
"TIMESTAMP" => $timestamp, "SIGNATURE" => $signature,
]);
$resp = dx_payment_http_post($apiBase . "/ready", $body);
$result = json_decode($resp, true);
if (!isset($result["PAY_URL"])) {
dx_payment_error_html($result["RETURNMSG"] ?? "오류");
return;
}
echo '<script>window.location.href = ' . json_encode($result["PAY_URL"]) . ';</script>';
});
사례 9 — 관리자 대시보드 커스텀 위젯
dx_admin_dashboard_widgets 훅을 사용하면 관리자 대시보드에 커스텀 통계 위젯을 추가할 수 있습니다.
예제 — 일일 매출•회원 현황 위젯
// plugins/dashboard-stats/plugin.php
dx_add_hook("dx_admin_dashboard_widgets", function() {
$db = Database::getInstance();
// 오늘 매출 집계
$todaySales = $db->query(
"SELECT IFNULL(SUM(amount), 0) as total FROM " .
$db->table("orders") . " WHERE DATE(created_at) = CURDATE() AND status = 'paid'"
);
$sales = $todaySales[0]["total"] ?? 0;
// 신규 회원 수
$newMembers = $db->query(
"SELECT COUNT(*) as cnt FROM " . $db->table("members") .
" WHERE DATE(created_at) = CURDATE()"
);
$memberCnt = $newMembers[0]["cnt"] ?? 0;
// 위젯 HTML 출력
echo '<div class="adm_widget" style="background:#1E3A5F;color:#fff;padding:20px;border-radius:10px;">';
echo ' <h3 style="margin:0 0 12px">📊 오늘의 현황</h3>';
echo ' <div style="display:flex;gap:20px">';
echo ' <div><div style="font-size:28px;font-weight:bold">' . number_format($sales) . '원</div>';
echo ' <div style="font-size:12px;opacity:.8">오늘 매출</div></div>';
echo ' <div><div style="font-size:28px;font-weight:bold">' . $memberCnt . '명</div>';
echo ' <div style="font-size:12px;opacity:.8">신규 회원</div></div>';
echo ' </div>';
echo '</div>';
});
사례 10 — 개발•디버깅용 성능 측정 오버레이
예제 플러그인( plugins/example-plugin/plugin.php )에서 실제로 제공하는 디버그 오버레이입니다. DX_DEBUG 상수가 true일 때만 활성화되어 프로덕션에서는 동작하지 않습니다.
예제 — 실행 시간 + 쿼리 카운트 오버레이 (실제 소스)
// plugins/example-plugin/plugin.php (실제 소스)
// 모든 페이지 하단에 실행 시간 표시 (개발용)
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); // 우선순위 999 → 모든 훅 중 가장 마지막
}
💡 실무 팁
우선순위를 999로 설정하면 다른 모든 훅이 실행된 후 마지막에 실행됩니다. 성능 측정 오버레이처럼 "모든 처리가 끝난 뒤 결과를 보여줘야 하는" 경우에 적합합니다.
반대로, 우선순위를 1로 설정하면 가장 먼저 실행됩니다. 보안 검사, FOUC 방지 스크립트처럼 "다른 것보다 먼저 실행되어야 하는" 경우에 사용합니다.
사례 11 — 다크모드 FOUC 방지 (extend/top 활용)
실제 내장 파일 extend/top/01_darkmode_early.php 의 구현입니다. ob_start 콜백으로 출력 버퍼를 가로채어 <head> 직후에 인라인 스크립트를 삽입합니다. 이 방식은 훅( dx_add_hook )이 아닌 extend 폴더 기반이지만, extend 실행 후 dx_run_hook("dx_extend_top") 이 실행되어 상호 연동됩니다.
// extend/top/01_darkmode_early.php (핵심 발췌)
ob_start(function($buffer) {
// 1) localStorage에서 다크모드 설정 읽기 (렌더링 전 즉시 적용)
$earlyScript = '<script>(function(){'
. 'try{'
. 'var t=localStorage.getItem("dx-theme");'
. 'var sys=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;'
. 'if(t==="dark"||(t===null&&sys)){'
. 'document.documentElement.classList.add("dx-dark-early");'
. 'document.documentElement.style.visibility="hidden";' // 깜빡임 방지
. '}}catch(e){}})();</script>';
// 2) FOUC 방지 인라인 CSS
$earlyStyle = '<style>'
. '.dx-dark-early body{background-color:#0f172a!important;color:#f1f5f9!important}'
. '</style>';
// 3) <head> 바로 뒤에 삽입
return preg_replace('/<head([^>]*)>/i', '<head$1>' . $earlyScript . $earlyStyle, $buffer, 1);
});
// extend/bottom/02_darkmode_engine.php에서 엔진 JS 로드
// → visibility:hidden 해제, localStorage 동기화 완료
사례 12 — 방문자 통계 자동 기록 (extend/middle 활용)
실제 내장 파일 extend/middle/01_visit_tracker.php 의 핵심 로직입니다. register_shutdown_function을 사용하여 사용자에게 응답을 먼저 보낸 후 DB에 기록하므로 체감 속도에 영향이 없습니다.
// extend/middle/01_visit_tracker.php (핵심 발췌)
// 봇 트래픽 완전 차단 (visit_logs 테이블 용량 절약)
if ($_vt_isBot) return;
// DxCache로 순방문자 판단 (DB 조회 없음)
$cacheKey = "vt_u:" . date("Y-m-d") . ":" . substr(md5($ip . $browser), 0, 12);
if (!DxCache::get($cacheKey, false)) {
DxCache::set($cacheKey, 1, strtotime("tomorrow") - time());
$isUnique = true;
}
// ★ 핵심: 응답 완료 후 DB 기록 (사용자 체감 속도 향상)
register_shutdown_function(function() use ($data) {
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request(); // PHP-FPM: 즉시 응답 완료
}
// UPSERT로 방문 집계 (쿼리 1회)
$pdo->prepare("
INSERT INTO dx_visits (visit_date, visit_count, unique_count)
VALUES (?, 1, ?)
ON DUPLICATE KEY UPDATE
visit_count = visit_count + 1,
unique_count = unique_count + {$uniqueInc}
")->execute([$date, $uniqueInc]);
});
4. Hook vs Extend 비교
DXCMS에는 두 가지 확장 방법이 있습니다. 상황에 맞게 선택하세요.| Hook (dx_add_hook) | Extend (extend/ 폴더) |
|---|---|
| 코드에서 dx_add_hook()으로 등록 | 파일을 폴더에 넣으면 자동 실행 |
| 주로 플러그인(plugin.php) 내에서 사용 | 독립 PHP 파일로 작성 |
| 개발자에게 권장 (타입 안전, 우선순위 제어) | 비개발자도 쉽게 사용 가능 |
| 특정 이벤트 포인트에 반응 | 3개 슬롯(top/middle/bottom)에 실행 |
| 플러그인 활성화 여부와 연계 가능 | 파일 존재 시 항상 실행 |
| dx_remove_hook()으로 동적 해제 가능 | 파일 삭제/비활성화(.disabled)로 해제 |
선택 기준
• 특정 이벤트(로그인, 글 저장 등)에 반응해야 한다면 → Hook 사용
• 페이지 렌더링 전/중/후에 항상 실행되어야 한다면 → Extend 파일 사용
• 플러그인 내부에서는 → Hook이 표준
• 전역 설정, 미들웨어 성격의 코드 → Extend가 더 자연스러움
5. 심화 패턴
5.1 훅 제거 — 기존 동작 오버라이드
등록된 훅을 제거하여 기본 동작을 비활성화하거나 교체할 수 있습니다.
// 방법 1: 특정 콜백만 제거
function my_header_callback() { /* ... */ }
dx_add_hook("dx_head", "my_header_callback");
// ... 나중에 제거
dx_remove_hook("dx_head", "my_header_callback");
// 방법 2: 훅 이름의 모든 콜백 제거
dx_remove_hook("dx_head"); // dx_head에 등록된 모든 콜백 제거
// 방법 3: HookManager 직접 사용
$hm = HookManager::getInstance();
$hm->remove("dx_head", null); // 전체 제거
5.2 Filter 훅으로 컨텐츠 변환
Filter 훅( dx_apply_filter )은 값을 변환하여 반환합니다. 여러 개의 필터가 체인으로 연결되어 순서대로 값을 변환합니다.
// ── 커스텀 필터 포인트 정의 (예: 게시글 출력 전) ──
// 코어 코드에서 필터 적용
$content = dx_apply_filter("dx_post_content", $rawContent, ["post_id" => $postId]);
// 플러그인에서 필터 등록
// 필터 1: HTML 이스케이프 (우선순위 5)
dx_add_filter("dx_post_content", function($content, $args) {
// $content를 받아 변환 후 반환해야 함
return nl2br(htmlspecialchars($content, ENT_QUOTES));
}, 5);
// 필터 2: URL 자동 링크화 (우선순위 10)
dx_add_filter("dx_post_content", function($content, $args) {
return preg_replace(
'/(https?:\/\/[\S]+)/',
'<a href="$1" target="_blank">$1</a>',
$content
);
}, 10);
// 필터 3: 금지어 마스킹 (우선순위 20)
dx_add_filter("dx_post_content", function($content, $args) {
return str_replace(["욕설1", "욕설2"], ["***", "***"], $content);
}, 20);
// 실행 결과: 원본 → 이스케이프 → 링크화 → 금지어 마스킹 순으로 처리
5.3 참조(&) 전달로 데이터 수정
일부 훅은 인자를 참조( &$data )로 전달합니다. dx_board_before_save , dx_board_list_context 등이 이 방식을 사용합니다.
// $args["data"]가 참조(&)로 전달된 경우
dx_add_hook("dx_board_before_save", function($args) {
// $args["data"]는 실제 저장될 데이터의 참조
$data = &$args["data"];
// 직접 수정 → 저장 로직에 자동 반영
$data["title"] = trim($data["title"]);
$data["view_count"] = 0; // 신규 글 조회수 강제 0
// 주의: &$args["data"]가 아닌 $args["data"]로 받으면 수정이 반영되지 않음!
// 잘못된 예: $data = $args["data"]; ← 복사본이라 수정이 무효
});
6. 훅 디버깅
6.1 실행된 훅 목록 확인
// HookManager에서 실행된 훅 목록 조회
$hm = HookManager::getInstance();
// 실행된 훅 이름 목록
$executed = $hm->getExecuted();
// 현재 등록된 훅 이름 목록
$registered = $hm->getAll();
// 특정 훅에 등록된 콜백 수
$count = $hm->count("dx_after_login"); // 예: 2
// 특정 훅 등록 여부 확인
$exists = dx_has_hook("dx_after_login"); // true/false
// 디버그 출력 (개발 환경에서만)
if (defined("DX_DEBUG") && DX_DEBUG) {
error_log("Executed hooks: " . implode(", ", $executed));
}
6.2 훅 추적 유틸리티
// 모든 훅 실행을 로그에 기록하는 디버그 래퍼
// extend/top/99_hook_tracer.php (개발 환경에서만 활성화)
if (!defined("DX_DEBUG") || !DX_DEBUG) return;
// dx_run_hook을 감싸는 방법은 없으므로,
// 주요 훅 포인트에 로거를 선제 등록 (우선순위 0)
$hooksToTrace = ["dx_head", "dx_top", "dx_middle", "dx_bottom",
"dx_after_login", "dx_after_register", "dx_board_before_save"];
foreach ($hooksToTrace as $hookName) {
dx_add_hook($hookName, function($args) use ($hookName) {
$time = round((microtime(true) - DX_START_TIME) * 1000, 2);
error_log("[HOOK TRACE] {$hookName} fired at {$time}ms");
}, 0); // 우선순위 0 → 가장 먼저
}
7. 베스트 프랙티스
7.1 성능 고려사항
1. 무거운 작업은 shutdown_function으로 비동기 처리 — DB INSERT, 외부 API 호출 등은 register_shutdown_function을 사용하여 응답 후 처리합니다.
2. 캐시 적극 활용 — DxCache를 사용하여 중복 DB 조회를 방지합니다. 순방문자 판단, 설정값 캐싱 등에 활용합니다.
3. 봇 트래픽 필터링 — 방문자 통계 등 비필수 처리는 User-Agent 기반 봇 판별 후 조기 return합니다.
4. 우선순위 설계 — 보안 검사(1~5), 일반 기능(10~50), 분석/로깅(100~999)으로 계층화합니다.
2. 캐시 적극 활용 — DxCache를 사용하여 중복 DB 조회를 방지합니다. 순방문자 판단, 설정값 캐싱 등에 활용합니다.
3. 봇 트래픽 필터링 — 방문자 통계 등 비필수 처리는 User-Agent 기반 봇 판별 후 조기 return합니다.
4. 우선순위 설계 — 보안 검사(1~5), 일반 기능(10~50), 분석/로깅(100~999)으로 계층화합니다.
7.2 안전성 주의사항
5. 반드시 인자 존재 확인 — $args["key"] ?? null 패턴으로 undefined index 에러를 방지합니다.
6. 사용자 입력 검증 — before_save 훅에서 데이터를 수정할 때 반드시 sanitize를 거칩니다.
7. 무한 루프 방지 — 훅 내에서 같은 훅을 트리거하는 동작을 피합니다.
8. 플러그인 활성화 확인 — 결제/에디터 등 단일 선택 플러그인은 dx_active_plugin() 확인 후 훅을 등록합니다.
6. 사용자 입력 검증 — before_save 훅에서 데이터를 수정할 때 반드시 sanitize를 거칩니다.
7. 무한 루프 방지 — 훅 내에서 같은 훅을 트리거하는 동작을 피합니다.
8. 플러그인 활성화 확인 — 결제/에디터 등 단일 선택 플러그인은 dx_active_plugin() 확인 후 훅을 등록합니다.
7.3 코드 구조 권장 사항
// ✅ 권장: 익명 함수 사용 (간결)
dx_add_hook("dx_after_login", function($args) {
// 처리 로직
}, 10);
// ✅ 권장: 네임드 함수 사용 (제거 가능)
function my_plugin_after_login($args) { /* ... */ }
dx_add_hook("dx_after_login", "my_plugin_after_login", 10);
// 나중에 제거 가능:
dx_remove_hook("dx_after_login", "my_plugin_after_login");
// ✅ 권장: 클래스 메서드 사용 (대형 플러그인)
class MyPlugin {
public function onAfterLogin($args) { /* ... */ }
public function register() {
dx_add_hook("dx_after_login", [$this, "onAfterLogin"], 10);
}
}
$plugin = new MyPlugin();
$plugin->register();