서명 기능 활용

서명 기능 활용

사용자의 서명을 관리하고 표시하는 애드온을 구현하는 방법을 학습합니다.

서명 관리 시스템

기본 서명 애드온

<?php
// addons/signature_system/signature_system.addon.php

if(!defined("__XE__")) exit();

/**
 * 서명 기능 애드온
 */
class SignatureSystem
{
    private $max_signature_length = 500;
    private $allowed_tags = '<b><i><u><br><a><img>';

    public function __construct()
    {
        // 설정 로드
        $this->loadConfiguration();
    }

    // 설정 로드
    private function loadConfiguration()
    {
        $config = getModel('module')->getModuleConfig('signature_system');

        if($config) {
            $this->max_signature_length = $config->max_length ?: 500;
            $this->allowed_tags = $config->allowed_tags ?: '<b><i><u><br><a><img>';
        }
    }

    // 댓글/게시물 출력 시 서명 추가
    public function triggerDisplayContent(&$obj)
    {
        if(!$obj) return;

        // 작성자 정보 확인
        $member_srl = $obj->get('member_srl');
        if(!$member_srl) return;

        // 서명 조회
        $signature = $this->getUserSignature($member_srl);
        if(!$signature) return;

        // 내용에 서명 추가
        $content = $obj->get('content');
        $content .= '<div class="user-signature">' . $signature . '</div>';
        $obj->add('content', $content);
    }

    // 사용자 서명 조회
    public function getUserSignature($member_srl)
    {
        $args = new stdClass();
        $args->member_srl = $member_srl;

        $output = executeQuery('signature_system.getSignature', $args);

        if($output->toBool() && $output->data) {
            return $this->processSignature($output->data->signature);
        }

        return null;
    }

    // 서명 처리 (필터링, 변환)
    private function processSignature($signature)
    {
        if(!$signature) return null;

        // HTML 태그 필터링
        $signature = strip_tags($signature, $this->allowed_tags);

        // 길이 제한
        if(mb_strlen($signature) > $this->max_signature_length) {
            $signature = mb_substr($signature, 0, $this->max_signature_length) . '...';
        }

        // 금지어 필터링
        $signature = $this->filterProhibitedWords($signature);

        // 이미지 크기 제한
        $signature = preg_replace('/<img([^>]+)>/i', '<img$1 style="max-width:200px;max-height:100px;">', $signature);

        return $signature;
    }

    // 금지어 필터링
    private function filterProhibitedWords($text)
    {
        $prohibited_words = $this->getProhibitedWords();

        foreach($prohibited_words as $word) {
            $text = str_ireplace($word, str_repeat('*', mb_strlen($word)), $text);
        }

        return $text;
    }

    // 금지어 목록 조회
    private function getProhibitedWords()
    {
        $output = executeQuery('signature_system.getProhibitedWords');

        if($output->toBool() && $output->data) {
            return array_column($output->data, 'word');
        }

        return array();
    }

    // 서명 저장
    public function saveSignature($member_srl, $signature)
    {
        // 권한 확인
        $logged_info = Context::get('logged_info');
        if(!$logged_info || $logged_info->member_srl != $member_srl) {
            return new BaseObject(-1, '권한이 없습니다.');
        }

        // 서명 유효성 검사
        $validation_result = $this->validateSignature($signature);
        if(!$validation_result->toBool()) {
            return $validation_result;
        }

        // 기존 서명 확인
        $args = new stdClass();
        $args->member_srl = $member_srl;
        $output = executeQuery('signature_system.getSignature', $args);

        if($output->data) {
            // 업데이트
            $args->signature = $signature;
            $args->last_update = date('YmdHis');
            $output = executeQuery('signature_system.updateSignature', $args);
        } else {
            // 새로 생성
            $args->signature = $signature;
            $args->regdate = date('YmdHis');
            $args->last_update = date('YmdHis');
            $output = executeQuery('signature_system.insertSignature', $args);
        }

        return $output;
    }

    // 서명 유효성 검사
    private function validateSignature($signature)
    {
        // 길이 검사
        if(mb_strlen($signature) > $this->max_signature_length) {
            return new BaseObject(-1, sprintf('서명은 %d자 이내로 작성해주세요.', $this->max_signature_length));
        }

        // HTML 태그 검사
        $clean_signature = strip_tags($signature, $this->allowed_tags);
        if($clean_signature !== $signature) {
            return new BaseObject(-1, '허용되지 않은 HTML 태그가 포함되어 있습니다.');
        }

        // 이미지 개수 제한
        $image_count = preg_match_all('/<img[^>]+>/i', $signature);
        if($image_count > 3) {
            return new BaseObject(-1, '이미지는 최대 3개까지만 사용할 수 있습니다.');
        }

        // 링크 개수 제한
        $link_count = preg_match_all('/<a[^>]+>/i', $signature);
        if($link_count > 5) {
            return new BaseObject(-1, '링크는 최대 5개까지만 사용할 수 있습니다.');
        }

        return new BaseObject();
    }

    // 서명 통계
    public function getSignatureStats()
    {
        $stats = new stdClass();

        // 전체 서명 개수
        $output = executeQuery('signature_system.getSignatureCount');
        $stats->total_count = $output->data ? $output->data->count : 0;

        // 오늘 등록된 서명
        $args = new stdClass();
        $args->start_date = date('Ymd') . '000000';
        $args->end_date = date('Ymd') . '235959';
        $output = executeQuery('signature_system.getTodaySignatureCount', $args);
        $stats->today_count = $output->data ? $output->data->count : 0;

        // 인기 서명 태그
        $output = executeQuery('signature_system.getPopularTags');
        $stats->popular_tags = $output->data ?: array();

        return $stats;
    }

    // 서명 미리보기
    public function previewSignature($signature)
    {
        $processed = $this->processSignature($signature);

        return array(
            'original' => $signature,
            'processed' => $processed,
            'char_count' => mb_strlen($signature),
            'max_length' => $this->max_signature_length,
            'remaining' => $this->max_signature_length - mb_strlen($signature)
        );
    }
}

// 애드온 실행
$signature_system = new SignatureSystem();

// 트리거 포인트별 실행
switch($called_position) {
    case 'before_module_proc':
        // 서명 관련 액션 처리
        $act = Context::get('act');
        if($act == 'procSignatureSave') {
            $member_srl = Context::get('member_srl');
            $signature = Context::get('signature');

            $output = $signature_system->saveSignature($member_srl, $signature);

            if($output->toBool()) {
                $signature_system->add('message', '서명이 저장되었습니다.');
            } else {
                $signature_system->setError($output->getError());
                $signature_system->setMessage($output->getMessage());
            }

            $signature_system->setRedirectUrl(getNotEncodedUrl('', 'mid', Context::get('mid'), 'act', 'dispMemberModifyInfo'));
        }
        break;

    case 'after_module_proc':
        // 회원정보 수정 페이지에 서명 폼 추가
        if($act == 'dispMemberModifyInfo') {
            $logged_info = Context::get('logged_info');
            if($logged_info) {
                $signature = $signature_system->getUserSignature($logged_info->member_srl);
                Context::set('user_signature', $signature);
            }
        }
        break;

    case 'before_display_content':
        // 게시물/댓글에 서명 추가
        if(is_a($oDocument, 'documentItem')) {
            $signature_system->triggerDisplayContent($oDocument);
        }

        if(is_a($oComment, 'commentItem')) {
            $signature_system->triggerDisplayContent($oComment);
        }
        break;
}
?>

XML 쿼리 정의

서명 관련 쿼리

<!-- queries/getSignature.xml -->
<query id="getSignature" action="select">
    <tables>
        <table name="user_signatures" />
    </tables>
    <columns>
        <column name="*" />
    </columns>
    <conditions>
        <condition operation="equal" column="member_srl" var="member_srl" notnull="notnull" />
    </conditions>
</query>

<!-- queries/insertSignature.xml -->
<query id="insertSignature" action="insert">
    <tables>
        <table name="user_signatures" />
    </tables>
    <columns>
        <column name="member_srl" var="member_srl" />
        <column name="signature" var="signature" />
        <column name="regdate" var="regdate" />
        <column name="last_update" var="last_update" />
    </columns>
</query>

<!-- queries/updateSignature.xml -->
<query id="updateSignature" action="update">
    <tables>
        <table name="user_signatures" />
    </tables>
    <columns>
        <column name="signature" var="signature" />
        <column name="last_update" var="last_update" />
    </columns>
    <conditions>
        <condition operation="equal" column="member_srl" var="member_srl" notnull="notnull" />
    </conditions>
</query>

<!-- queries/getSignatureCount.xml -->
<query id="getSignatureCount" action="select">
    <tables>
        <table name="user_signatures" />
    </tables>
    <columns>
        <column name="COUNT(*)" alias="count" />
    </columns>
</query>

<!-- queries/getProhibitedWords.xml -->
<query id="getProhibitedWords" action="select">
    <tables>
        <table name="signature_prohibited_words" />
    </tables>
    <columns>
        <column name="word" />
    </columns>
    <conditions>
        <condition operation="equal" column="is_active" var="is_active" default="Y" />
    </conditions>
</query>

서명 편집기

회원정보 수정 페이지 확장

<!-- 서명 편집 폼 -->
<div class="signature-editor" cond="$logged_info">
    <h3>나의 서명</h3>

    <form action="{getUrl()}" method="post" id="signature-form">
        <input type="hidden" name="act" value="procSignatureSave" />
        <input type="hidden" name="member_srl" value="{$logged_info->member_srl}" />

        <!-- 서명 편집기 -->
        <div class="form-group">
            <label for="signature">서명 내용</label>
            <div class="signature-editor-wrapper">
                <textarea 
                    name="signature" 
                    id="signature" 
                    class="form-control"
                    placeholder="나만의 서명을 작성해보세요."
                    maxlength="500"
                    rows="5">{$user_signature}</textarea>

                <!-- 편집 도구 -->
                <div class="signature-tools">
                    <button type="button" onclick="insertTag('b')" title="굵게">
                        <i class="xi-bold"></i>
                    </button>
                    <button type="button" onclick="insertTag('i')" title="기울임">
                        <i class="xi-italic"></i>
                    </button>
                    <button type="button" onclick="insertTag('u')" title="밑줄">
                        <i class="xi-underline"></i>
                    </button>
                    <button type="button" onclick="insertLink()" title="링크">
                        <i class="xi-link"></i>
                    </button>
                    <button type="button" onclick="insertImage()" title="이미지">
                        <i class="xi-camera"></i>
                    </button>
                </div>
            </div>

            <!-- 글자 수 카운터 -->
            <div class="signature-counter">
                <span id="char-count">0</span> / 500자
                <span class="remaining" id="char-remaining">500자 남음</span>
            </div>
        </div>

        <!-- 미리보기 -->
        <div class="form-group">
            <label>미리보기</label>
            <div class="signature-preview" id="signature-preview">
                <div class="empty-message">서명을 입력하면 미리보기가 표시됩니다.</div>
            </div>
        </div>

        <!-- 가이드라인 -->
        <div class="signature-guidelines">
            <h4>서명 작성 가이드라인</h4>
            <ul>
                <li>최대 500자까지 작성 가능합니다.</li>
                <li>이미지는 최대 3개까지 사용할 수 있습니다.</li>
                <li>링크는 최대 5개까지 사용할 수 있습니다.</li>
                <li>부적절한 내용은 자동으로 필터링됩니다.</li>
                <li>서명은 게시물과 댓글 하단에 자동으로 표시됩니다.</li>
            </ul>
        </div>

        <!-- 저장 버튼 -->
        <div class="form-actions">
            <button type="submit" class="btn btn-primary">
                <i class="xi-check"></i> 서명 저장
            </button>
            <button type="button" class="btn btn-secondary" onclick="clearSignature()">
                <i class="xi-trash"></i> 초기화
            </button>
        </div>
    </form>
</div>

<style>
.signature-editor {
    background: #f8f9fa;
    padding: 20px;
    border-radius: 8px;
    margin: 20px 0;
}

.signature-editor-wrapper {
    position: relative;
    border: 1px solid #ddd;
    border-radius: 4px;
    background: #fff;
}

.signature-editor-wrapper textarea {
    border: none;
    resize: vertical;
    min-height: 120px;
    padding: 10px;
}

.signature-tools {
    display: flex;
    gap: 5px;
    padding: 8px 10px;
    border-top: 1px solid #eee;
    background: #f8f9fa;
}

.signature-tools button {
    background: none;
    border: 1px solid #ddd;
    border-radius: 3px;
    padding: 5px 8px;
    cursor: pointer;
    transition: all 0.3s;
}

.signature-tools button:hover {
    background: #007bff;
    color: #fff;
    border-color: #007bff;
}

