You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
많은 데이터를 기반으로 원하는 정보를 빠르고 효율적으로 찾을 수 있도록 하는 데 중점을 두어 검색 기능을 개발했습니다.
마크다운을 파싱하여 검색에 필요한 데이터를 빌드 타임에 생성하고� FlexSearch 패키지를 사용해 검색 인덱스를 생성했습니다.
이러한 방식으로, 사용자는 원하는 정보를 쉽게 찾을 수 있도록 최대한 많은 데이터를 기반으로 검색을 할 수 있게 됩니다. 또한 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 구조화했습니다. 이를 통해 사용자는 원하는 정보를 빠르고 효율적으로 찾을 수 있습니다.
추가로 구현 과정에서 궁금했던 Gatsby에서 Link 컴포넌트는 어떻게 사용자가 페이지를 �방문할 것이라 판단해서 필요한 리소스를 미리 로드하여, 페이지를 빠르게 로드하는지 내부 구현 코드를 천천히 살펴보며 파악한 과정도 작성했습니다.
동기 부여
작업의 핵심은 사용자가 원하는 정보를 쉽게 찾을 수 있도록 검색 기능을 구현하는 것입니다. 이를 위해 글의 제목과 전체적인 내용을 검색에 활용할 데이터로 지정했습니다. 또한, 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 구조화했습니다.
기본적으로 대부분의 검색은 글의 제목, 키워드, 요약된 본문 3가지 정도의 데이터를 기반으로 검색합니다. 그리고 이에 보여주는 검색 결과도 찾는 키워드에 대한 정보가 있는 섹션이 아닌, 무조건 해당 글로 이동시켜주기만 할 뿐, 그 이상의 동작을 하지 않습니다.
예를 들어, javascript의 filter 함수를 사용하는 예시를 보고 싶어 사용자가 javascript filter example 라는 검색어를 입력하면 검색 결과로 javascript filter 글만 나올 뿐, example 섹션으로 사용자를 이동시켜주지 않습니다.
최악의 경우는 이와 관련된 글이 있음에도 설정해둔 키워드나 제목을 살짝 벗어나 검색 결과로 나오지 않는 것도 있겠죠.
위의 이유가 검색 기능을 직접 개발하고자 하는 가장 큰 동기 부여였고, 이건 내가 만드는 글이고 웹사이트이므로 언제든지 직접 자유롭게 검색 결과를 다루고자 하는 마음도 컸습니다.
검색 데이터 생성
먼저, 빌드 타임에서 마크다운 파일을 파싱하여 검색에 필요한 데이터를 생성합니다. 이 데이터는 제목과 각 섹션의 내용을 포함하며, 각 섹션은 해당 섹션의 제목과 섹션의 url을 key로 가지는 객체로 구성됩니다. 이렇게 만들어진 데이터는 사용자가 검색하려 할 때 사용됩니다. 따라서 처음 페이지 로드할 땐 굳이 다운 받지 않아도 됩니다. public/lazy-dev-data-${localeKey}.json 파일에 데이터를 저장해서 따로 분리해두겠습니다.
여기서 데이터를 구성할 때 주의할 점이 2가지 있습니다.
검색에 활용할 가치가 없는 데이터는 포함시키지 않아야 합니다. 보통 이런 데이터 종류는 다음과 같습니다:
pre 요소 내 콘텐츠
img 요소 내 콘텐츠
input 요소 내 콘텐츠
참고 섹션 -> 참고한 웹 사이트의 주소만 콘텐츠로 지니고 있기에 검색에 활용할 데이터로써 가치가 없음
첫 섹션(heading)이 나오기 전에 작성된 콘텐츠는 key를 빈 문자열로 하고 저장해둡니다.
이제 검색에 필요한 데이터는 마련해두었습니다. 그러나 이렇게 생성된 데이터를 검색할 때 모든 요소를 순회하지 않고도 빠르게 검색할 수 있도록 인덱스를 생성하는 것과 필드 가중치를 부여하는 것을 부여하는 건 꽤 까다롭습니다. 따라서 이 부분은 잘 만들어진 외부 패키지의 힘을 빌리는 게 시간적으로나 사용자에게도 좋습니다.
검색 인덱스는 FlexSearch 패키지를 사용하여 생성합니다. 이 패키지는 문서의 내용을 토큰화하여 빠른 검색을 가능하게 합니다. 먼저 검색할 데이터를 불러온 후(loadIndexesImpl 함수), 페이지 인덱스와 섹션 인덱스를 생성합니다. 페이지 인덱스는 페이지의 제목과 내용을 인덱싱하며, 섹션 인덱스는 각 섹션의 제목과 내용을 인덱싱합니다.
검색을 빠르게 하기 위해 데이터를 특정한 구조로 정리한 것입니다. 예를 들어, 책의 색인에서 특정 단어가 어떤 페이지에 있는지 빠르게 찾을 수 있도록 하는 것과 비슷합니다. 검색 인덱스를 사용하면 데이터베이스에서 모든 데이터를 순차적으로 검색하는 것보다 훨씬 빠르게 원하는 데이터를 찾을 수 있습니다.
왜 FlexSearch인가
큰 이유는 없습니다. 트윗에서 FlexSearch가 검색 패키지들 중 속도 및 메모리 측면에서 가장 뛰어나다는 벤치마크를 보아서 관심을 가지고 있었습니다. 이전에는 Fuse를 활용해서 검색 기능을 만들어 봤었기 때문에 이번엔 새로운 걸 다뤄보고 싶었던 가장 컸습니다.
FlexSearch 와 같은 패키지들은 어떻게 빠른 검색을 가능하게 할까요?
FlexSearch는 문서의 내용을 토큰화하기 위해 내부적으로 다양한 알고리즘을 사용합니다. 토큰화란 텍스트 데이터를 작은 단위인 '토큰'으로 분리하는 과정입니다. 예를 들어, "Hello, World!"라는 문장을 토큰화하면 "Hello"와 "World"라는 두 개의 토큰으로 분리됩니다. FlexSearch는 이러한 토큰화 과정을 통해 문서의 내용을 분석하고, 각 토큰이 어떤 문서에 속하는지를 인덱스에 저장합니다.
검색이 이루어질 때, 사용자가 입력한 검색어도 토큰화되고(한글은 흠...😇), 이 토큰들이 인덱스에서 어떤 문서와 매칭되는지를 찾습니다. 이렇게 하면 검색어와 관련된 문서를 빠르게 찾을 수 있습니다. 이러한 과정을 통해 FlexSearch는 대량의 텍스트 데이터에서도 빠른 검색을 가능하게 합니다.
검색 인덱스 불러와 할당
loadIndexes 함수는 검색 인덱스를 불러오는 역할을 합니다. 검색 인덱스를 생성하는 loadIndexesImpl 함수를 호출하고, 그 결과를 캐싱하여 재사용합니다. 이를 통해 검색 성능을 향상시키고, 불필요한 재계산을 방지하도록 합니다.
사용자가 검색어를 입력하면, 이제 검색 인덱스를 불러오고 검색을 시작합니다. 페이지 인덱스와 섹션 인덱스에서 검색어와 일치하는 결과를 찾아 반환합니다. 결과는 페이지 제목이 매칭되는 횟수가 많은 순서대로 정렬되며, 같은 페이지 내에서는 섹션의 순서대로 정렬되도록 했습니다. 실제 코드는 더 길지만 간략한 코드로 보면 아래와 같습니다:
constdoSearch=(search: string)=>{// 페이지 인덱스와 섹션 인덱스를 가져옵니다.const[pageIndex,sectionIndex]=indexes[locale];// 검색어와 일치하는 페이지 결과를 찾아 반환합니다.constpageResults=pageIndex.search(...);// 검색 결과를 저장하는 배열입니다. 각 결과는 페이지 인덱스와 섹션 인덱스에서 찾은 결과를 포함하며,// 각 페이지와 섹션의 제목, 내용, URL 등의 정보를 포함합니다.constresults=[];// 페이지 제목이 검색어와 일치하는 횟수를 저장하는 객체입니다. // 결과를 정렬할 때 사용되며 페이지 제목이 검색어와 많이 일치하는 결과가 먼저 나오도록 하기 위함입니다.constpageTitleMatches: Record<number,number>={};// 페이지 인덱스에서 검색어와 일치하는 결과를 찾습니다. 각 결과에 대해, 섹션 인덱스에서 // 해당 페이지의 섹션 중 검색어와 일치하는 결과를 찾습니다.for(leti=0;i<pageResults.length;i++){
...
constsectionResults=sectionIndex.search(...);// 섹션 인덱스에서 검색어와 일치하는 결과를 찾습니다. 각 결과는 results 배열에 추가됩니다.for(letj=0;j<sectionResults.length;j++){
...
results.push({page_rk: i,section_rk: j, url, title, content });}}// 페이지 제목이 검색어와 많이 일치하는 순서, 그리고 같은 페이지 내에서는 섹션의 순서대로 정렬constprioritizedResults=results.sort(...);// 정렬된 결과를 사용자에게 보여줄 형태로 변환합니다.constdisplayableResults=prioritizedResults.map((result)=>({id: `${result._page_rk}_${result._section_rk}`,route: result.route,prefix: result.prefix,children: result.children,}));setResults(displayableResults);}constonChange=async(value: string)=>{setSearchQuery(value);if(loading)return;if(!indexes[locale]){setLoading(true);try{awaitloadIndexes(locale);}catch(error){console.log(error);setError(true);}setLoading(false);}doSearch(value);};
이제 사용자는 원하는 정보를 쉽게 찾을 수 있도록 최대한 많은 데이터를 기반으로 검색을 할 수 있게 되었습니다. 또한 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 작업해두었습니다.
이를 사용자에게 결과 화면으로 보여주기만 하면 됩니다.
결과 화면 보여주기
검색어에 해당하는 결과가 없을 때 보여주는 UI를 구성해보기
아직 블로그에 게시한 글이 많이 없기 때문에 사용자가 찾는 검색어에 대한 결과가 없을 수도 있습니다. 이 경우, 사용자가 원하는 주제에 대해 글 작성을 요청할 수 있는 페이지로 연결해주도록 UI를 구성했습니다.
empty-result.mov
검색 결과 화면을 담당하는 Search
이렇게 생성한 데이터를 활용해 사용자에게 결과 화면으로 보여주는 건 Search 컴포넌트가 담당합니다.
검색어를 입력하면 검색 결과를 보기 좋게 다듬어 사용자에게 리스트 형태로 보여줍니다.
컴포넌트 이름에서 알 수 있듯, Select가 아닌 Search이기 때문에 첫 번째 요소가 반드시 자동으로 선택되는 로직은 없습니다. 그저 사용자가 검색어를 입력하고 마우스 또는 키보드 입력을 통해 선택하면 됩니다. 그러면 사용자가 현재 선택한 요소를 알 수 있도록 하이라이트 해주는 컴포넌트가 필요하겠네요.
추가로 굳이 작업할 필요는 없겠지만 좋은 경험을 제공하기 위해선 없어선 안 될 기능인 검색어와 일치하는 텍스트를 하이라이트 해주는 로직도 필요했습니다. 아마 잘 만들어진 패키지가 있겠지만 직접 만드는 게 더 재밌을 것 같아 구현해보았습니다.
또한 각 리스트는 Gatsby에서 제공하는 Link 컴포넌트를 활용해 페이지를 빠르게 탐색할 수 있도록 합니다.
Link 컴포넌트의 동작 과정을 보면 Gatsby를 사용하면 페이지가 빠르게 로드되는 것처럼 느껴지는 이유를 알 수 있습니다.
Gatsby는 페이지 단위로 청크를 생성해두고 사용자가 특정 페이지에 방문할 것이라 판단되면 이 작은 코드 조각들을 미리 가져옵니다. 가져올 파일을 작게 쪼개어 놓았기 때문에 다운 받는 속도가 빠르겠죠. 이렇게 미리 확보해 놓은 리소스를 사용하여 페이지를 즉시 표시할 수 있는 것입니다.
Gatsby의 Link API 핵심 구현
Gatsby의 Link는 내부적으로 어떻게 구현이 되어 있길래 이런 과정을 가능하게 한 걸까요? Gatsby 코드를 하나씩 살펴보며 알아가보겠습니다. 크게 두 가지 클래스를 보면 됩니다.
프리패치 트리거 역할을 하는 GatsbyLink 클래스
페이지 데이터를 불러오는 역할을 하는 ProdLoader 클래스
프리패치를 위한 사전 데이터 준비 작업
우선 사전에 프리패치를 하기 위한 데이터를 준비하는 작업이 필요합니다. Gatsby는 페이지별로 필요한 청크 목록을 page-data.json이란 파일을 생성해 데이터를 저장합니다. 빌드 결과물이 저장되는 public 폴더는 아래와 같은 구조를 가집니다:
두 개 페이지(home, about)가 있고 각 페이지가 로딩할 js 파일이 생성됩니다. 그리고 화면과 청크의 매핑 정보를 담은 page-data.json 파일을 page-data 폴더에 담습니다. 이 작업이 프리로드와 프리패치를 하기 위한 준비 작업이라고 볼 수 있습니다.
프리패치를 트리거하는 GatsbyLink 클래스
사용자가 방문할 것을 어떻게 예측할 수 있을까요? Gatsby의 Link 컴포넌트는 프리패치를 두 가지 시점에 발생시킵니다.
Link 컴포넌트가 화면에 들어왔을 때
해당 컴포넌트가 마운트되고 화면에 들어오면(inViewPort) 로더에 경로를 전달(enqueue)합니다. 로더는 전달 받은 경로를 이용해 프리패치 작업을 시작합니다. 이때 다운 받는 리소스에 대한 우선순위는 낮게 설정됩니다. 아무래도 유휴시간에 다운 받도록 해야 하는 게 좋을테니까요.
// packages/gatsby-link/src/index.js// IntersectionObserver(웹 페이지의 특정 요소가 뷰포트 내에 있는지 감지하는 API) 인스턴스를 생성하고, // 요소가 뷰포트 내에 들어왔을 때 콜백 함수를 호출하도록 설정합니다// 이 콜백 함수는 __prefetch 함수를 호출해 페이지 데이터를 미리 가져옵니다constcreateIntersectionObserver=(el,cb)=>{constio=newwindow.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(){letcurrentPath=window.location.pathname+window.location.searchif(this.props._location&&this.props._location.pathname){currentPath=this.props._location.pathname+this.props._location.search}constrewrittenPath=rewriteLinkPath(this.props.to,currentPath)constparsed=parsePath(rewrittenPath)constnewPathName=parsed.pathname+parsed.searchif(currentPath!==newPathName){return___loader.enqueue(newPathName)}returnundefined}// 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}elseif(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.jsreturn(<ReachRouterLink...// hover 이벤트 발생 시에 로더의 hovering 함수를 호출합니다// 이는 밑에서 더 자세히 설명하겠지만 prefetch 함수를 유도합니다onMouseEnter={e=>{if(onMouseEnter){onMouseEnter(e)}constparsed=parsePath(prefixedTo)___loader.hovering(parsed.pathname+parsed.search)}}/>)
여기까지 살펴본 코드는 GatsbyLink 클래스 컴포넌트로, 프리패치가 트리거되는 시점을 확인해보았습니다. 이제 페이지 리소스를 프리패치하는 메커니즘을 담당하는 BaseLoader와 ProdLoader 클래스를 살펴보겠습니다.
실제로 프리패치하는 메커니즘을 담당하는 Loader 클래스
// packages/gatsby/cache-dir/loader.jsclassBaseLoader{// 네트워크 요청을 통해 페이지 데이터를 가져오는 함수입니다.// createPageDataUrl을 사용하여 페이지 데이터 URL을 생성하고, prefetchHelper를 통해 해당 URL을 프리패치합니다// 필요할 경우, 부분 하이드레이션 데이터도 프리패치합니다doPrefetch(pagePath){// page-data.json 경로를 만듭니다// /page-data/about/page-data.jsonconstpageDataUrl=createPageDataUrl(pagePath)if(하이드레이션_데이터_프리패치_여부){// 생략}else{// 링크 태그를 돔에 추가해 리소스를 다운로드 합니다// <link rel="prefetch" href="/page-data/about/page-data.json" crossorigin="anonymous" as="fetch">returnprefetchHelper(pageDataUrl,{crossOrigin: `anonymous`,as: `fetch`,}).then(()=>this.loadPageDataJson(pagePath))}}// 프리패치할지 여부를 결정합니다shouldPrefetch(pagePath){if(사용자가_느린_환경이거나_제한된_연결_상태라면){returnfalse}if(크롤러_봇이라면){returnfalse}if(페이지가_존재하는지_확인){returnfalse}returntrue}// 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)})}
...
}classProdLoaderextendsBaseLoader{// 실제로 페이지 데이터를 프리패치하는 함수입니다doPrefetch(pagePath){returnsuper.doPrefetch(pagePath).then(result=>{if(result.status!==PageResourceStatus.Success){returnPromise.resolve()}constpageData=result.payload// page-data.json에서 청크 이름을 조회합니다// component---src-pages-about-tsxconstchunkName=pageData.componentChunkName// 청크 이름과 컴포넌트 매핑 테이블로 컴포넌트 리소스 Url 을 만듭니다// /component---src-pages-about-tsx-c2d770e505fff20bd4f6.jsconstcomponentUrls=createComponentUrls(chunkName)// 추가 리소스를 다운로드 합니다// get: /component---src-pages-about-tsx-c2d770e505fff20bd4f6.jsreturnPromise.all(componentUrls.map(prefetchHelper)).then(()=>pageData)})}
...
}
추가 설명
로더에서 링크를 돔에 동적으로 추가해 리소스를 다운 받는 로직은 아래 prefetch 모듈을 참고하면 되겠습니다.
about 페이지가 사용할 청크 이름(componentChunkName)을 가지고 있습니다. 이걸 이용해 다음 화면에서 사용할 청크를 미리 가져오기 위함입니다.
하지만 빌드된 청크이름 형식을 보면 해쉬값(c2d770e505fff20bd4f6)이 따라오는데요. 이 해쉬값까지 더해 주어야 유효한 Uri를 완성할 수 있는데, 이를 위해 Gatsby는 빌드 타임때 매핑 테이블을 미리 준비해 둡니다. window.___chunkMapping 값을 통해 확인할 수 있습니다. 매핑 테이블은 아래와 같은 구조를 가집니다:
/page-data/about/page-data.json 데이터를 프리패치한 것처럼 완성한 componentUrl들을 마지막으로 한 번 더 프리패치합니다.
영상으로 확인해보기
아래 영상에서 검색 결과의 리스트 아이템에서 마우스를 호버할 경우 different-ways-to-copy-objects-in-js 경로를 지닌 페이지의 리소스를 동적으로 돔에 추가해 리소스를 다운 받는 것을 확인할 수 있습니다:
link-prefetch.mov
이렇게 페이지별로 정적 자원을 청크로 먼저 나누어 두고, 방문할 페이지라 판단되면 이 청크를 미리 가져와 다운했습니다. 때문에 화면 로딩이 거의 없는 것처럼 느껴집니다.
사용자가 현재 선택한 요소를 하이라이트
Highlight 컴포넌트가 사용자가 현재 선택한 요소를 하이라이트하는 역할을 담당합니다. 사용자가 검색 결과 중 하나를 마우스 또는 키보드로 선택하면, 해당 결과가 하이라이트되어 사용자에게 표시됩니다. 이를 통해 사용자는 현재 선택한 결과를 쉽게 식별할 수 있습니다.
위 아래로 자연스럽게 포지션이 변경되는 애니메이션을 주고 싶어 직접 좌표 값을 계산합니다. 이 과정에서 렌더링을 최적화하기 위해 layout 및 paint 작업을 최대한 생략하는 transform 속성을 이용해 위치를 지정합니다. height는 어쩔 수 없이 변경되어야 하므로 top, left 속성만 대체합니다.
요약
많은 데이터를 기반으로 원하는 정보를 빠르고 효율적으로 찾을 수 있도록 하는 데 중점을 두어 검색 기능을 개발했습니다.
마크다운을 파싱하여 검색에 필요한 데이터를 빌드 타임에 생성하고�
FlexSearch
패키지를 사용해 검색 인덱스를 생성했습니다.이러한 방식으로, 사용자는 원하는 정보를 쉽게 찾을 수 있도록 최대한 많은 데이터를 기반으로 검색을 할 수 있게 됩니다. 또한 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 구조화했습니다. 이를 통해 사용자는 원하는 정보를 빠르고 효율적으로 찾을 수 있습니다.
추가로 구현 과정에서 궁금했던 Gatsby에서
Link
컴포넌트는 어떻게 사용자가 페이지를 �방문할 것이라 판단해서 필요한 리소스를 미리 로드하여, 페이지를 빠르게 로드하는지 내부 구현 코드를 천천히 살펴보며 파악한 과정도 작성했습니다.동기 부여
작업의 핵심은 사용자가 원하는 정보를 쉽게 찾을 수 있도록 검색 기능을 구현하는 것입니다. 이를 위해 글의
제목
과전체적인 내용
을 검색에 활용할 데이터로 지정했습니다. 또한, 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 구조화했습니다.기본적으로 대부분의 검색은 글의
제목
,키워드
,요약된 본문
3가지 정도의 데이터를 기반으로 검색합니다. 그리고 이에 보여주는 검색 결과도 찾는 키워드에 대한 정보가 있는 섹션이 아닌, 무조건 해당 글로 이동시켜주기만 할 뿐, 그 이상의 동작을 하지 않습니다.예를 들어, javascript의 filter 함수를 사용하는 예시를 보고 싶어 사용자가
javascript filter example
라는 검색어를 입력하면 검색 결과로javascript filter
글만 나올 뿐, example 섹션으로 사용자를 이동시켜주지 않습니다.최악의 경우는 이와 관련된 글이 있음에도 설정해둔
키워드
나제목
을 살짝 벗어나 검색 결과로 나오지 않는 것도 있겠죠.위의 이유가 검색 기능을 직접 개발하고자 하는 가장 큰 동기 부여였고, 이건 내가 만드는 글이고 웹사이트이므로 언제든지 직접 자유롭게 검색 결과를 다루고자 하는 마음도 컸습니다.
검색 데이터 생성
먼저, 빌드 타임에서 마크다운 파일을 파싱하여 검색에 필요한 데이터를 생성합니다. 이 데이터는 제목과 각 섹션의 내용을 포함하며, 각 섹션은 해당 섹션의 제목과 섹션의 url을 key로 가지는 객체로 구성됩니다. 이렇게 만들어진 데이터는 사용자가 검색하려 할 때 사용됩니다. 따라서 처음 페이지 로드할 땐 굳이 다운 받지 않아도 됩니다.
public/lazy-dev-data-${localeKey}.json
파일에 데이터를 저장해서 따로 분리해두겠습니다.여기서 데이터를 구성할 때 주의할 점이 2가지 있습니다.
pre
요소 내 콘텐츠img
요소 내 콘텐츠input
요소 내 콘텐츠참고
섹션 -> 참고한 웹 사이트의 주소만 콘텐츠로 지니고 있기에 검색에 활용할 데이터로써 가치가 없음lazy-dev/html-parser.ts
Lines 1 to 73 in 3dbab7e
lazy-dev/gatsby-node.ts
Lines 189 to 201 in 3dbab7e
검색 인덱스 생성
이제 검색에 필요한 데이터는 마련해두었습니다. 그러나 이렇게 생성된 데이터를 검색할 때 모든 요소를 순회하지 않고도 빠르게 검색할 수 있도록 인덱스를 생성하는 것과 필드 가중치를 부여하는 것을 부여하는 건 꽤 까다롭습니다. 따라서 이 부분은 잘 만들어진 외부 패키지의 힘을 빌리는 게 시간적으로나 사용자에게도 좋습니다.
검색 인덱스는
FlexSearch
패키지를 사용하여 생성합니다. 이 패키지는 문서의 내용을 토큰화하여 빠른 검색을 가능하게 합니다. 먼저 검색할 데이터를 불러온 후(loadIndexesImpl
함수),페이지 인덱스
와섹션 인덱스
를 생성합니다. 페이지 인덱스는 페이지의 제목과 내용을 인덱싱하며, 섹션 인덱스는 각 섹션의 제목과 내용을 인덱싱합니다.lazy-dev/src/features/search/flex-search.tsx
Lines 33 to 113 in 3dbab7e
왜 FlexSearch인가
큰 이유는 없습니다. 트윗에서
FlexSearch
가 검색 패키지들 중 속도 및 메모리 측면에서 가장 뛰어나다는 벤치마크를 보아서 관심을 가지고 있었습니다. 이전에는Fuse
를 활용해서 검색 기능을 만들어 봤었기 때문에 이번엔 새로운 걸 다뤄보고 싶었던 가장 컸습니다.FlexSearch 와 같은 패키지들은 어떻게 빠른 검색을 가능하게 할까요?
FlexSearch
는 문서의 내용을 토큰화하기 위해 내부적으로 다양한 알고리즘을 사용합니다.토큰화
란 텍스트 데이터를 작은 단위인 '토큰'으로 분리하는 과정입니다. 예를 들어, "Hello, World!"라는 문장을 토큰화하면 "Hello"와 "World"라는 두 개의 토큰으로 분리됩니다.FlexSearch
는 이러한토큰화
과정을 통해 문서의 내용을 분석하고, 각 토큰이 어떤 문서에 속하는지를 인덱스에 저장합니다.검색이 이루어질 때, 사용자가 입력한 검색어도 토큰화되고(한글은 흠...😇), 이 토큰들이 인덱스에서 어떤 문서와 매칭되는지를 찾습니다. 이렇게 하면 검색어와 관련된 문서를 빠르게 찾을 수 있습니다. 이러한 과정을 통해 FlexSearch는 대량의 텍스트 데이터에서도 빠른 검색을 가능하게 합니다.
검색 인덱스 불러와 할당
loadIndexes
함수는 검색 인덱스를 불러오는 역할을 합니다. 검색 인덱스를 생성하는loadIndexesImpl
함수를 호출하고, 그 결과를 캐싱하여 재사용합니다. 이를 통해 검색 성능을 향상시키고, 불필요한 재계산을 방지하도록 합니다.lazy-dev/src/features/search/flex-search.tsx
Lines 19 to 31 in 3dbab7e
검색
사용자가 검색어를 입력하면, 이제 검색 인덱스를 불러오고 검색을 시작합니다.
페이지 인덱스
와섹션 인덱스
에서 검색어와 일치하는 결과를 찾아 반환합니다. 결과는 페이지 제목이 매칭되는 횟수가 많은 순서대로 정렬되며, 같은 페이지 내에서는 섹션의 순서대로 정렬되도록 했습니다. 실제 코드는 더 길지만 간략한 코드로 보면 아래와 같습니다:이제 사용자는 원하는 정보를 쉽게 찾을 수 있도록 최대한 많은 데이터를 기반으로 검색을 할 수 있게 되었습니다. 또한 각 섹션의 단어를 입력해도 해당 섹션으로 바로 이동할 수 있도록 데이터를 작업해두었습니다.
이를 사용자에게 결과 화면으로 보여주기만 하면 됩니다.
결과 화면 보여주기
검색어에 해당하는 결과가 없을 때 보여주는 UI를 구성해보기
아직 블로그에 게시한 글이 많이 없기 때문에 사용자가 찾는 검색어에 대한 결과가 없을 수도 있습니다. 이 경우, 사용자가 원하는 주제에 대해 글 작성을 요청할 수 있는 페이지로 연결해주도록 UI를 구성했습니다.
empty-result.mov
검색 결과 화면을 담당하는 Search
이렇게 생성한 데이터를 활용해 사용자에게 결과 화면으로 보여주는 건
Search
컴포넌트가 담당합니다.검색어를 입력하면 검색 결과를 보기 좋게 다듬어 사용자에게 리스트 형태로 보여줍니다.
컴포넌트 이름에서 알 수 있듯,
Select
가 아닌Search
이기 때문에 첫 번째 요소가 반드시 자동으로 선택되는 로직은 없습니다. 그저 사용자가 검색어를 입력하고 마우스 또는 키보드 입력을 통해 선택하면 됩니다. 그러면 사용자가 현재 선택한 요소를 알 수 있도록 하이라이트 해주는 컴포넌트가 필요하겠네요.추가로 굳이 작업할 필요는 없겠지만 좋은 경험을 제공하기 위해선 없어선 안 될 기능인 검색어와 일치하는 텍스트를 하이라이트 해주는 로직도 필요했습니다. 아마 잘 만들어진 패키지가 있겠지만 직접 만드는 게 더 재밌을 것 같아 구현해보았습니다.
또한 각 리스트는 Gatsby에서 제공하는
Link
컴포넌트를 활용해 페이지를 빠르게 탐색할 수 있도록 합니다.아래 코드는 검색 결과를 출력하는 Search 컴포넌트에 대한 코드입니다:
lazy-dev/src/features/search/search.tsx
Lines 35 to 256 in 3dbab7e
Gatsby에서 제공하는
Link
컴포넌트는 어떻게 페이지를 빠르게 탐색하도록 할까요?Link
컴포넌트의 동작 과정을 보면 Gatsby를 사용하면 페이지가 빠르게 로드되는 것처럼 느껴지는 이유를 알 수 있습니다.Gatsby는 페이지 단위로 청크를 생성해두고 사용자가 특정 페이지에 방문할 것이라 판단되면 이 작은 코드 조각들을 미리 가져옵니다. 가져올 파일을 작게 쪼개어 놓았기 때문에 다운 받는 속도가 빠르겠죠. 이렇게 미리 확보해 놓은 리소스를 사용하여 페이지를 즉시 표시할 수 있는 것입니다.
Gatsby의 Link API 핵심 구현
Gatsby의 Link는 내부적으로 어떻게 구현이 되어 있길래 이런 과정을 가능하게 한 걸까요? Gatsby 코드를 하나씩 살펴보며 알아가보겠습니다. 크게 두 가지 클래스를 보면 됩니다.
GatsbyLink
클래스ProdLoader
클래스프리패치를 위한 사전 데이터 준비 작업
우선 사전에 프리패치를 하기 위한 데이터를 준비하는 작업이 필요합니다. Gatsby는 페이지별로 필요한 청크 목록을
page-data.json
이란 파일을 생성해 데이터를 저장합니다. 빌드 결과물이 저장되는 public 폴더는 아래와 같은 구조를 가집니다:두 개 페이지(home, about)가 있고 각 페이지가 로딩할 js 파일이 생성됩니다. 그리고 화면과 청크의 매핑 정보를 담은
page-data.json
파일을page-data
폴더에 담습니다. 이 작업이 프리로드와 프리패치를 하기 위한 준비 작업이라고 볼 수 있습니다.프리패치를 트리거하는 GatsbyLink 클래스
사용자가 방문할 것을 어떻게 예측할 수 있을까요? Gatsby의 Link 컴포넌트는 프리패치를 두 가지 시점에 발생시킵니다.
onMouseOver
이벤트가 트리거 되었을 때(호버)onMouseEnter
속성에 로더의 hovering() 함수를 호출하는 콜백함수를 전달합니다. 마찬가지로 로더가 프리패칭 작업을 시작합니다. 이때는 리소스에 대한 우선순위를 높게 업그레이드합니다.여기까지 살펴본 코드는
GatsbyLink
클래스 컴포넌트로, 프리패치가 트리거되는 시점을 확인해보았습니다. 이제 페이지 리소스를 프리패치하는 메커니즘을 담당하는BaseLoader
와ProdLoader
클래스를 살펴보겠습니다.실제로 프리패치하는 메커니즘을 담당하는 Loader 클래스
추가 설명
로더에서 링크를 돔에 동적으로 추가해 리소스를 다운 받는 로직은 아래
prefetch
모듈을 참고하면 되겠습니다.https://github.com/gatsbyjs/gatsby/blob/7f349ff7b526b12f4d390c601217dfec36dccef0/packages/gatsby/cache-dir/prefetch.js#L16-L56
이렇게 해서 미리 다운로드한
/page-data/about/page-data.json
데이터는 아래와 같은 구조를 가집니다:about
페이지가 사용할 청크 이름(componentChunkName)을 가지고 있습니다. 이걸 이용해 다음 화면에서 사용할 청크를 미리 가져오기 위함입니다.하지만 빌드된 청크이름 형식을 보면 해쉬값(c2d770e505fff20bd4f6)이 따라오는데요. 이 해쉬값까지 더해 주어야 유효한 Uri를 완성할 수 있는데, 이를 위해 Gatsby는 빌드 타임때 매핑 테이블을 미리 준비해 둡니다. window.___chunkMapping 값을 통해 확인할 수 있습니다. 매핑 테이블은 아래와 같은 구조를 가집니다:
이 테이블을 이용해 유효한 Uri를 구성하는 건
createComponentUrls
함수가 담당합니다./page-data/about/page-data.json
데이터를 프리패치한 것처럼 완성한componentUrl
들을 마지막으로 한 번 더 프리패치합니다.영상으로 확인해보기
아래 영상에서 검색 결과의 리스트 아이템에서 마우스를 호버할 경우
different-ways-to-copy-objects-in-js
경로를 지닌 페이지의 리소스를 동적으로 돔에 추가해 리소스를 다운 받는 것을 확인할 수 있습니다:link-prefetch.mov
이렇게 페이지별로 정적 자원을 청크로 먼저 나누어 두고, 방문할 페이지라 판단되면 이 청크를 미리 가져와 다운했습니다. 때문에 화면 로딩이 거의 없는 것처럼 느껴집니다.
사용자가 현재 선택한 요소를 하이라이트
Highlight
컴포넌트가 사용자가 현재 선택한 요소를 하이라이트하는 역할을 담당합니다. 사용자가 검색 결과 중 하나를 마우스 또는 키보드로 선택하면, 해당 결과가 하이라이트되어 사용자에게 표시됩니다. 이를 통해 사용자는 현재 선택한 결과를 쉽게 식별할 수 있습니다.위 아래로 자연스럽게 포지션이 변경되는 애니메이션을 주고 싶어 직접 좌표 값을 계산합니다. 이 과정에서 렌더링을 최적화하기 위해
layout
및paint
작업을 최대한 생략하는transform
속성을 이용해 위치를 지정합니다.height
는 어쩔 수 없이 변경되어야 하므로top
,left
속성만 대체합니다.lazy-dev/src/features/search/highlight.tsx
Lines 26 to 70 in 3dbab7e
검색어와 일치하는 텍스트를 하이라이트
HighlightMatches
컴포넌트가 검색어와 전체 문자열을 받아 하이라이트된 검색 결과를 반환하는 역할을 담당합니다.공백을 기준으로 단어를 분리하고 정규식에서 사용할 수 있도록 여러 처리를 거치고 일치하는 문자에 대해 하이라이트된 결과를 생성해 반환합니다.
lazy-dev/src/features/search/highlight-matches.tsx
Lines 1 to 89 in 3dbab7e
The text was updated successfully, but these errors were encountered: