본문 바로가기

Front-End

초성 검색 기능 구현하기

728x90

INDEX

    Stack

    #VanillaJS, #Jquery

    Preview

    이번 포스팅은 초성 검색 알고리즘을 참고해 실무에 적용해 본 기능에 대한 내용입니다.
    검색 기능 중 마치 노래방 검색기능 초성 검색 기능이 있으면 매우 편리해집니다.
    대부분의 금융앱에서 이체 내역 화면을 보면 계좌번호 혹은 상대방의 이름을 통해서 검색해 원하는 계좌만 볼 수 있도록 하는 기능들 많이 보셨을 겁니다.

     

    신영증권 MTS

     

    이 알고리즘을 활용된 부분입니다. Array.prototype.includes(), String.prototype.includes()를 사용해 문자열에서 원하는 원소를 찾는 방법을 사용할 수도 있지만 초성 검색 기능 혹은 한 문자열 안에서 이어져있는 문자열만 찾는 것이 아니라 좀 떨어져 있는 문자열도 찾는 기능을 추가하면 훨씬 더 나은 사용자 경험을 제공할 수 있을 겁니다.

    Methodology

    const testArr = [
        {
            'OPNG_BANK_ACNM' : "김철수",
            'OPNG_BANK_ACNO' : '100-203230-001'
        },
        {
            'OPNG_BANK_ACNM' : "이영희",
            'OPNG_BANK_ACNO' : '100-123423-001'
        },
        {
            'OPNG_BANK_ACNM' : "이홍길",
            'OPNG_BANK_ACNO' : '100-129990-002'
        },
        {
            'OPNG_BANK_ACNM' : "김인수",
            'OPNG_BANK_ACNO' : '122-235830-001'
        }
    ]

    위와 같은 계좌정보가 담긴 객체 배열이 있다고 가정합니다. 이체 내역 캡처 사진을 보면 검색어를 포함한 객체만 보여야 하고 또한 검색어에 highlight 가 들어가야 합니다. 가령 혹은 를 입력했을 때 이 초성을 포함한 객체만 필터링되고 김 철수의 형태로 변환이 되어 이를 화면에 보여줘야지만 "김" 자에 highlight 가 들어가게 되겠죠.

    1. 문자열이 초성 및 음절을 포함하는지 체크하는 정규식을 생성합니다.  
    2. 객체의 새로운 필드값으로 특정글자에 Highlight를 주는 script 코드를 저장합니다.  
    ex) {
            OPNG_BANK_ACNM : "김인수",
            OPNG_BANK_ACNO : "122-235830-001",
            OPNG\_BANK_ACNM_2 = "김 철수"
        }
    3. 위에서 새로 만든 필드값을 보유하고 있는 객체만 배열에서 걸러냅니다.

    Code

    function matchList(search, arr, sTag = "", eTag = ""){
        const reESC = /[\\^$.*+?()[\]{}|]/g, reChar = /[가-힣]/, reJa = /[ㄱ-ㅎ]/, offset = 44032;
        const con2syl = Object.fromEntries('ㄱ:가,ㄲ:까,ㄴ:나,ㄷ:다,ㄸ:따,ㄹ:라,ㅁ:마,ㅂ:바,ㅃ:빠,ㅅ:사'.split(",").map((v)=>{
            const entry = v.split(":");
            entry[1] = entry[1].charCodeAt(0);
            return entry;
        }));
    
        //입력된 문자열로 정규식 패턴을 만들어냅니다.
        function pattern(ch){
            let r;
            if(reJa.test(ch)){
                const begin = con2syl[ch] || ((ch.charCodeAt(0) - 12613) * 588 + con2syl['ㅅ']);
                const end = begin + 587;
                r = `[${ch}\\u${begin.toString(16)}-\\u${end.toString(16)}]`;
            }else if(reChar.test(ch)){
                const chCode = ch.charCodeAt(0) - offset;
                if(chCode % 28 > 0) return ch;
                const begin = Math.floor(chCode / 28) * 28 + offset;
                const end = begin + 27;
                r = `[\\u${begin.toString(16)}-\\u${end.toString(16)}]`;
            }else r= ch.replace(reESC, '\\$&');
    
            return `(${r})`;
        };
    
        //정규식 패턴으로 조건에 맞는 문자열을 Script Tag 로 감쌉니다.
        function matcher (v, matches, sTag, eTag, tagLen){
            let distance = Number.MAX_VALUE, first = -1, last = 0, vLast = 0, vPrev = 0, acc = v;
            for(let i = 1, j = matches.length; i < j; i++){
                const curr = matches[i];
                vLast = v.indexOf(curr, vLast);
                if(first == -1) first = vLast;
                if(vLast && distance > vLast - vPrev) distance = vLast - vPrev;
                vPrev = vLast;
                last = acc.indexOf(curr, last);
                acc = `${acc.substring(0, last)}${sTag}${curr}${eTag}${acc.substr(last + 1)}`;
                last += tagLen;
            }
            return [acc, distance, v.length, first];
        };
    
        const reg = new RegExp(search.split('').map(pattern).join('.*?'), "i");
        const tagLen = sTag.length + eTag.length;
    
        //Tag로 감싸진 문자열을 포함한 내용을 새로운 필드 _2 에 삽입합니다.
        const replaceArr = arr.map((item)=>{
            const nItem = {...item};
            const {OPNG_BANK_ACNM, OPNG_BANK_ACNO} = nItem;
            const matches_name = reg.exec(OPNG_BANK_ACNM);
            if(matches_name) nItem['OPNG_BANK_ACNM_2'] = matcher(OPNG_BANK_ACNM, matches_name, sTag, eTag, tagLen)[0];
            const matches_acc = reg.exec(OPNG_BANK_ACNO);
            if(matches_acc) nItem['OPNG_BANK_ACNO_2'] = matcher(OPNG_BANK_ACNO, matches_acc, sTag, eTag, tagLen)[0];
            return nItem;
        });
    
        //조건에 맞는 아이템만 거릅니다.
        return replaceArr.filter((item)=> item['OPNG_BANK_ACNM_2'] || item['OPNG_BANK_ACNO_2']);
    };
    
    console.log(matchList('ㅇㅎㄱ', testArr, '<span style={"color" : "red"}>', '</span>'));

    console에 찍힌 내용을 보면 다음과 같습니다.

    초성에 대한 검색은 잘 됩니다. 하지만 특이한 점이 있습니다.

    matchList("기", testArr, '<span style={"color" : "red"}>', "</span>")

    이처럼 받침이 없는 글자를 입력한 경우 조건에 맞게 잘 처리하나 받침이 있는 글자를 입력하는 경우 필터링은 잘 되지만 script tag가 잘 감싸지지 않은걸 볼 수 있습니다.

    matchList("김", testArr, '<span style={"color" : "red"}>', "</span>")

    초성 검색에 한해서 구현해 놓은 알고리즘 및 정규식이어서 그렇습니다.

    이 부분에 대해 수정하려면

    pattern 함수에서 else if 부분을 자세히 봐야 합니다.

    else if(reChar.test(ch)){
        const chCode = ch.charCodeAt(0) - offset;
        if(chCode % 28 > 0) return ch;
        const begin = Math.floor(chCode / 28) * 28 + offset;
        const end = begin + 27;
        r = `[\\u${begin.toString(16)}-\\u${end.toString(16)}]`;
     }

    가-힣까지의 글자는 이 분기문을 탈 텐데 받침이 들어간 글자의 경우 chCode % 28 > 0의 조건을 만족시켜 글자 그대로 return 이 됩니다.

    string.charCodeAt(index) 은 UTF-16 글자표에서 string의 index 번째에 있는 글자의 10진수 값을 반환합니다.

    ("".charCodeAt(0)-offset)%28;

    "" 안에 문자열을 대입해 테스트를 해보니 초성의 경우 음수, 모음과 자음이 들어간 음절의 경우 0, 받침이 들어가면 양수가 나옵니다.

    만약 주석으로 처리해 아래 코드까지 진행되게 한다면 다음과 같은 현상이 발생합니다.

    "김"으로 검색한 결과

    이홍길 씨의 "길" 글자도 변환이 되었네요. 우리가 원하는 건 이게 아닙니다.

    우리는 이 받침이 들어간 글자까지 검색해 Highlight 효과를 주고 싶기 때문에 그대로 return 하지 않고 초성 검색과 같은 경우처럼 return 해주도록 합니다.

    if (chCode % 28 > 0) return `(${ch})`;

    이제 이 OPNG_BANK_ACNM_2 혹은 OPNG_BANK_ACNO_2를 사용해 스크립트를 표현하면 됩니다.

    물론 직접 짜면 좋지만.. 저걸 다 짤 생각하니 머리가 지끈지끈해서 검색해 보니 좋은 자료가 있더라구요ㅎㅎ

    이 글도 누군가에겐 도움이 되길 바라며 작성을 마칩니다.

    Reference

    https://www.bsidesoft.com/8517

    [[js] 초성 자동완성 검색

    초성검색을 통해 자동완성용 리스트를 만들어봅니다.

    www.bsidesoft.com](https://www.bsidesoft.com/8517)

    다른 몇몇 블로그의 알고리즘도 사용해 보았지만 몇 가지 에러('(', '', '?')와 같은 특수문자를 입력 시 invalide regular expression 정규식 에러가 발생해 사용할 수 없었습니다.*