[Electron] 7.검색 기능 구현(1): 검색 결과 및 페이징 - 레시피 일렉트론 앱

검색 기능 및 결과 화면 정의

기능 구현을 위한 IPC 통신 방식을 설계했으니, 본격적인 구현을 시작해야 한다. 우선 레시리 검색 결과를 표시하는 검색 기능과 그 결과 목록 화면에 대한 것을 정의했다.

  1. 검색어를 입력하고, 컬럼을 선택해 제출하면 검색 결과 목록을 표시한다.
  2. 레시피는 한 페이지에 10개 표시하며, 페이지 버튼 또한 10개씩 표시하고 하단 부에 위치시킨다.
  3. 검색 결과 목록의 각 레시피는 이미지, title, ingredients, 다운로드 버튼, 상세 보기 버튼을 포함한다. (id, instrunctions 제외)
  4. ingredients 컬럼 값의 개수(배열 크기)가 3개 이상일 때는 값 목록을 접고 펼치도록 한다. (추가)

현 단계에서는 3번까지 개발하고, 4번 항목은 마크업, CSS 등이 적용된 후에 개발할 예정이다.

검색 쿼리문

//rcp_sqlite_db.js
selectRcpByKeyword(keyword, column, offset) {
        if (this.db == null)
            return '';

        if (column == 'all') {
            column = 'tb_fts_recipe'
        }

        const resultTable = this.db.prepare(
            `
            SELECT id, title, ingredients, instructions
            FROM tb_fts_recipe
            WHERE ${column} MATCH '${keyword}'
            ORDER BY bm25(tb_fts_recipe, 10.0, 3.0)
            LIMIT ${offset}, 10
            `
        ).all();

        const resultTotal = this.db.prepare(
            `
            SELECT COUNT(*) AS total
            FROM tb_fts_recipe
            WHERE ${column} MATCH '${keyword}'
            `
        ).all();

        const result = {resultTable, resultTotal}

        return result;    

}
  • keyword: 검색어
  • column: 검색할 컬럼
  • offset: 현재 페이지의 row number

2개의 쿼리문

DB 클래스에 위와 같이 검색 함수를 정의한다. 이때 쿼리문은 페이징을 위해 offset에 따라 10개 씩 결과를 가져오는 쿼리와 결과의 전체 개수를 가져오는 COUNT 쿼리 2개를 작성한다. 두 쿼리를 한 함수에 돌리고, 결과는 함께 반환한다.

컬럼 인자

column 명을 인자로 받아 어느 컬럼에서 검색할 지 정한다. Fts 테이블이기에 간결하게 가능하다. ‘all’ 이라는 인자를 컬럼으로 받을 때는, 해당 Fts 테이블 명으로 교체하여 사용하면 모든 컬럼에서 검색을 가능케 한다.

bm25 알고리즘

ORDER BY 항목을 살펴보면, 컬럼이름이 아닌 bm25 라는 함수로 설정되어 있는 것을 확인할 수 있다. bm25는 정보 검색 분야에서 사용되는 알고리즘 중 하나로 sqlite3의 fts 테이블에서도 검색 결과 순위를 정해주는 알고리즘으로 사용되고 있다.

-- full text search table
CREATE VIRTUAL TABLE tb_fts_recipe
USING FTS5(
title,
ingredients,
instructions,
id,
tokenize = 'porter'
);

// select with bm25
SELECT id, title, ingredients, instructions            
FROM tb_fts_recipe
WHERE tb_fts_recipe MATCH 'egg'
ORDER BY bm25(tb_fts_recipe, 10.0, 3.0)

 

Sqlite3 Fts5 bm25 함수 docs: https://www.sqlite.org/fts5.html#the_bm25_function

 

SQLite FTS5 Extension

1. Overview of FTS5 FTS5 is an SQLite virtual table module that provides full-text search functionality to database applications. In their most elementary form, full-text search engines allow the user to efficiently search a large collection of documents f

www.sqlite.org

bm25 함수에 첫번째 인자는 해당 테이블의 이름, 두번째 인자는 해당 테이블의 첫번째 필드에 설정할 가중치, 세번째 인자는 두번째 필드에 설정할 가중치이다. 따라서 fts 테이블의 첫번째, 두번째 필드 순서는 중요하다고 할 수 있다. Pirmary key인 id 컬럼을 원본 레시피 테이블과 달리 첫번째 컬럼으로 두지 않은 이유이기도 하다.

