넷째 날: 한글 문자열 자모 단위 일치 검사

저자

@gypark - gypark.pe.kr의 주인장. 홈페이지에 Perl에 대해 정리해두는 취미가 있고, Raymundo라는 닉을 사용하기도 한다.

시작하며

인터넷 검색 엔진이나 쇼핑몰 등의 검색 창을 보면, 타이핑을 시작하기 무섭게 검색어를 알아서 완성시켜 주기도 하고, 검색어의 일부만 입력해도 원하는 검색 결과를 보여주기도 합니다. 그림 1과 같이 말이죠.

form-sample 그림 1. 검색 엔진의 검색어 예측 (원본)

그림 1을 보면 정규표현식을 입력하는 과정에서 일시적으로 정귶이라는 문자열이 만들어졌습니다 (세벌식 자판을 사용한다면 이런 일이 없겠지만...). 단순한 정규식 일치나 index 함수를 써서 검사한다면, 정귶정규표현식에 일치되지 않는 것으로 판정되어 버립니다. 직관적으로 생각해보면, 이 문제를 해결하기 위해서는 문자열을 일단 음소 단위로 분리하면 될 것 같습니다. 그러면 이제 ㅈㅓㅇㄱㅠㅍㅛㅎㅕㄴㅅㅣㄱ 안에서 ㅈㅓㅇㄱㅠㅍ을 찾는 문제가 되고, 이 검사는 일치 판정을 받을 것입니다. 이렇게 음소 단위로 분리하는 작업을 펄 모듈을 이용하여 해 봅시다.

준비물

필요한 모듈은 다음과 같습니다.

직접 CPAN을 이용해서 설치한다면 다음 명령을 이용해서 모듈을 설치합니다.

$ sudo cpan \
    Lingua::KO::Hangul::Util \
    Lingua::KO::Hangul::JamoCompatMapping

사용자 계정으로 모듈을 설치하는 방법을 정확하게 알고 있거나 perlbrew를 이용해서 자신만의 Perl을 사용하고 있다면 다음 명령을 이용해서 모듈을 설치합니다.

$ cpan \
    Lingua::KO::Hangul::Util \
    Lingua::KO::Hangul::JamoCompatMapping

시작

일단 간단한 테스트 코드를 만들어봅시다.

지금부터 나오는 코드들은, 문자열을 특정 인코딩 규약에 의해 인코딩된 '바이트 스트림'이 아니라, 디코드되어 펄 내부에서 사용되는 '문자열'인 상태로 다루게 됩니다. 이를 위해서, 따옴표 문자열 상수들을 바이트 스트림이 아닌 문자열로 취급하도록 utf8 프래그마를 사용하고 있습니다. 입력을 외부에서 받는다면, 적절하게 디코드한 후에 작업할 수 있도록 신경을 써야 합니다.

use utf8;   # 문자열 리터럴을 자동으로 디코드하여 사용
            # 이 스크립트 자체도 인코딩을 UTF-8로 지정하여 저장해야 함
use 5.010;  # say를 쓰기 위해서

binmode STDOUT, ':encoding(UTF-8)'; # 윈도우 명령 프롬프트창이라면 UTF-8 대신 cp949

my @targets = ( '고우영', '공지영' );

my @search = ( 'ㄱ', '고', '공', '고우', '공ㅈ', '공지' );

say "---- regex ----";
for my $s ( @search ) {
    for my $t ( @targets ) {
        say "$s - $t : ",
            $t =~ $s ? "MATCH" : "NOT MATCH";
    }
}

코드만 보아도 무엇을 하려는지 쉽게 알 수 있을 것입니다. 우리가 고우영을 검색하든 공지영을 검색하든, 검색어를 입력할 때는 , , 순으로 타이핑하게 됩니다. 따라서 이 세 가지 검색어에 대해서는 고우영공지영 모두 일치하는 것으로 판정되어야 할 것입니다. 그러나 막상 실행해보면 (당연하게도) 그렇게 되지 않습니다.

---- regex ----
ㄱ   - 고우영 : NOT MATCH   (x)
ㄱ   - 공지영 : NOT MATCH   (x)
고   - 고우영 : MATCH
고   - 공지영 : NOT MATCH   (x)
공   - 고우영 : NOT MATCH   (x)
공   - 공지영 : MATCH
고우 - 고우영 : MATCH
고우 - 공지영 : NOT MATCH
공ㅈ - 고우영 : NOT MATCH
공ㅈ - 공지영 : NOT MATCH   (x)
공지 - 고우영 : NOT MATCH
공지 - 공지영 : MATCH

위 출력 결과에서, 우리가 원하는 결과가 나오지 않은 부분에만 따로 (x) 표시를 하였습니다. =~ 연산자를 써서 정규식 일치 검사를 하는 대신 index($t, $s)와 같이 부분문자열 검색을 시도해도 마찬가지 결과가 나옵니다. 고우영이나 공지영에 포함되지 않는 것으로 판정됩니다.

음소 단위로 분리

처음에 말했던 것처럼, 한글 단어를 음소 즉 자음과 모음 단위로 분리해보도록 합시다. 이를 위해서 Lingua::KO::Hangul::Util 모듈을 사용합니다. 이 모듈에 있는 decomposeSyllable() 함수는 음절을 자모음 단위로 분리한 형태의 문자열을 반환합니다.

# "가"(\x{AC00}) 를 "ㄱ"(\x{1100})과 "ㅏ"(\x{1161})의 조합으로 분리
my $decomposed = decomposeSyllable("\x{AC00}"); # "\x{1100}\x{1161}"

# 주의:
# 분리된 문자열을 출력한다고 해서 "ㄱㅏ"가 출력되는 것은 아님.
# 유니코드 정규화 명세에 의해, 이것은 "\x{AC00}"와 똑같이 "가"를 만든다.
# 따라서 이 출력은 여전히 "가"로 보인다. (사용하는 터미널이 충분히 똑똑하다면)
# 하지만 eq 로 검사할 경우는 원래의 문자열과는 같지 않은 걸로 판정된다.
print $decomposed;

저 함수를 사용하여 검색 대상 문자열과 검색어 문자열을 음소 단위로 분리한 후 일치 검사를 해 봅시다. 앞에서 작성했던 코드에 다음 코드를 추가합니다.

use Lingua::KO::Hangul::Util qw(:all);

# 일치 검사하는 서브루틴
sub jamo_match {
    my ( $target, $search ) = @_;

    # $target, $search 를 각각 음소 단위로 분리
    my $target_jamo = decomposeSyllable($target);
    my $search_jamo = decomposeSyllable($search);

    # 분리된 형태의 문자열을 사용하여 일치 검사
    return ( $target_jamo =~ $search_jamo );
}

say "---- decompose ----";
for my $s ( @search ) {
    for my $t ( @targets ) {
        say "$s - $t : ",
            # 이제 정규식 대신 jamo_match()로 검사
            jamo_match( $t, $s ) ? "MATCH" : "NOT MATCH";
    }
}

실행 결과는 다음과 같습니다.

---- decompose ----
ㄱ   - 고우영 : NOT MATCH   (x)
ㄱ   - 공지영 : NOT MATCH   (x)
고   - 고우영 : MATCH
고   - 공지영 : MATCH       (해결)
공   - 고우영 : NOT MATCH   (x)
공   - 공지영 : MATCH
고우 - 고우영 : MATCH
고우 - 공지영 : NOT MATCH
공ㅈ - 고우영 : NOT MATCH
공ㅈ - 공지영 : NOT MATCH   (x)
공지 - 고우영 : NOT MATCH
공지 - 공지영 : MATCH

음... 결과가 만족스럽지 않습니다. 공지영에 일치한다는 판정을 받아내는 것에는 성공했으나, 나머지 네 경우는 여전히 일치하지 않는군요. 이런 문제가 생기는 이유는 다음과 같습니다.

첫째로, decomposeSyllable() 함수에 의해 분리된 음소들은 유니코드의 '한글 자모 영역'에 있는 문자들입니다. 이 한글 자모 영역에 있는 자음은 초성과 종성이 서로 별개의 문자로 구분되어 있습니다. 위 코드에 출력문을 넣어서 눈으로 확인해 봅시다.

my $target_jamo = decomposeSyllable($target);
say "$target_jamo : ",
    join(':', map { sprintf( "0x%04X", ord($_) ) } split //, $target_jamo);
my $search_jamo = decomposeSyllable($search);
say "$search_jamo : ",
    join(':', map { sprintf( "0x%04X", ord($_) ) } split //, $search_jamo);

음소로 분리된 형태의 각 문자의 코드값을 출력시켜서 비교해 보면 아래와 같습니다.

.        초성ㄱ:중성ㅗ:초성ㅇ:중성ㅜ:초성ㅇ:중성ㅕ:종성ㅇ
고우영 : 0x1100:0x1169:0x110B:0x116E:0x110B:0x1167:0x11BC
.        초성ㄱ:중성ㅗ:종성ㅇ
공     : 0x1100:0x1169:0x11BC

즉, 을 분리했을 때 나오는 세 번째 음소는 종성 이응인데, 고우영의 세 번째 음소는 초성 이응이기 때문에 일치하지 않는 것으로 판정됩니다. 그러면 은 그렇다치고, 어째서 은 일치하지 않는 것일까요? 코드값을 확인해봅시다.

고우영 : 0x1100:0x1169:0x110B:0x116E:0x110B:0x1167:0x11BC
ㄱ     : 0x3131

의 코드값이 0x3131입니다. 이것은 초성 기역도 종성 기역도 아닙니다. 한글을 입력할 때 완성된 글자가 아니라 자음이나 모음 하나만 단독으로 입력하는 경우, 그 음소는 유니코드의 '한글 호환 자모(Hangul Compatibility Jamo)' 영역에 있는 것을 사용하게 됩니다. 지금처럼 제가 이 스크립트를 만들면서 에디터에 입력한 도, 표준 입력으로 입력한 도, 웹에서 폼을 이용해 입력한 도 마찬가지입니다.

한글 호환 자모 영역에 있는 자모들은 서로 조립되지 않으며 (예를 들어 "\x{3131}\x{314F}"를 출력하면 로 조립되지 않고 그냥 ㄱㅏ로 출력됨), 자음의 경우 초성과 종성의 구분이 따로 없이 음소 당 하나씩만 있습니다.

이제 원인을 파악했으니, 해결책도 알 수 있을 것 같습니다. 문자열을 음소 단위로 분리한 다음, 이 음소들을 비교할 때 자음의 초성과 종성을 구분하지 않도록 하면 됩니다. 하지만 종성이 나올 때마다 똑같은 음소의 초성으로 바꾸기는 불편하니, 아예 '한글 자모' 영역에 있는 자음을 일괄적으로 '한글 호환 자모' 영역에 있는 자음으로 변환합시다. 즉 초성 기역(U+1100)종성 기역(U+11A8)을 일괄적으로 한글 문자 기역(U+3131)으로 변환한 다음 비교하는 것입니다.

한글 호환 자모로 변환

한글 자모 영역에 해당하는 문자를 한글 호환 자모 영역에 있는 문자로 변환하기 위해서, Lingua::KO::Hangul::JamoCompatMapping 모듈을 사용할 수 있습니다. 이 모듈에 있는 jamo_to_compat() 함수는 인자로 한글 자모 영역에 있는 음소를 전달하면 한글 호환 자모 영역에 있는 같은 음소를 반환합니다.

# 한글 초성 기역 (\x{1100}) => 한글 문자 기역 (\x{3131})
$letter = jamo_to_compat("\x{1100}");

이제 음소 분리 후 자음을 변환한 후 일치 검사를 하는 함수를 만들고, 그 함수를 사용해봅시다.

use Lingua::KO::Hangul::JamoCompatMapping qw(jamo_to_compat);

sub jamo_compat_match {
    my ( $target, $search ) = @_;

    # jamo_to_compat()은 입력을 음소 하나만 받기 때문에,
    # decomposeSyllable()의 결과로 나온 문자열을
    # 다시 개별 문자들로 나누기 위해 split 사용하고
    # 변환된 결과를 다시 join으로 합침
    #
    # 또한, 인자가 변환 가능한 한글 자모 영역의 음소가 아닌 경우
    # undef을 반환하므로, 'ㄱ'과 같이 처음부터 한글 호환 자모인
    # 음소는 변환 후 undef이 되어버리기 때문에 이 경우 원래 값을
    # 그대로 쓰도록 defined-or 연산자를 사용하고 있음

    my $target_jamo_compat = join '', map { jamo_to_compat($_) // $_ } split //, decomposeSyllable($target);
    my $search_jamo_compat = join '', map { jamo_to_compat($_) // $_ } split //, decomposeSyllable($search);

    return ( $target_jamo_compat =~ $search_jamo_compat )
}

say "---- decompose and convert ----";
for my $s ( @search ) {
    for my $t ( @targets ) {
        say "$s - $t : ",
            jamo_compat_match( $t, $s ) ? "MATCH" : "NOT MATCH";
    }
}

결과는 다음과 같습니다.

---- decompose and convert ----
ㄱ   - 고우영 : MATCH       (해결)
ㄱ   - 공지영 : MATCH       (해결)
고   - 고우영 : MATCH
고   - 공지영 : MATCH       (해결)
공   - 고우영 : MATCH       (해결)
공   - 공지영 : MATCH
고우 - 고우영 : MATCH
고우 - 공지영 : NOT MATCH
공ㅈ - 고우영 : NOT MATCH
공ㅈ - 공지영 : MATCH       (해결)
공지 - 고우영 : NOT MATCH
공지 - 공지영 : MATCH

이제 우리가 원하는 결과가 나오고 있습니다.

정리하며

한글 단어를 음소 단위로 분리하고, 초성과 종성의 자음을 구분하지 않게 함으로써 입력이 완료되지 않은 상태의 문자열을 대상으로 일치 검사를 수행하는 법을 살펴보았습니다. 여기에다가 퍼지 검색 알고리즘을 적용하여, 입력에 오타가 있더라도 적절한 검색 결과를 내어주도록 하는 식으로 응용할 수도 있습니다.

참고

솔직히 말씀드리면 다국어나 유니코드 처리에 관한 글을 쓰기에는 저도 아는 게 많이 부족합니다. 제가 이 기사를 작성하면서 참고한 웹페이지들을 나열하니 자세한 것은 이곳에서 직접 살펴보세요.

blog comments powered by Disqus