NextJS로 만든 기술블로그에서 검색 기능 구현하기
개발 블로그 개발 여정
reactnextjs
Tue Jul 02 2024
이 글은 개발 블로그 개발 여정의 게시글이예요
해당 포스트는 NextJS를 이용하여 개발 블로그를 만들며 작성한 포스트입니다.
기술 블로그의 전체 코드는 📖 yonglog github 에서 확인 하실 수 있습니다.
- 1 . 개발블로그 제작을 시작하며
- 2 . CI/CD 파이프라인 구성하기
- 3 . tailwind 환경 설정 및 디자인 레퍼런스 찾기
- 4 . / 경로 레이아웃 , 페이지 디자인 생성하기
- 5 . [BUG] Build 시 발생하는 typeError 해결하기
- 6 . MDX를 사용하기 위한 라이브러리들 설치 및 환경 설정
- 7 . Post들이 저장된 FileSystem 파싱하기
- 8 . PageNavigation UI 만들기
- 9 . tag , series Identifier 만들기
- 10 . / 경로 UI 디자인 하기
- 11 . 서버 컴포넌트에 Dynamic Routing 추가하기
- 12 . Post/page.tsx 생성하기
- 13 . 마크다운 파일 코드블록 꾸미기
- 14 . [BUG] Vercel 에 환경 변수 추가하기
- 15 . Loading Suspense 구현하기
- 16 . generateStaticParams 이용해 SSR 에서 SSG로 넘어가자
- 17 . SSG를 이용한 블로그에서 테마 변경하기
- 18 . 인터렉티브한 사이드바 만들기
- 19 . 기술블로그에 giscus를 이용하여 댓글 기능을 추가하기
- 20 . 기술 블로그의 SEO를 최적화 하기 위한 방법 Part1
- 21 . 기술 블로그의 SEO를 최적화 하기 위한 방법 Part2
- 22 . 바닐라 자바스크립트로 깃허브 OAuth 를 구현해보자
- 23 . 라이브러리 없이 깃허브 API를 이용해 댓글창을 구현해보자
- 24 . 리팩토링 : Promise 패턴을 이용하여 비동기 처리중인 전역 객체에 접근하기
- 25 . NextJS로 만든 기술블로그에서 검색 기능 구현하기

검색 기능 구현하기 전 준비
이제 거의 마지막 기능 구현이 될 것 같다. :)
검색 기능을 구현하기 위해 이전 포스트에서 리팩토링을 거쳤다.
내가 생각 하는 플로우 차트는 다음과 같다.
GNB에 존재하는 Search Icon
해당 아이콘을 클릭하면 조그만 모달창이 뜨고 , 해당 모달 창에 검색어를 입력하면 /search?text=입력한 검색어
로 이동 되게 하는 것이다.
이후 /search
경로에 대한 페이지를 생성해주면 될 것 같다. :)
왜
/search
경로를 추가로 생성했는가 ?많은 고민을 했었다. 원래는 기존
/
경로에서 입력되는 입력값에 따라 게시글들을 필터링 하는 로직을 구현 하려고 했었다.그 방법을 선택하지 않은 이유는 다른
searchParams
들과 추가하고자 하는searchParam
이 충돌하기 때문이였다.해결하기 위한 다른 방법이 있을 거 같기도 했지만 가장 쉽게 해결 되는 방법을 선택했다. 블로그의 다양성도 늘릴 수 있기도 하고 말이다. :)
입력 창 UI 생성하기
'use client';
import { useState } from 'react';
import { FaSearch } from '@/components/Icons';
const SearchZone = () => {
const [isSerach, setIsSearch] = useState<boolean>(false);
const handleClick = () => {
setIsSearch(!isSerach);
};
const translateX = isSerach ? '280px' : '0px';
return (
<div className='w-[300px] h-[40px] overflow-hidden'>
<div
className='flex transition-transform'
style={{ transform: `translateX(${translateX}px)` }}
>
<FaSearch
className='my-[10px] mr-[10px] text-base hover:text-blue-500 cursor-pointer font-semibold'
size={20}
onClick={handleClick}
/>
<div className='flex w-[270px] font-semibold'>
<input
className='w-[200px] px-2 bg-transparent border-b-[2px] focus:outline-none'
type='text'
/>
<button className='ml-[5px] w-[60px] border-none bg-indigo-800 rounded-2xl hover:bg-indigo-500'>
search
</button>
</div>
</div>
</div>
);
};
export default SearchZone;
import Link from 'next/link';
import ThemeButton from './client/ThemeButton';
import { FaGithub } from './Icons';
import SearchZone from './client/SearchZone';
const GlobalNav = () => {
return (
<nav
id='GNB'
className='fixed z-[999] top-0 left-0 right-0 lg: py-3 bg-indigo-950 text-white'
>
<ul className='flex justify-between items-center shadow-md mx-0 sm:mx-auto w-full sm:w-3/4 lg:w-1/2'>
<li>
<h1 className='text-2xl font-bold flex items-start '>
<Link href='/'>abonglog</Link>
</h1>
</li>
<ul className='flex gap-4 items-center'>
<li>
<FaSearch />
<SearchZone />
</li>
<li className='text-base hover:text-blue-500 cursor-pointer font-semibold'>
<ThemeButton />
</li>
<li className='text-base hover:text-blue-500 cursor-pointer font-semibold'>
<FaGithub size={20} />
</li>
</ul>
</ul>
</nav>
);
};
export default GlobalNav;
클릭 유무에 따라 나타나는 검색창 필드
이전엔 GNB
에서 <FaSearch />
아이콘만 존재하였지만 이젠 상태 값에 따라 입력 필드가 나타나는 SearchZone
컴포넌트로 수정해주었다.
입력 필드가 슝슝 나타나게 하는 것은 단순히 overflow-hidden
기능과 transform - translate-x
기능을 이용해서 구현해주었다.
처음에는 모달창으로 구현할까 했는데 개인적으로 페이지에서 모달창이 있으면 좀 답답한 느낌이 들어서 그냥 translate
로 슝슝 나타나게 해줬다.
라우팅 기능 추가하기
이제 입력창에 입력된 글자로 라우팅을 시켜주도록 하자
search
버튼을 누르면 input
필드에 입력된 입력값을 서치파라미터로 하는 /search
도메인으로 라우팅 시켜주도록 하자
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { FaSearch } from '@/components/Icons';
const SearchZone = () => {
const [isSerach, setIsSearch] = useState<boolean>(false);
const [searchText, setSearchText] = useState<string>('');
const handleClick = () => {
setIsSearch(!isSerach);
};
const handleText = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
};
const translateX = isSerach ? '280px' : '0px';
return (
<div className='w-[300px] h-[40px] overflow-hidden'>
<div
className={`flex transition-transform`}
style={{ transform: `translateX(${translateX}px)` }}
>
<FaSearch
className='my-[10px] mr-[10px] text-base hover:text-blue-500 cursor-pointer font-semibold'
size={20}
onClick={handleClick}
/>
<div className='flex w-[270px] font-semibold'>
<input
className='w-[200px] px-2 bg-transparent border-b-[2px] focus:outline-none'
type='text'
onChange={handleText}
placeholder='검색어를 입력해주세요 :)'
/>
<Link
className='flex items-center justify-center ml-[5px] w-[60px] border-none bg-indigo-800 rounded-2xl hover:bg-indigo-500'
href={searchText ? `/search?text=${searchText}` : '#'}
>
Search
</Link>
</div>
</div>
</div>
);
};
export default SearchZone;
이후 Search
버튼을 Link
컴포넌트로 변경하여 라우팅을 가능하게 하고 input
필드에 적힌 SearchText
값을 이용해 라우팅을 하도록 했다.
useRouter
나 이것 저것을 써볼까 싶기도 했는데 그냥 Link
컴포넌트를 이용하는 것이 가장 직관적인 방법인 것 같았다.
조건부적으로 라우팅 기능을 어떻게 할까 ? 싶었는데 그냥 삼항 연산자로 라우팅 기능을 끄고 싶을 땐 #
로 가버리게 해버렸다.
검색어에 따라 블로그의 게시글을 검색하는 로직 생성하기
게시글을 검색하는 것은 그닥 어려운 일이 아녔다. 어차피 게시글이란건 엄청나게 긴 문자열이기 때문에 정규표현식을 이용해 검색어를 검색해주면 될 것이다.
String.match
의 반환 값은 문자열에서 검색어가 존재하는 횟수만큼의 길이의 배열이 반환되기 때문에 등장한 횟수를 세는 것 또한 매우 쉽다.
const reg = new RegExp('c', 'gi');
const str1 = 'cabccC';
console.log(str1.match(reg)); // [ 'c', 'c', 'c', 'C' ]
해당 로직을 이용하여 게시글들에서 charToFind
(검색어) 가 존재하는 게시글들만 골라내어 반환하는 모델을 생성해보도록 하자
import { PostInfo, PostMeta } from '@/types/post';
import postParser, { PostParser } from './postParserModel';
class PostSearchEngine {
posts: Promise<PostInfo[]>;
constructor(postParser: PostParser) {
this.posts = postParser.Posts;
}
async searchPosts(charToFind: string): Promise<PostMeta[]> {
/* 인수를 global 하게 찾는 정규표현식 인스턴스 생성
flag = 전체를 찾고 대소문자 무시하기 */
const regex = new RegExp(charToFind, 'gi');
const posts = await this.posts;
const searchedPosts: PostMeta[] = [];
const machedMap = new Map();
posts.forEach(({ meta, content }) => {
const matches = content.match(regex);
if (matches) {
machedMap.set(meta.title, matches.length);
searchedPosts.push(meta);
}
});
return searchedPosts.toSorted((prev, cur) => {
return machedMap.get(cur.title) - machedMap.get(prev.title);
});
}
}
const postSearchEngine = new PostSearchEngine(postParser);
export default postSearchEngine;
전체 포스트의 글 들을 불러온 후 정규 표현식을 이용해 게시글을 검색하는 클래스를 생성해주었다.
클래스에 대한 설명은 📖 리팩토링 : Promise 패턴을 이용하여 비동기 처리중인 전역 객체에 접근하기 해당 게시글을 보면 이해하기 쉽다.
/search 페이지 생성하기
import SearchTitle from '@/components/SearchTitle';
import PostGrid from '@/components/PostGrid';
import postSearchEngine from '../lib/postSearchEngine';
const SearchPage = async ({
searchParams,
}: {
searchParams: { text: string };
}) => {
const { text } = searchParams;
const searchedPostMeta = await postSearchEngine.searchPosts(text);
return (
<>
<section className='mx-0 sm:mx-auto w-full lg:w-1/2'>
<SearchTitle text={text} postNum={searchedPostMeta.length} />
<PostGrid postMetas={searchedPostMeta} />
</section>
</>
);
};
export default SearchPage;
이후 위에서 생성한 postSearchEngine
모델을 이용하여 searchParams
에 존재하는 검색어를 이용해 본문들을 가져오고
해당 본문들을 이용하여 검색 화면을 렌더링 하는 페이지를 생성해주었다.
search 페이지의 예시
나머지 SearchTitle , PostGrid
의 경우엔 단순한 UI
역할만을 하는 컴포넌트이기 때문에 특별한 설명은 하지 않겠다.
다만 이번에 좀 공부한 것은 --webkit-background-clip , fill-color
이다.
해당 기술은 📖 깃허브 공식 블로그를 레퍼런스 삼아 만들었다.
그래디언트가 적용된 텍스트
다음과 같이 텍스트의 색상에 그래디언트를 만들어주는 방식인데 해당 방식은 SearchTitle
컴포넌트에서 사용되었다.
const SearchTitle = ({ text, postNum }: { text: string; postNum: number }) => {
return (
<div className=' mt-12 py-12 mx-0 sm:mx-auto w-full sm:w-3/4 lg:w-full'>
<p className='text-[16px] text-gray-500'>abonglog Search</p>
<h1 className='text-[48px] font-black'>
<span className='gradient-text'>{text}</span>에 대한 본문 검색 결과
</h1>
<p className='text-[16px] text-gray-500'>
{postNum}개의 게시글이 검색 되었어요
</p>
</div>
);
};
export default SearchTitle;
/* /search 에서 쓰인 그래디언트 텍스트 */
.gradient-text {
background: linear-gradient(to right, rgb(99 102 241), rgb(6 182 212));
background-clip: border-box;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
해당 스타일링 기법은 태그에 백그라운드로 그래디언트를 준 후 , -webkit-background-clip
으로 배경 색이 text
에서만 보이게 설정해주고
-webkit-text-fill-clolor
로 백그라운드의 색을 가져와 사용해줄 수 있도록 하였다.
/search 페이지를 구성하는 컴포넌트들
해당 컴포넌트들은 단순한 UI 만을 다루고 있기 때문에 특별한 설명 없이 코드만 첨부하도록 하겠다.
const SearchTitle = ({ text, postNum }: { text: string; postNum: number }) => {
return (
<div className=' mt-12 py-12 mx-0 sm:mx-auto w-full sm:w-3/4 lg:w-full'>
<p className='text-[16px] text-gray-500'>abonglog Search</p>
<h1 className='text-[48px] font-black'>
<span className='gradient-text'>{text}</span>에 대한 본문 검색 결과
</h1>
<p className='text-[16px] text-gray-500'>
{postNum}개의 게시글이 검색 되었어요
</p>
</div>
);
};
export default SearchTitle;
import Image from 'next/image';
import type { PostMeta } from '@/types/post';
import Link from 'next/link';
const PostCard = ({ postMeta }: { postMeta: PostMeta }) => {
const { title, description, validThumbnail, series, postId } = postMeta;
return (
<Link href={`https://abonglog.me/post/${postId}`}>
<div data-card className='max-w-sm rounded-xl overflow-hidden shadow-md'>
<div className='w-full h-[300px] relative'>
<Image
src={validThumbnail}
alt={title}
layout='fill'
objectFit='cover'
/>
</div>
<div className='px-6 py-4'>
<div className='font-bold text-xl mb-2'>{title}</div>
<p className='text- text-base'>{description}</p>
<p className='py-2 text-sm text-end'>{series}</p>
</div>
</div>
</Link>
);
};
const PostGrid = ({ postMetas }: { postMetas: PostMeta[] }) => {
return (
<section className='grid grid-cols-3 gap-4'>
{postMetas.map((postMeta, idx) => (
<PostCard postMeta={postMeta} key={idx} />
))}
</section>
);
};
export default PostGrid;