My Boundary As Much As I Experienced

express로 검색 기능 구현하기(정규식을 이용하여) 본문

BackEnd/Node.js

express로 검색 기능 구현하기(정규식을 이용하여)

Bumang 2024. 8. 31. 02:25

검색기능을 구현하려한다.

어떻게 구현해야할까? 구현하려고 하는 스펙을 정리해보자면,

프론트
1. 검색창과 검색 인풋이 있는 리스트 페이지
2. 검색 시 get요청으로 검색어를 쿼리스트링에 포함하여 조회
3. 응답을 받아 리스트를 재갱신

백엔드
1. 프론트의 요청을 받으면 제목, 본문에서  해당 문자열이 포함된 것들을 모두 db조회
2. 중복 배열이 있는 것은 제거하고 배열에 담아 프론트에 다시 반환

 

라고 정리할 수 있을 것이다.

프론트엔드 먼저 어떻게 구현했는지 보여주겠다.

 

잠깐, 프론트에서 get요청이 아니라 post요청이 자연스럽지 않나?

라고 생각이 들 수 있다. 나도 처음에 post요청으로 구현할뻔했으니까.

프론트 입장에서 body에 정보를 포함하여 서버에 요청보낼 땐 본능적으로 post를 떠올리는게 자연스럽다.

 

그러나 의미적으로 db를 '조회'해온다는 것이기 때문에 get요청이 더 자연스럽고

프론트 내에서 검색 페이지 간 이동이 더 자유로울려면 쿼리스트링이 포함된 url을

get요청하는게 더 자연스럽다는걸 알 수 있다.

 

물론 정답은 없다. post요청으로 구현하고 싶으면 post요청으로 구현하는게 불가능한건 아니니

원하는대로 하면 된다. 그러나 특별한 이유가 없다면 기존 웹개발의 컨벤션을 따르는게 무난히 좋은 선택일터.

 

다른 서비스들을 봐보자. 티스토리를 포함해 다른 서비스들의 검색기능들의 url을 관찰해보면

보통 페이지네이션과 검색기능을 쿼리스트링으로 처리한다는걸 알 수 있다.

 

Post요청으로 짰던 코드

프론트:

(하 컴포넌트 쓰고싶다.. 오랜만에 바닐라 자바스크립트로 createElement 오만번 하니 어지럽네.)

일단 preventDefault로 페이지 이동을 막고 fetch API로 response를 받는다.

그리고 ListContiner 역할을 하는 div의 내용물을 없애고, 새롭게 받은 데이터로 반복문을 돌려 내용을 채워넣는다.

      const formEl = document.querySelector("#search-form");
      formEl.addEventListener("submit", async (e) => {
        e.preventDefault();
        const searchInputValue = document.querySelector("#inputText").value;

        const response = await fetch("/search", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            inputText: searchInputValue,
          }),
        })
          .then((response) => response.json())
          .then((data) => {
            const listContainer = document.querySelector(".white-bg");
            listContainer.innerHTML = ""; // 기존 내용을 지움

            // 새롭게 받은 데이터를 사용해 리스트를 업데이트
            data.doc.forEach((doc) => {
              const listItem = document.createElement("div");
              listItem.className = "list-box";
              listItem.innerHTML = `
                <a href="/detail/${doc._id}">
                  <h4>${doc.title}</h4>
                </a>
                <p>${doc.content}</p>
                ${
                  doc.img
                    ? `<img src="${doc.img}" style="width: 200px; height: 200px; object-fit: cover" />`
                    : ""
                }
                <a href="/modify/${doc._id}">수정</a>
                <button class="delete" data-id="${doc._id}">🗑️</button>
              `;
              listContainer.appendChild(listItem);
            });
          });
      });
    }

 

백엔드:

요청의 body에 포함된 inputText를 추출하여 정규식을 생성한다.

regex는 해당 키워드가 들어간 모든 문자열을 포함하여 해당하도록 한다.

예를 들어 '사과'를 검색하면 '사과' 뿐만 아니라 '풋사과', '사과하는 법'까지 모두 찾게 만든다.

 

그리고 이 regex가 title(제목)이나 content(본문)에 해당하는 것들을 모두 찾는다.

그리고 그 결과들을 merged라는 이름으로 모두 합친 배열을 반환해주면 끝! 인줄 알았는데

 

여기서 문제 발생. 단순 배열 합치기로 만드니, 제목과 본문 모두 '사과'가 들어있는 글은 2번 포함된다...

그래서 나는 간편한 중복 제거 방법으로 set자료구조에 넣고 다시 배열로 바꾸는 방법을 쓰려고 했는데

이를 위해 직렬화하고 비교하는게 더 귀찮을거 같아서 더 간단하게 filter메소드를 사용했다.

 

filter메소드로 _id가 같은 두 아이템이 인덱스까지 똑같으면 남기고, 다르면 없애는 방식으로

처음 나온 글만 남기고 모두 삭제하는 로직을 구현했다. 

app.post("/search", async (req, res) => {
  const keyword = req.body.inputText;
  const regex = new RegExp(keyword, "i");

  console.log(keyword, "keyword");
  console.log(regex, "regex");

  const collection = db.collection("post");
  const title = await collection.find({ title: regex }).toArray();
  // const title = await collection.find({ title: { $regex: keyword } }).toArray(); // 이렇게도 가능
  const content = await collection.find({ content: regex }).toArray();

  const merged = [...title, ...content];

  console.log(merged, "merged");

  const uniqueArray = merged.filter(
    (item, index, self) =>
      index ===
      self.findIndex((t) => {
        return t._id.toString() === item._id.toString();
      })
  );

  if (uniqueArray.length) {
    res.json({ doc: uniqueArray });
  } else {
    res.send("데이터가 없어요~");
  }
});

 

+그리고 나중에 안 사실인데 regex를 생성하여 find에 넣는거 이외에도 find메소드 내에 $regex로 키워드를 전달하면

정규식 조회를 할 수 있다고 한다. (mongoDB 숙련도도 나중에 올려야겠다.)

const title = await collection.find({ title: { $regex: keyword } }).toArray(); // 이렇게도 가능

 

 

QueryString을 이용한 방법

프론트

쿼리스트링에 넣어서 보낸다.

이때, encodedURIComponent라는 함수로 키워드를 wrapping해줘서

공백문자라던가 특수문자도 잘 출력되도록 보장할 수 있다고 한다.

 

공백문자는 '%20'으로 변환된다고 한다.

인터넷 이용하다가 쿼리 스트링이 내 입력대로 안 보이고 괴랄해보이는 경우 다 encoded된 값이어서 그랬던 것이다.

      const formEl = document.querySelector("#search-form");
      formEl.addEventListener("submit", async (e) => {
        e.preventDefault();
        const searchInputValue = document.querySelector("#inputText").value;

        const response = await fetch(
          `/search?keyword=${encodeURIComponent(searchInputValue)}`
        )
      // 이하 같아서 생략...

백엔드

req.query에 담겨져 있는 keyword를 받아 db조회하면 된다.

app.get("/search", async (req, res) => {
  const keyword = req.query.keyword; // 쿼리 스트링에서 'keyword' 파라미터 읽기

  const collection = db.collection("post");
  const title = await collection.find({ title: { $regex: keyword } }).toArray();
  const content = await collection
    .find({ content: { $regex: keyword } })
    .toArray();
  // 이하 같아서 생략...

 

 

공부 후기

사실 백엔드보다 프론트엔드 구현이 더 어려웠다..

백엔드는 db조회하는건 이제 익숙해져서 추가적으로 안 찾아보더라도 혼자서 해낼 수 있었는데

프론트는 오히려 더 검색해보고 찾아보는 과정이 필요했다.

리액트같은 spa 프레임워크에서 axios요청을 주로하다가 바닐라 자바스크립트로 ejs 템플릿 파일을 다루는게 더 어렵넹..

 

그리고 사실 이렇게 db를 다 조회하는 것은 db에 요소가 100만개 있으면

100만번 다 조회해야되는 방법이어서 매우 비효율적인 방법이라 한다.

'인덱스'를 생성하면 더 효율적으로 자료 조회를 수행할 수 있다고 하니,

조만간 인덱스 만들기를 배워 포스팅해보겠다.