서명 기능 활용¶
사용자의 서명을 관리하고 표시하는 애드온을 구현하는 방법을 학습합니다.
서명 관리 시스템¶
기본 서명 애드온¶
<?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>
모범 사례¶
- 필터링: 적절한 HTML 태그만 허용
- 길이 제한: 과도한 서명 방지
- 캐싱: 서명 조회 결과 캐싱
- 권한 관리: 관리자 승인 시스템
- 모바일 최적화: 작은 화면에서도 잘 보이도록
다음 단계¶
서명 기능을 완료했다면, 애드온 기초를 학습하세요.