Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[문서] 검색 기능 구현 (feat.Gatsby Link API 동작 원리) #70

Closed
jaem1n207 opened this issue Dec 4, 2023 · 1 comment
Closed
Assignees
Labels
documentation Improvements or additions to documentation enhancement New feature or request

Comments

@jaem1n207
Copy link
Owner

jaem1n207 commented Dec 4, 2023

요약

많은 데이터를 기반으로 원하는 정보를 빠르고 효율적으로 찾을 수 있도록 하는 데 중점을 두어 검색 기능을 개발했습니다.

마크다운을 파싱하여 검색에 필요한 데이터를 빌드 타임에 생성하고� FlexSearch 패키지를 사용해 검색 인덱스를 생성했습니다.

이러한 방식으로, 사용자는 원하는 정보를 쉽게 찾을 수 있도록 최대한 많은 데이터를 기반으로 검색을 할 수 있게 됩니다. 또한 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 구조화했습니다. 이를 통해 사용자는 원하는 정보를 빠르고 효율적으로 찾을 수 있습니다.

추가로 구현 과정에서 궁금했던 Gatsby에서 Link 컴포넌트는 어떻게 사용자가 페이지를 �방문할 것이라 판단해서 필요한 리소스를 미리 로드하여, 페이지를 빠르게 로드하는지 내부 구현 코드를 천천히 살펴보며 파악한 과정도 작성했습니다.

동기 부여

작업의 핵심은 사용자가 원하는 정보를 쉽게 찾을 수 있도록 검색 기능을 구현하는 것입니다. 이를 위해 글의 제목전체적인 내용을 검색에 활용할 데이터로 지정했습니다. 또한, 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 구조화했습니다.

기본적으로 대부분의 검색은 글의 제목, 키워드, 요약된 본문 3가지 정도의 데이터를 기반으로 검색합니다. 그리고 이에 보여주는 검색 결과도 찾는 키워드에 대한 정보가 있는 섹션이 아닌, 무조건 해당 글로 이동시켜주기만 할 뿐, 그 이상의 동작을 하지 않습니다.
예를 들어, javascript의 filter 함수를 사용하는 예시를 보고 싶어 사용자가 javascript filter example 라는 검색어를 입력하면 검색 결과로 javascript filter 글만 나올 뿐, example 섹션으로 사용자를 이동시켜주지 않습니다.
최악의 경우는 이와 관련된 글이 있음에도 설정해둔 키워드제목을 살짝 벗어나 검색 결과로 나오지 않는 것도 있겠죠.

위의 이유가 검색 기능을 직접 개발하고자 하는 가장 큰 동기 부여였고, 이건 내가 만드는 글이고 웹사이트이므로 언제든지 직접 자유롭게 검색 결과를 다루고자 하는 마음도 컸습니다.

검색 데이터 생성

먼저, 빌드 타임에서 마크다운 파일을 파싱하여 검색에 필요한 데이터를 생성합니다. 이 데이터는 제목과 각 섹션의 내용을 포함하며, 각 섹션은 해당 섹션의 제목과 섹션의 url을 key로 가지는 객체로 구성됩니다. 이렇게 만들어진 데이터는 사용자가 검색하려 할 때 사용됩니다. 따라서 처음 페이지 로드할 땐 굳이 다운 받지 않아도 됩니다. public/lazy-dev-data-${localeKey}.json 파일에 데이터를 저장해서 따로 분리해두겠습니다.

여기서 데이터를 구성할 때 주의할 점이 2가지 있습니다.

  1. 검색에 활용할 가치가 없는 데이터는 포함시키지 않아야 합니다. 보통 이런 데이터 종류는 다음과 같습니다:
  • pre 요소 내 콘텐츠
  • img 요소 내 콘텐츠
  • input 요소 내 콘텐츠
  • 참고 섹션 -> 참고한 웹 사이트의 주소만 콘텐츠로 지니고 있기에 검색에 활용할 데이터로써 가치가 없음
  1. 첫 섹션(heading)이 나오기 전에 작성된 콘텐츠는 key를 빈 문자열로 하고 저장해둡니다.

lazy-dev/html-parser.ts

Lines 1 to 73 in 3dbab7e

import { Element, load } from 'cheerio';
import type { StructuredData } from '@/common/types/types';
/**
* 전달받은 마크다운 콘텐츠 구조를 검색에 유용한 구조화된 데이터로 추출합니다.
*/
export function extractContentByHeading(html: string): StructuredData {
const $ = load(html);
const contentByHeading: StructuredData = {};
$('img, pre, input').remove();
let isReferenceSection = false; // '참고' 섹션 여부
// eslint-disable-next-line unused-imports/no-unused-vars
let hasFirstHeadingFound = false; // 첫 heading 요소 발견 여부
// 첫 heading 요소 이전의 콘텐츠를 처리합니다.
const processPreHeadingContent = () => {
let content = '';
const bodyChildren = $('body').children();
for (let i = 0; i < bodyChildren.length; i++) {
const child = bodyChildren[i];
if (!$(child).is('h1, h2, h3, h4, h5, h6')) {
content += $(child).text() + ' ';
} else {
break;
}
}
return content.trim();
};
// heading 요소의 콘텐츠를 다음 heading 요소를 만날 때까지 추출합니다.
const processContent = (element: Element) => {
let content = '';
let nextElem = $(element).next();
while (nextElem.length && !nextElem.is('h1, h2, h3, h4, h5, h6')) {
content += nextElem.text() + ' ';
nextElem = nextElem.next();
}
return content.trim();
};
// 첫 heading 요소 이전의 콘텐츠를 처리합니다.
contentByHeading[''] = processPreHeadingContent();
$('h1, h2, h3, h4, h5, h6').each((i, element) => {
const heading = $(element);
const headingText = heading.text();
const headingId = heading.attr('id');
if (headingText.includes('참고')) {
isReferenceSection = true;
return; // '참고' 문자열이 포함된 heading 요소는 건너뜁니다.
}
if (isReferenceSection) {
isReferenceSection = false;
return;
}
hasFirstHeadingFound = true;
if (headingId) {
const key = `${encodeURIComponent(headingId)}#${headingId}`;
contentByHeading[key] = processContent(element);
}
});
return contentByHeading;
}

