티스토리 뷰

We think about IT

[NLP] 한글 유니코드 자모 분리 (Python)

알 수 없는 사용자 2020. 11. 15. 14:43

Why So Serious? Just For Fun. No Fun No Gain.

안녕하세요. 누누 컴퍼니의 으악SOUND입니다. 한국어 자연어 처리를 하다보면 자모를 분리해야하는 일이 가끔 생깁니다. 구글에서 "한글 자모분리"를 검색하면 다양한 결과물의 소스코드를 발견하실 수 있고, 이를 가져다 사용하실 수 있습니다. 또한 라이브러리 형태로 되어있는 모듈을 다운로드받아 진행하실 수도 있겠습니다. 그래서 이번 포스트에서는 자모를 분리하는 소스 코드도 소개하지만 조금 더 원론적인 이야기를 해보려 합니다. (그래서 데이터베이스는 언제..?)

 

유니코드를 이용하여 한글의 자모를 분리하는 것은 계산식이 들어가게 됩니다. 계산식을 도출하기에 앞서 한글의 기본 구성에 대해서 알아보겠습니다. 한글의 기본 구성은 아래와 같습니다.

 

  • 자음 (19자)
    • 기본 자음자: ㄱ, ㄴ, ㄷ, ㄹ, ㅁ, ㅂ, ㅅ, ㅇ, ㅈ, ㅊ, ㅋ, ㅌ, ㅠ, ㅎ (14자)
    • 복합 자음자: ㄲ, ㄸ, ㅃ, ㅆ, ㅉ (5자)
  • 모음 (21자)
    • 기본 모음자: ㅏ, ㅑ, ㅓ, ㅕ, ㅗ, ㅛ, ㅜ, ㅠ, ㅡ, ㅣ (10자)
    • 복합 모음자: ㅐ, ㅒ, ㅔ, ㅖ, ㅘ, ㅙ, ㅚ, ㅝ, ㅞ, ㅟ, ㅢ (11자)
  • 받침 (27자)
    • 기본 받침: ㄱ, ㄴ, ㄷ, ㄹ, ㅁ, ㅂ, ㅅ, ㅇ, ㅈ, ㅊ, ㅋ, ㅌ, ㅍ, ㅎ (14자)
    • 곁받침: ㄳ, ㄵ, ㄶ, ㄺ, ㄻ, ㄼ, ㄽ, ㄾ, ㄿ, ㅀ, ㅄ (11자)
    • 쌍받침: ㄲ, ㅆ (2자)

위와 같은 한글의 모든 낱자를 한데 모아쓰도록 하고 있고, 이를 음절이라고 말합니다. 음절에서 초성부분과 중성부분은 무조건 존재하고 종성은 있을수도 있고 없을수도 있습니다. 유니코드에서는 모아쓰기를 통해 완성된 음절을 44032(0xAC00, '가')부터 55203(0xD7A3, '힣')의 인덱스를 부여하여 관리하고 있습니다. 음절별로 부여된 유니코드에 대한 자세한 내용은 아래의 링크를 통해 보실 수 있습니다.

 

www.unicode.org/charts/PDF/UAC00.pdf

 

링크를 토대로 한글 음절의 유니코드 배열을 직접 구현하여 하나씩 뜯어보도록 하겠습니다.

# numpy 설치 필요

import numpy as np

hangul_syllables = np.array([chr(code) for code in range(44032, 55204)])
hangul_syllables = hangul_syllables.reshape(19, 21, 28)

print(f"'가'와 관련된 음절 리스트: {hangul_syllables[0][0]}")
print(f"'개'와 관련된 음절 리스트: {hangul_syllables[0][1]}")

print()

print(f"'ㄱ'와 관련된 마지막 음절 리스트: {hangul_syllables[0][20]}")
print(f"'ㄲ'와 관련된 첫번째 음절 리스트: {hangul_syllables[1][0]}")

해당 코드를 직접 실행해보면 유니코드에서 어떤 방식으로 한글 음절을 관리하는지 알 수 있습니다. (numpy 설치 필요) 종성이 없는 '가'를 시작으로 '가'와 관련된 마지막 '갛'까지 진행한 후 그 다음 모음인 'ㅐ'를 넣어 '개'에 관련된 음절이 시작됩니다. 또한 'ㄱ'에 관련된 음절이 종료되면 'ㄲ'에 관련된 음절이 시작됩니다. 이를 통해 우리는 초성, 중성, 종성(없음 포함)의 낱자에 인덱스를 붙일 수 있게 됩니다.

chosung_list = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
                'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']

jungsung_list = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
                 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']

jongsung_list = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ',
                 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ',
                 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']

위 코드에서 인덱스를 토대로 초성, 중성, 종성에 대해 리스트를 생성했습니다. 지금까지 알아본 내용을 토대로 음절의 유니코드를 구하는 계산식을 도출할 수 있게 되었습니다. 계산식부터 소개해드리면 아래와 같습니다.

 

(초성인덱스 * 588) + (중성인덱스 * 28) + 종성인덱스 + 44032

 

초성의 인덱스에 588을 곱하는 이유는 초성은 588글자마다 바뀌기 때문입니다. 588이라는 숫자는 중성의 개수 21개와 종성의 개수(없음 포함) 28개를 곱한 숫자입니다. 중성의 경우에는 종성이 전부 돌고 난 후 즉, 28글자마다 바뀌기 때문에 28을 곱해줍니다. 마지막에 44032를 더해주는 이유는 유니코드의 첫 번째인 '가'의 코드가 44032이기 때문입니다. 이를 토대로 몇 개의 음절에 대해 계산을 해보겠습니다.

