이 글은 개발 블로그 개발 여정의 게시글이예요

해당 포스트는 NextJS를 이용하여 개발 블로그를 만들며 작성한 포스트입니다.

기술 블로그의 전체 코드는 🪢 yonglog github 에서 확인 하실 수 있습니다.

NextJS로 만든 기술블로그에서 검색 기능 구현하기의 썸네일

검색 기능 구현하기 전 준비

이제 거의 마지막 기능 구현이 될 것 같다. :)

검색 기능을 구현하기 위해 이전 포스트에서 리팩토링을 거쳤다.

내가 생각 하는 플로우 차트는 다음과 같다.

GNB에 존재하는 Search IconGNB에 존재하는 Search Icon

해당 아이콘을 클릭하면 조그만 모달창이 뜨고 , 해당 모달 창에 검색어를 입력하면 /search?text=입력한 검색어 로 이동 되게 하는 것이다.

이후 /search 경로에 대한 페이지를 생성해주면 될 것 같다. :)

/search 경로를 추가로 생성했는가 ?

많은 고민을 했었다. 원래는 기존 / 경로에서 입력되는 입력값에 따라 게시글들을 필터링 하는 로직을 구현 하려고 했었다.

그 방법을 선택하지 않은 이유는 다른 searchParams 들과 추가하고자 하는 searchParam 이 충돌하기 때문이였다.

해결하기 위한 다른 방법이 있을 거 같기도 했지만 가장 쉽게 해결 되는 방법을 선택했다. 블로그의 다양성도 늘릴 수 있기도 하고 말이다. :)

입력 창 UI 생성하기

@/components/client/SearchZone.tsx
'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;
@/components/GlobalNav.tsx
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 도메인으로 라우팅 시켜주도록 하자

라우팅 기능이 추가된 SearchZone
'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 의 반환 값은 문자열에서 검색어가 존재하는 횟수만큼의 길이의 배열이 반환되기 때문에 등장한 횟수를 세는 것 또한 매우 쉽다.

정규표현식을 이용한 match 메소드의 반환값 예시
const reg = new RegExp('c', 'gi');
 
const str1 = 'cabccC';
console.log(str1.match(reg)); // [ 'c', 'c', 'c', 'C' ]

해당 로직을 이용하여 게시글들에서 charToFind (검색어) 가 존재하는 게시글들만 골라내어 반환하는 모델을 생성해보도록 하자

@/lib/postSearchEngine.tsx
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 페이지 생성하기

/search/page.tsx
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 페이지의 예시search 페이지의 예시

나머지 SearchTitle , PostGrid 의 경우엔 단순한 UI 역할만을 하는 컴포넌트이기 때문에 특별한 설명은 하지 않겠다.

다만 이번에 좀 공부한 것은 --webkit-background-clip , fill-color 이다.

해당 기술은 🪢 깃허브 공식 블로그를 레퍼런스 삼아 만들었다.

그래디언트가 적용된 텍스트그래디언트가 적용된 텍스트

다음과 같이 텍스트의 색상에 그래디언트를 만들어주는 방식인데 해당 방식은 SearchTitle 컴포넌트에서 사용되었다.

@/components/client/SearchTitle.tsx
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;
/global.css
/* /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 만을 다루고 있기 때문에 특별한 설명 없이 코드만 첨부하도록 하겠다.

@/components/SearchTitle.tsx
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;
@/components/PostGrid.tsx
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;