태그 시스템¶
게시판에서 태그 기능을 구현하고 활용하는 방법을 학습합니다.
태그 시스템 개요¶
기본 개념¶
태그는 게시물을 분류하고 검색하기 위한 메타데이터입니다.
// 게시물에 태그 저장
$oDocument = new documentItem();
$oDocument->setTags(array('라이믹스', '게시판', 'PHP'));
태그 데이터베이스 설계¶
테이블 구조¶
-- 태그 마스터 테이블
CREATE TABLE tags (
tag_srl INT PRIMARY KEY AUTO_INCREMENT,
tag_name VARCHAR(250) NOT NULL,
use_count INT DEFAULT 0,
regdate DATETIME,
INDEX idx_tag_name (tag_name),
INDEX idx_use_count (use_count)
);
-- 게시물-태그 연결 테이블
CREATE TABLE document_tags (
document_srl INT,
tag_srl INT,
regdate DATETIME,
PRIMARY KEY (document_srl, tag_srl),
INDEX idx_tag_srl (tag_srl)
);
태그 입력 인터페이스¶
자동완성 기능¶
<div class="tag-input-container">
<input type="text" id="tag-input" placeholder="태그를 입력하세요 (콤마로 구분)" />
<div class="tag-suggestions" id="tag-suggestions"></div>
<div class="selected-tags" id="selected-tags"></div>
<input type="hidden" name="tags" id="tags-hidden" />
</div>
<script>
class TagInput {
constructor(inputId, suggestionsId) {
this.input = document.getElementById(inputId);
this.suggestions = document.getElementById(suggestionsId);
this.selectedTags = new Set();
this.initEvents();
}
initEvents() {
this.input.addEventListener('input', this.handleInput.bind(this));
this.input.addEventListener('keydown', this.handleKeydown.bind(this));
}
async handleInput(e) {
const query = e.target.value.trim();
if (query.length < 2) {
this.hideSuggestions();
return;
}
const suggestions = await this.fetchSuggestions(query);
this.showSuggestions(suggestions);
}
async fetchSuggestions(query) {
const response = await fetch('/api/tags/suggest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query })
});
const data = await response.json();
return data.suggestions || [];
}
showSuggestions(suggestions) {
this.suggestions.innerHTML = '';
suggestions.forEach(tag => {
const item = document.createElement('div');
item.className = 'suggestion-item';
item.textContent = `${tag.name} (${tag.count})`;
item.onclick = () => this.addTag(tag.name);
this.suggestions.appendChild(item);
});
this.suggestions.style.display = 'block';
}
addTag(tagName) {
if (!this.selectedTags.has(tagName)) {
this.selectedTags.add(tagName);
this.renderSelectedTags();
this.updateHiddenInput();
}
this.input.value = '';
this.hideSuggestions();
}
removeTag(tagName) {
this.selectedTags.delete(tagName);
this.renderSelectedTags();
this.updateHiddenInput();
}
renderSelectedTags() {
const container = document.getElementById('selected-tags');
container.innerHTML = '';
this.selectedTags.forEach(tag => {
const tagElement = document.createElement('span');
tagElement.className = 'selected-tag';
tagElement.innerHTML = `
${tag}
<button type="button" onclick="tagInput.removeTag('${tag}')">×</button>
`;
container.appendChild(tagElement);
});
}
updateHiddenInput() {
document.getElementById('tags-hidden').value = Array.from(this.selectedTags).join(',');
}
}
// 초기화
const tagInput = new TagInput('tag-input', 'tag-suggestions');
</script>
<style>
.tag-input-container {
position: relative;
margin: 10px 0;
}
#tag-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
max-height: 200px;
overflow-y: auto;
display: none;
z-index: 1000;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.suggestion-item:hover {
background: #f5f5f5;
}
.selected-tags {
margin-top: 10px;
}
.selected-tag {
display: inline-block;
background: #007bff;
color: white;
padding: 4px 8px;
margin: 2px;
border-radius: 3px;
font-size: 12px;
}
.selected-tag button {
background: none;
border: none;
color: white;
margin-left: 5px;
cursor: pointer;
}
</style>
태그 검색 기능¶
태그 클라우드¶
<div class="tag-cloud">
<h3>인기 태그</h3>
<div class="tags">
<!--@foreach($popular_tags as $tag)-->
<a href="{getUrl('', 'search_target', 'tag', 'search_keyword', $tag->tag_name)}"
class="tag tag-size-{$tag->size_class}">
{$tag->tag_name} ({$tag->use_count})
</a>
<!--@endforeach-->
</div>
</div>
<style>
.tag-cloud .tags {
line-height: 2;
}
.tag {
display: inline-block;
padding: 2px 6px;
margin: 2px;
background: #f8f9fa;
color: #495057;
text-decoration: none;
border-radius: 3px;
transition: all 0.3s;
}
.tag:hover {
background: #007bff;
color: white;
}
.tag-size-1 { font-size: 12px; }
.tag-size-2 { font-size: 14px; }
.tag-size-3 { font-size: 16px; font-weight: bold; }
.tag-size-4 { font-size: 18px; font-weight: bold; }
.tag-size-5 { font-size: 20px; font-weight: bold; }
</style>
태그 관리 기능¶
관리자 태그 관리¶
class TagManager
{
/**
* 태그 통계 업데이트
*/
public function updateTagStatistics()
{
// 사용되지 않는 태그 삭제
$query = "
DELETE FROM tags
WHERE tag_srl NOT IN (
SELECT DISTINCT tag_srl FROM document_tags
)
";
executeQuery('', $query);
// 태그 사용 횟수 업데이트
$query = "
UPDATE tags t SET use_count = (
SELECT COUNT(*) FROM document_tags dt
WHERE dt.tag_srl = t.tag_srl
)
";
executeQuery('', $query);
}
/**
* 비슷한 태그 병합
*/
public function mergeSimilarTags($primaryTagSrl, $duplicateTagSrls)
{
// 중복 태그를 메인 태그로 병합
foreach ($duplicateTagSrls as $duplicateSrl) {
// 문서-태그 연결 업데이트
$args = new stdClass();
$args->primary_tag_srl = $primaryTagSrl;
$args->duplicate_tag_srl = $duplicateSrl;
executeQuery('tag.mergeDocumentTags', $args);
// 중복 태그 삭제
$args = new stdClass();
$args->tag_srl = $duplicateSrl;
executeQuery('tag.deleteTag', $args);
}
// 통계 업데이트
$this->updateTagStatistics();
}
/**
* 금지 태그 관리
*/
public function validateTag($tagName)
{
$bannedTags = array(
'스팩', '광고', '도배',
'비속어', '욕설', '불법'
);
foreach ($bannedTags as $banned) {
if (stripos($tagName, $banned) !== false) {
return false;
}
}
// 길이 제한
if (mb_strlen($tagName) > 50) {
return false;
}
// 특수문자 제한
if (preg_match('/[<>"\']/', $tagName)) {
return false;
}
return true;
}
}
태그 API¶
RESTful 태그 API¶
class TagController
{
/**
* 태그 제안 API
*/
public function procTagSuggest()
{
$query = Context::get('query');
if (strlen($query) < 2) {
return new BaseObject(-1, '검색어가 너무 짧습니다.');
}
$args = new stdClass();
$args->query = '%' . $query . '%';
$args->list_count = 10;
$output = executeQuery('tag.getTagSuggestions', $args);
if (!$output->toBool()) {
return $output;
}
$suggestions = array();
if ($output->data) {
foreach ($output->data as $tag) {
$suggestions[] = array(
'name' => $tag->tag_name,
'count' => $tag->use_count
);
}
}
$this->add('suggestions', $suggestions);
$this->setTemplatePath('');
$this->setTemplateFile('');
}
/**
* 인기 태그 조회
*/
public function getPopularTags($limit = 20)
{
$args = new stdClass();
$args->list_count = $limit;
$output = executeQuery('tag.getPopularTags', $args);
if (!$output->toBool()) {
return array();
}
// 태그 크기 계산 (1-5 단계)
$tags = $output->data;
if ($tags) {
$maxCount = max(array_column($tags, 'use_count'));
$minCount = min(array_column($tags, 'use_count'));
foreach ($tags as &$tag) {
if ($maxCount == $minCount) {
$tag->size_class = 3;
} else {
$ratio = ($tag->use_count - $minCount) / ($maxCount - $minCount);
$tag->size_class = ceil($ratio * 4) + 1;
}
}
}
return $tags;
}
}
모범 사례¶
- 사용자 경험: 직관적인 태그 입력 인터페이스
- 성능: 태그 검색과 제안 기능 최적화
- 데이터 무결성: 중복 태그 방지 및 정리
- 접근성: 키보드 내비게이션 지원
- 보안: 태그 입력 검증 및 필터링
다음 단계¶
태그 시스템을 구현했다면, 인터렉션 기능에서 더 많은 사용자 상호작용 기능을 학습하세요.