본문 바로가기

python

Fluent Python (챕터 4)

텍스트와 바이트

 

현재 문자를 가장 잘 정의한 것은 유니코드 문자다. 유니코드 표준은 문자의 단위 원소와 특정 바이트 표현을 명확히 구분한다.

문자의 단위 원소(코드 포인트)는 10진수 0에서 1,114,111까지의 숫자이며, 유니코드 표준에서는 U+ 접두사를 붙여 4자리에서 6자리 사이의 16진수로 표현한다.

문자를 표현하는 실제 바이트는 사용하는 인코딩에 따라 달라진다. 인코딩은 코드 포인트를 바이트 시퀀스로 변환하는 알고리즘이다. 

s = 'café'
print(len(s))
> 4

b = s.encode('utf8')
print(b, len(b))
> b'caf\xc3\xa9' 5

print(b.decode('utf8'))
> café

café 문자열은 네 개의 유니코드 문자를 갖고 있다. UTF-8 인코딩을 이용하여 str을 bytes로 인코딩 했을 때, bytes 리터럴은 접두사 b로 시작한다. 인코딩된 b는 5 바이트로 구성된다. é 가 2 바이트로 인코딩되기 때문이다.

 

참고) decode() 와 encode() 를 헷갈리지 않고 구분하는 방법이 있다. bytes 시퀀스는 알아보기 어려운 기계 메모리 덤프이고 유니코드 str 은 사람이 읽을 수 있는 텍스트로 생각하면 된다. 그러면 bytes 시퀀스를 텍스트로 디코딩(해독) 하고, str 을 저장하거나 전송하기 위해 bytes 로 인코딩(암호화) 한다는 말이 이해될 것이다.

 

파이썬에서 이진 시퀀스를 위해 사용되는 내장 자료형은 bytes와 bytearray 두 가지가 있다. bytes 는 불변형이고, bytearray는 가변형이다. bytes와 bytearray에 들어 있는 각 항목은 0에서 255 사이의 정수이며, 이진 시퀀스를 슬라이싱하면 언제나 동일한 자료형의 이진 시퀀스가 만들어진다.

 

cafe = bytes('café', encoding='utf_8')
print(cafe)
> b'caf\xc3\xa9' 

print(cafe[0])
> 99 

print(cafe[:1])
> b'c'

cafe_arr = bytearray(cafe)
print(cafe_arr)
> bytearray(b'caf\xc3\xa9')

print(cafe_arr[-1:])
> bytearray(b'\xa9')

 

str 을 인코딩한 bytes의 각 항목에는 range(256)에 들어가는 정수가 있다. 또한 bytes는 슬라이싱을 해도 bytes이다. 슬라이스가 한 바이트일 때도 마찬가지다.

bytearray에 대한 리터럴 구문은 없고, bytes 리터럴(b'~')을 인수로 사용해서 bytearray()를 표현한다. bytearray는 슬라이싱해도 bytearray다.

 

참고) s[0] == s[:1] 인 시퀀스 형은 str 이 유일하다. 그 외의 시퀀스 형은 s[i]는 항목 하나를, s[i:i+1]은 안에 s[i] 항목을 가진 동일한 자료형의 시퀀스를 반환한다.

 

이진 시퀀스는 각 바이트 값에 따라 다음 세 가지 형태로 출력된다.

  • 화면에 출력 가능한 아스키 문자(공백에서 물결(~)까지)는 아스키 문자 그대로 출력된다.
  • 탭, 개행문자, 캐리지 리턴, 백슬래시는 이스케이프 시퀀스(\t, \n, \r, \\)로 출력한다.
  • 그 외의 값은 널 바이트를 나타내는 \x00처럼 16진수 이스케이스 시퀀스로 출력한다.

 

bytes와 bytearray는 포매팅하는 format()과 format_map() 메서드를 제외하고는 str이 제공하는 메서드를 모두 지원한다.

또한 str 대신 이진 시퀀스로 정규 표현식을 컴파일하면 re 모듈에서 제공하는 정규 표현식 함수를 이진 시퀀스에도 적용할 수 있다. 파이썬 3.5 버전부터는 이진 시퀀스에 퍼센트(%) 연산자를 사용할 수 있다.

 

이진 시퀀스는 fromhex() 라는 str에 없는 클래스 메서드도 제공하는데, 이 메서드를 이용하면 공백으로 구분된 16진수 쌍을 파싱해서 이진 시퀀스를 만들 수 있다.

