애드온 개발 기초

애드온 개발 기초

애드온이란?

애드온(Addon)은 Rhymix/XE의 모든 실행 과정에 개입하여 기능을 추가하거나 수정할 수 있는 확장 시스템입니다.

애드온의 특징

  • 전역적: 모든 페이지에서 실행 가능
  • 이벤트 기반: 특정 시점에 실행되는 훅(Hook) 시스템
  • 비침투적: 기존 코드 수정 없이 기능 확장
  • 독립적: 모듈과 독립적으로 작동

애드온 구조

기본 파일 구조

addons/
└── my_addon/
    ├── conf/
    │   └── info.xml            # 애드온 정보 (필수)
    ├── lang/
    │   ├── ko.php              # 한국어 언어팩
    │   └── en.php              # 영어 언어팩
    ├── tpl/
    │   └── config.html         # 설정 화면 (선택)
    ├── my_addon.addon.php      # 메인 애드온 파일 (필수)
    ├── my_addon.class.php      # 애드온 클래스 (선택)
    └── README.md               # 설명서 (선택)

info.xml 작성

<?xml version="1.0" encoding="UTF-8"?>
<addon version="0.2">
    <title xml:lang="ko">나의 애드온</title>
    <title xml:lang="en">My Addon</title>

    <description xml:lang="ko">
        사용자 정의 기능을 추가하는 애드온입니다.
    </description>
    <description xml:lang="en">
        An addon that adds custom functionality.
    </description>

    <version>1.0.0</version>
    <date>2024-01-01</date>

    <author email="user@example.com" link="https://example.com">
        <name xml:lang="ko">개발자명</name>
        <name xml:lang="en">Developer Name</name>
    </author>

    <!-- 의존성 -->
    <depends>
        <module name="board" />
        <module name="member" />
    </depends>

    <!-- 설정 변수 -->
    <extra_vars>
        <var name="enable_feature" type="select">
            <title xml:lang="ko">기능 활성화</title>
            <description xml:lang="ko">특정 기능을 활성화합니다</description>
            <options value="Y">
                <title xml:lang="ko">사용</title>
            </options>
            <options value="N">
                <title xml:lang="ko">사용안함</title>
            </options>
        </var>

        <var name="api_key" type="text">
            <title xml:lang="ko">API 키</title>
            <description xml:lang="ko">외부 API 연동을 위한 키</description>
        </var>

        <var name="target_modules" type="text">
            <title xml:lang="ko">대상 모듈</title>
            <description xml:lang="ko">쉼표로 구분된 모듈 ID 목록</description>
            <default>board,page</default>
        </var>
    </extra_vars>
</addon>

기본 애드온 파일

my_addon.addon.php

<?php
/**
 * 나의 애드온
 * 
 * @author 개발자명
 * @version 1.0.0
 */

// 애드온 실행 함수
function my_addon($called_position, $obj)
{
    // 애드온 설정 가져오기
    $addon_config = AddonModel::getInstance()->getAddonConfig('my_addon');

    // 기능이 비활성화된 경우 종료
    if ($addon_config->enable_feature !== 'Y') {
        return;
    }

    // 대상 모듈 체크
    $target_modules = explode(',', $addon_config->target_modules ?? 'board,page');
    $current_module = Context::get('module');

    if (!in_array($current_module, $target_modules)) {
        return;
    }

    // 호출 위치별 처리
    switch ($called_position) {
        case 'before_module_init':
            handleBeforeModuleInit($obj);
            break;

        case 'after_module_proc':
            handleAfterModuleProc($obj);
            break;

        case 'before_display_content':
            handleBeforeDisplayContent($obj);
            break;

        case 'after_display_content':
            handleAfterDisplayContent($obj);
            break;
    }
}

/**
 * 모듈 초기화 전 처리
 */
function handleBeforeModuleInit($obj)
{
    // Context에 변수 추가
    Context::set('addon_active', true);

    // 추가 CSS/JS 로드
    Context::loadFile([
        './addons/my_addon/assets/my_addon.css',
        './addons/my_addon/assets/my_addon.js'
    ], true);
}

/**
 * 모듈 실행 후 처리
 */
function handleAfterModuleProc($obj)
{
    // 결과 데이터 수정
    if ($obj instanceof BaseObject) {
        // 성공적인 처리 후 추가 작업
        if ($obj->toBool()) {
            // 로그 기록, 알림 발송 등
            writeCustomLog('Module processed successfully');
        }
    }
}

/**
 * 콘텐츠 출력 전 처리
 */
function handleBeforeDisplayContent($obj)
{
    // 헤더에 메타태그 추가
    Context::addHtmlHeader('<meta name="addon-version" content="1.0.0">');

    // 특정 조건에서 리다이렉트
    $act = Context::get('act');
    if ($act === 'dispSpecialPage' && !isAuthorized()) {
        header('Location: ' . getNotEncodedUrl('', 'mid', 'login'));
        exit;
    }
}

/**
 * 콘텐츠 출력 후 처리
 */
function handleAfterDisplayContent($obj)
{
    // 페이지 하단에 추가 스크립트 삽입
    $additional_script = '
    <script>
    jQuery(function($) {
        // 애드온 관련 스크립트
        console.log("My Addon is active");

        // 특정 기능 초기화
        initCustomFeatures();
    });
    </script>';

    $content = Context::get('content');
    $content .= $additional_script;
    Context::set('content', $content);
}

/**
 * 권한 체크 함수
 */
function isAuthorized()
{
    $logged_info = Context::get('logged_info');
    return $logged_info && $logged_info->is_admin === 'Y';
}

/**
 * 커스텀 로그 작성
 */
function writeCustomLog($message)
{
    $log_file = './files/_logs/my_addon.log';
    $log_entry = date('Y-m-d H:i:s') . " - " . $message . PHP_EOL;
    file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
}

/**
 * 초기화 함수 (설치 시 실행)
 */
function initCustomFeatures()
{
    // 초기화 작업
}
?>

애드온 클래스 방식

my_addon.class.php

<?php
/**
 * 애드온 클래스
 */
class MyAddon
{
    private $config;
    private static $instance;

    /**
     * 싱글톤 인스턴스 반환
     */
    public static function getInstance()
    {
        if (!self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * 생성자
     */
    private function __construct()
    {
        $this->config = AddonModel::getInstance()->getAddonConfig('my_addon');
    }

    /**
     * 애드온 실행
     */
    public function triggerFunction($called_position, $obj)
    {
        if (!$this->isEnabled()) {
            return;
        }

        $method_name = 'handle' . ucfirst(str_replace('_', '', $called_position));

        if (method_exists($this, $method_name)) {
            $this->$method_name($obj);
        }
    }

    /**
     * 애드온 활성화 여부
     */
    private function isEnabled()
    {
        return $this->config->enable_feature === 'Y';
    }

    /**
     * 대상 모듈 체크
     */
    private function isTargetModule()
    {
        $target_modules = explode(',', $this->config->target_modules ?? '');
        $current_module = Context::get('module');

        return in_array($current_module, array_map('trim', $target_modules));
    }

    /**
     * 모듈 초기화 전
     */
    public function handleBeforemoduleinit($obj)
    {
        if (!$this->isTargetModule()) {
            return;
        }

        // 특정 액션에서만 실행
        $act = Context::get('act');
        $allowed_acts = ['dispBoardContent', 'dispBoardWrite', 'dispBoardView'];

        if (in_array($act, $allowed_acts)) {
            $this->loadAssets();
            $this->setContextVariables();
        }
    }

    /**
     * 에셋 로드
     */
    private function loadAssets()
    {
        Context::loadFile([
            './addons/my_addon/assets/style.css',
            './addons/my_addon/assets/script.js'
        ], true);
    }

    /**
     * Context 변수 설정
     */
    private function setContextVariables()
    {
        Context::set('my_addon_config', $this->config);
        Context::set('my_addon_active', true);
    }

    /**
     * API 요청 처리
     */
    public function handleApiRequest($data)
    {
        if (!$this->config->api_key) {
            return ['error' => 'API key not configured'];
        }

        // 외부 API 호출
        $response = $this->callExternalAPI($data);

        return $response;
    }

    /**
     * 외부 API 호출
     */
    private function callExternalAPI($data)
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, 'https://api.example.com/endpoint');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $this->config->api_key
        ]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $response = curl_exec($ch);
        $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($http_code === 200) {
            return json_decode($response, true);
        }

        return ['error' => 'API request failed'];
    }
}

// 애드온 함수에서 클래스 사용
function my_addon($called_position, $obj)
{
    $addon = MyAddon::getInstance();
    $addon->triggerFunction($called_position, $obj);
}
?>

설정 화면

tpl/config.html

<!-- 애드온 설정 화면 -->
<form action="{getUrl()}" method="post" class="addon-config-form">
    <input type="hidden" name="module" value="admin" />
    <input type="hidden" name="act" value="procAdminInsertAddon" />
    <input type="hidden" name="addon" value="my_addon" />
    <input type="hidden" name="xe_validator_id" value="addon_config" />

    <div class="config-section">
        <h3>기본 설정</h3>

        <div class="form-group">
            <label for="enable_feature">기능 활성화</label>
            <select name="enable_feature" id="enable_feature">
                <option value="Y" selected="selected"|cond="$addon_config->enable_feature == 'Y'">
                    사용
                </option>
                <option value="N" selected="selected"|cond="$addon_config->enable_feature == 'N'">
                    사용안함
                </option>
            </select>
            <p class="help-text">애드온의 주요 기능을 활성화하거나 비활성화합니다.</p>
        </div>

        <div class="form-group">
            <label for="api_key">API 키</label>
            <input type="text" name="api_key" id="api_key" 
                   value="{$addon_config->api_key}" 
                   class="form-control" />
            <p class="help-text">외부 서비스 연동을 위한 API 키를 입력하세요.</p>
        </div>

        <div class="form-group">
            <label for="target_modules">대상 모듈</label>
            <input type="text" name="target_modules" id="target_modules" 
                   value="{$addon_config->target_modules}" 
                   placeholder="board,page,comment" 
                   class="form-control" />
            <p class="help-text">
                애드온이 작동할 모듈을 쉼표로 구분하여 입력하세요.
                <br>예: board,page,comment
            </p>
        </div>
    </div>

    <div class="config-section">
        <h3>고급 설정</h3>

        <div class="form-group">
            <label>
                <input type="checkbox" name="debug_mode" value="Y" 
                       checked="checked"|cond="$addon_config->debug_mode == 'Y'" />
                디버그 모드
            </label>
            <p class="help-text">개발 중에만 활성화하세요. 로그가 더 자세히 기록됩니다.</p>
        </div>

        <div class="form-group">
            <label for="cache_timeout">캐시 유지 시간 (초)</label>
            <input type="number" name="cache_timeout" id="cache_timeout" 
                   value="{$addon_config->cache_timeout ?: 3600}" 
                   min="0" max="86400" 
                   class="form-control" />
            <p class="help-text">0으로 설정하면 캐시를 사용하지 않습니다.</p>
        </div>
    </div>

    <div class="config-actions">
        <button type="submit" class="btn btn-primary">설정 저장</button>
        <button type="button" class="btn btn-secondary" onclick="testAddon()">
            연결 테스트
        </button>
    </div>
</form>

