• Home
  • About
    • JINH-ZERO-PARK photo

      JINH-ZERO-PARK

      Welcome.

    • Learn More
    • Email
    • Instagram
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

한국인만 알아볼 수 있는 리뷰 만들기

05 Aug 2018

Reading time ~8 minutes

한국인만 알아볼 수 있는 리뷰 만들기

Python을 이용한 한글 유니코드 분리

최근에 페이스북 게시물들을 보다가, 재밌는 글을 하나 발견했다. 숙소 어플에서 한국인이 남긴 리뷰가 화제가 된 것이다. “한국인만 알아볼 수 있는 리뷰”라는 제목이었는데, 아래처럼 한글을 일부러 변형해서 쓴 모습이었다. image ZARA…

아마도 한국말로 안 좋은 리뷰를 작성하면 호텔 주인이 번역기로 돌려서 확인하고 삭제할까봐 번역기를 아예 돌리지 못하게 이런 식으로 글을 쓴 것 같다. 한국인의 근성이란…

재밌어서 더 찾아보니, 실제로 외국 호텔 리뷰는 (악평을 할 경우)이런 식으로 작성한 경우가 꽤나 많았다. 하지만 저렇게 한땀한땀 변형해서 적는것도 귀찮은 일, 정상적으로 글을 작성하면 저런 식으로 변형하는 프로그램이 있으면 어떨까?라고 생각했고, 바로 실행에 옮겼다.


우선, 변형을 어떤 방식으로 할 지 생각해야한다.

  • 초성을 된소리로 바꿀까? ex) 박진호 → 빡찐호
  • 모음을 비슷한 발음으로 변형할까? ex) 박진호 → 뱍쥔효
  • 아니면 둘 다? ex) 박진호 → 뺙쮠효

이외에도 여러가지 방법이 있겠지만, 필자는 두 번째 방법을 선택했다. 첫 번째 방식을 하기엔 된소리가 없는 자음(ㄴ, ㅇ, ㅎ 등)이 너무 많았고, 그렇다고 세 번째 방식으로 하기엔 한국인도 읽기 힘들거라 생각했기 때문이다.

그럼 모음을 어떻게 바꿀까? 특별한 기준은 없고, 그냥 필자 기준에서 발음해 봤을 때 비슷하다고 느껴지는 것들로 표를 작성했다. image 너무 대충 만든 것에 대해 국어 전공자 분들의 양해를 구합니다.
노란색으로 표시된 칸은 딱히 바꿀 모음이 떠오르지 않아서 그대로 둔 모음이다.
ex) 박진호 -> 뱍즨효 아무튼, 여차저차해서 글자를 어떻게 바꿀지 계획은 다 세웠으니, 코딩을 해보자.


image

대략적인 계획은 다음과 같다.

  1. 글자를 초성, 중성, 종성으로 분리한다.
  2. 중성을 다른 모음으로 변형한다.
  3. 변형한 중성을 포함한 초성, 중성, 종성을 다시 합쳐 글자로 만든다.

이렇게 하려면 우선 한글 글자를 분리하는 방법 에 대해서 알아야하는데, 이를 위해 우선 한글 유니코드에 대해 알아보자.

한글 글자는 유니코드에 나열되어 있으며 ‘가’부터 ‘힣’까지 초성, 중성, 종성 순으로 총 11172개의 칸(U+AC00~U+D7A3)을 차지하고 있다.(U+XXXX안의 XXXX는 16진수 자연수로, 유니코드 상에서 해당 글자가 몇 번째에 나열되어 있는지를 말해준다. 즉 ‘가’는 AC00~(16)~(=44032~(10)~) 번째 글자라는 뜻이다.) image

초성: “ㄱ”, “ㄲ”, “ㄴ”, “ㄷ”, “ㄸ”, “ㄹ”, “ㅁ”, “ㅂ”, “ㅃ”, “ㅅ”, “ㅆ”, “ㅇ”, “ㅈ”, “ㅉ”, “ㅊ”, “ㅋ”, “ㅌ”, “ㅍ”, “ㅎ” (총 19개)

중성: “ㅏ”, “ㅐ”, “ㅑ”, “ㅒ”, “ㅓ”, “ㅔ”, “ㅕ”, “ㅖ”, “ㅗ”, “ㅘ”, “ㅙ”, “ㅚ”, “ㅛ”, “ㅜ”, “ㅝ”, “ㅞ”, “ㅟ”, “ㅠ”, “ㅡ”, “ㅢ”, “ㅣ” (총 21개)

종성: “”, “ㄱ”, “ㄲ”, “ㄳ”, “ㄴ”, “ㄵ”, “ㄶ”, “ㄷ”, “ㄹ”, “ㄺ”, “ㄻ”, “ㄼ”, “ㄽ”, “ㄾ”, “ㄿ”, “ㅀ”, “ㅁ”, “ㅂ”, “ㅄ”, “ㅅ”, “ㅆ”, “ㅇ”, “ㅈ”, “ㅊ”, “ㅋ”, “ㅌ”, “ㅍ”, “ㅎ” (총 28개)

초성, 중성, 종성의 개수가 각각 19, 21, 28이므로 19*21*28 = 11172개의 칸을 차지하고, 우선순위가 초성 > 중성 > 종성이기 때문에 위 그림처럼 가, 각, 갂, … 순서대로 나열된다.

이 사실을 이용하면 한글의 어떤 글자가 유니코드상에서 ‘가’로부터 몇 번째에 배치되어 있는지(이를 순수한글코드 라고 하자.) 다음 공식을 통해 구할 수 있다.

(초성 * 21 * 28) + (중성 * 28) + 종성

= ( (초성 * 21) + 중성 ) * 28 + 종성

예를 들어, ‘박’의 경우 초성 ‘ㅂ’은 7번째(‘ㄱ’을 0번째로 센다), ‘ㅏ’는 0번째, ‘ㄱ’은 1번째이므로 순수한글코드는 ((7*21)+0)*28+1=4117, ‘가’가 44032번째 유니코드이므로 ‘박’은 44032+4117=48129번째, 즉 U+BC15에 해당된다.

또한 역으로, 글자를 받아서 그 글자의 초성, 중성, 종성이 몇 번째에 해당하는지 다음 공식을 통해 구할 수 있다.

  1. 종성

    순수한글코드 % 20 = 종성

  2. 중성

    ( (순수한글코드 - 종성) / 28 ) % 21 = 중성

  3. 초성

    ( ( ( 순수한글코드 - 종성) / 28) - 중성) ) / 21 = 초성

예를 들어 ‘박’의 경우, 순수한글코드가 4117이다. 이를 위 식에 대입하면 초성은 7번쨰, 중성은 0번째, 종성은 1번째에 해당한다는 결과를 얻을 수 있으며 이는 각각 ‘ㅂ’, ‘ㅏ’, ‘ㄱ’에 해당된다.

이제 모든 준비를 마쳤으니 본격적으로 프로그램을 짜보자.


####개발 환경: Python 3

Python에서 해당 글자가 유니코드상 몇 번째에 위치하는지는 기본 내장 함수인 ord를 통해 구할 수 있다.

print('가: {}'.format(ord('가')))
print('개: {}'.format(ord('개')))
가: 44032
개: 44060

출력 형식은 당연히 int다.

type(ord('가'))
int

이를 다시 글자로 변환하고 싶다면, 역시 기본 내장 함수인 chr를 이용하면 된다. 즉, ord와 chr는 서로 역함수 관계이다.

chr(44032)
'가'

연습 삼아 위에서 했던 예시들을 코딩해보자. 우선 초성, 중성, 종성에 대한 정보를 리스트로 저장한다.

#초성
iniL = [ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
        "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ","ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ" ]
#중성
neuL = [ "ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ",
         "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ" ]
#종성
finL = [ "", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ",
         "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ",
         "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ","ㅍ", "ㅎ" ]

순수한글코드를 x라는 변수에 담아 초성, 중성, 종성을 각각 구하여 확인해본다.

x = ord('박') - ord('가')
ini = x%28
neu = ((x-ini)//28)%21
fin = (((x-ini)//28)-neu)//21

print('{}, {}, {}'.format(ini, neu, fin))
print('{}, {}, {}'.format(iniL[ini], neuL[neu], finL[fin]))
7, 0, 1
ㅂ, ㅏ, ㄱ

자, 생각했던 것과 똑같은 결과가 나왔음을 확인할 수 있다.

코딩을 하다 보니 하나의 글자를 그 글자와 초성, 중성, 종성에 대한 정보를 담는 하나의 객체로 만들어서 관리하면 편리할 것이라는 생각이 들었다. 그래서 Hangul이라는 class를 생성했다.

전체적인 코드는 다음과 같다.

class Hangul:
    #초성: ini 중성: neu 종성: fin
    element_query = {
        'ini' : [ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
                "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ","ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ" ],

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

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

    def __init__(self, letter_or_element):
        if type(letter_or_element) == str:
            self.letter = letter_or_element
            self.element = self.separate(letter_or_element)
        elif type(letter_or_element) == list:
            self.letter = self.combine(letter_or_element)
            self.element = letter_or_element
        else:
            self.letter = ""
            self.element = ["","",""]          

    def __str__(self):        
        return self.letter

    def separate(self, letter):
        x = ord(letter) - ord('가')
        fin_ord = x % 28
        neu_ord = ((x - fin_ord) // 28) % 21
        ini_ord = (((x - fin_ord) // 28)- neu_ord) // 21

        ini = self.element_query['ini'][ini_ord]
        neu = self.element_query['neu'][neu_ord]
        fin = self.element_query['fin'][fin_ord]
        return [ini, neu, fin]

    def combine(self, element):
        ini_ord = self.element_query['ini'].index(element[0])
        neu_ord = self.element_query['neu'].index(element[1])
        fin_ord = self.element_query['fin'].index(element[2])

        hangul_ord = (((ini_ord * 21) + neu_ord) * 28) + fin_ord

        return chr(hangul_ord + ord('가'))

    def encrypt(self):
        neus_encrypt = [ "ㅑ", "ㅒ", "ㅒ", "ㅖ", "ㅕ", "ㅖ", "ㅖ", "ㅒ", "ㅛ",
                 "ㅙ", "ㅞ", "ㅞ", "ㅛ", "ㅠ", "ㅞ", "ㅝ", "ㅟ", "ㅠ", "ㅢ", "ㅢ", "ㅣ" ]
        ret = self
        ret.element[1] = neus_encrypt[self.element_query['neu'].index(self.element[1])]
        ret.letter = self.combine(ret.element)

        return ret

코드가 생각보다 길어지므로 하나하나 차근차근 설명하려한다.


우선 클래스 변수 element_query에 초성, 중성, 종성에 대한 정보를 저장한다.

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

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

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

다음은 클래스의 생성자에 해당하는 부분이다. 객체를 생성할 때 인자로 글자(letter)를 받거나 초성, 중성, 종성 조합(element)을 받는 두 가지 경우를 모두 허용하려고 했다. 그런데, Python은 생성자 오버로딩을 지원하지 않는다. 따라서 아래와 같이 if ~ else구문을 이용해서 구현하였다.

인스턴스는 두 변수 letter와 element를 가지며, letter는 글자 자체, 즉 ‘박’에 해당하며 element는 초성, 중성, 종성의 리스트, 즉 ['ㅂ', 'ㅏ','ㄱ']에 해당한다.

def __init__(self, letter_or_element):
    if type(letter_or_element) == str:
        self.letter = letter_or_element
        self.element = self.separate(letter_or_element)
    elif type(letter_or_element) == list:
        self.letter = self.combine(letter_or_element)
        self.element = letter_or_element
    else:
        self.letter = ""
        self.element = ["","",""]  

생성자를 테스트 해보면 다음과 같이 잘 작동하는 모습을 볼 수 있다.

a = Hangul('돌')
b = Hangul(['ㄱ','ㅔ','ㅁ'])
print('{}{}'.format(a.letter,b.letter))
돌겜

출력될 때 어떤 방식으로 자신의 내용물을 보여줄지 결정하는 __str__ 함수이다. ‘가’를 담고 있는 객체면 ‘가’를 출력하는게 적합하므로 letter를 반환한다.

def __str__(self):        
    return self.letter

__str__함수에 대해 잠깐 짚고 넘어가자. __str__ 함수를 따로 선언하지 않았을 경우 다음 코드는 아래와 같은 출력을 가진다.

han = Hangul('한')
print(han)
<__main__.Hangul object at 0x0000024A78491E10> 하지만 `__str__` 함수를 위와 같이 선언해주면, 똑같은 코드에 대해 아래와 같이 출력된다.

'한'

즉 __str__은 객체의 얼굴을 담당하는 함수이다.


글자를 받으면 그것을 초성, 중성, 종성으로 분리하는 separate함수와 초성, 중성, 종성의 리스트를 받으면 그것을 글자로 합치는 combine함수이다. 로직은 위에서 한 예시와 같으므로 생략한다.

def separate(self, letter):
    x = ord(letter) - ord('가')
    fin_ord = x % 28
    neu_ord = ((x - fin_ord) // 28) % 21
    ini_ord = (((x - fin_ord) // 28)- neu_ord) // 21

    ini = self.element_query['ini'][ini_ord]
    neu = self.element_query['neu'][neu_ord]
    fin = self.element_query['fin'][fin_ord]
    return [ini, neu, fin]

def combine(self, element):
    ini_ord = self.element_query['ini'].index(element[0])
    neu_ord = self.element_query['neu'].index(element[1])
    fin_ord = self.element_query['fin'].index(element[2])

    hangul_ord = (((ini_ord * 21) + neu_ord) * 28) + fin_ord

    return chr(hangul_ord + ord('가'))

한 가지 주의할 점은, Python은 C와는 다르게 정수 사이의 연산이어도 / 연산자가 몫이 아닌 실제로 나눈 실수 값, float을 반환한다. 따라서 몫을 반환하는 연산자인 //를 써야한다.


우리가 의도한 대로 글자를 변형하여 반환하는 함수 encrypt이다. 초성, 중성, 종성 리스트에서 중성을 설정한 값 neus_encrypt로 변환하고 그것에 해당하는 글자를 만들어 Hangul 객체를 반환한다.

def encrypt(self):
    neus_encrypt = [ "ㅑ", "ㅒ", "ㅒ", "ㅖ", "ㅕ", "ㅖ", "ㅖ", "ㅒ", "ㅛ",
             "ㅙ", "ㅞ", "ㅞ", "ㅛ", "ㅠ", "ㅞ", "ㅝ", "ㅟ", "ㅠ", "ㅢ", "ㅢ", "ㅣ" ]
    ret = self
    ret.element[1] = neus_encrypt[self.element_query['neu'].index(self.element[1])]
    ret.letter = self.combine(ret.element)

    return ret

작성한 encrypt 함수가 잘 작동하는지 테스트해보자.

for letter in '세종대왕':
    print(Hangul(letter).encrypt(), end = "")
셰죵댸왱

잘 작동한다. 어찌된게 조금 약올리는(?) 느낌이 들지만 넘어가자.


이제 글자 하나를 변형하는 방법을 완성했으니, 글자 여러 개로 이루어진 텍스트를 변형할 차례이다. 이를 위해 새로운 함수 encrypt_text를 선언하였다. encrypt_text 함수는 문자열을 받아 변형된 문자열을 반환한다.

def encrypt_text(text):
    encrypted = ""
    for letter in text:
        if ord('가') <= ord(letter) <= ord('힣'):
            encrypted += Hangul(letter).encrypt().letter
        else:
            encrypted += letter
    return encrypted

Hangul 객체는 한글 글자 가~힣만을 받는 것을 전제로 하고 있으므로 알파벳 같은 다른 문자가 들어가면 에러를 발생시킨다. 그래서 함수 내에 문자가 가~힣에 있을 때만 encrypt하고, 나머지 문자는 그대로 나오도록 하였다.

encrypt_text를 테스트해보자.

text = "동해물과 백두산이 마르고 닳도록"
print(encrypt_text(text))
둉햬뮬괘 뱩듀샨이 먀릐교 댫됴룍

잘 작동한다.

이 글의 목적이 호텔 리뷰 작성이었으므로 호텔 리뷰도 변형해본다.

review = "이 호텔 시설이 너무 별로였어요. 서비스도 좋지 않았습니다."
print(encrypt_text(review))
이 효톌 시셜이 녀뮤 볠료옜여요. 셔비싀됴 죻지 얂얐싑니댜.

정말 한국인만 알아볼 수 있는 리뷰를 만들어냈다! 정말 그럴까? 구글 번역기에 출력된 결과를 넣고 돌려보자. image

의도와 일치하는 결과를 보여주었다. 구글 번역기도 안 통하는, 한국인만 알아볼 수 있는 리뷰 작성 프로그램을 완성했다!

…그런데 아무리 봐도 약오르는 느낌을 지울 수가 없어서 아예 약올리는 용도로 사용해보았다.

text = "야~ 따라하지 말라고~."
print(encrypt_text(text))
얘~ 땨랴햐지 먈랴교~.

image

출처

[1] http://dream.ahboom.net/entry/한글-유니코드-자소-분리-방법

[2] https://docs.python.org/ko/3/library/functions.html#chr

[3] https://www.unicode.org/charts/PDF/UAC00.pdf