print(bytes.fromhex('31 4B CE A9'))
> b'1K\xce\xa9'

생성자에는 다음과 같은 인수를 이용해서 이진 시퀀스 객체를 만들 수 있다.

  • str과 encoding 키워드 인수
  • 0에서 255사이의 값을 제공하는 반복 가능형
  • bytes, bytearray, memoryview, array.array 등 버퍼 프로토콜을 구현하는 객체. 이 메서드를 사용하면 원본 객체의 바이트를 복사해서 바이트 시퀀스를 새로 생성한다.
import array
numbers = array.array('h', [-2, -1, 0, 1, 2])
octet = bytes(numbers)
print(octet)

> b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'

- 'h' 타입코드는 short int(16비트) 형의 배열을 생성한다.

- octet 은 numbers를 구성하는 바이트들의 사본을 가지고 있다.

- 다섯 개의 short int 형을 나타내는 10바이트다.

 

 

텍스트를 바이트 혹은 바이트를 텍스트로 변환하기 위해 파이썬에는 100여개의 코덱(인코더/디코더)이 포함되어 있다. 각 코덱은 utf_8과 같은 이름을 갖고 있는데, utf_8은 utf8, utf-8, U8 등으로 불리기도 한다. 코덱은 open(), str.encode(), bytes.decode() 등의 함수를 호출할 때, encoding 인수에 전달해서 사용할 수 있다.

다양한 코덱 중 utf8은 모든 유니코드의 코드 포인트를 처리할 수 있게 만들어졌다. 현재 웹에서 8비트 인코딩을 하기 위해 가장 널리 사용되는 인코딩 방식으로, 아스키코드와 하위 호환 된다.

 

UnicodeError 라는 범용 예외가 있긴 하지만, 일반적으로 UnicodeEncodeError, UnicodeDecodeError 같은 구체적인 예외가 발생한다. 유니코드 에러가 발생할 때, 이러한 정확한 유형을 파악하는 것이 중요하다.

 

UnicodeEncodeError 는 비UTF 코덱이 유니코드 문자의 일부만을 처리할 수 있으므로, 문자가 인코딩에 정의되어 있지 않아 발생하는 에러이다.

UnicodeDecodeError 는 이진 시퀀스를 텍스트로 변환할 때, 문자로 변환할 수 없으면 발생한다. 하지만 'cp1252', 'iso8859_1' 등 많은 레거시 8비트 코덱은 무작위 비트 배열에 대해서도 에러를 발생시키지 않고 디코딩할 수 있다. 이 때 왜곡된 문자를 그렘린(gremlin) 혹은 문자 깨짐이라고 한다.

 

파이썬 3버전 부터는 UTF-8을 기본 인코딩 방식으로 사용한다. 인코딩 선언 없이 비UTF-8로 인코딩된 모듈을 로딩하면 SyntaxError가 발생한다.

 

바이트 시퀀스의 인코딩 방식을 알아낼 수는 없다. 별도의 인코딩 정보를 가져와야 한다. Chardet 이라는 프로그램은 인코딩 방식에 따른 결과의 특징을 통해서 역으로 인코딩 방식을 추정한다.

UTF 인코딩을 통한 바이트 시퀀스는 BOM(byte order mark) 이라는 바이트 순서 표시를 추가함으로써, 인코딩 방식이 UTF 방식이라는 것을 유추할 수 있다. 하지만 파이썬은 BOM이 추가되어있다고 하더라도, 자동으로 UTF-8로 인코딩되어 있다고 가정하지는 않는다.

 

 

텍스트를 처리하는 가장 좋은 방법은 유니코드 샌드위치다. 

이는 입력할 때 가능하면 빨리 bytes를 str로 변환해야 한다는 것을 의미한다. 샌드위치에 들어가는 고기는 프로그램의 비즈니스 논리에 해당하는 부분이며, 여기서는 텍스트를 오로지 str 객체로 다룬다. 출력할 때는 가능한 한 늦게 str을 bytes로 인코딩한다.

파이썬 3의 open() 함수는 파일을 텍스트 모드로 읽고 쓸 때 필요한 모든 인코딩과 디코딩 작업을 수행해주므로 my_file.read() 에서 str 객체를 가져와서 처리하고 my_file.write() 에 전달하면 된다.

 

이 때, 실행하는 컴퓨터에 따라 인코딩 방식을 지정해주지 않으면 버그가 발생할 수 있다. 윈도우의 경우 기본 인코딩이 UTF8이 아닐 수 있기 때문이다.

 

가장 중요한 점은 컴퓨터나 OS 환경에 따르는 기본 인코딩에 의존하지 않는 것이다. 항상 인코딩 방식을 명시하는 것이 좋다.

 

 

유니코드에는 결합 문자가 있기 때문에 문자열 비교가 쉽지 않다. 발음 구별 기호는 앞 문자와 하나로 결합되어 출력된다.

s1 = 'café'
s2 = 'cafe\u0301'
print(s1, s2)
> café café

print(len(s1), len(s2))
> 4 5

print(s1 == s2)
> False

 

유니코드 표준에서는 'é' 와 '\u0301' 이 두개의 시퀀스를 규범적으로 동일하다고 보며, 애플리케이션은 이 두 시퀀스를 동일하게 처리해야 하지만 파이썬에서는 서로 동일하지 않다고 판단한다.

 

이 때, unicodedata.normalize() 함수가 제공하는 정규화를 이용해야 한다. 이 함수의 첫 번째 인수는 'NFC', 'NFD', 'NFKC', 'NFKD' 중 하나여야 한다.

 

NFC(Normalization Form C) 는 코드 포인트를 조합해서 가장 짧은 동일 문자열을 생성하는 반면, NFD는 조합된 문자를 기본 문자와 별도의 결합 문자로 분리한다.

from unicodedata import normalize
ss1 = 'café'
ss2 = 'cafe\u0301'

print(len(normalize('NFC', ss1)), len(normalize('NFC', ss2)))
> 4 4

print(len(normalize('NFD', ss1)), len(normalize('NFD', ss2)))
> 5 5

print(normalize('NFC', ss1) == normalize('NFC', ss2))
> True

print(normalize('NFD', ss1) == normalize('NFD', ss2))
> True

 

키보드는 일반적으로 결합된 문자를 입력할 수 있으므로, 사용자가 입력하는 텍스트는 기본적으로 NFC 형태다. 그러나 안전을 보장하기 위해 파일에 저장하기 전 normalize('NFC', user_text) 코드로 문자열을 청소하는 것이 좋다.

그럼에도 NFC에 의해 서로 다르게 정규화 되는 문자가 있다. 전기 저항을 나타내는 문자 Ω (옴) 기호는 그리스어 대문자 오메가로 정규화 된다.

나머지 두 인수 NFKC 와 NFKD 에서의 K는 호환성(compatibility)을 나타낸다. 정규화의 더 강력한 형태로서, 호환성 문자에 영향을 미친다. 위 방식에서 각 호환성 문자는 포매팅 손실이 발생하더라도 선호하는 형태의 하나 이상의 문자로 구성된 호환성 분할로 치환된다. 예를 들면,  절반을 나타내는 '½' 문자의 호환성 분할은 세 개 문자의 시퀀스인 '1/2' 로 치환된다. 즉, 일반적으로 많이 사용되고 사용자들이 선호하는 형태로 분할 및 치환됨을 의미한다.

from unicodedata import normalize, name

half = '½'
print(normalize('NFKC', half))
> 1/2

NFKC와 NFKD 는 검색 및 색인 생성을 위한 편리한 중간 형태를 생성할 수 있는 장점이 있지만, 정보를 왜곡하여 데이터가 손실될 수 있기 때문에 영구 저장할 경우에는 사용하지 않는 것이 좋다.

 

 

케이스 폴딩은 모든 텍스트를 소문자로 변환하는 연산이며, 약간의 변환을 동반한다.

micro = 'µ'
print(name(micro))
> MICRO SIGN

micro_cf = micro.casefold()
print(name(micro_cf))
> GREEK SMALL LETTER MU

micro 를 뜻하는 문자는 케이스 폴딩 후에 그리스 문자 '뮤' 로 변환된다. 파이썬에는 str.casfold() 와 str.lower() 가 서로 다른 문자를 반환하는 코드 포인트가 116개 있다. 

 

import unicodedata
import string

greek = 'Ζέφυρος, Zéfiro'

def shave_marks_latin(txt):
    """Remove all diacritic marks from Latin base characters"""
    norm_txt = unicodedata.normalize('NFD', txt)  # <1>
    latin_base = False
    keepers = []
    for c in norm_txt:
        if unicodedata.combining(c) and latin_base:   # <2>
            continue  # ignore diacritic on Latin base char
        keepers.append(c)                             # <3>
        # if it isn't combining char, it's a new base char
        if not unicodedata.combining(c):              # <4>
            latin_base = c in string.ascii_letters
    shaved = ''.join(keepers)
    return unicodedata.normalize('NFC', shaved)   # <5>

print(shave_marks_latin(greek))

> Ζέφυρος, Zefiro

 

위 코드는 라틴 문자에서 결합 표시 기호를 제거하는 함수이다. 결합 문자의 바로 앞 기반 문자가 라틴 텍스트인지를 판단하여 그 후에 오는 결합 문자를 무시하는 방식이다.

 

또는 특정 텍스트를 아스키에 해당하는 문자로 바꾸는 방법도 있다.

single_map = str.maketrans("""‚ƒ„†ˆ‹‘’“”•–—˜›""",  # <1>
                           """'f"*^<''""---~>""")

multi_map = str.maketrans({  # <2>
    '€': '<euro>',
    '…': '...',
    'Œ': 'OE',
    '™': '(TM)',
    'œ': 'oe',
    '‰': '<per mille>',
    '‡': '**',
})

multi_map.update(single_map)  # <3>


def dewinize(txt):
    """Replace Win1252 symbols with ASCII chars or sequences"""
    return txt.translate(multi_map)  # <4>

 

str.maketrans 로 치환할 문자와 치환 결과를 매핑한 뒤에 translate 함수에 해당 매핑 정보를 인자로 넣으면 자동으로 치환이 된다. single_map 은 문자 대 문자 치환을 표현하였으며, multi_map 은 문자 대 문자열 치환이다.

 

유니코드 텍스트 정렬에 대해 알아본다.

문자열의 경우 각 단어의 코드 포인트를 비교하여 정렬한다. 비아스키 문자의 경우에는 부적절한 결과가 발생할 수 있다. 정렬 규칙은 현지 언어에 따라 달라질 수 있기 때문이다. 비아스키 텍스트는 locale.strxfrm() 함수를 이용해서 변환하는 것이 표준이다. strxfrm() 함수는 문자열을 현지어 비교에 사용할 수 있는 문자열로 치환한다.

locale.strxfrm() 함수를 정렬 함수의 키로 사용하면 된다. 하지만 그 전에 locale.setlocale(LC_COLLATE, <지역_언어>) 를 반드시 먼저 호출해야 한다.

다만, 지역 설정은 시스템 전역에 영향을 미치므로 라이브러리의 setlocale() 을 호출하는 것은 권장되지 않는다. 애플리케이션이나 프레임워크는 프로세스를 시작할 때 지역을 설정하고, 그 후에는 변경하면 안된다.

 

PyPI 에서 제공되는 PyUCA 라이브러리가 이 문제를 더욱 간단하게 해결해준다. PyUCA 는 지역 정보를 고려하지 않는다. 대신 유니코드 대조 테이블을 키로 사용한다. 이 테이블은 유니코드 데이터베이스를 구성하는 여러 테이블 중 하나이다.

 

유니코드 표준은 수많은 구조화된 텍스트 파일의 형태로 하나의 완전한 데이터베이스를 제공한다. 이 데이터베이스에는 코드 포인트를 문자명으로 매핑한 테이블뿐만 아니라 각 문자에 대한 메타데이터 및 문자의 연관 방법을 담고 있다. 파이썬에서는 unicodedata 모듈에 이러한 정보를 반환하는 함수들이 있다.

import unicodedata
import re

re_digit = re.compile(r'\d')

sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'

for char in sample:
    print('U+%04x' % ord(char),                       # <1>
          char.center(6),                             # <2>
          're_dig' if re_digit.match(char) else '-',  # <3>
          'isdig' if char.isdigit() else '-',         # <4>
          'isnum' if char.isnumeric() else '-',       # <5>
          format(unicodedata.numeric(char), '5.2f'),  # <6>
          unicodedata.name(char),                     # <7>
          sep='\t')
          
U+0031    1     re_dig  isdig   isnum    1.00   DIGIT ONE
U+00bc    ¼     -       -       isnum    0.25   VULGAR FRACTION ONE QUARTER
U+00b2    ²     -       isdig   isnum    2.00   SUPERSCRIPT TWO
U+0969    ३     re_dig  isdig   isnum    3.00   DEVANAGARI DIGIT THREE
U+136b    ፫     -       isdig   isnum    3.00   ETHIOPIC DIGIT THREE
U+216b    Ⅻ     -       -       isnum   12.00   ROMAN NUMERAL TWELVE
U+2466    ⑦     -       isdig   isnum    7.00   CIRCLED DIGIT SEVEN
U+2480    ⒀     -       -       isnum   13.00   PARENTHESIZED NUMBER THIRTEEN
U+3285    ㊅    -       -       isnum    6.00   CIRCLED IDEOGRAPH SIX

unicodedata 의 numeric() 함수가 사용된 부분을 보면 실제 사람이 인식하는 숫자로 잘 표현됨을 볼 수 있다. 반면 re 모듈은 유니코드를 잘 인식하지 못한다. 이를 보완하기 위해 PyPI를 통해 새로 제공되는 regex 모듈은 re 모듈을 대체하기 위해 만들어졌으며, 유니코드를 더욱 잘 지원한다.

 

참고) 문자열 포매팅 시, '%x' 와 같은 방식으로 16진수를 표현할 수 있다.

 

 

파이썬의 re와 os 모듈은 str이나 bytes 인수를 모두 받으며, 인수의 자료형에 따라 다르게 동작한다.

re 모듈은 bytes 로 정규 표현식을 만들면 \d, \w 같은 패턴은 아스키 문자만 매칭되지만, str 로 이 패턴을 만들면 아스키 문자 외에 유니코드 숫자나 문자도 매칭된다.

 

os 모듈은 bytes 인수를 os 함수에 전달해서 bytes 반환값을 가져옴으로써, 파일이나 경로명에 깨진 문자가 있는지 여부를 고려하지 않아도 된다.

또한 파일이나 경로명을 수작업으로 처리하는 것을 도와주기 위해 fsencode(파일명), fsdecode(파일명) 같은 함수를 제공한다. os 모듈의 이러한 함수들은 예상치 않은 바이트에서 문제가 생기는 것을 방지하기 위해 surrogateescape 에러 처리기를 사용한다.

이는 시스템 문자 데이터베이스에서 디코딩 할 수 없는 바이트를 유니코드 표준에서 '하위 써로게이트 영역' 이라고 하는 코드 포인트로 치환한다. 이 영역의 코드 포인트에는 문자가 할당되어 있지 않고, 애플리케이션 내부 용도로 사용할 수 있도록 예약되어 있다. 인코딩할 때 이 코드 포인트는 치환된 원래 바이트로 다시 변환된다.

 

 

정리

유니코드 문자는 현재 사용되는 문자를 가장 잘 정의해 놓은 체계이다. 문자의 단위 원소는 ord(char) 로 알 수 있으며, 0~1,114,111 까지의 숫자이다. 유니코드 코드 포인트는 +U04x 형태의 16진수 표기법이다. 

인코딩은 문자를 바이트 시퀀스로 변환하는 작업, 디코딩은 바이트 스퀀스를 문자열로 변환하는 것을 말한다. 코덱은 대부분 UTF8 을 사용한다. 그러나 개발 환경에 따라 기본 코덱이 다를 수 있으므로 항상 encoding 인수에 명시해주는 것이 좋다.

유니코드에는 결합 문자가 존재하며, 이는 바로 앞에 위치한 기반문자와 더해진다. 결합 문자가 있는 유니코드를 unicodedata 모듈 안에 있는 normalize 함수를 통해 정규화 할 수 있으며, 'NFC', 'NFD', 'NFKC', 'NFKD' 네 가지 방식이 있다.

크게 결합 문자를 분리하는지의 여부에 따라 NFC/NFD 가 구분되며, 일반적으로 키보드로 결합된 문자를 입력할 수 있으므로 NFC가 일반적인 정규화 방식이라고 볼 수 있다.

unicodedata 모듈에는 re 모듈보다 유니코드를 더 잘 반영한 함수가 있다.

re, os 모듈은 str과 bytes 모두를 인수로 받는 이중모드 API이다. 어떠한 자료형을 인수로 받는지에 따라 다르게 동작한다.

os 모듈은 디코딩이 불가한 바이트를 써로게이트 영역이라는 범위에 정의된 코드 포인트로 치환하여, 예기치 못한 바이트에 대한 문제를 예방한다.

'python' 카테고리의 다른 글

Fluent Python (챕터 5)  (0) 2021.10.22
Architecture Patterns with Python(2장)  (0) 2021.10.15
Fluent Python (챕터 3)  (0) 2021.10.09
Architecture Patterns with Python(1장)  (0) 2021.10.09
Fluent Python (챕터 2)  (0) 2021.10.07