<script>
function testAddon() {
    var apiKey = document.getElementById('api_key').value;

    if (!apiKey) {
        alert('API 키를 먼저 입력하세요.');
        return;
    }

    // AJAX로 연결 테스트
    jQuery.ajax({
        url: request_uri,
        type: 'POST',
        data: {
            module: 'addon',
            act: 'procTestAddon',
            addon: 'my_addon',
            api_key: apiKey
        },
        success: function(response) {
            if (response.error === '0') {
                alert('연결 테스트 성공!');
            } else {
                alert('연결 실패: ' + response.message);
            }
        },
        error: function() {
            alert('테스트 중 오류가 발생했습니다.');
        }
    });
}
</script>

<style>
.addon-config-form {
    max-width: 600px;
}

.config-section {
    margin-bottom: 30px;
    padding: 20px;
    border: 1px solid #eee;
    border-radius: 8px;
}

.config-section h3 {
    margin: 0 0 20px;
    color: #333;
}

.form-group {
    margin-bottom: 20px;
}

.form-group label {
    display: block;
    margin-bottom: 5px;
    font-weight: 600;
}

.form-control {
    width: 100%;
    padding: 8px 12px;
    border: 1px solid #ddd;
    border-radius: 4px;
}

.help-text {
    margin-top: 5px;
    font-size: 13px;
    color: #666;
}

.config-actions {
    text-align: right;
    padding-top: 20px;
    border-top: 1px solid #eee;
}

.btn {
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    margin-left: 10px;
}

.btn-primary {
    background: #007bff;
    color: white;
}

.btn-secondary {
    background: #6c757d;
    color: white;
}
</style>

애드온 설치 및 관리

설치 스크립트

<?php
/**
 * 애드온 설치 시 실행되는 스크립트
 * my_addon.install.php
 */

function my_addon_install()
{
    // 필요한 디렉토리 생성
    $directories = [
        './files/_logs',
        './files/_cache/my_addon'
    ];

    foreach ($directories as $dir) {
        if (!is_dir($dir)) {
            FileHandler::makeDir($dir);
        }
    }

    // 기본 설정 저장
    $config = new stdClass();
    $config->enable_feature = 'Y';
    $config->target_modules = 'board,page';
    $config->cache_timeout = 3600;

    $oAddonAdminController = getAdminController('addon');
    $oAddonAdminController->makeCacheFile('my_addon', $config);

    return new BaseObject();
}

/**
 * 애드온 제거 시 실행되는 스크립트
 * my_addon.uninstall.php
 */
function my_addon_uninstall()
{
    // 생성된 파일들 정리
    $files_to_remove = [
        './files/_logs/my_addon.log',
        './files/_cache/my_addon'
    ];

    foreach ($files_to_remove as $file) {
        if (file_exists($file)) {
            if (is_dir($file)) {
                FileHandler::removeDir($file);
            } else {
                FileHandler::removeFile($file);
            }
        }
    }

    return new BaseObject();
}
?>

유용한 패턴들

조건부 실행

function my_addon($called_position, $obj)
{
    // 관리자 페이지에서는 실행하지 않음
    if (Context::get('module') === 'admin') {
        return;
    }

    // 모바일에서만 실행
    if (!Mobile::isFromMobilePhone()) {
        return;
    }

    // 특정 액션에서만 실행
    $act = Context::get('act');
    $allowed_acts = ['dispBoardContent', 'dispBoardView'];
    if (!in_array($act, $allowed_acts)) {
        return;
    }

    // 로그인 사용자에게만
    $logged_info = Context::get('logged_info');
    if (!$logged_info) {
        return;
    }
}

성능 최적화

// 정적 변수로 중복 실행 방지
function my_addon($called_position, $obj)
{
    static $executed = false;

    if ($executed) {
        return;
    }

    // 처리 로직

    $executed = true;
}

// 캐시 활용
function getCachedData($key)
{
    static $cache = [];

    if (!isset($cache[$key])) {
        $cache[$key] = expensive_operation($key);
    }

    return $cache[$key];
}