lazy-dev/gatsby-node.ts

Lines 189 to 201 in 3dbab7e

const indexes: SearchData = {};
posts.map((edge) => {
const { node } = edge;
const title = node.frontmatter?.title!;
const route = node.fields?.slug!;
const contentByHeading = extractContentByHeading(node.html!);
indexes[route] = { title, data: contentByHeading };
});
// 마크다운의 frontmatter.locale에 따라서 분기 처리 지원 예정
const localeKey = 'ko';
writeFileSync(`public/lazy-dev-data-${localeKey}.json`, JSON.stringify(indexes, null, 2));

검색 인덱스 생성

이제 검색에 필요한 데이터는 마련해두었습니다. 그러나 이렇게 생성된 데이터를 검색할 때 모든 요소를 순회하지 않고도 빠르게 검색할 수 있도록 인덱스를 생성하는 것과 필드 가중치를 부여하는 것을 부여하는 건 꽤 까다롭습니다. 따라서 이 부분은 잘 만들어진 외부 패키지의 힘을 빌리는 게 시간적으로나 사용자에게도 좋습니다.

검색 인덱스는 FlexSearch 패키지를 사용하여 생성합니다. 이 패키지는 문서의 내용을 토큰화하여 빠른 검색을 가능하게 합니다. 먼저 검색할 데이터를 불러온 후(loadIndexesImpl 함수), 페이지 인덱스섹션 인덱스를 생성합니다. 페이지 인덱스는 페이지의 제목과 내용을 인덱싱하며, 섹션 인덱스는 각 섹션의 제목과 내용을 인덱싱합니다.

const loadIndexesImpl = async (locale: string) => {
// 빌드 타임 때 생성한 검색 데이터를 가져옵니다.
const searchData = await fetch(`/lazy-dev-data-${locale}.json`).then<SearchData>((res) =>
res.json(),
);
const pageIndex: PageIndex = new FlexSearch.Document({
// 문서에선 인코더 함수로 한글 문자열 지원을 하는 것으로 파악되지만, 잘 동작하지 않습니다 https://github.com/nextapps-de/flexsearch#cjk-word-break-chinese-japanese-korean
cache: 100,
tokenize: 'full',
document: {
id: 'id',
index: 'content',
store: ['title'],
},
context: {
resolution: 9,
depth: 2,
bidirectional: true,
},
});
const sectionIndex: SectionIndex = new FlexSearch.Document({
cache: 100,
tokenize: 'full',
document: {
id: 'id',
index: 'content',
tag: 'pageId',
store: ['title', 'content', 'url'],
},
context: {
resolution: 9,
depth: 2,
bidirectional: true,
},
});
// 각 페이지와 섹션에 대해 인덱스를 추가합니다.
let pageId = 0;
for (const [route, StructuredData] of Object.entries(searchData)) {
let pageContent = '';
++pageId;
for (const [key, content] of Object.entries(StructuredData.data)) {
const [headingId, headingValue] = key.split('#');
const url = route + (headingId ? '#' + headingId : '');
const title = headingValue || StructuredData.title;
const paragraphs = content.split('\n');
sectionIndex.add({
id: url,
url,
title,
pageId: `page_${pageId}`,
content: title,
...(paragraphs[0] && { display: paragraphs[0] }),
});
for (let i = 0; i < paragraphs.length; i++) {
sectionIndex.add({
id: `${url}_${i}`,
url,
title,
pageId: `page_${pageId}`,
content: paragraphs[i],
});
}
pageContent += ` ${title} ${content}`;
}
pageIndex.add({
id: pageId,
title: StructuredData.title,
content: pageContent,
});
}
indexes[locale] = [pageIndex, sectionIndex];
};

💡 검색 인덱스란

검색을 빠르게 하기 위해 데이터를 특정한 구조로 정리한 것입니다. 예를 들어, 책의 색인에서 특정 단어가 어떤 페이지에 있는지 빠르게 찾을 수 있도록 하는 것과 비슷합니다. 검색 인덱스를 사용하면 데이터베이스에서 모든 데이터를 순차적으로 검색하는 것보다 훨씬 빠르게 원하는 데이터를 찾을 수 있습니다.

왜 FlexSearch인가

큰 이유는 없습니다. 트윗에서 FlexSearch가 검색 패키지들 중 속도 및 메모리 측면에서 가장 뛰어나다는 벤치마크를 보아서 관심을 가지고 있었습니다. 이전에는 Fuse를 활용해서 검색 기능을 만들어 봤었기 때문에 이번엔 새로운 걸 다뤄보고 싶었던 가장 컸습니다.

