애드온 예제

애드온 예제

실용적인 애드온 예제들을 통해 애드온 개발을 학습합니다.

🔧 예제 1: 로그인 필수 체크 애드온

특정 페이지에 로그인하지 않은 사용자의 접근을 제한하는 애드온입니다.

info.xml

<?xml version="1.0" encoding="UTF-8"?>
<addon version="0.2">
    <title xml:lang="ko">로그인 필수 체크</title>
    <title xml:lang="en">Login Required Check</title>
    <description xml:lang="ko">특정 페이지에 로그인 필수 설정</description>
    <version>1.0.0</version>
    <date>2024-01-01</date>
    <author email_address="admin@example.com">
        <name xml:lang="ko">관리자</name>
    </author>
</addon>

login_required.addon.php

<?php
if(!defined("__XE__")) exit();

function login_required($obj)
{
    // 관리자는 제외
    if(Context::get('is_logged')) return;

    // 로그인이 필요한 mid 목록
    $required_mids = array('notice', 'qna', 'download');

    $current_mid = Context::get('mid');

    if(in_array($current_mid, $required_mids))
    {
        // 로그인 페이지로 리다이렉트
        $url = getNotEncodedUrl('', 'act', 'dispMemberLoginForm', 'success_return_url', Context::getRequestUri());
        header('Location: ' . $url);
        exit();
    }
}
?>

🔧 예제 2: 방문자 통계 애드온

페이지별 방문자 수를 기록하는 애드온입니다.

visitor_stats.addon.php

<?php
if(!defined("__XE__")) exit();

function visitor_stats($obj)
{
    // 봇이나 관리자 제외
    if(Context::get('is_logged') && Context::get('logged_info')->is_admin == 'Y') return;

    $current_mid = Context::get('mid');
    if(!$current_mid) return;

    $today = date('Y-m-d');
    $client_ip = \RX_CLIENT_IP;

    // 같은 날 같은 IP는 중복 제거
    $args = new stdClass();
    $args->mid = $current_mid;
    $args->visit_date = $today;
    $args->ip_address = $client_ip;

    $output = executeQuery('addon.visitor_stats.getVisit', $args);

    if(!$output->data)
    {
        // 새로운 방문 기록
        $args->reg_date = date('Y-m-d H:i:s');
        executeQuery('addon.visitor_stats.insertVisit', $args);
    }
}
?>

🔧 예제 3: 게시글 자동 태그 애드온

게시글 제목에서 자동으로 태그를 추출하여 설정하는 애드온입니다.

auto_tag.addon.php

<?php
if(!defined("__XE__")) exit();

function auto_tag($obj)
{
    // 게시글 등록/수정 시에만 동작
    if(!in_array($obj->act, array('procBoardInsertDocument', 'procBoardUpdateDocument'))) return;

    $title = Context::get('title');
    if(!$title) return;

    // 한글 키워드 추출 (2글자 이상)
    preg_match_all('/[가-힣]{2,}/u', $title, $matches);

    if($matches[0])
    {
        $tags = array_unique($matches[0]);
        $tag_string = implode(',', array_slice($tags, 0, 5)); // 최대 5개

        Context::set('tags', $tag_string);
    }
}
?>

🔧 예제 4: 금지어 필터 애드온

게시글과 댓글에서 금지어를 체크하여 등록을 차단하는 애드온입니다.

word_filter.addon.php

<?php
if(!defined("__XE__")) exit();

function word_filter($obj)
{
    $filtered_acts = array(
        'procBoardInsertDocument',
        'procBoardUpdateDocument', 
        'procBoardInsertComment',
        'procBoardUpdateComment'
    );

    if(!in_array($obj->act, $filtered_acts)) return;

    // 금지어 목록
    $banned_words = array('스팸', '광고', '도배');

    $content = Context::get('content');
    $title = Context::get('title');

    $check_text = $title . ' ' . $content;

    foreach($banned_words as $word)
    {
        if(strpos($check_text, $word) !== false)
        {
            return new BaseObject(-1, sprintf('금지어 "%s"가 포함되어 있습니다.', $word));
        }
    }
}
?>

📚 추가 리소스

설정 파일 활용

config.php 파일을 통해 애드온 설정을 관리할 수 있습니다.

<?php
// config.php
$config = new stdClass();
$config->enabled_mids = array('notice', 'qna');
$config->excluded_ips = array('127.0.0.1');
$config->notification_email = 'admin@example.com';
?>

언어팩 지원

다국어 지원을 위한 언어팩 파일 구성:

<?php
// lang/ko.php
$lang->addon_name = '애드온명';
$lang->addon_description = '애드온 설명';
$lang->error_message = '오류가 발생했습니다.';
?>

🔧 예제 5: 고급 캐싱 애드온

페이지 캐싱을 통해 성능을 향상시키는 애드온입니다.

page_cache.addon.php

<?php
if(!defined("__XE__")) exit();

class PageCacheAddon
{
    private $cache_dir;
    private $cache_time = 3600; // 1시간

    public function __construct()
    {
        $this->cache_dir = _XE_PATH_ . 'files/cache/page_cache/';
        if(!is_dir($this->cache_dir)) {
            FileHandler::makeDir($this->cache_dir);
        }
    }

    public function getCacheKey()
    {
        $uri = $_SERVER['REQUEST_URI'];
        $user_agent = $_SERVER['HTTP_USER_AGENT'];
        $is_mobile = Mobile::isMobile() ? 'mobile' : 'desktop';

        return md5($uri . $user_agent . $is_mobile);
    }

    public function isCacheable()
    {
        // GET 요청만 캐싱
        if($_SERVER['REQUEST_METHOD'] !== 'GET') return false;

        // 로그인 사용자는 캐싱하지 않음
        if(Context::get('is_logged')) return false;

        // 관리자 페이지는 캐싱하지 않음
        if(Context::get('module') === 'admin') return false;

        return true;
    }

    public function getCache()
    {
        if(!$this->isCacheable()) return null;

        $cache_key = $this->getCacheKey();
        $cache_file = $this->cache_dir . $cache_key . '.html';

        if(file_exists($cache_file)) {
            $cache_time = filemtime($cache_file);
            if(time() - $cache_time < $this->cache_time) {
                return file_get_contents($cache_file);
            }
        }

        return null;
    }

    public function setCache($content)
    {
        if(!$this->isCacheable()) return;

        $cache_key = $this->getCacheKey();
        $cache_file = $this->cache_dir . $cache_key . '.html';

        // 캐시에 타임스탬프 추가
        $cached_content = $content . '<!-- Cached at ' . date('Y-m-d H:i:s') . ' -->';

        file_put_contents($cache_file, $cached_content);
    }
}

$page_cache = new PageCacheAddon();

switch($called_position) {
    case 'before_module_proc':
        // 캐시된 페이지가 있으면 즉시 출력
        $cached_content = $page_cache->getCache();
        if($cached_content) {
            echo $cached_content;
            exit;
        }

        // 출력 버퍼링 시작
        if($page_cache->isCacheable()) {
            ob_start();
        }
        break;

    case 'before_display_content':
        // 출력 버퍼 내용을 캐시에 저장
        if($page_cache->isCacheable() && ob_get_level()) {
            $content = ob_get_contents();
            if($content) {
                $page_cache->setCache($content);
            }
        }
        break;
}
?>

🔧 예제 6: SEO 최적화 애드온

검색엔진 최적화를 위한 메타태그와 구조화된 데이터를 자동으로 생성하는 애드온입니다.

seo_optimizer.addon.php

<?php
if(!defined("__XE__")) exit();

class SEOOptimizer
{
    public function generateMetaTags()
    {
        $oDocument = Context::get('oDocument');
        $module_info = Context::get('module_info');

        $meta_tags = array();

        if($oDocument && $oDocument->isExists()) {
            // 게시물 페이지
            $title = $oDocument->getTitleText();
            $description = $this->getDescription($oDocument->get('content'));
            $image = $this->getFirstImage($oDocument->get('content'));
            $url = getFullUrl('', 'document_srl', $oDocument->document_srl);

            // 구조화된 데이터
            $structured_data = array(
                '@context' => 'https://schema.org',
                '@type' => 'Article',
                'headline' => $title,
                'description' => $description,
                'author' => array(
                    '@type' => 'Person',
                    'name' => $oDocument->getNickName()
                ),
                'datePublished' => date('c', $oDocument->get('regdate')),
                'dateModified' => date('c', $oDocument->get('last_update'))
            );

            if($image) {
                $structured_data['image'] = $image;
            }

        } else {
            // 목록 페이지
            $title = $module_info->browser_title;
            $description = $module_info->description;
            $url = getFullUrl('', 'mid', Context::get('mid'));
        }

        // Open Graph 태그
        $meta_tags[] = '<meta property="og:title" content="' . htmlspecialchars($title) . '">';
        $meta_tags[] = '<meta property="og:description" content="' . htmlspecialchars($description) . '">';
        $meta_tags[] = '<meta property="og:url" content="' . $url . '">';
        $meta_tags[] = '<meta property="og:type" content="website">';

        if($image) {
            $meta_tags[] = '<meta property="og:image" content="' . $image . '">';
        }

        // Twitter Card
        $meta_tags[] = '<meta name="twitter:card" content="summary_large_image">';
        $meta_tags[] = '<meta name="twitter:title" content="' . htmlspecialchars($title) . '">';
        $meta_tags[] = '<meta name="twitter:description" content="' . htmlspecialchars($description) . '">';

        // 구조화된 데이터
        if(isset($structured_data)) {
            $meta_tags[] = '<script type="application/ld+json">' . json_encode($structured_data, JSON_UNESCAPED_UNICODE) . '</script>';
        }

        return implode("\n", $meta_tags);
    }

    private function getDescription($content, $length = 160)
    {
        $text = strip_tags($content);
        $text = preg_replace('/\s+/', ' ', $text);
        $text = trim($text);

        if(mb_strlen($text) > $length) {
            $text = mb_substr($text, 0, $length) . '...';
        }

        return $text;
    }

    private function getFirstImage($content)
    {
        preg_match('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches);

        if($matches[1]) {
            $image_url = $matches[1];

            // 상대 경로를 절대 경로로 변환
            if(strpos($image_url, 'http') !== 0) {
                $image_url = getFullUrl() . ltrim($image_url, '/');
            }

            return $image_url;
        }

        return null;
    }
}

$seo_optimizer = new SEOOptimizer();

if($called_position == 'before_display_content') {
    // 게시물이나 게시판 페이지에서만 동작
    if(in_array(Context::get('act'), array('dispBoardContent', 'dispBoardContentView'))) {
        $meta_tags = $seo_optimizer->generateMetaTags();
        Context::addHtmlHeader($meta_tags);
    }
}
?>

🔧 예제 7: 실시간 알림 애드온

WebSocket을 사용한 실시간 알림 시스템 애드온입니다.

realtime_notification.addon.php

<?php
if(!defined("__XE__")) exit();

class RealtimeNotification
{
    private $redis;
    private $enabled = false;

    public function __construct()
    {
        // Redis 연결 확인
        if(class_exists('Redis')) {
            $this->redis = new Redis();
            try {
                $this->redis->connect('127.0.0.1', 6379);
                $this->enabled = true;
            } catch(Exception $e) {
                $this->enabled = false;
            }
        }
    }

    public function sendNotification($type, $data)
    {
        if(!$this->enabled) return false;

        $notification = array(
            'type' => $type,
            'data' => $data,
            'timestamp' => time()
        );

        // Redis 채널에 발행
        $this->redis->publish('notifications', json_encode($notification));

        return true;
    }

    public function getClientScript()
    {
        if(!$this->enabled) return '';

        $logged_info = Context::get('logged_info');
        if(!$logged_info) return '';

        return '
        <script>
        (function() {
            var socket = new WebSocket("ws://localhost:8080");

            socket.onopen = function(e) {
                console.log("실시간 알림 연결됨");

                // 사용자 인증
                socket.send(JSON.stringify({
                    action: "auth",
                    member_srl: ' . $logged_info->member_srl . '
                }));
            };

            socket.onmessage = function(event) {
                var notification = JSON.parse(event.data);
                showNotification(notification);
            };

            socket.onclose = function(event) {
                console.log("실시간 알림 연결 종료");
            };

            function showNotification(notification) {
                // 브라우저 알림
                if(Notification.permission === "granted") {
                    new Notification(notification.title, {
                        body: notification.message,
                        icon: "/common/img/notification-icon.png"
                    });
                }

                // 페이지 내 알림 표시
                var alertContainer = document.getElementById("notification-container");
                if(!alertContainer) {
                    alertContainer = document.createElement("div");
                    alertContainer.id = "notification-container";
                    alertContainer.style.cssText = "position:fixed;top:20px;right:20px;z-index:9999;";
                    document.body.appendChild(alertContainer);
                }

                var alertDiv = document.createElement("div");
                alertDiv.className = "alert alert-info";
                alertDiv.innerHTML = notification.message;
                alertDiv.style.cssText = "margin-bottom:10px;padding:10px;background:#d1ecf1;border:1px solid #bee5eb;border-radius:4px;";

                alertContainer.appendChild(alertDiv);

                // 5초 후 자동 제거
                setTimeout(function() {
                    alertDiv.remove();
                }, 5000);
            }

            // 알림 권한 요청
            if(Notification.permission === "default") {
                Notification.requestPermission();
            }
        })();
        </script>';
    }
}

$realtime_notification = new RealtimeNotification();

switch($called_position) {
    case 'after_module_proc':
        // 새 댓글 알림
        if(Context::get('act') === 'procBoardInsertComment') {
            $comment_srl = Context::get('comment_srl');
            $document_srl = Context::get('document_srl');

            if($comment_srl && $document_srl) {
                // 원본 글 작성자에게 알림
                $oDocumentModel = getModel('document');
                $oDocument = $oDocumentModel->getDocument($document_srl);

                if($oDocument->isExists()) {
                    $realtime_notification->sendNotification('new_comment', array(
                        'target_member_srl' => $oDocument->get('member_srl'),
                        'title' => '새 댓글 알림',
                        'message' => '회원님의 글에 새 댓글이 달렸습니다.',
                        'document_srl' => $document_srl,
                        'comment_srl' => $comment_srl
                    ));
                }
            }
        }

        // 새 게시물 알림 (특정 게시판)
        if(Context::get('act') === 'procBoardInsertDocument') {
            $document_srl = Context::get('document_srl');
            $module_srl = Context::get('module_srl');

            // 공지사항 게시판인 경우 전체 알림
            if($module_srl == 123) { // 공지사항 게시판 module_srl
                $realtime_notification->sendNotification('new_notice', array(
                    'type' => 'broadcast',
                    'title' => '새 공지사항',
                    'message' => '새로운 공지사항이 등록되었습니다.',
                    'document_srl' => $document_srl
                ));
            }
        }
        break;

    case 'before_display_content':
        // 로그인한 사용자에게만 실시간 알림 스크립트 추가
        if(Context::get('logged_info')) {
            $script = $realtime_notification->getClientScript();
            Context::addHtmlFooter($script);
        }
        break;
}
?>

💡 고급 개발 팁

  1. 클래스 기반 설계: 객체지향 프로그래밍으로 코드 구조화
  2. 의존성 주입: 외부 서비스(Redis, DB 등) 연결 시 에러 처리
  3. 이벤트 기반: 비동기 처리를 위한 이벤트 시스템 활용
  4. 캐싱 전략: 성능 향상을 위한 다층 캐싱 구현
  5. 모니터링: 애드온 동작 상태를 추적할 수 있는 로깅 시스템

🔍 디버깅과 테스트

애드온 디버깅 도구

<?php
class AddonDebugger
{
    public static function log($message, $data = null)
    {
        if(__DEBUG__) {
            $log_entry = array(
                'time' => date('Y-m-d H:i:s'),
                'message' => $message,
                'data' => $data,
                'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)
            );

            file_put_contents(
                _XE_PATH_ . 'files/debug/addon_debug.log',
                json_encode($log_entry) . "\n",
                FILE_APPEND
            );
        }
    }
}

// 사용 예시
AddonDebugger::log('애드온 시작', array('act' => Context::get('act')));
?>

📦 배포와 관리

버전 관리

  • 시맨틱 버저닝 사용 (1.0.0, 1.1.0, 2.0.0)
  • 변경사항 문서화 (CHANGELOG.md)
  • 업데이트 스크립트 제공

성능 모니터링

  • 실행 시간 측정
  • 메모리 사용량 추적
  • 데이터베이스 쿼리 최적화

다음 단계

애드온 예제들을 학습했다면, 고급 애드온 기법을 통해 더 복잡한 기능을 구현해보세요.