본문 바로가기
Infra/Database

한글 검색기 만들기

by Wordbe 2021. 8. 16.
728x90

한글 검색기 만들어보기

주식에 대한 정보를 제공해주는 앱이 있다고 하자.

사용자는 원하는 종목을 검색하고 싶다.

검색창에 한글자, 한글자 입력할 때마다 javascript 는 해당 글자에 관한 검색 결과를 반환한다.

검색 결과는 중요도 우선순위로 정렬되어 보일 수 있는데, 사람들이 많이 클릭했던 순, 시가총액 순 등의 기준을 세울 수 있을 것이다.

investing.com 에서 appl 검색 결과

위 그림의 결과를 예시로 들 수 있다.

우리는 이를 로직으로 구현하기 위해 sql 에서 like 구문을 생각해볼 수 있다.

SELECT item_name
  FROM item
 WHERE item_name like '%'||:keyword||'%'
  • item 테이블에는 종목이름이 들어있다.

위 쿼리는 like 검색이기 때문에 인덱스 적용이 힘들고, 원하는 성능의 결과를 얻을 수 없을 수도 있다.

혹은 앞글자만 정확히 입력하면 뒷글자만 비슷하게 찾아주는 검색방식으로 타협을 볼 수도 있다. (아래와 같이하면 인덱스는 사용할 수 있다.)

SELECT item_name
  FROM item
 WHERE item_name like :keyword||'%'

Like 검색은 비효율적일 수 있지만 간단하게 생각해볼 수 있는 가장 단순한 구현이면서도, 문자열의 길이가 짧고 데이터의 수가 적은 케이스의 경우 잘 사용될 수 있는 방법이기도 하다.

더 복잡한 데이터를 다루기 위해서는 Elasticsearch 와 같이 역색인을 사용하는 검색 엔진, 자연어 처리 분석 등의 도입을 고려해볼 수 있다.

이 글에서는 sql 을 통해 검색하는 것만 다룬다.


한글은 글자의 구성이 조금 다르다.

영어의 경우, apple 의 알파벳구성은 a, p, p, l, e 로 확실하게 나누어져있다.

글자가 확실히 나누어져 있고, 알파벳들은 모두 like 검색이 가능하다.

하지만 한글의 경우를 생각해보면, 검색어 애플은 애, 플로 나누어져 있다.

'앺' 을 검색했을 때도 애플이 나오도록 하고싶다면, 위에서 작성한 sql 와 지금의 테이블 구성으로써는 작동이 어렵다.

자판을 입력하는 과정을 생각해보자.

애플을 검색하는 경우 :

ㅇ → 애 → 앺 → 애프 → 애플

검색창에 적히는 글자는 위의 5개 순서로 적용이 된다.

생각해보면 ㅇ, 앺, 애프 로 검색했을 경우는 원하는 검색결과가 안나온다는 것을 알 수 있다.

IDEA

애플이라는 단어의 '애', '플'과 같이 소리나는 단위 한 글자를 '음절' 이라고 한다.

하지만, 우리의 니즈는 음절 단위보다 하나 더 작은 단위를 원한다.

애플은 'ㅇ', 'ㅐ', 'ㅍ', 'ㅡ', 'ㄹ' 로 나눌 수 있고, 이를 '음소' 라고 한다.

즉, 테이블에 종목이름으로 '애플' 이 담겨있다면 이를 'ㅇㅐㅍㅡㄹ' 로 분리해주면 된다는 방법을 생각해볼 수 있다.

그러면 'ㅇ' 을 키워드로 검색해도 like 검색이 가능하고,

'앺' 키워드를 검색하는 경우 'ㅇㅐㅍ' 로 나누어 키워드를 입력한다면 like 검색이 가능하다.


초중성을 분리하는 방법

자바에서는 Normalize 가 글자를 분해/합성하는 기능을 지원한다.

private void printWord(String word) {
  for (int i = 0; i< word.length(); i++) {
    int codePoint = word.codePointAt(i);
    System.out.println(word.charAt(i) + String.format(" U+%04X ", codePoint) + codePoint);
  }
  System.out.println();
}

String word = "애플";
printWord(word);

String nfd = Normalizer.normalize(word, Normalizer.Form.NFD);
printWord(nfd);
애 U+C560 50528
플 U+D50C 54540

ᄋ U+110B 4363
ᅢ U+1162 4450
ᄑ U+1111 4369
ᅳ U+1173 4467
ᆯ U+11AF 4527
  • Normalizer.Form.NFD 를 사용하여 한글을 정규화하면 한글을 각 음소별로 나누어준다.
  • 각 음소의 유니코드값과 코드포인트 값을 출력하였다.
  • 코드포인트(Code Point)
    • 문자열에 할당된 코드값이다.
    • 동일한 문자열에 할당된 코드값이더라도 인코딩 종류마다 다르게 표현된다.

 

다른 종목도 넣어보았다.

String word = "애버크롬비 & 피치";
애 U+C560 50528
버 U+BC84 48260
크 U+D06C 53356
롬 U+B86C 47212
비 U+BE44 48708
  U+0020 32
& U+0026 38
  U+0020 32
피 U+D53C 54588
치 U+CE58 52824

ᄋ U+110B 4363
ᅢ U+1162 4450
ᄇ U+1107 4359
ᅥ U+1165 4453
ᄏ U+110F 4367
ᅳ U+1173 4467
ᄅ U+1105 4357
ᅩ U+1169 4457
ᆷ U+11B7 4535
ᄇ U+1107 4359
ᅵ U+1175 4469
  U+0020 32
& U+0026 38
  U+0020 32
ᄑ U+1111 4369
ᅵ U+1175 4469
ᄎ U+110E 4366
ᅵ U+1175 4469

이제 종목테이블에서 아래와 같이 한글을 분해시키고,

item
name decomposed_name
애플 ㅇㅐㅍㅡㄹ
애브비 ㅇㅐㅂㅡㅂㅣ
애버크롬비 ㅇㅐㅂㅓㅋㅡㄹㅗㅁㅂㅣ
페이스북 ㅍㅔㅇㅣㅅㅡㅂㅜㄱ
넷플릭스 ㄴㅔㅅㅍㅡㄹㄹㅣㄱㅅㅡ

 

keyword 에 분해된 한글을 넣어서 검색하면 된다.

즉, 키워드가 '애' 라면 'ㅇㅐ'로 분해하여 검색하는 것이다.

SELECT item_name
  FROM item
 WHERE item_name like '%'||:keyword||'%'
애플
애브비
애버크롬비

 


앗 그런데, 초성과 종성이 다르다?

Normalizer.Form.NFD 를 사용하면 완성형 한글을 간단하게 초, 중, 종성으로 나누어 주지만, 초성과 종성의 모든 글자가 완전히 다르게 매핑된다.

즉, '넷플릭스'에서 '플'의 ㄹ 받침과 '릭'의 ㄹ은 다른 유니코드로 분리된다는 것이다. 하지만 우리가 원하는 검색을 위해서는 둘은 같음 음소로 분리해야 한다. 따라서 초중성을 분리하는 함수를 직접만들어보자.