FlexSearch 와 같은 패키지들은 어떻게 빠른 검색을 가능하게 할까요?

FlexSearch는 문서의 내용을 토큰화하기 위해 내부적으로 다양한 알고리즘을 사용합니다. 토큰화란 텍스트 데이터를 작은 단위인 '토큰'으로 분리하는 과정입니다. 예를 들어, "Hello, World!"라는 문장을 토큰화하면 "Hello"와 "World"라는 두 개의 토큰으로 분리됩니다. FlexSearch는 이러한 토큰화 과정을 통해 문서의 내용을 분석하고, 각 토큰이 어떤 문서에 속하는지를 인덱스에 저장합니다.
검색이 이루어질 때, 사용자가 입력한 검색어도 토큰화되고(한글은 흠...😇), 이 토큰들이 인덱스에서 어떤 문서와 매칭되는지를 찾습니다. 이렇게 하면 검색어와 관련된 문서를 빠르게 찾을 수 있습니다. 이러한 과정을 통해 FlexSearch는 대량의 텍스트 데이터에서도 빠른 검색을 가능하게 합니다.

검색 인덱스 불러와 할당

loadIndexes 함수는 검색 인덱스를 불러오는 역할을 합니다. 검색 인덱스를 생성하는 loadIndexesImpl 함수를 호출하고, 그 결과를 캐싱하여 재사용합니다. 이를 통해 검색 성능을 향상시키고, 불필요한 재계산을 방지하도록 합니다.

// 검색 인덱스 로드를 위한 Promise를 캐싱하는 객체
const loadIndexesPromises = new Map<string, Promise<void>>();
const loadIndexes = (locale: string): Promise<void> => {
const key = `@${locale}`;
if (loadIndexesPromises.has(key)) {
return loadIndexesPromises.get(key)!;
}
const promise = loadIndexesImpl(locale);
loadIndexesPromises.set(key, promise);
return promise;
};

검색

사용자가 검색어를 입력하면, 이제 검색 인덱스를 불러오고 검색을 시작합니다. 페이지 인덱스섹션 인덱스에서 검색어와 일치하는 결과를 찾아 반환합니다. 결과는 페이지 제목이 매칭되는 횟수가 많은 순서대로 정렬되며, 같은 페이지 내에서는 섹션의 순서대로 정렬되도록 했습니다. 실제 코드는 더 길지만 간략한 코드로 보면 아래와 같습니다:

const doSearch = (search: string) => {
  // 페이지 인덱스와 섹션 인덱스를 가져옵니다.
  const [pageIndex, sectionIndex] = indexes[locale];

  // 검색어와 일치하는 페이지 결과를 찾아 반환합니다.
  const pageResults = pageIndex.search(...);

  // 검색 결과를 저장하는 배열입니다. 각 결과는 페이지 인덱스와 섹션 인덱스에서 찾은 결과를 포함하며,
  // 각 페이지와 섹션의 제목, 내용, URL 등의 정보를 포함합니다.
  const results = [];
  // 페이지 제목이 검색어와 일치하는 횟수를 저장하는 객체입니다. 
  // 결과를 정렬할 때 사용되며 페이지 제목이 검색어와 많이 일치하는 결과가 먼저 나오도록 하기 위함입니다.
  const pageTitleMatches: Record<number, number> = {};

  // 페이지 인덱스에서 검색어와 일치하는 결과를 찾습니다. 각 결과에 대해, 섹션 인덱스에서 
  // 해당 페이지의 섹션 중 검색어와 일치하는 결과를 찾습니다.
  for (let i = 0; i < pageResults.length; i++) {
    ...
    const sectionResults = sectionIndex.search(...);
    
    // 섹션 인덱스에서 검색어와 일치하는 결과를 찾습니다. 각 결과는 results 배열에 추가됩니다.
    for (let j = 0; j < sectionResults.length; j++) {
      ...
      results.push({ page_rk: i, section_rk: j, url, title, content });
    }
  }
  
  // 페이지 제목이 검색어와 많이 일치하는 순서, 그리고 같은 페이지 내에서는 섹션의 순서대로 정렬
  const prioritizedResults = results.sort(...);
  // 정렬된 결과를 사용자에게 보여줄 형태로 변환합니다.
  const displayableResults = prioritizedResults.map((result) => ({
      id: `${result._page_rk}_${result._section_rk}`,
      route: result.route,
      prefix: result.prefix,
      children: result.children,
    }));
    
  setResults(displayableResults);
}

const onChange = async (value: string) => {
  setSearchQuery(value);

  if (loading) return;

  if (!indexes[locale]) {
    setLoading(true);
    try {
      await loadIndexes(locale);
    } catch (error) {
      console.log(error);
      setError(true);
    }
    setLoading(false);
  }

  doSearch(value);
};

이제 사용자는 원하는 정보를 쉽게 찾을 수 있도록 최대한 많은 데이터를 기반으로 검색을 할 수 있게 되었습니다. 또한 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 작업해두었습니다.
이를 사용자에게 결과 화면으로 보여주기만 하면 됩니다.

결과 화면 보여주기

검색어에 해당하는 결과가 없을 때 보여주는 UI를 구성해보기

아직 블로그에 게시한 글이 많이 없기 때문에 사용자가 찾는 검색어에 대한 결과가 없을 수도 있습니다. 이 경우, 사용자가 원하는 주제에 대해 글 작성을 요청할 수 있는 페이지로 연결해주도록 UI를 구성했습니다.

