유니코드 HOWTO

유니코드 HOWTO

이 HOWTO에서는 텍스트 데이터를 표현하기 위한 유니코드 규격에 대한 Python의 지원을 논의하고, 유니코드를 다룰 때 사람들이 흔히 마주치는 여러 문제를 설명해요. 유니코드라는 개념부터 Python의 문자열, 인코딩, 파일 입출력, 그리고 유니코드를 고려한 프로그램을 작성하는 요령까지, 차근차근 짚어 볼게요.

출처: Python 공식 문서

유니코드 소개

오늘날의 프로그램은 아주 다양한 문자를 다룰 수 있어야 해요. 애플리케이션은 흔히 사용자가 고른 다양한 언어로 메시지와 출력을 보여주기 위해 국제화돼 있고, 같은 프로그램이 영어·프랑스어·일본어·히브리어·러시아어로 오류 메시지를 출력해야 할 수도 있어요. 웹 콘텐츠는 이 모든 언어로 쓰일 수 있고 다양한 이모지 기호도 포함할 수 있죠. Python의 문자열 타입은 문자를 나타낼 때 유니코드 표준을 사용해서, Python 프로그램이 이 모든 서로 다른 문자를 다룰 수 있게 해요.

정의

유니코드(https://www.unicode.org/)는 인간 언어에서 쓰이는 모든 문자를 목록화하고 각 문자에 고유한 코드를 부여하는 것을 목표로 하는 규격이에요. 유니코드 규격은 새로운 언어와 기호를 추가하기 위해 끊임없이 개정·갱신돼요.

문자는 텍스트의 가장 작은 구성 요소예요. 'A', 'B', 'C' 등은 모두 서로 다른 문자죠. 'È'과 'Í'도 마찬가지예요. 문자는 이야기하고 있는 언어나 맥락에 따라 달라져요. 예를 들어 대문자 'I'와는 별개인 '로마 숫자 일' 'Ⅰ'이라는 문자가 있어요. 보통은 똑같이 보이겠지만, 서로 다른 뜻을 가진 두 개의 다른 문자예요.

유니코드 표준은 문자가 코드 포인트로 어떻게 표현되는지 설명해요. 코드 포인트 값은 0부터 0x10FFFF(약 110만 개 값, 실제 할당된 수는 그보다 적어요) 범위의 정수예요. 표준과 이 문서에서 코드 포인트는 U+265E 표기법으로 쓰는데, 값이 0x265e(십진수 9822)인 문자를 뜻해요.

유니코드 표준은 문자와 그에 대응하는 코드 포인트를 나열한 표를 많이 담고 있어요.

0061    'a'; LATIN SMALL LETTER A
0062    'b'; LATIN SMALL LETTER B
0063    'c'; LATIN SMALL LETTER C
...
007B    '{'; LEFT CURLY BRACKET
...
2167    'Ⅷ'; ROMAN NUMERAL EIGHT
2168    'Ⅸ'; ROMAN NUMERAL NINE
...
265E    '♞'; BLACK CHESS KNIGHT
265F    '♟'; BLACK CHESS PAWN
...
1F600   '😀'; GRINNING FACE
1F609   '😉'; WINKING FACE
...

엄밀히 말하면, '이건 U+265E 문자다'라고 말하는 건 의미가 없어요. U+265E는 코드 포인트이고, 어떤 특정 문자를 나타내죠. 이 경우 'BLACK CHESS KNIGHT', 즉 '♞' 문자를 나타내요. 비공식적인 맥락에서는 코드 포인트와 문자의 구분이 때때로 잊히기도 하지만요.

문자는 화면이나 종이 위에 글리프(glyph)라는 그래픽 요소 집합으로 표현돼요. 예를 들어 대문자 A의 글리프는 두 개의 대각선 획과 하나의 가로 획이지만, 정확한 세부 사항은 쓰는 글꼴에 따라 달라져요. 대부분의 Python 코드는 글리프에 신경 쓸 필요가 없어요. 표시할 올바른 글리프를 알아내는 건 일반적으로 GUI 툴킷이나 터미널의 글꼴 렌더러 역할이니까요.

인코딩

앞 절을 요약하면: 유니코드 문자열은 0부터 0x10FFFF(십진수 1,114,111)까지의 숫자인 코드 포인트의 시퀀스예요. 이 코드 포인트 시퀀스는 코드 유닛(code unit) 집합으로 메모리에 표현돼야 하고, 코드 유닛은 다시 8비트 바이트로 매핑돼요. 유니코드 문자열을 바이트 시퀀스로 변환하는 규칙을 문자 인코딩(character encoding), 줄여서 인코딩이라고 불러요.

가장 먼저 떠올리는 인코딩은 코드 유닛으로 32비트 정수를 쓰고, CPU의 32비트 정수 표현을 사용하는 거예요. 이 표현에서 문자열 "Python"은 이렇게 보일 수도 있어요.

   P           y           t           h           o           n
0x50 00 00 00 79 00 00 00 74 00 00 00 68 00 00 00 6f 00 00 00 6e 00 00 00
   0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23

이 표현은 직관적이지만 사용하면 여러 문제가 있어요.

  • 이식성이 없어요. 프로세서마다 바이트 순서를 다르게 정하거든요.
  • 공간을 매우 낭비해요. 대부분의 텍스트에서 코드 포인트 대다수는 127보다 작거나 255보다 작아서, 0x00 바이트에 공간이 많이 차지돼요. 위 문자열은 ASCII 표현에 필요한 6바이트에 비해 24바이트를 차지해요. RAM 사용량 증가는 별로 중요하지 않지만(데스크톱 컴퓨터는 몇 GB의 RAM이 있고 문자열은 보통 그렇게 크지 않아요), 디스크와 네트워크 대역폭 사용을 4배로 늘리는 건 견딜 수 없어요.
  • strlen() 같은 기존 C 함수와 호환되지 않아서, 새로운 와이드 문자열 함수 계열을 써야 해요.

그래서 이 인코딩은 별로 쓰이지 않고, 사람들은 UTF-8처럼 더 효율적이고 편리한 다른 인코딩을 골라요.

UTF-8은 가장 흔히 쓰이는 인코딩 중 하나이고, Python도 종종 기본값으로 사용해요. UTF는 "Unicode Transformation Format"을 뜻하고, '8'은 인코딩에 8비트 값이 쓰인다는 뜻이에요. (UTF-16과 UTF-32 인코딩도 있지만, UTF-8보다 덜 쓰여요.) UTF-8은 다음 규칙을 사용해요.

  • 코드 포인트가 128보다 작으면 해당 바이트 값으로 표현돼요.
  • 코드 포인트가 128 이상이면 2, 3, 또는 4바이트 시퀀스로 바뀌는데, 각 바이트는 128과 255 사이예요.

UTF-8에는 편리한 성질이 몇 가지 있어요.

  • 어떤 유니코드 코드 포인트도 처리할 수 있어요.
  • 유니코드 문자열은 널 문자(U+0000)를 나타낼 때만 임베디드 0바이트를 담는 바이트 시퀀스로 바뀌어요. 즉 UTF-8 문자열은 strcpy() 같은 C 함수로 처리할 수 있고, 문자열 끝 표시자 외의 용도로 0바이트를 처리할 수 없는 프로토콜로 보낼 수 있어요.
  • ASCII 텍스트 문자열은 유효한 UTF-8 텍스트이기도 해요.
  • UTF-8은 꽤 컴팩트해요. 흔히 쓰이는 문자 대부분은 1~2바이트로 표현할 수 있어요.
  • 바이트가 손상되거나 유실돼도, 다음 UTF-8로 인코딩된 코드 포인트의 시작을 알아내서 재동기화할 수 있어요. 임의의 8비트 데이터가 유효한 UTF-8처럼 보일 가능성도 낮아요.
  • UTF-8은 바이트 지향 인코딩이에요. 인코딩이 각 문자를 하나 이상의 바이트의 특정 시퀀스로 표현한다고 지정하죠. 이는 UTF-16·UTF-32 같은 정수·워드 지향 인코딩에서 발생할 수 있는, 문자열이 인코딩된 하드웨어에 따라 바이트 시퀀스가 달라지는 바이트 순서 문제를 피해 줘요.

참고 자료

유니코드 컨소시엄 사이트에는 문자 차트, 용어집, 유니코드 규격의 PDF 버전이 있어요. 다소 어렵게 읽힐 각오는 하세요. 유니코드의 기원과 발전 연대기도 그 사이트에 있어요.

Computerphile YouTube 채널에서 Tom Scott가 유니코드와 UTF-8의 역사를 간단히 다루는 영상(9분 36초)이 있어요.

표준을 이해하는 데 도움을 주려고, Jukka Korpela가 유니코드 문자표 읽는 법을 소개하는 입문 가이드를 썼어요.

또 다른 좋은 입문 글은 Joel Spolsky가 썼어요. 이 소개가 명확하지 않았다면, 계속하기 전에 그 대체 글을 읽어 보는 게 좋아요.

위키백과 항목도 자주 도움이 돼요. 예를 들어 "character encoding"과 UTF-8 항목을 보세요.

Python의 유니코드 지원

이제 유니코드의 기초를 배웠으니 Python의 유니코드 기능을 살펴볼게요.

문자열 타입

Python 3.0 이후로, 언어의 str 타입은 유니코드 문자를 포함해요. 즉 "unicode rocks!", 'unicode rocks!', 또는 삼중 따옴표 문자열 문법으로 만든 어떤 문자열이든 유니코드로 저장된다는 뜻이에요.

Python 소스 코드의 기본 인코딩은 UTF-8이에요. 그래서 문자열 리터럴에 유니코드 문자를 그냥 포함할 수 있어요.

try:
    with open('/tmp/input.txt', 'r') as f:
        ...
except OSError:
    # 'File not found' error message.
    print("Fichier non trouvé")

참고로 Python 3는 식별자에서 유니코드 문자를 쓰는 것도 지원해요.

répertoire = "/tmp/records.log"
with open(répertoire, "w") as f:
    f.write("test\n")

에디터에서 특정 문자를 입력할 수 없거나 어떤 이유로 소스 코드를 ASCII만으로 유지하고 싶다면, 문자열 리터럴에서 이스케이프 시퀀스를 쓸 수도 있어요. (시스템에 따라 실제 대문자 델타 글리프가 아니라 u 이스케이프를 볼 수도 있어요.)

>>> "\N{GREEK CAPITAL LETTER DELTA}"  # Using the character name
'\u0394'
>>> "\u0394"                          # Using a 16-bit hex value
'\u0394'
>>> "\U00000394"                      # Using a 32-bit hex value
'\u0394'

게다가 bytesdecode() 메서드로 문자열을 만들 수도 있어요. 이 메서드는 UTF-8 같은 인코딩 인자를, 그리고 선택적으로 errors 인자를 받아요.

errors 인자는 입력 문자열이 인코딩 규칙에 따라 변환될 수 없을 때의 응답을 지정해요. 이 인자의 유효한 값은 'strict'(UnicodeDecodeError 예외 발생), 'replace'(U+FFFD, REPLACEMENT CHARACTER 사용), 'ignore'(그냥 결과에서 문자를 빼 버림), 'backslashreplace'(\xNN 이스케이프 시퀀스 삽입)예요. 다음 예시들이 차이를 보여줘요.

>>> b'\x80abc'.decode("utf-8", "strict")
Traceback (most recent call last):
    ...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80 in position 0:
  invalid start byte
>>> b'\x80abc'.decode("utf-8", "replace")
'\ufffdabc'
>>> b'\x80abc'.decode("utf-8", "backslashreplace")
'\\x80abc'
>>> b'\x80abc'.decode("utf-8", "ignore")
'abc'

인코딩은 인코딩의 이름을 담은 문자열로 지정돼요. Python에는 인코딩이 약 100개 정도 내장돼 있고, 목록은 Python 라이브러리 레퍼런스의 '표준 인코딩'에 있어요. 어떤 인코딩은 이름이 여러 개예요. 예를 들어 'latin-1', 'iso_8859_1', '8859'는 모두 같은 인코딩의 동의어예요.

한 문자짜리 유니코드 문자열은 내장 함수 chr()로도 만들 수 있는데, 정수를 받아 해당 코드 포인트를 담은 길이 1의 유니코드 문자열을 반환해요. 반대 연산은 내장 함수 ord()예요. 한 문자짜리 유니코드 문자열을 받아 코드 포인트 값을 반환하죠.

>>> chr(57344)
'\ue000'
>>> ord('\ue000')
57344

바이트로 변환하기

bytes.decode()의 반대 메서드는 str.encode()예요. 요청된 인코딩으로 인코딩된 유니코드 문자열의 bytes 표현을 반환하죠.

errors 매개변수는 decode() 메서드의 매개변수와 같지만, 더 많은 핸들러를 지원해요. 'strict', 'ignore', 'replace'(이 경우엔 인코딩 불가 문자 대신 물음표를 삽입) 외에도, 'xmlcharrefreplace'(XML 문자 참조 삽입), backslashreplace(\uNNNN 이스케이프 시퀀스 삽입), namereplace(\N{...} 이스케이프 시퀀스 삽입)가 있어요.

다음 예시가 서로 다른 결과를 보여줘요.

>>> u = chr(40960) + 'abcd' + chr(1972)
>>> u.encode('utf-8')
b'\xea\x80\x80abcd\xde\xb4'
>>> u.encode('ascii')
Traceback (most recent call last):
    ...
UnicodeEncodeError: 'ascii' codec can't encode character '\ua000' in
  position 0: ordinal not in range(128)
>>> u.encode('ascii', 'ignore')
b'abcd'
>>> u.encode('ascii', 'replace')
b'?abcd?'
>>> u.encode('ascii', 'xmlcharrefreplace')
b'ꀀabcd޴'
>>> u.encode('ascii', 'backslashreplace')
b'\\ua000abcd\\u07b4'
>>> u.encode('ascii', 'namereplace')
b'\\N{YI SYLLABLE IT}abcd\\u07b4'

가용한 인코딩을 등록하고 접근하는 저수준 루틴은 codecs 모듈에 있어요. 새 인코딩을 구현하는 것도 codecs 모듈을 이해해야 해요. 하지만 이 모듈이 반환하는 인코딩·디코딩 함수는 보통 필요한 것보다 더 저수준이고, 새 인코딩을 쓰는 건 전문적인 작업이라서, 이 HOWTO에서는 이 모듈을 다루지 않을게요.

Python 소스 코드의 유니코드 리터럴

Python 소스 코드에서 특정 유니코드 코드 포인트는 \u 이스케이프 시퀀스로 쓸 수 있는데, 뒤에 코드 포인트를 주는 네 개의 16진 숫자가 따라와요. \U 이스케이프는 비슷하지만 네 개가 아니라 여덟 개의 16진 숫자를 기대해요.

>>> s = "a\xac\u1234\u20ac\U00008000"
... #     ^^^^ two-digit hex escape
... #         ^^^^^^ four-digit Unicode escape
... #                     ^^^^^^^^^^ eight-digit Unicode escape
>>> [ord(c) for c in s]
[97, 172, 4660, 8364, 32768]

127보다 큰 코드 포인트에 이스케이프 시퀀스를 쓰는 건 소량일 때는 괜찮지만, 프랑스어 같은 악센트가 붙는 언어의 메시지가 들어 있는 프로그램처럼 악센트 문자를 많이 쓰면 성가셔져요. chr() 내장 함수로 문자열을 조립할 수도 있지만, 그건 더 지루해요.

이상적으로는 모국어의 자연 인코딩으로 리터럴을 쓸 수 있으면 좋겠죠. 그러면 좋아하는 에디터로 Python 소스 코드를 편집해서 악센트 문자가 자연스럽게 표시되고, 런타임에 올바른 문자가 사용되게 할 수 있으니까요.

Python은 기본적으로 UTF-8로 소스 코드를 쓰는 것을 지원하지만, 사용 중인 인코딩을 선언하면 거의 모든 인코딩을 쓸 수 있어요. 소스 파일의 첫 번째나 두 번째 줄에 특별한 주석을 넣는 것으로 할 수 있어요.

#!/usr/bin/env python
# -*- coding: latin-1 -*-

u = 'abcdé'
print(ord(u[-1]))

이 문법은 Emacs가 파일에 지역적인 변수를 지정하는 표기법에서 영감을 받았어요. Emacs는 많은 변수를 지원하지만, Python은 'coding'만 지원해요. -*- 기호는 Emacs에게 이 주석이 특별하다고 알려주는 것이지, Python에게는 아무 의미가 없고 그냥 관례예요. Python은 주석에서 coding: name 또는 coding=name을 찾아요.

이런 주석을 넣지 않으면 기본 인코딩은 이미 말한 대로 UTF-8로 사용돼요. 자세한 내용은 PEP 263을 참고하세요.

유니코드 속성

유니코드 규격은 코드 포인트에 대한 정보 데이터베이스를 포함해요. 정의된 각 코드 포인트에 대해 그 정보는 문자의 이름, 범주, 해당되면 숫자 값(로마 숫자나 1/3, 4/5 같은 분수를 나타내는 문자처럼 숫자 개념을 나타내는 문자용)을 포함해요. 양방향 텍스트에서 코드 포인트를 어떻게 쓰는지 같은 표시 관련 속성도 있어요.

다음 프로그램이 몇 개 문자에 대한 정보를 표시하고, 특정 문자 하나의 숫자 값을 출력해요.

import unicodedata

u = chr(233) + chr(0x0bf2) + chr(3972) + chr(6000) + chr(13231)

for i, c in enumerate(u):
    print(i, '%04x' % ord(c), unicodedata.category(c), end=" ")
    print(unicodedata.name(c))

# Get numeric value of second character
print(unicodedata.numeric(u[1]))

실행하면 이렇게 출력해요.

0 00e9 Ll LATIN SMALL LETTER E WITH ACUTE
1 0bf2 No TAMIL NUMBER ONE THOUSAND
2 0f84 Mn TIBETAN MARK HALANTA
3 1770 Lo TAGBANWA LETTER SA
4 33af So SQUARE RAD OVER S SQUARED
1000.0

범주 코드는 문자의 성질을 설명하는 약어예요. 이들은 "Letter", "Number", "Punctuation", "Symbol" 같은 범주로 묶이고, 다시 하위 범주로 나뉘어요. 위 출력의 코드를 보면, 'Ll'은 'Letter, lowercase', 'No'는 "Number, other", 'Mn'은 "Mark, nonspacing", 'So'는 "Symbol, other"를 뜻해요. 범주 코드 목록은 유니코드 문자 데이터베이스 문서의 'General Category Values' 절에서 볼 수 있어요.

문자열 비교하기

유니코드는 문자열 비교에 몇 가지 복잡함을 더해요. 같은 문자 집합이 서로 다른 코드 포인트 시퀀스로 표현될 수 있기 때문이에요. 예를 들어 'ê' 같은 문자는 단일 코드 포인트 U+00EA로, 또는 'e'의 코드 포인트 뒤에 'COMBINING CIRCUMFLEX ACCENT' 코드 포인트가 오는 U+0065 U+0302로 표현될 수 있어요. 이것들은 출력하면 같은 결과를 내지만, 하나는 길이 1의 문자열이고 다른 하나는 길이 2예요.

대소문자 무시 비교를 위한 한 도구는 casefold() 문자열 메서드예요. 유니코드 표준이 설명하는 알고리즘을 따라 문자열을 대소문자 무시 형태로 변환하죠. 이 알고리즘은 독일어 문자 'ß'(코드 포인트 U+00DF) 같은 문자에 대한 특별 처리가 있는데, 이 문자가 소문자 한 쌍 'ss'가 돼요.

>>> street = 'Gürzenichstraße'
>>> street.casefold()
'gürzenichstrasse'

두 번째 도구는 unicodedata 모듈의 normalize() 함수예요. 문자열을 여러 정규 형식 중 하나로 변환하는데, 결합 문자 뒤에 오는 문자는 단일 문자로 대체돼요. normalize()는 두 문자열이 결합 문자를 다르게 쓸 때 잘못 부등을 보고하는 일이 없는 문자열 비교에 쓸 수 있어요.

import unicodedata

def compare_strs(s1, s2):
    def NFD(s):
        return unicodedata.normalize('NFD', s)

    return NFD(s1) == NFD(s2)

single_char = 'ê'
multiple_chars = '\N{LATIN SMALL LETTER E}\N{COMBINING CIRCUMFLEX ACCENT}'
print('length of first string=', len(single_char))
print('length of second string=', len(multiple_chars))
print(compare_strs(single_char, multiple_chars))

실행하면 이렇게 출력해요.

$ python compare-strs.py
length of first string= 1
length of second string= 2
True

normalize() 함수의 첫 번째 인자는 원하는 정규 형식을 주는 문자열로, 'NFC', 'NFKC', 'NFD', 'NFKD' 중 하나가 될 수 있어요.

유니코드 표준은 대소문자 무시 비교를 하는 방법도 지정해요.

import unicodedata

def compare_caseless(s1, s2):
    def NFD(s):
        return unicodedata.normalize('NFD', s)

    return NFD(NFD(s1).casefold()) == NFD(NFD(s2).casefold())

# Example usage
single_char = 'ê'
multiple_chars = '\N{LATIN CAPITAL LETTER E}\N{COMBINING CIRCUMFLEX ACCENT}'

print(compare_caseless(single_char, multiple_chars))

이것은 True를 출력해요. (왜 NFD()가 두 번 호출될까요? casefold()가 비정규화된 문자열을 반환하게 만드는 문자가 몇 개 있기 때문에 결과를 다시 정규화해야 하거든요. 논의와 예시는 유니코드 표준 3.13 절을 보세요.)

유니코드 정규 표현식

re 모듈이 지원하는 정규 표현식은 bytes나 문자열로 제공될 수 있어요. \d, \w 같은 특수 문자 시퀀스 중 일부는 패턴이 bytes로 주어지느냐 문자열로 주어지느냐에 따라 다른 의미를 가져요. 예를 들어 \d는 bytes에서는 [0-9]와 일치하지만 문자열에서는 'Nd' 범주에 있는 어떤 문자와도 일치해요.

이 예시의 문자열에는 숫자 57이 타이와 아라비아 숫자로 둘 다 쓰여 있어요.

import re
p = re.compile(r'\d+')

s = "Over \u0e55\u0e57 57 flavours"
m = p.search(s)
print(repr(m.group()))

실행하면 \d+가 타이 숫자와 일치해서 출력해요. compile()re.ASCII 플래그를 주면 \d+가 대신 "57" 부분 문자열과 일치해요.

비슷하게 \w는 넓은 범위의 유니코드 문자와 일치하지만, bytes이거나 re.ASCII가 주어지면 [a-zA-Z0-9_]만 일치해요. \s는 유니코드 공백 문자나 [ \t\n\r\f\v]와 일치해요.

참고 자료

Python의 유니코드 지원에 대한 좋은 대체 논의가 몇 가지 있어요.

  • Nick Coghlan의 "Processing Text Files in Python 3".
  • Ned Batchelder의 PyCon 2012 발표 "Pragmatic Unicode".
  • str 타입은 Python 라이브러리 레퍼런스의 'Text Sequence Type — str'에 설명돼 있어요.
  • unicodedata 모듈 문서.
  • codecs 모듈 문서.
  • Marc-André Lemburg가 EuroPython 2002에서 "Python and Unicode"(PDF 슬라이드)라는 발표를 했어요. 슬라이드는 Python 2의 유니코드 기능(유니코드 문자열 타입이 unicode라고 불리고 리터럴이 u로 시작하던 때) 설계에 대한 훌륭한 개요예요.

유니코드 데이터 읽고 쓰기

유니코드 데이터를 다루는 코드를 좀 작성했다면, 다음 문제는 입력/출력이에요. 유니코드 문자열을 프로그램에 어떻게 넣고, 유니코드를 저장이나 전송에 적합한 형태로 어떻게 변환할까요?

입력 소스와 출력 대상에 따라 아무 것도 안 해도 될 수 있어요. 애플리케이션이 쓰는 라이브러리가 유니코드를 기본 지원하는지 확인해 보세요. 예를 들어 XML 파서는 종종 유니코드 데이터를 반환해요. 많은 관계형 데이터베이스도 유니코드 값을 지원하는 열을 갖고 SQL 쿼리에서 유니코드 값을 반환할 수 있어요.

유니코드 데이터는 보통 디스크에 쓰거나 소켓으로 보내기 전에 특정 인코딩으로 변환돼요. 모든 작업을 직접 하는 것도 가능해요. 파일을 열고, 8비트 bytes 객체를 읽고, bytes.decode(encoding)으로 그 bytes를 변환하죠. 하지만 수동 접근 방식은 권장되지 않아요.

한 가지 문제는 인코딩의 다중 바이트 성질이에요. 하나의 유니코드 문자는 여러 바이트로 표현될 수 있죠. 파일을 임의 크기 청크(예: 1024나 4096 바이트)로 읽고 싶다면, 청크 끝에서 하나의 유니코드 문자를 인코딩한 바이트 일부만 읽히는 경우를 잡는 오류 처리 코드를 작성해야 해요. 한 해법은 파일 전체를 메모리로 읽은 다음 디코딩하는 거지만, 그러면 아주 큰 파일을 다룰 수 없어요. 2GiB 파일을 읽어야 한다면 2GiB의 RAM이 필요하니까요. (사실 더 필요해요. 적어도 잠시 동안은 인코딩된 문자열과 그 유니코드 버전을 둘 다 메모리에 둬야 하거든요.)

해법은 부분적인 코딩 시퀀스의 경우를 잡는 저수준 디코딩 인터페이스를 사용하는 거예요. 이 구현을 하는 작업은 이미 당신을 위해 끝나 있어요. 내장 open() 함수가 파일 내용이 지정된 인코딩이라고 가정하고 read(), write() 같은 메서드에 유니코드 매개변수를 받아들이는 파일 같은 객체를 반환할 수 있죠. 이는 open()encodingerrors 매개변수로 동작하고, 그것들은 str.encode()bytes.decode()에서처럼 해석돼요.

그래서 파일에서 유니코드를 읽는 것은 간단해요.

with open('unicode.txt', encoding='utf-8') as f:
    for line in f:
        print(repr(line))

읽기와 쓰기를 둘 다 허용하는 갱신 모드로 파일을 여는 것도 가능해요.

with open('test', encoding='utf-8', mode='w+') as f:
    f.write('\u4500 blah blah blah\n')
    f.seek(0)
    print(repr(f.readline()[:1]))

유니코드 문자 U+FEFF는 바이트 순서 표시(BOM)로 쓰이고, 파일의 바이트 순서 자동 감지를 돕기 위해 파일의 첫 문자로 쓰는 일이 많아요. UTF-16 같은 어떤 인코딩은 파일 시작에 BOM이 있어야 해요. 그런 인코딩을 쓰면 BOM이 첫 문자로 자동으로 쓰이고, 파일을 읽을 때는 조용히 버려져요. 리틀 엔디언·빅 엔디언 인코딩을 위한 'utf-16-le', 'utf-16-be' 같은 이 인코딩들의 변형도 있는데, 하나의 특정 바이트 순서를 지정하고 BOM을 건너뛰지 않아요.

어떤 영역에서는 UTF-8로 인코딩된 파일의 시작에 'BOM'을 쓰는 게 관례기도 해요. UTF-8은 바이트 순서에 의존하지 않으므로 이 이름은 오해를 부르지만요. 이 표시는 단지 파일이 UTF-8로 인코딩됐다고 알려줘요. 그런 파일을 읽으려면 'utf-8-sig' 코덱을 써서 있으면 표시를 자동으로 건너뛰세요.

유니코드 파일 이름

오늘날 흔히 쓰이는 운영 체제 대부분은 임의의 유니코드 문자를 담은 파일 이름을 지원해요. 보통 유니코드 문자열을 시스템에 따라 달라지는 어떤 인코딩으로 변환해서 구현되죠. 오늘날 Python은 UTF-8로 수렴하고 있어요. macOS의 Python은 몇 버전 동안 UTF-8을 써 왔고, Python 3.6은 Windows에서도 UTF-8로 전환했어요. Unix 시스템에서는 LANG이나 LC_CTYPE 환경 변수를 설정했다면 파일시스템 인코딩이 있을 거예요. 설정하지 않았다면 기본 인코딩은 다시 UTF-8이에요.

sys.getfilesystemencoding() 함수는 현재 시스템에서 사용할 인코딩을 반환해요. 수동으로 인코딩하고 싶다면 쓰면 되지만, 신경 쓸 이유는 많지 않아요. 파일을 읽거나 쓰기 위해 열 때 보통 파일 이름으로 유니코드 문자열을 주기만 하면 자동으로 올바른 인코딩으로 변환돼요.

filename = 'filename\u4500abc'
with open(filename, 'w') as f:
    f.write('blah\n')

os.stat() 같은 os 모듈의 함수도 유니코드 파일 이름을 받아들여요.

os.listdir() 함수는 파일 이름을 반환하는데, 문제를 제기해요. 유니코드 버전의 파일 이름을 반환해야 할까요, 아니면 인코딩된 버전을 담은 bytes를 반환해야 할까요? os.listdir()은 둘 다 할 수 있어요. 디렉터리 경로를 bytes로 줬는지 유니코드 문자열로 줬는지에 따라 달라지죠. 경로로 유니코드 문자열을 넘기면 파일 이름이 파일시스템 인코딩으로 디코딩되고 유니코드 문자열 리스트가 반환되는 반면, bytes 경로를 넘기면 파일 이름이 bytes로 반환돼요. 예를 들어 기본 파일시스템 인코딩이 UTF-8이라고 가정하고, 다음 프로그램을 실행하면:

fn = 'filename\u4500abc'
f = open(fn, 'w')
f.close()

import os
print(os.listdir(b'.'))
print(os.listdir('.'))

다음 출력이 나와요.

$ python listdir-test.py
[b'filename\xe4\x94\x80abc', ...]
['filename\u4500abc', ...]

첫 번째 리스트는 UTF-8로 인코딩된 파일 이름을 담고, 두 번째 리스트는 유니코드 버전을 담아요.

대부분의 경우 이 API들에서 유니코드를 그냥 쓰는 게 좋아요. bytes API는 디코딩할 수 없는 파일 이름이 존재할 수 있는 시스템에서만 써야 해요. 그건 이제 거의 Unix 시스템뿐이에요.

유니코드를 고려한 프로그램 작성 요령

이 절에서는 유니코드를 다루는 소프트웨어를 작성하는 몇 가지 제안을 해요.

가장 중요한 요령은 이거예요.

소프트웨어는 내부에서 유니코드 문자열로만 작업해야 하고, 입력 데이터는 가능한 한 빨리 디코딩하고 출력은 마지막에만 인코딩해야 해요.

유니코드와 바이트 문자열을 둘 다 받아들이는 처리 함수를 작성하려 하면, 두 종류의 문자열을 결합하는 곳마다 프로그램이 버그에 취약해지는 걸 발견하게 될 거예요. 자동 인코딩·디코딩은 없어요. 예를 들어 str + bytes를 하면 TypeError가 발생해요.

웹 브라우저나 다른 신뢰할 수 없는 소스에서 오는 데이터를 쓸 때 흔한 기법은, 생성된 명령줄에서 문자열을 쓰거나 데이터베이스에 저장하기 전에 문자열에 불법 문자가 있는지 확인하는 거예요. 이때 인코딩된 bytes 데이터가 아니라 디코딩된 문자열을 확인하도록 주의하세요. 어떤 인코딩은 전단사(bijective)가 아니거나 완전히 ASCII 호환이 아닌 흥미로운 성질을 가질 수 있기 때문이에요. 입력 데이터가 인코딩을 지정하기도 한다면 특히 그렇죠. 공격자가 인코딩된 바이트스트림에 악성 텍스트를 숨길 영리한 방법을 고를 수 있으니까요.

파일 인코딩 사이 변환하기

StreamRecorder 클래스는 인코딩 사이를 투명하게 변환할 수 있어요. 인코딩 #1의 데이터를 반환하는 스트림을 받아 인코딩 #2의 데이터를 반환하는 스트림처럼 동작하죠.

예를 들어 Latin-1인 입력 파일 f가 있다면, StreamRecorder로 감싸서 UTF-8로 인코딩된 bytes를 반환하게 할 수 있어요.

new_f = codecs.StreamRecorder(f,
    # en/decoder: used by read() to encode its results and
    # by write() to decode its input.
    codecs.getencoder('utf-8'), codecs.getdecoder('utf-8'),

    # reader/writer: used to read and write to the stream.
    codecs.getreader('latin-1'), codecs.getwriter('latin-1') )

(역자 주: 원문의 클래스명은 StreamRecorder로 표기되어 있으나, 공식 문서에는 StreamRecorder가 아니라 StreamRecoder로 표기되어 있어요. 실제 API는 codecs.StreamRecoder입니다.)

알 수 없는 인코딩의 파일

파일을 변경해야 하는데 그 파일의 인코딩을 모른다면 어떻게 할까요? 인코딩이 ASCII 호환이라는 걸 알고 ASCII 부분만 검사하거나 수정하려 한다면, surrogateescape 오류 핸들러로 파일을 열 수 있어요.

with open(fname, 'r', encoding="ascii", errors="surrogateescape") as f:
    data = f.read()

# make changes to the string 'data'

with open(fname + '.new', 'w',
          encoding="ascii", errors="surrogateescape") as f:
    f.write(data)

surrogateescape 오류 핸들러는 U+DC80에서 U+DCFF까지의 특별한 범위의 코드 포인트로 모든 비-ASCII 바이트를 디코딩해요. 이 코드 포인트들은 surrogateescape 오류 핸들러로 데이터를 인코딩해 다시 쓸 때 원래 바이트로 돌아가요.

참고 자료

David Beazley의 PyCon 2010 발표 "Mastering Python 3 Input/Output"의 한 절이 텍스트 처리와 이진 데이터 처리를 논의해요.

Marc-André Lemburg의 발표 "Writing Unicode-aware Applications in Python"의 PDF 슬라이드는 문자 인코딩 문제와 애플리케이션을 국제화·지역화하는 방법을 논의해요. 이 슬라이드는 Python 2.x만 다뤄요.

"The Guts of Unicode in Python"은 Benjamin Peterson의 PyCon 2013 발표로, Python 3.3의 내부 유니코드 표현을 논의해요.

감사의 말

이 문서의 초기 초안은 Andrew Kuchling이 썼어요. 이후 Alexander Belopolsky, Georg Brandl, Andrew Kuchling, Ezio Melotti가 더 개정했어요.

이 글에서 오류를 지적하거나 제안을 해 준 다음 사람들에게 감사드려요: Éric Araujo, Nicholas Bastin, Nick Coghlan, Marius Gedminas, Kent Johnson, Ken Krugler, Marc-André Lemburg, Martin von Löwis, Terry J. Reedy, Serhiy Storchaka, Eryk Sun, Chad Whitacre, Graham Wideman.

더 알아보기 (Learn more)

  • str 타입 동작의 전모는 라이브러리 레퍼런스의 Text Sequence Type — str 문서를 보세요.
  • unicodedata, codecs 모듈, 그리고 PEP 263을 함께 읽으면 인코딩 선언 규칙까지 다 잡을 수 있어요.