요점은 결국 각 순서에 해당하는 컬럼에 더 가중치를 매겨 순위를 정하며, 검색어의 빈도 수, 문서의 길이(길수록 낮은 점수) 또한 점수에 영향을 미친다고 할 수 있다.

검색 기능 구현

//preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('apis', {
    
    req_searchRcpList: (keyword, column, pageNum) => ipcRenderer.send('req_searchRcpList', keyword, column, pageNum),
    resp_searchRcpList: (searchResult) => ipcRenderer.on('resp_searchRcpList', searchResult),

  });

preload.js: renderer에서 사용할 ipc 함수를 (송신, 수신) window 전역 변수에 설정한다.

//rcp_ipc.js
addSearchRcpList() {
        ipcMain.on('req_searchRcpList', (event, keyword, column, pageNum) => {
            try {
                keyword = keyword.trim();
                let offset = (pageNum-1)*10;

                let searchResult = this.db.selectRcpByKeyword(keyword, column, offset);
                event.reply('resp_searchRcpList', searchResult);

            } catch (error) {
                console.log(error);
                event.reply('resp_searchRcpList', 'error');
            }

        });

}

rcp_ipc.js: main에서 renderer 측 송신에 대한 응답 리스너를 생성한다. 응답 시에 sqlite 쿼리문을 호출하여 DB로 부터 결과를 받아서 renderer로 응답한다. 이때 renderer에서 넘어온 pageNum(현재 페이지) 인자를 사용하여 offset 값을 계산 후, 쿼리문 호출 시에 인자로 넘겨준다. 

1페이지 일때는 0번째 row 부터, 2 페이지 일때는 10번째 row 부터, 3 페이지 일때는 20번째 row 부터 SELECT 하면 되므로, (pageNum-1) x 10 을 해주면 offset을 구할 수 있다. 

//renderer.js
window.$ = window.jQuery = require('jquery');

// Global variable for search & paging
let gKeyword;
let gColumn;
let gTotalRcp;
let gLastPage;
let gRange = 1;
let gPageNum = 1;

/**
 * When loading a search_result page from another page, first set (gKeyword) and search
 */
window.onload = () => {
    const urlParams = new URLSearchParams(window.location.search);
    gKeyword = urlParams.get('keyword');
    gColumn = urlParams.get('column');
    $('#keyword').val(gKeyword);

    requestSearch(gKeyword, gColumn, 1);
}

/**
 * A function that requests search results from all columns.
 * @param {*} keyword 
 * @param {*} pageNum 
 */
function requestSearch(keyword, column, pageNum) {
    gPageNum = pageNum;
    window.apis.req_searchRcpList(keyword, column, pageNum); //A search request to the main process.
}

/**
 * A listener that receives responses to all column search requests.
 */
window.apis.resp_searchRcpList((event, searchResult) => {
    if (searchResult.length == 0) {
        alert('No results were found for your search.');

    } else if (searchResult != 'error') {
        displaySearchResult(searchResult.resultTable); // Display received search results.
        gTotalRcp = searchResult.resultTotal[0].total;

    } else { 
        alert('Error');
    }

    gLastPage = Math.ceil(gTotalRcp / 10);
    displayPagination(gPageNum); // After calculating the last page, display 'pagination' immediately.

});

/**
 * A function that displays the received search results in HTML on the page.
 * @param {*} searchResult 
 * @returns 
 */
