해당 Git Blog를 만들면서 어려웠던 부분, 정리하고 싶은 부분을 기록하기 위한 포스트입니다.
해당 포스팅에는 JavaScript와 정적 사이트 생성 프레임워크 중 하나인 Jekyll을 활용하여 검색 엔진을 만들어낸 과정이 설명되어있습니다.
1. 검색기능 동작범위 결정
블로거가 작성한 게시물 중에서 사용자가 원하는 게시물을 찾아내기 위해 ‘검색 기능’을 사용합니다.
자신이 만든 GitBlog에서 검색 기능을 추가하는 방법을 찾아보니 ‘구글 검색 엔진을 등록하는 방법’, ‘Lunr.js를 사용하는 방법’, ‘Simple-Jekyll-Search 플러그인’을 사용하는 방법 등 여러가지를 찾을 수 있었습니다.
하지만, 저는 구글 검색 엔진은 제 블로그에 미관상 적합하지 않다고 판단했고, 외부 라이브러리 사용은 추후에 적절한 커스터마이징을 하기 어려울 수 있다고 판단했습니다. 따라서 비슷한 이유로 플러그인도 사용하지 않기로 했습니다. 이러한 이유도 있지만 사실 그냥 한 번 만들어보고 싶어서 직접 구현해보기로 결정했습니다.
저는 우선 다른 사이트들을 둘러보면서 어느 정도의 범위까지 키워드에 대한 검색이 작동하는지 살펴봤습니다. 만약, 게시물의 본 내용까지 검색 범위로 설정된다면 게시물의 수와 탐색 범위가 비례하여 넓어지기 때문에 검색 속도에 영향을 미칠것이라고 생각하였기 때문입니다.
그렇게 내린 결론은 ‘제목’, ‘태그’, 그리고 ‘excerpt’에 존재하는 키워드에 대해서 검색이 진행되도록 범위를 설정하였습니다.
-> 따라서 모든 포스팅 excerpt에 주요 키워드를 최대한 녹여낼 수 있어야 합니다..
2.기본 동작 과정
1. 우선, 게시물의 필요한 부분만 사용하기 위해 모든 포스트의 '제목', '태그', 'excerpt'를 추출하는 JSON 파일을 생성합니다.
2. JS에서는 submit button(사용자가 검색 내용을 입력하고 클릭하는 돋보기 버튼)과 input(사용자가 검색하고자 하는 내용이 들어있는 html 태그)를 가져와서 변수를 지정합니다.
3. 이전에 생성한 파일을 이용하여 모든 게시물들을 JSON 형태로 변경합니다.
4. JSON 데이터를 순회하면서 검색 키워드와 매칭되는 부분이 있다면 해당 게시물을 배열에 담아 리턴합니다.
5. 다른 페이지에서 사용할 수 있도록 해당 리턴 값을 sessionStorage에 저장합니다.
6. 이후 검색 결과를 보여주는 페이지로 window.location.href를 넘겨줍니다.
7. Result 페이지에서 동작하는 JS는 이전에 저장한 sessionStorage에서 검색된 포스팅을 추출하여 사용자가 볼 수 있도록 html을 작성합니다.
위 동작 과정에 맞춰 작성한 제 코드를 한 번 리뷰해보겠습니다.
2-1. 게시물 Json 변환
우선, JavaScript로 현재 작성된 모든 포스팅과 관련된 정보에 접근할 수 있어야 합니다. 여기서 말한 정보는 이전에 결정한 ‘제목’, ‘태그’, 그리고 ‘excerpt’에 접근할 수 있어야합니다. Jekyll에서 사용할 수 있는 liquid 문법을 이용하여 모든 게시물을 지정한 JSON 타입에 맞도록 변경하는 부분입니다.
---
---
[
{% for post in site.posts %}
{
"title": "{{ post.title | escape }}",
"url": "{{ post.url | absolute_url }}",
"excerpt": {{ post.excerpt | strip_html | normalize_whitespace | jsonify }},
"tags": {{ post.tags | jsonify }}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
2-2. Button과 Input에 EventListener 추가
다음으로는 js파일에서 검색 기능의 Submit과 Input을 담당하는 html 요소를 호출하여 변수에 저장합니다.
var searchInput = document.getElementById('searchInput');
var submit = document.getElementById('submit');
이후 해당 변수에 EventListener를 추가하여 사용자의 지정된 동작을 감지하도록 합니다.
submit.addEventListener('click', e => {
e.preventDefault();
handleSearch(e);
});
searchInput.addEventListener('keydown', e => {
if (e.code === "Enter" || e.keyCode === 13) {
e.preventDefault();
handleSearch(e);
}
});
submit은 돋보기 기능이므로 ‘click’ 이벤트를 감지하도록 합니다.
또한, 검색어를 입력하고 엔터를 눌렀을 때 동작하는 익숙한 경험을 제공하기 위해
searchInput에 keydown ‘Enter’ 이벤트를 감지하도록 합니다.
위 코드를 보면 알겠지만, handleSearch(e)라는 함수를 호출하도록 되어있습니다. 해당 코드는 아래와 같습니다.
function handleSearch(e){
e.preventDefault(); // 폼의 기본 제출 동작 방지
var searchValue = searchInput.value.trim();
if (searchValue === '') {
return;
}
fetch('/_data/posts.json')
.then(response => response.json())
.then(data => {
let filteredResults = getlist(data, searchValue);
// 결과를 sessionStorage에 저장
sessionStorage.setItem('filteredResults', JSON.stringify(filteredResults));
// 검색 결과 페이지로 이동
window.location.href = '/search-results/';
})
// .catch(error => console.error('Error fetching data:', error));
}
해당 부분이 바로 2-1에서 생성했던 JSON 파일을 이용하여 데이터를 필터링하는 과정입니다.
searchValue 변수를 선언하여 사용자가 검색하고자하는 키워드를 저장합니다.
이후 getlist함수를 호출하고 이를 파라미터로 넘겨주면서 키워드에 매칭된 포스팅들을 배열 형태로 가져옵니다. 또한, sessionStorage를 사용하여 다른 페이지에서도 사용할 수 있도록 합니다.
여기서 생각해봐야할 지점은 우선 sessionStorage를 사용하여 다른 페이지에서도 filteredResults를 사용할 수 있도록 하였습니다.
sessionStorage는 클라이언트 측에서 모든 데이터를 처리하도록 하기 때문에 중요한 정보는 저장되면 안됩니다.
물론, 게시물의 JSON타입이 중요한 정보는 아니지만 암호화하는 방법을 사용하여 개선해볼 필요가 있어보입니다.
다음으로는 위 코드에서 선언된 getlist 함수입니다.
function getlist(data, value){
var result = [];
data.forEach(item => {
['title', 'tags', 'excerpt'].forEach(key => {
let content = item[key];
// 값이 숫자나 다른 데이터 유형일 경우 문자열로 변환
if (content !== null && content !== undefined) {
content = content.toString().toLowerCase();
value = value.toString().toLowerCase(); // 검색 대소문자 무시
if (content.includes(value)) {
if (!result.includes(item)) { // 중복 추가 방지
result.push(item);
}
}
}
});
});
return result; // 필터링된 결과를 반환
}
‘title’, ‘tags’, ‘excerpt’에 매칭되는 단어를 찾아서 존재한다면 배열에 추가하여 반환하는 부분입니다.
2-3.Result Page에 검색된 포스트 출력
Result Page에 검색된 포스트를 출력하기 위해 seesionStorage에 저장된 데이터를 가져와야 합니다.
var results = document.getElementById('results');
var filteredResults = JSON.parse(sessionStorage.getItem('filteredResults'));
if (filteredResults.length > 0) {
updateResultsDisplay(filteredResults, results);
} else {
results.innerHTML = '';
const element = document.createElement('div');
element.className = 'no-result';
element.innerHTML = `
<h2> No result found! </h2>
<div id = "no-result-message">
<span>No posts contain the searched word in their title or excerpt. The search term may be a typo.</span>
<span>If it is a typo, please try again with the correct spelling.</span>
</div>
`;
results.appendChild(element);
console.error('No filtered results found or element with id "results" not found');
}
filteredResults에 저장된 데이터를 불러와서 저장하고 위처럼 개발자가 원하는 구성에 맞춰서 어떻게 표시할지 html을 작성해서 넣으면 됩니다.
3. 발생했던 오류
우선 제 JavaScript 파일을 살펴보면 위 사진과 같이 submit.js와 view_results.js로 나눠져 있습니다.
submit은 사용자가 데이터를 입력했을 때 발생하는 이벤트를 처리하는 부분이고, view_results는 해당 이벤트의 결과를 results 페이지에 보여줄 때 동작하는 부분입니다.
따라서 제 디렉토리 구조에서는 검색 결과를 보여주는 result.html이 별도로 존재합니다.
제가 마주했던 문제는 2-3에 작성된 .innerHTML을 호출하게되면 results라는 변수를 찾아오지 못했던 것이었습니다.
문제 해결은 아주 간단했습니다. 저는 Header에 submit.js와 view_results.js 파일을 모두 넣고 호출하고 있었습니다.
어차피 Header는 두 페이지 모두 동일하게 재사용되는 부분이기 때문에 상관없다고 생각했습니다. 하지만, 실제로 실행해본 결과는 예상과 달랐고 각 스크립트 파일을 각각의 html에 나누어서 삽입해주면서 문제는 해결되었습니다. 하지만, 이 문제를 해결하는데 놀랍게도 순수 10시간 이상의 시간을 사용했습니다.
덕분에 ‘document.addEventListener(‘DOMContentLoaded’, function())’을 사용해야 모든 Html이 로드되고 스크립트가 실행된다는 정보는 뼈저리게 이해할 수 있었습니다.
또한, defer 속성을 js 호출하는 부분에 사용하면 모든 Html 문서의 파싱이 완료된 후에 이루어진다는 것도 알 수 있었습니다.
(안타깝게도 두 방법 모두 효과는 없었습니다. ㅠ_ㅠ)
4. 마무리
제가 해당 블로그의 검색 엔진을 만들면서 겪었던 과정과 어려움에 대해서 공유해봤습니다. 다음에는 또 다른 어려움인 toggle 메뉴를 만드는 과정을 공유해보겠습니다.