에루샤
erusya
Back-end Developer
Web Geek
Anime Otaku
에루샤
기록
eruLabo

블로그 작업노트 15: 해시태그 최적화 작업

14 views as of December 26, 2024.

해시태그

블로그나 SNS에서 해시태그(#)는 특정 주제나 키워드에 대해 콘텐츠를 쉽게 분류하고 검색할 수 있게 도와주는 기능이다.
이런 해시태그는 콘텐츠의 대표 성질을 가지므로 일반적으로 이를 메타 데이터(Metadata)라고도 한다.

시작은 블로그같은 온라인 게시물의 메타태그로 파생되었지만 실질적인건 SNS의 해시태그로써 널리 알려져 있다.

이런 해시태그는 메타데이터의 영역을 넘어서 글쓴이의 부가적인 생각과 의견을 공유하는 하나의 영역으로도 사용이 되기도 한다.

블로그 사이트에서는 일반적으로는 해당 게시글과 비슷한 게시글을 찾게해주는 검색 링크로써의 역할이 강하나 카테고리와는 다른 느낌으로 이를 써야한다.


내 블로그에서 해시태그

나도 처음에는 그냥 내 생각이나 글에서 사용되는 키워드 단어를 주르륵 나열해두는 식으로 사용했다.
하지만 막상 해시태그로 검색할일도 없거니와 이미 블로그 본문에 텍스트라는 대량의 정보가 있는 상황에서 해시태그에 눈이 안가는건 어쩔 수 없는 상황이었다.

그래서 이걸 없애야되나 유지해야되나 고민하다가 블로그 검색기능에 해시태그 내용을 참조해서 검색하면 유의미한 메타 데이터가 되지 않을까 생각했다.

public function showSearch(Request $request)
{
    // Retrieve the keyword from the request
    $keyword = $request->input('keyword');

    // Initialize the query
    $posts = Post::query();
    
    // Check if keyword is present
    if (!empty($keyword)) {
        if (strpos($keyword, 'coll:') === 0) {
            // "coll:"로 시작하는 키워드 검색
            $collectionKeyword = str_replace('coll:', '', $keyword);
    
            // collection 컬럼에서 검색
            $posts = Post::where('collection', 'LIKE', "%{$collectionKeyword}%")
                ->orderByDesc('created_at')
                ->paginate()->appends($request->input());
        } elseif (strpos($keyword, 'cate:') === 0) {
            // "cate:"로 시작하는 키워드 검색
            $categoryKeyword = str_replace('cate:', '', $keyword);
    
            // category 컬럼에서 검색
            $posts = Post::where('category', 'LIKE', "%{$categoryKeyword}%")
                ->orderByDesc('created_at')
                ->paginate()
                ->appends($request->input());
        } else {
            // Normal search for title, if no prefix is present
            $posts = Post::where('title', 'LIKE', "%{$keyword}%")
                ->orWhereRaw('FIND_IN_SET(?, keywords)', [$keyword])
                ->orderByDesc('created_at')
                ->paginate()
                ->appends($request->input());
        }
    } else {
        $posts = [];
    }

    // Return the results to the view
    return view('search', compact('posts'));
}Copy

위는 내 블로그 사이트의 검색 메소드의 구성이다.

coll:콜렉션명, cate:카테고리명 을 제외한 나머지 검색에 대해서 기존에는 where('title', 'LIKE', "%{$keyword}%") 처럼 제목 검색만 실행했다.
하지만 여기에 키워드 검색또한 검색영역에 포함시켜 검색이 되게 변경했다.


상단 검색창에서 "aws"로 검색하면 나오는 화면

이 스크린샷이 위 검색 로직을 증명한다.
"aws"로 검색하면 첫번째 게시글은 제목에 "AWS" 문자열이 포함되어있어서 검색이되고, 두번째 게시글은 해시태그에 "#AWS"가 있어서 검색되었음을 알 수 있다.

왜 이런 번거로운 작업을 했냐 묻는다면 본문 전체검색은 데이터양이 많아질수록 데이터베이스 부하가 많기 때문에 일반적으로 메타데이터 검색을 지향해야하고, 해시태그는 보통 본문의 대표적인 키워드를 적어두는 곳이기 때문에 해시태그 = 본문 키워드로 인지시켜 데이터베이스 부하를 줄일 수 있기 때문이다.

결국은 개개인이 어떤 기준으로 시스템을 기획하고 구성하냐에 대한 차이라고 볼 수 있다.


글쓰기 화면에서 해시태그

이런 해시태그는 일관성과 연동성을 유지시키려면 가능한한 기존에 사용했던 해시태그를 재사용하는것이 좋다.

무슨소리냐하면 내가 이전글은 "AWS"라고 쓰고 다음글은 "AmazonWebService"라고쓰면 둘다 똑같이 아마존 웹 서비스를 지칭하는 단어지만 "AWS"라고 검색하면 #AWS 해시태그만 검색될 것이다.
반대로 "Amazon"이라고 검색하면 #AWS는 검색결과에 뜨지않을 것이다.

이를 방지하기위해 기존에 썻던 해시태그를 사용자에게 보여주면서 가능하면 기존 해시태그를 사용하고, 그거게 맞는게 없을 시 새로 해시태그를 입력하는 방식으로 UX를 구성해볼 수 있다.


해시태그 입력하는 인풋창에 포커스를 주변 나오게 설계

위 스크린샷은 위의 내용이 녹아있는 내 글쓰기 화면이다.

해시태그를 입력하는 란을 활성화하면 글쓰기 카테고리별로 등록된 해시태그를 나열해서 보여주며, 현재 input창에 등록된 내용을 추출해서 활성화된 해시태그에 active 효과를 주는 기능으로 만들었다.

그리고 백단에는 아래와 같은 모델 메소드를 통해 카테고리별로 해시태그를 추출해볼 수 있다.

public static function getKeywordsByCategory()
{
    // Fetch 'keywords' and 'category' columns, grouped by category
    $categoriesWithKeywords = self::query()
        ->selectRaw("category, GROUP_CONCAT(keywords SEPARATOR ',') as all_keywords")
        ->groupBy('category')
        ->get()
        ->mapWithKeys(function ($item) {
            // Split keywords by comma and trim whitespace
            $keywordsArray = array_map('trim', explode(',', $item->all_keywords));

            // Remove duplicates and sort by frequency
            $keywordCounts = array_count_values($keywordsArray);
            arsort($keywordCounts);

            return [$item->category => array_keys($keywordCounts)];
        });

    return $categoriesWithKeywords;
}Copy


이런 메소드를 통해 반환되는 값은 아래와 같다.

[
    "개발" => ["Laravel", "php", "AWS", "Nginx", ...],
    "애니" => ["판타지", "이세계", "슬로우라이프", ...],
    "기록" => ["블로그", "그림", ...],
]Copy


그러면 이를 뷰단에서 아래와 같이 파싱해서 적용할 수 있다.

<div class="grp_keyword_input">
    <input type="text" class="form-control" id="keywords" name="keywords" value="{{ $post->keywords ?? '' }}" onchange="updateActiveKeywords(this)" required>
    <div class="grp_keywords">
        @foreach(\App\Models\Post::getKeywordsByCategory() as $category => $keywords)
            <div class="grp_keyword_category mb-2">
                <div class="category">{{ $category }}</div>
                @foreach($keywords as $keyword)
                    <span role="button" class="keyword" onclick="appendKeyword('{{ $keyword }}')" data-keyword="{{ $keyword }}">#{{ $keyword }}</span>
                @endforeach
            </div>
        @endforeach
    </div>
</div>Copy

더불어 태그를 클릭해서 input에 기록하거나, 반대로 input에 태그가 기록이되면 아래 태그에 active 클래스를 부여하는 스크립트는 아래와 같이 구현할 수 있다.

function appendKeyword(keyword) {
    // Get the input element by ID
    const input = document.getElementById('keywords');

    // Get the current value of the input
    let currentValue = input.value.trim();

    // Normalize the current value
    if (currentValue) {
        // Handle cases where the input is just commas or improperly formatted
        currentValue = currentValue
            .replace(/,+/g, ',')          // Replace multiple commas with a single comma
            .replace(/(^,|,$)/g, '')     // Remove leading/trailing commas
            .replace(/\s*,\s*/g, ', ');  // Normalize spaces around commas

        // Check if the value is empty after normalization (e.g., only commas were present)
        if (!currentValue) {
            input.value = keyword; // Set the keyword as the new value
        } else {
            // Split current value into an array by comma
            const keywordsArray = currentValue.split(',').map(k => k.trim());

            // Prevent adding duplicate keywords
            if (!keywordsArray.includes(keyword)) {
                // Add the new keyword to the input value
                input.value = keywordsArray.join(', ') + ', ' + keyword;
            }
        }
    } else {
        // If no current value, set the input value to the keyword
        input.value = keyword;
    }

    // Trigger 'change' event and set focus
    input.dispatchEvent(new Event('change'));
    setTimeout(() => input.focus(), 0);
}

function updateActiveKeywords(inputElement) {
    // 입력 필드의 키워드들을 가져오기
    const inputKeywords = inputElement.value
        .split(',') // 쉼표로 분리
        .map(keyword => keyword.trim()) // 각 키워드 공백 제거
        .filter(keyword => keyword); // 빈 값 제거

    // grp_keywords 내부의 모든 .keyword 요소 가져오기
    const keywordElements = document.querySelectorAll('.grp_keywords .keyword');

    // 모든 키워드 요소에 대해 검사
    keywordElements.forEach(element => {
        const keyword = element.getAttribute('data-keyword').trim(); // data-keyword 값 가져오기

        if (inputKeywords.includes(keyword)) {
            element.classList.add('active'); // 입력 필드에 있는 키워드라면 .active 클래스 추가
        } else {
            element.classList.remove('active'); // 그렇지 않다면 .active 클래스 제거
        }
    });
}
updateActiveKeywords(document.getElementById('keywords'));Copy


뭐 일단 이정도로 마무리 해보는걸로... 끝!

#블로그 #해시태그
0 개의 댓글
×