게시판 - 검색 기능 만들기 1 (제목, 본문 검색)

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 페이지 기능 만들기 2에서 이어집니다.

board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard 4f8bca4
git reset --soft de726f2
npm install
atom .

board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 4f8bca4
git reset --soft de726f2
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/4f8bca4f76ff92be9aea81cc249c529d43d553a9


게시물 검색 기능을 만들어 봅시다.

검색타입(searchType), 검색어(searchText)를 query string으로 /posts route에 전달받아 해당 게시물만 보여주는 기능을 추가하고, post의 index view에 해당 기능을 수행할 수 있도록 form을 만듭니다. 검색타입은 'title'(제목), 'body'(본문), 'title,body'로 'title,body'는 title이나 body 둘 중 하나에만 검색어가 포함되어도 검색됩니다.

검색어는 해당항목의 일부분과 일치하면 해당 게시물이 검색됩니다. 예를 들어 title이 'test'인 게시물을 검색하는 경우, 제목이 정확히 'test'인 게시물만 찾는 것이 아니라, title에 'test'가 들어간 모든 게시물을 찾게 됩니다. 본문도 마찬가지입니다.

폴더 구조

코드 - js

// util.js

util.getPostQueryString = function(req, res, next){
  res.locals.getPostQueryString = function(isAppended=false, overwrites={}){    
    var queryString = '';
    var queryArray = [];
    var page = overwrites.page?overwrites.page:(req.query.page?req.query.page:'');
    var limit = overwrites.limit?overwrites.limit:(req.query.limit?req.query.limit:'');
    var searchType = overwrites.searchType?overwrites.searchType:(req.query.searchType?req.query.searchType:''); // 1
    var searchText = overwrites.searchText?overwrites.searchText:(req.query.searchText?req.query.searchText:''); // 1

    if(page) queryArray.push('page='+page);
    if(limit) queryArray.push('limit='+limit);
    if(searchType) queryArray.push('searchType='+searchType); // 1
    if(searchText) queryArray.push('searchText='+searchText); // 1

    if(queryArray.length>0) queryString = (isAppended?'&':'?') + queryArray.join('&');

    return queryString;
  }
  next();
}

module.exports = util;

1. 페이지 기능과 마찬가지로 검색에 관련된 query string들이 게시물과 관련된 route들에서 계속 따라다닐 수 있도록 getPostQueryString 함수에 searchTypesearchText를 추가해줍니다.

// routes/post.js

...

// Index
router.get('/', async function(req, res){
  var page = Math.max(1, parseInt(req.query.page));
  var limit = Math.max(1, parseInt(req.query.limit));
  page = !isNaN(page)?page:1;
  limit = !isNaN(limit)?limit:10;

  var searchQuery = createSearchQuery(req.query); // 1

  var skip = (page-1)*limit;
  var count = await Post.countDocuments(searchQuery); // 1-1
  var maxPage = Math.ceil(count/limit);
  var posts = await Post.find(searchQuery) // 1-2
    .populate('author')
    .sort('-createdAt')
    .skip(skip)
    .limit(limit)
    .exec();

  res.render('posts/index', {
    posts:posts,
    currentPage:page,
    maxPage:maxPage,
    limit:limit,
    searchType:req.query.searchType, // 2
    searchText:req.query.searchText  // 2
  });
});

...

// create
router.post('/', util.isLoggedin, function(req, res){
  req.body.author = req.user._id;
  Post.create(req.body, function(err, post){
    ...
    res.redirect('/posts'+res.locals.getPostQueryString(false, { page:1, searchText:'' })); // 3
  });
});

...

function createSearchQuery(queries){ // 4
  var searchQuery = {};
  if(queries.searchType && queries.searchText && queries.searchText.length >= 3){
    var searchTypes = queries.searchType.toLowerCase().split(',');
    var postQueries = [];
    if(searchTypes.indexOf('title')>=0){
      postQueries.push({ title: { $regex: new RegExp(queries.searchText, 'i') } });
    }
    if(searchTypes.indexOf('body')>=0){
      postQueries.push({ body: { $regex: new RegExp(queries.searchText, 'i') } });
    }
    if(postQueries.length > 0) searchQuery = {$or:postQueries};
  }
  return searchQuery;
}

1. 실제 게시물 검색은 Post.find(검색_쿼리_오브젝트)에 어떤 검색_쿼리_오브젝트가 들어가는지에 따라 결정됩니다. {title:"test title"}이라는 object가 들어가면 title이 정확히 "test title"인 게시물이 검색되고, {body:"test body"}라는 object가 들어가면 body가 정확히 "test body"인 게시물이 검색됩니다. 이처럼 검색기능에서는 검색_쿼리_오브젝트를 만드는 것이 중요한데, 이것을 만들기 위해 createSearchQuery함수를 만들고 이 함수를 통해 생성된 검색_쿼리_오브젝트searchQuery 변수에 담았습니다.

해당 함수의 코드는 4번에서 살펴보고, 여기서는 해당 함수를 통해 생성된 검색_쿼리_오브젝트searchQuery 변수에 넣었습니다.

1-1. 해당 검색_쿼리_오브젝트를 사용해서 전체 게시물 수와 게시물을 구합니다.

2. view에서 검색 form에 현재 검색에 사용한 검색타입과 검색어를 보여줄 수 있게 해당 데이터를 view에 보냅니다.

3. 새 글을 작성하면 검색 결과를 query string에서 제거하여 전체 게시물이 보이도록 합니다.

4. createSearchQuery함수의 코드를 살펴봅시다.

function createSearchQuery(queries){
  var searchQuery = {};
  if(queries.searchType && queries.searchText && queries.searchText.length >= 3){ // 1
    var searchTypes = queries.searchType.toLowerCase().split(',');
    var postQueries = [];
    if(searchTypes.indexOf('title')>=0){
      postQueries.push({ title: { $regex: new RegExp(queries.searchText, 'i') } }); // 2
    }
    if(searchTypes.indexOf('body')>=0){
      postQueries.push({ body: { $regex: new RegExp(queries.searchText, 'i') } });
    }
    if(postQueries.length > 0) searchQuery = {$or:postQueries}; // 3
  }
  return searchQuery;
}

1. query에 searchType, searchText가 존재하고, searchText가 3글자 이상인 경우에만 search query를 만들고, 이외의 경우에는 {}를 전달하여 모든 게시물이 검색되도록 합니다.

2. {$regex: Regex_오브젝트 }를 사용해서 regex 검색을 할 수 있습니다. 'i'는 대소문자를 구별하지 않는다는 regex의 옵션입니다. $regex query의 정확한 사용법은 https://docs.mongodb.com/manual/reference/operator/query/regex 에서 볼 수 있습니다.

3. {$or: 검색_쿼리_오브젝트_배열 }을 사용해서 or 검색을 할 수 있습니다. $or query의 정확한 사용법은 https://docs.mongodb.com/manual/reference/operator/query/or 에서 볼 수 있고 $and, $nor, $not query도 함께 공부해둡시다.

코드 - ejs

<!-- views/posts/index.ejs -->

      ...

      <form action="/posts" method="get" class="post-index-tool"> <!-- 1 -->
        <div class="form-row">

          <div class="form-group col-3">
            ...
          </div>

          <div class="form-group col-9"> <!-- 2 -->
            <label>Search</label>
            <div class="input-group">
              <select name="searchType" class="custom-select">
                <option value="title,body" <%= searchType=='title,body'?'selected':'' %>>Title, Body</option>
                <option value="title" <%= searchType=='title'?'selected':'' %>>Title</option>
                <option value="body" <%= searchType=='body'?'selected':'' %>>Body</option>
              </select>
              <input minLength="3" type="text" name="searchText" value="<%= searchText %>">
              <div class="input-group-append">
                <button class="btn btn-outline-primary" type="submit">search</button>
              </div>
            </div>
          </div>

        </div>
      </form>

    ...

1. 검색창의 사이즈를 조절하기 위해 post-index-tool CSS class를 추가했습니다.

2. 검색 form입니다. Bootstrap의 input group(https://getbootstrap.com/docs/4.1/components/input-group)을 사용하여 검색어 입력 form과 search 버튼을 이어붙였습니다.

코드 - css

/* public/css/master.css */

.post-index-tool {
  max-width: 450px;
}
.post-index-tool input[type='text']{
  max-width: 130px;
}

실행결과

게시판 아래에 검색 form이 생겼습니다. search type을 'Title, Body'로 하고, '6 title'를 검색해 봅시다.

'6 title'이 제목에 포함된 'Test Post 16 Title' 게시물과 'Test Post 6 Title' 게시물이 검색되었습니다.

마치며

$regex, $or같은 mongo DB 쿼리에 대해 처음으로 다루어 봤는데, https://docs.mongodb.com/manual/reference/operator/query 를 통해 다른 쿼리들도 한번 쯤 눈에 익혀둡시다

댓글

강성진 2020.09.14
친절한 강의 잘 보고 있습니다. 지금까지 문제 없이 잘 진행되었는데 이번 소스코드에서 TypeError: C:\workspace\board\views\posts\index.ejs:37     35|               </td>
    36|               <td class="author">
 >> 37|                   <a href="/posts<%= getPostQueryString(false, { searchType:'author!', searchText:post.author.username }) %>">
    38|                   <div data-search-highlight="author" class="ellipsis"><%= post.author ? post.author.username : "" %></div>
    39|                 </a>
    40|               </td>
Cannot read property 'username' of undefined 이런 에러가 발생하네요. 포스트 작성자를 클릭해서 보여주는 부분 같은데 강의에서 설명도 없고 해서.. 특별히 상관 없는 부분 같아서 주석처리하고 실행하면 잘 진행이 됩니다. 단지 post.author에 user._id를 넣었는데 이러한 방식으로 User모듈에 접근이 가능한지 궁금합니다.
I
Ian H 2020.09.14
@강성진,
Cannot read property 'username' of undefined 에러메세지를 보시면 undefined에서 'username'이라는 property 를 읽을 수 없다고 합니다.
코드를 보시면 post.author.username를 사용하고 있는데, post.auther가 undefined이기 때문에 발생하는 에러입니다.
아마 강의 초창기에 post-user(author) 관계를 설정하기 전에 만들어진 게시물이 있으신 것 같은데, 해당 게시물들을 찾아서 지우시거나, 아니면 모든 게시물들을 지우시면 문제없이 사용할 수 있으실 거예요.
해보시고 잘 안되시면 다시 댓글 남겨주세요^^
강성진 2020.09.15
@Ian H,
이 에러 발생 하는 부분은 이 다음 강의 게시물이었네요 ;;  아무튼 나머지 다 지우고 다시 clone해서 빌드해봐도 board 페이지로 넘어가면 같은 에러가 나옵니다. <a href="/posts<%= getPostQueryString(false, { searchType:'author!', searchText:post.author.username }) %>"> 위 부분을 주석처리하면 아래 하이라이트 처리 코드는 잘 실행이 됩니다.  <div data-search-highlight="author" class="ellipsis"><%= post.author ? post.author.username : "" %></div> 이 하이라이트 처리 코드에 post.author.username이 사용되어도 이상이 없는 것도 잘 이해가 안가네요.
강성진 2020.09.15
문제가 된 부분에 post.author가 null일 때 처리를 해주니 해결 되었습니다. <a href="/posts<%= getPostQueryString(false, { searchType:'author!', searchText:post.author ? post.author.username : "" }) %>"> 처음 포스트를 불러 올 때는 post.author 값이 들어 있지 않아서 페이지가 안열리고 에러가 났던 것 같습니다.
I
Ian H 2020.09.15
@강성진,
"지우고 다시 clone"하셨다는 걸로 봤을 때 프로그램 코드를 지우신 거 같은데, 코드가 아니라 DB의 데이터를 지우셔야 되요. 
강의 초반(https://www.a-mean-blog.com/ko/blog/Node-JS-첫걸음/게시판-만들기/게시판-Post-User-관계-relationship-만들기 전까지)는 게시물에 author가 생성되지 않는데, 이런 게시물들이 error를 내게 됩니다. 
물론 강성진님께서 제시하신 해결법도 문제를 해결할 수 있지만, 정확한 원인을 알려드리기 위해 설명드립니다^^;
K
KWANGJE MOON 2021.05.12
항상 도움많이 받고있습니다 감사합니다! 지금 혼자 연습으로 프로젝트를 진행하고있는데 SELECT 태그 없이 input 태그로만 구현(제목만 검색가능하게)을 하고싶은데 input태그 안 속성들을 어떻게 작성하면 좋을까요??
I
Ian H 2021.05.12
@KWANGJE MOON,
안녕하세요! 그냥 위 코드에서 select 태그 부분만 빼고 input부분은 그대로 둔 뒤 서버에서 req.query.searchText를 읽어서 제목만 검색하면 될 것같아요. 한번 해보세요^^
K
KWANGJE MOON 2021.05.13
@Ian H,
죄송합니다ㅠㅠ 헷갈리네요... <form id="search-form" method="GET" action="/posts" >               <input type="text" name="searchText" value="<%=searchText%>" class="input--text" placeholder="제목을 입력해주세요!!"></form> 이렇게 두고 routes/posts.js 부분에서 수정을 해야하는건가요??
I
Ian H 2021.05.13
@KWANGJE MOON,
네 그렇게 하시면 될 것 같아요^^
K
KWANGJE MOON 2021.05.14
@Ian H,
항상 감사드립니다!
두만욱 2021.07.01
안녕하세요 !  매번 도움을 주셔서 감사합니다. 바쁘시겠지만 .. 도움을 받을 수 있을까요 ?? <form method="GET" action="/posts">               <input type="checkbox" name="searchText" value="MID">미드               <input type="checkbox" name="searchText" value="TOP">탑               <input type="submit" onclick="searchoption()">                          </form>
function searchoption(){         $('#searchOP').val('helpline').prop("selected", true); 이렇게 검색 코드를 만들었는데, 체크박스로 둘 중 하나만 체크를 해서 써밋을 하면 정상적으로 잘 가지고 옵니다. 그런데 두개의 체크박스를 모두 체크해서 써밋을 하면  아무것도 가져오지를 못하는데, 혹시 방법이 있을까요 ??
I
Ian H 2021.07.01
@두만욱,
안녕하세요. Input 태그는 하나의 form 안에서 유일한 name을 가져야 합니다.(radio는 예외입니다)
현재 작성하신 코드에는 searchText 라는 name이 두번 사용되었죠.
두개의 체크박스에 각각 다른 name을 줘보세요^^
두만욱 2021.07.01
@Ian H,
넵,, 어떤 말씀이신지 잘 이해하였습니다. 그런데 그렇게 진행을 해보면 검색 쿼리를 만들지 못해서 검색이 되지 않는 것 같은데,, function createSearchQuery(queries){ // 여기 추가   var searchQuery = {};   if(queries.searchType && queries.searchText && queries.searchText.length >= 2){     var searchTypes = queries.searchType.toLowerCase().split(',');     var postQueries = [];     if(searchTypes.indexOf('title')>=0){       postQueries.push({ title: { $regex: new RegExp(queries.searchText, 'i') } });     }     if(searchTypes.indexOf('body')>=0){       postQueries.push({ body: { $regex: new RegExp(queries.searchText, 'i') } });     }     if(searchTypes.indexOf('name')>=0){       postQueries.push({ name: { $regex: new RegExp(queries.searchText, 'i') } });     }     if(searchTypes.indexOf('helpline')>=0){       postQueries.push({ helpline: { $regex: new RegExp(queries.searchText, 'i') } });     }     if(postQueries.length > 0) searchQuery = {$or:postQueries};   }   return searchQuery; }
그럼 변경된 이름 예를 들어 1개는 searchText, 1개는 searchText_1 이라고 name을 주었으면  searchText_1에 대한 쿼리도 다시 만들어줘야하는걸까요 ??
두만욱 2021.07.01
제가 만들고자 하는 기능은 , 현재는 chechbox 하나씩 선택하면 검색이 잘 되는데, 2개의 chechbox를 선택해서 검색하면 검색이 되지 않아서 이를 해결하고 싶습니다
I
Ian H 2021.07.01
@두만욱,
올려주신 form 코드는 제 강의의 form 코드와 구조가 많이 다르기 때문에 createSearchQuery의 코드도 완전히 새로 작성하셔야 합니다.  input의 name이 createSearchQuery의 항목이 된다는 것을 기억하고 작성해 보세요.
예를들어 searchText, searchText_1라고 name을 주었으면 query.searchText, query.searchText_1를 활용해서 createSearchQuery를 작성하면 됩니다.
<input type="checkbox" name="searchText" value="MID">미드 <input type="checkbox" name="searchText_1" value="TOP">탑
인 경우, 
searchText가 체크되었으면 createSearchQuery함수에서 queries.searchText = 'MID', 아니면 queries.searchText = undefined, searchText_1가 체크되었으면 queries.searchText_1= 'TOP', 아니면 queries.searchText_1= undefined, 둘다 체크되었으면 queries.searchText = 'MID', queries.searchText_1= 'TOP'
으로 값이 들어가게 됩니다.
두만욱 2021.07.05
@Ian H,
안녕하세요 !! 말씀해주신 내용 참고해서 쿼리 함수를 아래와 같이 수정을 하였습니다.
//쿼리 만드는 함수 function createSearchQuery(queries){    var searchQuery = {};   //query에 searchtype, searchtext가 존재하고 seacrhtext가 3글자인 경우에만 search query를 만들고,   //이 외의 경우에는 { } 를 전달하여 모든 게시물이 검색되도록 함   if(queries.searchType && queries.searchText && queries.searchText.length >= 2 || queries.searchText_1 ){     var searchTypes = queries.searchType.toLowerCase().split(',');     var postQueries = [];     var postQueriess = [];          if(searchTypes.indexOf('name')>=0){       //{$regex: Regex_오브젝트}를 사용해서 regex 검색을 할 수 있음       //i는 대소문자를 구별하지 않는다는 regex 의 옵션.       postQueries.push({ name: { $regex: new RegExp(queries.searchText_1, 'i') } });     }     if(searchTypes.indexOf('helpline')>=0){       postQueriess.push({ helpline: { $regex: new RegExp(queries.searchText, 'i') } });       postQueries.push({ helpline: { $regex: new RegExp(queries.searchText_1, 'i') } });     }                    if(postQueries.length > 0) searchQuery = {$or:postQueries};     if(postQueriess.length > 0) searchQuery = {$or:postQueriess};                  }   return searchQuery; }    여기에서 name이 searchText 인 값을 postQueriess이 가지고 있고 name이 searchText_1인 값을 postQueries이 가지고 있을 때에  postQueriess와 postQueriess 배열을 하나로 합쳐서 두 개의 배열 값을 한 번에 보여주고 싶은데 어떻게 해야 병합이 될까요 ..??
var postQueriesTemp= mine.concat(postQueries, postQueriess);
 searchQuery = {$or:postQueriesTeml}; 이렇게도 해보앗는데 검색이 안되는 것 같아서요 ㅠㅠ
I
Ian H 2021.07.05
@두만욱,
searchText만 포함한 데이터를 찾는 경우에는 new RegExp(queries.searchText,'i') searchText_1만 포함한 데이터를 경우에는 new RegExp(queries.searchText_1,'i') searchText와 searchText_1 둘 중 하나라도 포함하는 데이터를 찾는 경우에는 new RegExp(queries.searchText+'|'+queries.searchText_1,'i') 를 사용하시면 됩니다^^
두만욱 2021.07.06
@Ian H,
헉 !!!!!잘됩니다 !!!!!
혹시 저 궁금한게 있는데 ,  new RegExp(queries.searchText+'|'+queries.searchText_1,'i') 이 조건에서 왜 앞에는 " I " 이고 뒤에는 " i " 인지 알 수 있을까요 ?? 서로 차이가 있을까요 ??
두만욱 2021.07.06
추가로 , 만약 searchText_2 이 추가되는 경우에는   new RegExp(queries.searchText+'|'+queries.searchText_1+'|'+queries.searchText_2,'i') 이렇게 하는게아닌가용 ,,?? 제가 완전 초보라서 ,, 잘 모릅니다 ㅠ
I
Ian H 2021.07.06
@두만욱,
일단 뒤에 있는 'i'는 알파벳 소문자 '아이'이지만, 앞의 '|'는 대문자 '아이'가 아니라, 키보드의 엔터키와 백스페이스 주변에 있는 수직선입니다.
RegExp는 new RegExp(패턴, 플레그) 으로 생성합니다. 첫번째 파라메터는 정규표현식(Regular Expression)의 패턴이고, 두번째 파라메터는 플레그(옵션)입니다.
즉 queries.searchText+'|'+queries.searchText_1는 정규표현식 패턴이고, 'i'는 옵션입니다. '|' 는 정규표현식에서 or의 역할을 하는 문자이고, 'i'는 대소문자를 구별하지 말라는 옵션입니다.
참고문헌:  https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Regular_Expressions https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/RegExp https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/RegExp/RegExp
I
Ian H 2021.07.06
@두만욱,
searchText_2 가 추가되면 작성하신 대로 하면 됩니다^^
댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP