태그 시스템

태그 시스템

게시판에서 태그 기능을 구현하고 활용하는 방법을 학습합니다.

태그 시스템 개요

기본 개념

태그는 게시물을 분류하고 검색하기 위한 메타데이터입니다.

// 게시물에 태그 저장
$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}')">&times;</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;
    }
}

모범 사례

  1. 사용자 경험: 직관적인 태그 입력 인터페이스
  2. 성능: 태그 검색과 제안 기능 최적화
  3. 데이터 무결성: 중복 태그 방지 및 정리
  4. 접근성: 키보드 내비게이션 지원
  5. 보안: 태그 입력 검증 및 필터링

다음 단계

태그 시스템을 구현했다면, 인터렉션 기능에서 더 많은 사용자 상호작용 기능을 학습하세요.