.signature-counter {
    display: flex;
    justify-content: space-between;
    margin-top: 5px;
    font-size: 12px;
    color: #666;
}

.signature-counter.warning {
    color: #f39c12;
}

.signature-counter.danger {
    color: #e74c3c;
}

.signature-preview {
    min-height: 80px;
    padding: 15px;
    background: #fff;
    border: 1px solid #ddd;
    border-radius: 4px;
}

.signature-preview .empty-message {
    color: #999;
    font-style: italic;
    text-align: center;
}

.signature-guidelines {
    background: #e8f4fd;
    padding: 15px;
    border-radius: 4px;
    margin: 15px 0;
}

.signature-guidelines h4 {
    margin-bottom: 10px;
    color: #0066cc;
}

.signature-guidelines ul {
    margin: 0;
    padding-left: 20px;
}

.signature-guidelines li {
    margin-bottom: 5px;
}
</style>

<script>
// 서명 편집기 JavaScript
(function() {
    var textarea = document.getElementById('signature');
    var preview = document.getElementById('signature-preview');
    var charCount = document.getElementById('char-count');
    var charRemaining = document.getElementById('char-remaining');
    var form = document.getElementById('signature-form');

    // 글자 수 업데이트
    function updateCharCount() {
        var length = textarea.value.length;
        var remaining = 500 - length;

        charCount.textContent = length;
        charRemaining.textContent = remaining + '자 남음';

        var counter = document.querySelector('.signature-counter');
        counter.classList.remove('warning', 'danger');

        if(remaining < 50) {
            counter.classList.add('warning');
        }
        if(remaining < 0) {
            counter.classList.add('danger');
        }
    }

    // 미리보기 업데이트
    function updatePreview() {
        var content = textarea.value;

        if(!content.trim()) {
            preview.innerHTML = '<div class="empty-message">서명을 입력하면 미리보기가 표시됩니다.</div>';
            return;
        }

        // AJAX로 미리보기 요청
        fetch(location.href, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: 'module=addon&act=getSignaturePreview&signature=' + encodeURIComponent(content)
        })
        .then(response => response.json())
        .then(data => {
            if(data.error === 0) {
                preview.innerHTML = data.data.processed || content;
            } else {
                preview.innerHTML = '<div class="error">미리보기 로드 실패</div>';
            }
        })
        .catch(error => {
            preview.innerHTML = content;
        });
    }

    // 태그 삽입
    window.insertTag = function(tag) {
        var start = textarea.selectionStart;
        var end = textarea.selectionEnd;
        var selectedText = textarea.value.substring(start, end);
        var replacement;

        switch(tag) {
            case 'b':
                replacement = '<b>' + (selectedText || '굵은 텍스트') + '</b>';
                break;
            case 'i':
                replacement = '<i>' + (selectedText || '기울임 텍스트') + '</i>';
                break;
            case 'u':
                replacement = '<u>' + (selectedText || '밑줄 텍스트') + '</u>';
                break;
        }

        textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end);
        textarea.focus();

        updateCharCount();
        updatePreview();
    };

    // 링크 삽입
    window.insertLink = function() {
        var url = prompt('링크 URL을 입력하세요:', 'https://');
        if(!url) return;

        var text = prompt('링크 텍스트를 입력하세요:', url);
        if(!text) text = url;

        var start = textarea.selectionStart;
        var replacement = '<a href="' + url + '">' + text + '</a>';

        textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(textarea.selectionEnd);
        textarea.focus();

        updateCharCount();
        updatePreview();
    };

    // 이미지 삽입
    window.insertImage = function() {
        var url = prompt('이미지 URL을 입력하세요:', 'https://');
        if(!url) return;

        var alt = prompt('이미지 설명을 입력하세요:', '');

        var start = textarea.selectionStart;
        var replacement = '<img src="' + url + '" alt="' + (alt || '') + '" style="max-width:200px;max-height:100px;" />';

        textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(textarea.selectionEnd);
        textarea.focus();

        updateCharCount();
        updatePreview();
    };

    // 초기화
    window.clearSignature = function() {
        if(confirm('서명을 초기화하시겠습니까?')) {
            textarea.value = '';
            updateCharCount();
            updatePreview();
            textarea.focus();
        }
    };

    // 이벤트 리스너
    textarea.addEventListener('input', function() {
        updateCharCount();
        clearTimeout(this.previewTimer);
        this.previewTimer = setTimeout(updatePreview, 1000);
    });

    form.addEventListener('submit', function(e) {
        if(textarea.value.length > 500) {
            e.preventDefault();
            alert('서명이 너무 깁니다. 500자 이내로 작성해주세요.');
            return false;
        }
    });

    // 초기 실행
    updateCharCount();
    updatePreview();
})();
</script>

서명 표시

게시물/댓글에 서명 출력

<!-- 게시물 하단 서명 -->
<div class="document-signature" cond="$oDocument->get('signature')">
    <div class="signature-divider"></div>
    <div class="signature-content">
        {$oDocument->get('signature')}
    </div>
</div>

<!-- 댓글 하단 서명 -->
<div class="comment-signature" cond="$comment->get('signature')">
    <div class="signature-content">
        {$comment->get('signature')}
    </div>
</div>

<style>
/* 게시물 서명 스타일 */
.document-signature {
    margin-top: 30px;
    padding-top: 15px;
    border-top: 1px solid #eee;
}

.signature-divider {
    height: 1px;
    background: linear-gradient(to right, transparent, #ddd, transparent);
    margin-bottom: 15px;
}

.signature-content {
    font-size: 12px;
    color: #666;
    line-height: 1.4;
    padding: 10px;
    background: #f8f9fa;
    border-radius: 4px;
    border-left: 3px solid #007bff;
}

/* 댓글 서명 스타일 */
.comment-signature {
    margin-top: 10px;
    padding-top: 8px;
    border-top: 1px dotted #ddd;
}

.comment-signature .signature-content {
    font-size: 11px;
    background: none;
    border: none;
    padding: 5px 0;
}

/* 서명 내 이미지 스타일 */
.signature-content img {
    max-width: 200px;
    max-height: 100px;
    display: inline-block;
    vertical-align: middle;
    margin: 2px;
    border-radius: 3px;
}

/* 서명 내 링크 스타일 */
.signature-content a {
    color: #007bff;
    text-decoration: none;
}

.signature-content a:hover {
    text-decoration: underline;
}

/* 반응형 */
@media (max-width: 768px) {
    .signature-content {
        font-size: 11px;
        padding: 8px;
    }

    .signature-content img {
        max-width: 150px;
        max-height: 80px;
    }
}
</style>

관리자 기능

서명 관리 페이지

<!-- 관리자 서명 관리 -->
<div class="signature-admin" cond="$logged_info->is_admin == 'Y'">
    <h2>서명 관리</h2>

    <!-- 통계 -->
    {@
        $signature_stats = $signature_system->getSignatureStats();
    }
    <div class="signature-stats">
        <div class="stat-card">
            <h3>{number_format($signature_stats->total_count)}</h3>
            <p>전체 서명</p>
        </div>
        <div class="stat-card">
            <h3>{number_format($signature_stats->today_count)}</h3>
            <p>오늘 등록</p>
        </div>
    </div>

    <!-- 금지어 관리 -->
    <div class="prohibited-words-section">
        <h3>금지어 관리</h3>
        <form action="{getUrl()}" method="post">
            <input type="hidden" name="act" value="procSignatureProhibitedWord" />
            <div class="input-group">
                <input type="text" name="word" placeholder="금지어 입력" required />
                <button type="submit" class="btn btn-primary">추가</button>
            </div>
        </form>

        <ul class="prohibited-words-list">
            <li loop="$signature_stats->prohibited_words=>$word">
                {$word}
                <button type="button" onclick="removeProhibitedWord('{$word}')">
                    <i class="xi-close"></i>
                </button>
            </li>
        </ul>
    </div>

    <!-- 서명 목록 -->
    <div class="signature-list-section">
        <h3>최근 서명 목록</h3>
        <!-- 서명 목록 테이블 -->
    </div>
</div>

모범 사례

  1. 필터링: 적절한 HTML 태그만 허용
  2. 길이 제한: 과도한 서명 방지
  3. 캐싱: 서명 조회 결과 캐싱
  4. 권한 관리: 관리자 승인 시스템
  5. 모바일 최적화: 작은 화면에서도 잘 보이도록

다음 단계

서명 기능을 완료했다면, 애드온 기초를 학습하세요.