def get_unicode_number(chosung_index, jungsung_index, jongsung_index):
    return ((chosung_index * 588) + (jungsung_index * 28) + jongsung_index) + 44032


# 박 / ㅂ:7 / ㅏ:0 / ㄱ:1
print(get_unicode_number(7, 0, 1), chr(get_unicode_number(7, 0, 1)))

# 놔 / ㄴ:2 / ㅘ:9 / '':0
print(get_unicode_number(2, 9, 0), chr(get_unicode_number(2, 9, 0)))

# 밝 / ㅂ:7 / ㅏ:0 / ㄺ:9
print(get_unicode_number(7, 0, 9), chr(get_unicode_number(7, 0, 9)))

계산식이 잘 동작하는 것을 알 수 있습니다. 계산식이 잘 동작하는 것을 확인했으니 이제 반대로 계산식을 이용하여 초성 인덱스, 중성인덱스, 종성인덱스를 구하는 계산식도 도출하는 것이 가능해졌습니다.

 

  • 초성 인덱스 = (음절의 코드 - 44032) / 588
  • 중성 인덱스 = (음절의 코드 - 44032 - (초성 인덱스 * 588)) / 28
  • 종성 인덱스 = (음절의 코드 - 44032 - (초성 인덱스 * 588) - (중성 인덱스 * 28))
import re


class SeparateJaMo:
    """
    한글 자모를 분리하는 클래스

    target_text = 자모를 분리할 문자열
    blank_str = 공백을 처리할 문자
    remove_blank = 공백 제거 여부
    remove_special_character = 특수 문자 제거 여부
    refine_blank = 두개 이상의 공백 정제 여부
    refine_english = 영어 정제 여부
    """

    def __init__(self, original_text, blank_str=" ",
                 remove_blank=False, remove_special_character=False,
                 refine_blank=False, refine_english=False):
        self.__chosung_list = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
                               'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']
        self.__jungsung_list = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
                                'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']
        self.__jongsung_list = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ',
                                'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ',
                                'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']
        self.__original_text = original_text
        self.__blank_str = blank_str
        self.__remove_blank = remove_blank
        self.__remove_special_character = remove_special_character
        self.__refine_blank = refine_blank
        self.__refine_english = refine_english
        self.__processed_text = self.__make_processed_text()
        self.__jamo_list = self.__make_jamo_list()

    def __make_processed_text(self):
        """
        옵션에 알맞게 문자열을 정제하여 반환
        """
        processed_text = self.__original_text
        if self.__refine_blank:
            processed_text = " ".join(
                [word.strip() for word in processed_text.split(" ") if not len(word.strip()) == 0]
            )
        if self.__remove_blank:
            processed_text = re.sub(r'\s', '', processed_text)
        if self.__remove_special_character:
            processed_text = re.sub(r'[-=+,#/\?:^$.@*\"※~&%ㆍ!』\\‘|\(\)\[\]\<\>`\'…》]', '', processed_text)
            processed_text = re.sub(r'\W \S', '', processed_text)
        if self.__refine_english:
            processed_text = re.sub(r'[^ㄱ-ㅎㅣ가-힣]+', '', processed_text)
        processed_text = re.sub(r'\s', self.__blank_str, processed_text)
        return processed_text

    def __make_jamo_list(self):
        """
        전체 자모 리스트 반환
        """
        jamo_list = list()
        for syllable in list(self.__processed_text):
            if re.match(r'[ㄱ-ㅎㅣ가-힣]+', syllable):
                syllable_code = ord(syllable)
                chosung_index = int((syllable_code - 44032) / 588)
                jungsung_index = int((syllable_code - 44032 - (chosung_index * 588)) / 28)
                jongsung_index = int(syllable_code - 44032 - (chosung_index * 588) - (jungsung_index * 28))
                jamo_list.append(
                    [
                        self.__chosung_list[chosung_index],
                        self.__jungsung_list[jungsung_index],
                        self.__jongsung_list[jongsung_index]
                    ]
                )
            else:
                jamo_list.append([syllable, syllable, syllable])
        return jamo_list

    def get_full_jamo_list(self):
        return self.__jamo_list

    def get_chosung_list(self):
        return [jamo_list[0] for jamo_list in self.__jamo_list]

    def get_jungsung_list(self):
        return [jamo_list[1] for jamo_list in self.__jamo_list]

    def get_jongsung_list(self):
        return [jamo_list[2] for jamo_list in self.__jamo_list]


if __name__ == "__main__":
    text = 'facebook의 창시자 마크 저커버그는       "사람과 사람의 연결이 곧 비즈니스" 라고 말했다.'
    separate_jamo = SeparateJaMo(text, remove_special_character=True)
    print(separate_jamo.get_full_jamo_list())
    print(separate_jamo.get_chosung_list())
    print(separate_jamo.get_jungsung_list())
    print(separate_jamo.get_jongsung_list())

 

위 코드의 클래스를 이용하시면 여러가지 정제를 이용해서 한글 자모를 분리하실 수 있습니다. 긴 글 읽어주셔서 감사합니다.

 

댓글