empty-result.mov

검색 결과 화면을 담당하는 Search

이렇게 생성한 데이터를 활용해 사용자에게 결과 화면으로 보여주는 건 Search 컴포넌트가 담당합니다.
검색어를 입력하면 검색 결과를 보기 좋게 다듬어 사용자에게 리스트 형태로 보여줍니다.
컴포넌트 이름에서 알 수 있듯, Select가 아닌 Search이기 때문에 첫 번째 요소가 반드시 자동으로 선택되는 로직은 없습니다. 그저 사용자가 검색어를 입력하고 마우스 또는 키보드 입력을 통해 선택하면 됩니다. 그러면 사용자가 현재 선택한 요소를 알 수 있도록 하이라이트 해주는 컴포넌트가 필요하겠네요.
추가로 굳이 작업할 필요는 없겠지만 좋은 경험을 제공하기 위해선 없어선 안 될 기능인 검색어와 일치하는 텍스트를 하이라이트 해주는 로직도 필요했습니다. 아마 잘 만들어진 패키지가 있겠지만 직접 만드는 게 더 재밌을 것 같아 구현해보았습니다.
또한 각 리스트는 Gatsby에서 제공하는 Link 컴포넌트를 활용해 페이지를 빠르게 탐색할 수 있도록 합니다.

아래 코드는 검색 결과를 출력하는 Search 컴포넌트에 대한 코드입니다:

const isSearchItem = (el?: HTMLElement) => {
if (!el) return false;
return !!el.attributes.getNamedItem('data-search-anchor-item');
};
const Search = ({ value, onChange: _onChange, loading, error, results }: SearchProps) => {
// 현재로선 search 컴포넌트는 tablet 사이즈 이하인 기기에선 보이지 않습니다.
// 그러나 추후 tablet 사이즈 이하인 기기도 검색을 사용할 수 있도록 지원할 예정이기에 아래 조건을 추가합니다.
const displayKbd =
!window.__LAZY_DEV_DATA__.detectDevice.isTouch &&
window.__LAZY_DEV_DATA__.detectDevice.isDesktop;
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLLIElement>(null);
const ulRef = useRef<HTMLUListElement>(null);
const { rect, setRect } = useRect();
const [showHighlight, { on: onShowHighlight, off: onHideHighlight }] = useBoolean(false);
const [showResults, { on: onShowResults, off: onHideResults }] = useBoolean(false);
const renderResults = Boolean(value) && showResults;
const [preventHover, setPreventHover, preventHoverRef] = useCurrentState(false);
const hoverHandler = (e: MouseEvent<HTMLAnchorElement>) => {
if (preventHover) return;
if (!isSearchItem(e.target as HTMLAnchorElement)) return;
(e.target as HTMLAnchorElement).focus();
};
const focusHandler = (e: FocusEvent<HTMLAnchorElement>) => {
if (!isSearchItem(e.target as HTMLAnchorElement)) return;
setRect(e, () => ulRef.current);
onShowHighlight();
};
const blurHandler = () => {
onHideHighlight();
};
const focusTo = (child: HTMLAnchorElement) => {
if (!isSearchItem(child)) return;
setPreventHover(true);
/**
* 현재 이벤트 루프에서 발생하는 모든 동기적인 작업이 완료된 후(버블링 완료)에 호출되도록 합니다.
* 한글을 입력 처리와 child.focus()가 동시에 발생하면, 마지막 글자가 중복으로 입력되는 문제가 있기 때문입니다.
* @see https://github.com/jaem1n207/lazy-dev/issues/66
*/
setTimeout(() => child.focus(), 0);
};
const focusElement = (direction: 'next' | 'prev') => {
if (!ulRef.current) return;
const children = Array.from(getElements<HTMLAnchorElement>('a', ulRef.current));
if (children.length === 0) return;
const index = children.findIndex((child) => child === document.activeElement);
if (direction === 'next') {
if (index === -1 || index + 2 > children.length) return focusTo(children[0]);
focusTo(children[index + 1]);
} else {
if (index === -1 || index - 1 < 0) return focusTo(children[children.length - 1]);
focusTo(children[index - 1]);
}
};
const focusNextElement = () => focusElement('next');
const focusPrevElement = () => focusElement('prev');
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
_onChange(value);
value ? onShowResults() : onHideResults();
};
const finishSearch = () => {
inputRef.current?.blur();
ulRef.current?.scrollTo(0, 0);
_onChange('');
onHideResults();
onHideHighlight();
};
useEffect(() => {
// 키보드로 요소에 포커스를 주면, 마우스로 요소에 hover가 되지 않도록 합니다.
const eventHandler = () => {
if (!preventHoverRef.current) return;
setPreventHover(false);
};
document.addEventListener('mousemove', eventHandler);
return () => {
document.removeEventListener('mousemove', eventHandler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useHotkeys(`${Key.Meta} + k`, () => inputRef.current?.focus(), {
preventDefault: true,
});
useHotkeys(Key.ArrowDown, focusNextElement, {
preventDefault: true,
enableOnFormTags: ['INPUT'],
enabled: renderResults,
});
useHotkeys(Key.ArrowUp, focusPrevElement, {
preventDefault: true,
enableOnFormTags: ['INPUT'],
enabled: renderResults,
});
useHotkeys(Key.Escape, finishSearch, {
enableOnFormTags: ['INPUT'],
enabled: renderResults,
});
return (
<div className="relative w-256pxr tablet:hidden foldable:w-auto">
{renderResults && (
<div
className="fixed inset-0 z-10 cursor-zoom-out"
onClick={finishSearch}
onKeyDown={finishSearch}
role="button"
tabIndex={-1}
/>
)}
<div className="relative flex flex-none items-center gap-2pxr rounded-lg bg-gray-200 px-8pxr py-4pxr dark:bg-gray-50/10">
<input
ref={inputRef}
className="z-20 flex w-full flex-shrink flex-grow basis-auto appearance-none bg-transparent text-sm -outline-offset-2 transition-colors placeholder-shown:line-clamp-1 focus-within:outline-none focus-visible:outline-none tablet:text-base"
value={value}
onChange={onChange}
placeholder="주제, 내용 검색"
/>
<ClientOnly>
{displayKbd && (
<div className="select-none">
{value ? <Kbd>ESC</Kbd> : <Kbd keys="command">K</Kbd>}
</div>
)}
</ClientOnly>
</div>
{renderResults && (
<ul
ref={ulRef}
className="absolute right-0 top-full z-20 mt-8pxr max-h-400pxr w-screen max-w-lg overflow-auto overscroll-contain scroll-smooth rounded-xl border border-border-primary bg-bg-primary py-10pxr shadow-xl"
>
{error ? (
<div className="px-12pxr py-2pxr">
검색할 데이터를 가져오지 못했어요
<br />
문제가 계속 발생하면{' '}
<Anchor
className="focus-primary rounded-md bg-primary px-4pxr py-2pxr text-14pxr text-text-secondary"
href={getGithubIssueUrl({
title: `검색 기능이 제대로 작동하지 않아요. 확인해주세요! (검색어: \`${value}\`)`,
labels: 'bug',
})}
external
>
여기
</Anchor>
에 남겨주세요!
</div>
) : loading ? (
<div className="animate-pulse px-12pxr py-2pxr">
검색할 데이터를 불러오는 중이에요...
</div>
) : results.length === 0 ? (
<div className="px-12pxr py-2pxr">
<strong className="text-primary">&apos;{value}&apos;</strong>에 대한 검색결과가 없어요
<br />
<strong className="text-primary">&apos;{value}&apos;</strong>에 대한 내용이 궁금하다면{' '}
<Anchor
className="focus-primary rounded-md bg-primary px-4pxr py-2pxr text-14pxr text-text-secondary"
href={getGithubDiscussionUrl({
discussionId: DiscussionIds.TopicIdea,
})}
external
>
여기
</Anchor>
에 남겨주세요!
</div>
) : (
<>
<Highlight rect={rect} visible={showHighlight} />
{results.map(({ children, id, route, prefix }) => {
return (
<Fragment key={id}>
{prefix}
<li
ref={listRef}
className="mx-8pxr list-none break-words rounded-md text-gray-800 dark:text-slate-100"
>
<Link
to={route}
className="relative block scroll-m-1 px-10pxr py-8pxr transition-colors focus-visible:text-primary focus-visible:outline-none"
onClick={finishSearch}
onMouseOver={hoverHandler}
onFocus={focusHandler}
onBlur={blurHandler}
data-search-anchor-item
>
{children}
</Link>
</li>
</Fragment>
);
})}
</>
)}
</ul>
)}
</div>
);
};

Gatsby에서 제공하는 Link 컴포넌트는 어떻게 페이지를 빠르게 탐색하도록 할까요?

Link 컴포넌트의 동작 과정을 보면 Gatsby를 사용하면 페이지가 빠르게 로드되는 것처럼 느껴지는 이유를 알 수 있습니다.
Gatsby는 페이지 단위로 청크를 생성해두고 사용자가 특정 페이지에 방문할 것이라 판단되면 이 작은 코드 조각들을 미리 가져옵니다. 가져올 파일을 작게 쪼개어 놓았기 때문에 다운 받는 속도가 빠르겠죠. 이렇게 미리 확보해 놓은 리소스를 사용하여 페이지를 즉시 표시할 수 있는 것입니다.

Gatsby의 Link API 핵심 구현

Gatsby의 Link는 내부적으로 어떻게 구현이 되어 있길래 이런 과정을 가능하게 한 걸까요? Gatsby 코드를 하나씩 살펴보며 알아가보겠습니다. 크게 두 가지 클래스를 보면 됩니다.

  1. 프리패치 트리거 역할을 하는 GatsbyLink 클래스
  2. 페이지 데이터를 불러오는 역할을 하는 ProdLoader 클래스

프리패치를 위한 사전 데이터 준비 작업

우선 사전에 프리패치를 하기 위한 데이터를 준비하는 작업이 필요합니다. Gatsby는 페이지별로 필요한 청크 목록을 page-data.json이란 파일을 생성해 데이터를 저장합니다. 빌드 결과물이 저장되는 public 폴더는 아래와 같은 구조를 가집니다:

/public
  /page-data
    /home
      /page-data.json
    /about
      /page-data.json
  /component---src-pages-home-tsx-2924016db919bf7aa502.js
  /component---src-pages-about-tsx-c2d770e505fff20bd4f6.js

두 개 페이지(home, about)가 있고 각 페이지가 로딩할 js 파일이 생성됩니다. 그리고 화면과 청크의 매핑 정보를 담은 page-data.json 파일을 page-data 폴더에 담습니다. 이 작업이 프리로드와 프리패치를 하기 위한 준비 작업이라고 볼 수 있습니다.

프리패치를 트리거하는 GatsbyLink 클래스

사용자가 방문할 것을 어떻게 예측할 수 있을까요? Gatsby의 Link 컴포넌트는 프리패치를 두 가지 시점에 발생시킵니다.

  • Link 컴포넌트가 화면에 들어왔을 때
    • 해당 컴포넌트가 마운트되고 화면에 들어오면(inViewPort) 로더에 경로를 전달(enqueue)합니다. 로더는 전달 받은 경로를 이용해 프리패치 작업을 시작합니다. 이때 다운 받는 리소스에 대한 우선순위는 낮게 설정됩니다. 아무래도 유휴시간에 다운 받도록 해야 하는 게 좋을테니까요.
// packages/gatsby-link/src/index.js

// IntersectionObserver(웹 페이지의 특정 요소가 뷰포트 내에 있는지 감지하는 API) 인스턴스를 생성하고, 
// 요소가 뷰포트 내에 들어왔을 때 콜백 함수를 호출하도록 설정합니다
// 이 콜백 함수는 __prefetch 함수를 호출해 페이지 데이터를 미리 가져옵니다
const createIntersectionObserver = (el, cb) => {
  const io = new window.IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (el === entry.target) {
        cb(entry.isIntersecting || entry.intersectionRatio > 0)
      }
    })
  })
  
  io.observe(el)
  
  return { instance: io, el }
}

// 요소가 뷰포트 내에 들어왔을 때 호출 되는 콜백입니다
// ___loader.enqueue 를 호출해 페이지 데이터를 미리 가져옵니다. 해당 로더에 대한 부분은 아래에서 더 자세히 알아보겠습니다
_prefetch() {
  let currentPath = window.location.pathname + window.location.search

  if (this.props._location && this.props._location.pathname) {
    currentPath = this.props._location.pathname + this.props._location.search
  }

  const rewrittenPath = rewriteLinkPath(this.props.to, currentPath)
  const parsed = parsePath(rewrittenPath)

  const newPathName = parsed.pathname + parsed.search

  if (currentPath !== newPathName) {
    return ___loader.enqueue(newPathName)
  }

  return undefined
}

// reach-router 에서 제공하는 Link 컴포넌트에 대한 참조를 처리합니다
// IntersectionObserver를 설정하는 데 사용되고 있습니다. 또한 innerRef prop이 제공되면 이를 처리하는 로직도 포함하고 있네요
handleRef(ref) {
  if (
    this.props.innerRef &&
    Object.prototype.hasOwnProperty.call(this.props.innerRef, `current`)
  ) {
    this.props.innerRef.current = ref
  } else if (this.props.innerRef) {
    this.props.innerRef(ref)
  }

  if (this.state.IOSupported && ref) {
    this.io = createIntersectionObserver(ref, inViewPort => {
      if (inViewPort) {
        this.abortPrefetch = this._prefetch()
      } else {
        if (this.abortPrefetch) {
          this.abortPrefetch.abort()
        }
      }
    })
  }
}

return (
  <ReachRouterLink
  ...
  innerRef={this.handleRef}
  />
)
  • 링크 컴포넌트에 onMouseOver 이벤트가 트리거 되었을 때(호버)
    • onMouseEnter 속성에 로더의 hovering() 함수를 호출하는 콜백함수를 전달합니다. 마찬가지로 로더가 프리패칭 작업을 시작합니다. 이때는 리소스에 대한 우선순위를 높게 업그레이드합니다.
// packages/gatsby-link/src/index.js

return (
  <ReachRouterLink
  ...
  // hover 이벤트 발생 시에 로더의 hovering 함수를 호출합니다
  // 이는 밑에서 더 자세히 설명하겠지만 prefetch 함수를 유도합니다
  onMouseEnter={e => {
    if (onMouseEnter) {
      onMouseEnter(e)
    }
    const parsed = parsePath(prefixedTo)
    ___loader.hovering(parsed.pathname + parsed.search)
  }}
  />
)

여기까지 살펴본 코드는 GatsbyLink 클래스 컴포넌트로, 프리패치가 트리거되는 시점을 확인해보았습니다. 이제 페이지 리소스를 프리패치하는 메커니즘을 담당하는 BaseLoaderProdLoader 클래스를 살펴보겠습니다.

실제로 프리패치하는 메커니즘을 담당하는 Loader 클래스

// packages/gatsby/cache-dir/loader.js

class BaseLoader {
  // 네트워크 요청을 통해 페이지 데이터를 가져오는 함수입니다.
  // createPageDataUrl을 사용하여 페이지 데이터 URL을 생성하고, prefetchHelper를 통해 해당 URL을 프리패치합니다
  // 필요할 경우, 부분 하이드레이션 데이터도 프리패치합니다
  doPrefetch(pagePath) {
    // page-data.json 경로를 만듭니다
    //     /page-data/about/page-data.json
    const pageDataUrl = createPageDataUrl(pagePath)
    
    if (하이드레이션_데이터_프리패치_여부) {
      // 생략
    } else {
      // 링크 태그를 돔에 추가해 리소스를 다운로드 합니다
      //    <link rel="prefetch" href="/page-data/about/page-data.json" crossorigin="anonymous" as="fetch">
      return prefetchHelper(pageDataUrl, {
        crossOrigin: `anonymous`,
        as: `fetch`,
      }).then(() =>
        this.loadPageDataJson(pagePath)
      )
    }
  }
  
  // 프리패치할지 여부를 결정합니다
  shouldPrefetch(pagePath) {
    if (사용자가_느린_환경이거나_제한된_연결_상태라면) {
      return false
    }
    if (크롤러_봇이라면) {
      return false
    }
    if (페이지가_존재하는지_확인) {
      return false
    }
    return true
  }
  
  // shouldPrefetch가 true를 반환하면, 이 메소드가 호출되어 프리패치를 시작합니다
  // 페이지 경로를 받아 해당 페이지의 데이터를 미리 가져옵니다
  prefetch(pagePath) {
    ...
    this.doPrefetch(findPath(pagePath)).then(() => {
      if (!this.prefetchCompleted.has(pagePath)) {
        this.apiRunner(`onPostPrefetchPathname`, { pathname: pagePath })
        this.prefetchCompleted.add(pagePath)
      }

      dPromise.resolve(true)
    })
  }
  ...
}

class ProdLoader extends BaseLoader {
  // 실제로 페이지 데이터를 프리패치하는 함수입니다
  doPrefetch(pagePath) {
      return super.doPrefetch(pagePath).then(result => {
        if (result.status !== PageResourceStatus.Success) {
          return Promise.resolve()
        }
        
        const pageData = result.payload
        
        // page-data.json에서 청크 이름을 조회합니다
        //     component---src-pages-about-tsx
        const chunkName = pageData.componentChunkName
        
        // 청크 이름과 컴포넌트 매핑 테이블로 컴포넌트 리소스 Url 을 만듭니다
        //     /component---src-pages-about-tsx-c2d770e505fff20bd4f6.js
        const componentUrls = createComponentUrls(chunkName)
        
        // 추가 리소스를 다운로드 합니다
        //    get: /component---src-pages-about-tsx-c2d770e505fff20bd4f6.js
        return Promise.all(componentUrls.map(prefetchHelper)).then(() => pageData)
    })
  }
  ...
}

추가 설명

로더에서 링크를 돔에 동적으로 추가해 리소스를 다운 받는 로직은 아래 prefetch 모듈을 참고하면 되겠습니다.

https://github.com/gatsbyjs/gatsby/blob/7f349ff7b526b12f4d390c601217dfec36dccef0/packages/gatsby/cache-dir/prefetch.js#L16-L56

이렇게 해서 미리 다운로드한 /page-data/about/page-data.json 데이터는 아래와 같은 구조를 가집니다:

{
    "componentChunkName": "component---src-pages-about-tsx",
    "path": "/about/",
    "result": {
        "pageContext": {}
    },
    "staticQueryHashes": [
        "1368107247",
        "405323582"
    ],
    "slicesMap": {
        "footer": "footer",
        "nav": "nav"
    }
}

about 페이지가 사용할 청크 이름(componentChunkName)을 가지고 있습니다. 이걸 이용해 다음 화면에서 사용할 청크를 미리 가져오기 위함입니다.

하지만 빌드된 청크이름 형식을 보면 해쉬값(c2d770e505fff20bd4f6)이 따라오는데요. 이 해쉬값까지 더해 주어야 유효한 Uri를 완성할 수 있는데, 이를 위해 Gatsby는 빌드 타임때 매핑 테이블을 미리 준비해 둡니다. window.___chunkMapping 값을 통해 확인할 수 있습니다. 매핑 테이블은 아래와 같은 구조를 가집니다:

{
  "component---src-pages-about-tsx": ["/component---src-pages-about-tsx-c2d770e505fff20bd4f6.js"],
  ...
}

이 테이블을 이용해 유효한 Uri를 구성하는 건 createComponentUrls 함수가 담당합니다.

const createComponentUrls = componentChunkName =>
  (window.___chunkMapping[componentChunkName] || []).map(
    chunk => __PATH_PREFIX__ + chunk
  )

/page-data/about/page-data.json 데이터를 프리패치한 것처럼 완성한 componentUrl들을 마지막으로 한 번 더 프리패치합니다.

영상으로 확인해보기

아래 영상에서 검색 결과의 리스트 아이템에서 마우스를 호버할 경우 different-ways-to-copy-objects-in-js 경로를 지닌 페이지의 리소스를 동적으로 돔에 추가해 리소스를 다운 받는 것을 확인할 수 있습니다:

link-prefetch.mov

이렇게 페이지별로 정적 자원을 청크로 먼저 나누어 두고, 방문할 페이지라 판단되면 이 청크를 미리 가져와 다운했습니다. 때문에 화면 로딩이 거의 없는 것처럼 느껴집니다.

사용자가 현재 선택한 요소를 하이라이트

Highlight 컴포넌트가 사용자가 현재 선택한 요소를 하이라이트하는 역할을 담당합니다. 사용자가 검색 결과 중 하나를 마우스 또는 키보드로 선택하면, 해당 결과가 하이라이트되어 사용자에게 표시됩니다. 이를 통해 사용자는 현재 선택한 결과를 쉽게 식별할 수 있습니다.
위 아래로 자연스럽게 포지션이 변경되는 애니메이션을 주고 싶어 직접 좌표 값을 계산합니다. 이 과정에서 렌더링을 최적화하기 위해 layoutpaint 작업을 최대한 생략하는 transform 속성을 이용해 위치를 지정합니다. height는 어쩔 수 없이 변경되어야 하므로 top, left 속성만 대체합니다.