public String seperate(String string) {
        // 초성 19개
        String[] arr_cho =
                {"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"};

        // 중성 21개
        String[] arr_jung =
                {"ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"};

        // 종성 28개
        String[] arr_jong =
                {"", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"};

        StringBuffer sb = new StringBuffer();
        for (int i=0; i<string.length(); i++) {
            char uniVal = string.charAt(i);

            try {
                if (uniVal >= 0xAC00 && uniVal <= 0xD7A3) {
                    uniVal = (char)(uniVal - 0xAC00);
                    char cho = (char)(uniVal / 28 / 21);
                    char jung = (char)(uniVal / 28 % 21);
                    char jong = (char)(uniVal % 28);

                    sb.append(arr_cho[cho]);
                    sb.append(arr_jung[jung]);
                    sb.append(arr_jong[jong]);
                } else {
                    sb.append(uniVal);
                }
            } catch (RuntimeException e) {
                sb.append(uniVal);
            }
        }

        return sb.toString();
    }

한글 완성형의 유니코드 범위는 0xAC00 ~ 0xD7A3 이므로 해당 범위에서는 초중성을 분리하는 함수이다.

그 외의 문자는 변환되지 않고 그냥 나온다. try ~ catch 로 감싸준 이유는 혹시나 저 유니코드 사이에 속한 문자가 한글이 아닌 경우 들어갔다가는 out of index 에러를 반환시키기 때문에 추가해보았다. 한글만 들어온다는 확신히 든다면 제거해도 좋다.

Normalizer 이용

ᄂ U+1102 4354
ᅦ U+1166 4454
ᆺ U+11BA 4538
ᄑ U+1111 4369
ᅳ U+1173 4467
ᆯ U+11AF 4527
ᄅ U+1105 4357
ᅵ U+1175 4469
ᆨ U+11A8 4520
ᄉ U+1109 4361
ᅳ U+1173 4467

---------------------------------
seperate 함수이용

ㄴ U+3134 12596
ㅔ U+3154 12628
ㅅ U+3145 12613
ㅍ U+314D 12621
ㅡ U+3161 12641
ㄹ U+3139 12601
ㄹ U+3139 12601
ㅣ U+3163 12643
ㄱ U+3131 12593
ㅅ U+3145 12613
ㅡ U+3161 12641

 

이렇게 초중성을 분리한 후, 테이블에 데이터를 저장해서, like 검색으로 키워드검색을 하면 되겠다. 인덱스 검색이 어렵기 때문에 성능은 보장하지 못하지만 종목검색 등과 같이 그 개수가 약간은 한정된 데이터에서는 어느정도 힘을 발휘할 수 있을 것이다. 성능을 보장하려면 검색엔진을 사용하자.

 

 

 

유니코드와 인코딩

자세한 원리를 알고싶다면 컴퓨터에서 문자를 표현하는 방법을 알 필요가 있다.

한글과 같은 다국어는 기본 아스키코드에 포함되어 있지않고, 유니코드에 표현되어 있다.

유니코드(Unicode)

  • 세계에서 사용되는 모든 문자를 숫자코드와 1:1 대응시켜 컴퓨터에서 사용할 수 있도록 만든 코드표이다.
  • U+ 접두어를 가진다.
  • 예를 들어, A는 아스키코드 0x41를 가지고, 유니코드는 U+0041 이다.
    • 아스키코드(ASCII) 는 128개의 문자가 코드에 매칭되어 있다.
  • 다국어 기본 평면(BMP, Basic Multilingual Plane)은 유니코드가 표현하는 언어들에 대한 구성도이다. 0x0000부터 0xFFFF 까지 표기된다. 거의 모든 근대 문자, 특수문자, 한글, 통합한자 등이 담겨있다.
  • 유니코드에서 한글 글자마디(가,나,...힇)는 U+AC00..U+D7AF 사이 범위이다. 그 외에도 다른 유니코드에 자모단위도 모두 들어있다.
  • 유니코드 정규화 :
    • 한글과 같은 결합문자에 대해 분리되고, 결합된 문자가 필요하다.
    • 문자열 분해 (decomposition)
      • 정준 등가(canonical equivalance) - 의미, 모양이 정확히 일치하는 문자를 분리하여 독립적인 코드포인트로 인식한다.
      • 호환 등가(compatibility equivalance) - 의미는 같지만, 모양이 다를 수 있는 경우 서로 다른 문자열을 호환가능한 유사 문자열로 인식한다.
    • NFD (Normalization Form Canonical Decomposition) : Mac OSX, UNIX 등에서 주로 사용되고, 모든 음절을 분해하여 저장한다. (ㅇ+ㅐ+ㅍ+ㅡ+ㄹ.txt)
    • NFC (Normalization Form Canonical Composition) : Linux, Windows 등에서 주로 사용되고, 문자를 있는 그대로 저장한다. (애플.txt)

유니코드표에는 한글과 유니코드가 매칭되어 있으며, 컴퓨터에서 인식하기 위해서는 이 유니코드를 다시 2진법으로 바꾸어주는 인코딩의 과정이 필요하다.

인코딩(Encoding)

  • 위 코드를 2진법으로 표현하는 방법이다.
  • 종류로 UTF-8, UTF-16, EUC-KR, CP949 등이 있다.
  • EUC-KR, CP949 경우 한글을 2바이트로 인코딩할 수 있으며, CP949은 EUC-KR의 확장이므로 더 많은 한글을 지원한다.
  • UTF-8(Unicode Transformation Format-8), UTF-16 인코딩은 한글자가 1~4바이트 중 하나로 인코딩 될 수 있다. 1바이트 영역은 아스키코드를 표현할 수 있다. 한글은 3바이트 구간에 존재한다.

우리는 주로 UTF-8 을 많이 사용한다. (아스키코드를 호환할수 있고, 1~4바이트로 효율적이게 변환할 수 있다. 한글은 3바이트로 변환된다.)

728x90

'Infra > Database' 카테고리의 다른 글

[springboot] mysql database - java (JPA) 연동  (4) 2020.10.03
MariaDB 다운로드, 계정 설정  (261) 2020.07.31
[Boostcourse] JDBC 설명  (659) 2019.11.23
[Boostcourse] SQL, MySQL  (622) 2019.11.21

댓글