function displaySearchResult(searchResult) {
    $('#list').empty(); //init
    if (gTotalRcp == 0) {
        return;
    }
    try {
        searchResult.forEach((value, index) => {
            let child =
                `<li>
                    <div>
                        <img src="" class="link_img" id="img_${value.id}" />
                    </div>                
                    <div>
                        <div>
                            <!--
                            <div>
                                <strong>Recipe ID</strong>
                                <p>${value.id}</p>
                            </div>
                            -->
                            <div>
                                <strong>Title</strong>
                                <p>${value.title}</p>
                            </div>
                            <div>
                                <strong>Ingredients</strong>
                                <!--<p>${value.ingredients}</p>-->
                `;

            // Convert 'ingredients' value in string format to array
            let ingredients = value.ingredients.replace(/""/g, '"');
            let matchArray = ingredients.match(/'[^']*'|"[^"]*"/g);
            let ingredientsArray = matchArray.map(match => match.slice(1, -1));

            ingredientsArray.forEach(function (ingredient) {
                child += `<p>* ${ingredient}</p>`;
            });

            child +=
                `           </div>            
                            <div>
                                <strong>Instructions</strong>
                                <p>${value.instructions}</p>
                            </div>
                        </div>
                        <div>
                            <a href="javascript:void(0)" class="download_btn" id="download_${value.id}">Download Recipe File</a>
                            <a href="javascript:void(0)" class="view_btn" id="view_${value.id}">View Recipe</a>
                        </div>                        
                    </div>
                </li>`;

            $('#list').append(child);
        });
        /**
         * After displaying the search results, add additional functions 
         * (Because the work to encode the buffer needs to be done in 'Main')
         */
        displayRcpImage();
        //displayDownloadButton();
        //displayRcpViewButton();
    } catch (e) {
        /**
         * Catch errors when there is no search result 
         * so that only empty() is executed when there is no search result.
         */
        console.log('displaySearchResult: catch' + e);
    }
}

/**
 * A function that calculate the number of pages and display page buttons
 * @param {*} pageNum 
 */
function displayPagination(pageNum) {
    const RANGE_SIZE = 10;
    let lastRange = (gLastPage / RANGE_SIZE <= 1) ? 1 : Math.ceil(gLastPage / RANGE_SIZE);

    gRange = (pageNum <= 10) ? 1 : Math.ceil(pageNum / 10);

    let startPageInRange = (gRange == 1) ? 1 : gRange * 10 - 9;

    let idToken = 1;

    for (let i = startPageInRange; i < startPageInRange + 10; i++) {
        if (i > gLastPage) {
            $("#pg_" + idToken).hide();

        } else if (i == pageNum) {
            $("#pg_" + idToken).show();
            $('#pg_' + idToken).html(i);
            $("#pg_" + idToken).css("color", "red");

        } else {
            $("#pg_" + idToken).show();
            $('#pg_' + idToken).html(i);
            $("#pg_" + idToken).css("color", "black");

        }
        idToken++;
    }

    if (gRange == 1) {
        $("#pg_prev").hide();
        $("#pg_first").hide();

        if (lastRange == 1) {
            $("#pg_next").hide();
            $("#pg_last").hide();
        } else {
            $("#pg_next").show();
            $("#pg_last").show();
        }

    } else if (gRange > 1 && gRange < lastRange) {
        $("#pg_prev").show();
        $("#pg_first").show();
        $("#pg_next").show();
        $("#pg_last").show();

    } else {
        $("#pg_prev").show();
        $("#pg_first").show();
        $("#pg_next").hide();
        $("#pg_last").hide();

    }

    $('.result').scrollTop(0);
}

// paging envent
$('.pg_list').on('click', function () {
    requestSearch(gKeyword, gColumn, $(this).html());
});

$('#pg_first').on('click', function () {
    requestSearch(gKeyword, gColumn, 1);
});

$('#pg_next').on('click', function () {
    requestSearch(gKeyword, gColumn, 1 + (gRange * 10));
});

$('#pg_prev').on('click', function () {
    requestSearch(gKeyword, gColumn, (gRange - 1) * 10);
});

$('#pg_last').on('click', function () {
    requestSearch(gKeyword, gColumn, gLastPage);
});

renderer.js: main에서 송신한 응답을 수신하여 검색 결과에 맞게 HTML을 구성한다. 결과 페이지에서도 재검색을 할 수 있도록, 검색어, 현재 페이지 등 관련 변수를 전역으로 설정한다.

  1. 검색어와 컬럼을 입력하여 제출한다.
  2. renderer에서 main에게 ‘검색어, 컬럼, 현재 페이지’ 세 인자와 함께 검색 요청을 송신한다. (요청)
  3. main은 renderer의 요청을 수신하여, 받은 인자를 Sqlite 쿼리문을 호출하여 DB에게 전달한다.
  4. Sqlite DB는 검색을 처리하여 결과를 main에 반환한다.
  5. main은 DB로 부터 받은 결과를 renderer로 송신한다. (응답)
  6. renderer는 main으로 부터 받은 응답을 수신하여, 결과 화면 및 페이징을 HTML로 구성한다.