const Highlight = ({ rect, visible, ...props }: HighlightProps) => {
const ref = useRef<HTMLDivElement>(null);
const isFirstVisible = usePrevious(isUnplacedRect(rect));
const position = useMemo<HighlightPosition>(() => {
const width = rect.width;
const height = rect.height;
const left = rect.left + (rect.width - width) / 2;
const top = rect.elementTop + (rect.height - height) / 2;
return {
width,
height,
transform: `translate(${left}px, ${top}px)`,
// 초기 좌표 값은 translate(-1000px, -1000px) 입니다.
// 이때, 애니메이션을 적용하면 사용자에게 사이트가 느린 느낌을 줍니다.
// 따라서 첫 요소에는 바로 하이라이트가 적용될 수 있도록 애니메이션을 적용하지 않습니다.
transition: isFirstVisible ? 'opacity' : 'opacity, transform',
};
// `isFirstVisible` 을 의존성 배열에 넣지 않는 이유는, `isFirstVisible` 은 `rect` 가 변경될 때마다 변경되기 때문입니다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect]);
return (
<span
ref={ref}
className={classNames(
'absolute left-0pxr top-0pxr rounded-md bg-gray-300 text-primary duration-100 ease-linear will-change-transform dark:bg-gray-800',
{
'opacity-0': !visible,
'opacity-100': visible,
},
)}
style={{
opacity: visible ? 0.8 : 0,
width: position.width,
height: position.height,
transform: position.transform,
transitionProperty: position.transition,
}}
{...props}
/>
);
};

검색어와 일치하는 텍스트를 하이라이트

HighlightMatches 컴포넌트가 검색어와 전체 문자열을 받아 하이라이트된 검색 결과를 반환하는 역할을 담당합니다.
공백을 기준으로 단어를 분리하고 정규식에서 사용할 수 있도록 여러 처리를 거치고 일치하는 문자에 대해 하이라이트된 결과를 생성해 반환합니다.

import type { ReactNode } from 'react';
import escapeStringRegexp from 'escape-string-regexp';
interface HighlightMatchesProps {
/**
* 사용자가 입력한 검색어
*/
match: string;
/**
* 검색 대상 문자열
*/
value?: string;
}
/**
* 사용자의 검색어를 안전하게 정규식으로 변환합니다.
* @see https://github.com/jaem1n207/lazy-dev/issues/65
* @param searchTerm 사용자가 입력한 검색어
* @returns 검색어에 해당하는 정규식
*/
const processSearchTerm = (searchTerm: string): RegExp => {
const trimmedSearchTerm = searchTerm.trim();
// 공백을 기준으로 단어를 분리하고, 빈 문자열을 제거합니다.
const searchWords = trimmedSearchTerm.split(/\s+/).filter(Boolean);
if (searchWords.length > 0) {
// 단어들을 정규식에서 사용할 수 있도록 이스케이프 처리하고, OR 연산자(|)로 연결합니다.
const escapedSearch = searchWords.map(escapeStringRegexp).join('|');
return new RegExp(escapedSearch, 'ig');
}
// 검색어가 비어있는 경우, 매치되지 않는 정규식을 반환합니다.
return new RegExp('$.^', 'ig');
};
/**
* 검색 결과를 생성하고, 일치하는 부분을 하이라이트합니다.
* @param value 검색 대상 전체 텍스트
* @param regexp 사용자의 검색어에 해당하는 정규식
* @returns 하이라이트된 검색 결과와 남은 텍스트
*/
const createSearchResult = (value: string, regexp: RegExp) => {
const splitValue = value.split('');
let result;
let index = 0; // 현재 검색 위치를 추적합니다.
const content: (string | ReactNode)[] = [];
while ((result = regexp.exec(value))) {
if (result.index === regexp.lastIndex) {
// 빈 문자열에 대한 검색을 방지하기 위해 lastIndex를 증가시킵니다.
regexp.lastIndex++;
} else {
const before = splitValue.splice(0, result.index - index).join('');
const matched = splitValue.splice(0, regexp.lastIndex - result.index).join('');
content.push(
before,
<span key={result.index} className="text-primary">
{matched}
</span>,
);
// 다음 검색을 위해 현재 검색 위치를 갱신합니다.
index = regexp.lastIndex;
}
}
return { content, remaining: splitValue.join('') };
};
/**
* 검색어와 전체 문자열을 받아 하이라이트된 검색 결과를 반환하는 컴포넌트입니다.
*/
const HighlightMatches = ({ match, value }: HighlightMatchesProps) => {
if (!value) return null;
const regexp = processSearchTerm(match);
const { content, remaining } = createSearchResult(value, regexp);
return (
<>
{content}
{remaining}
</>
);
};
export default HighlightMatches;

@jaem1n207 jaem1n207 added documentation Improvements or additions to documentation enhancement New feature or request labels Dec 4, 2023
@jaem1n207 jaem1n207 self-assigned this Dec 4, 2023
@jaem1n207 jaem1n207 changed the title [노트] 검색 기능 구현 [문서] 검색 기능 구현 Dec 4, 2023
@jaem1n207 jaem1n207 changed the title [문서] 검색 기능 구현 [문서] 검색 기능 구현 (feat.Gatsby Link API 동작 원리) Dec 4, 2023
@jaem1n207
Copy link
Owner Author

complete

@jaem1n207 jaem1n207 pinned this issue May 16, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant