CodeOnWeb
로그인

챕터 15. 사례 연구: chardet을 파이썬 3로 이식하기

파이썬 2에서 작성한 chardet 모듈을 파이썬 3로 이식하는 법을 살펴봅니다.

Park Jonghyun 2015/09/01, 20:23

내용

파이썬 3에 뛰어들기 (22)

챕터 -1. “파이썬 3로 뛰어들기”에서 달라진 점 챕터 0. 파이썬 설치하기 챕터 1. 첫 파이썬 프로그램 챕터 2. 고유 자료형 챕터 3. 컴프리헨션(Comprehensions) 챕터 4. 문자열(Strings) 챕터 5. 정규표현식(Regular Expressions) 챕터 6. 클로저와 제너레이터(Closures & Generators) 챕터 7. 클래스와 반복자(Classes & Iterators) 챕터 8. 고급 반복자(Advanced Iterators) 챕터 9. 단위 테스트(Unit Testing) 챕터 10. 리팩토링(Refactoring) 챕터 11. 파일 챕터 12. XML 챕터 13. 파이썬 객체 직렬화 챕터 14. HTTP 웹서비스 챕터 15. 사례 연구: chardet을 파이썬 3로 이식하기 챕터 16. 파이썬 라이브러리 패키징하기 부록 A. 2to3를 이용해서 코드를 파이썬 3로 이식하기 부록 B. 특수 함수 이름 부록 C. 이제 어디로 가야 할까요? 부록 D. 문제 해결

"이야기, 이야기. 우리가 할 수 있는 건 그것밖에 없지."
— 로젠크란츠와 길덴스턴은 죽었다(Rosencrantz and Guildenstern are Dead)

뛰어들기

질문 하나 할까요. 웹이나 여러분의 메일함, 아니면 그냥 컴퓨터에서 어떤 글을 읽을 때 이상하게 깨진 문자가 나오는 가장 큰 이유는 무엇일까요? 문자 인코딩입니다. 문자열 챕터에서 문자 인코딩의 역사와 "모든 인코딩을 지배하는" 유니코드의 탄생에 대해 말씀드렸습니다. 만약 모든 문서 작성 프로그램이 정확한 인코딩 정보를 저장하고, 모든 전송 프로토콜이 유니코드를 인식하고, 모든 시스템이 인코딩을 바꿀 때 엄밀성을 유지해서 더 이상 웹페이지에서 깨진 문자를 보지 않을 수 있다면 정말 좋을겁니다.

저는 포니(조랑말)도 좋아합니다.

유니코드 포니.

유니포니라고나 할까요.

죄송합니다. 일단 문자 인코딩 자동 감지 정도로 만족해야 겠습니다.

문자 인코딩 자동 감지가 무엇일까요?

문자 인코딩이 알려지지 않은 바이트 배열을 받아서 인코딩이 무엇인지 추측해 내용을 읽을 수 있도록 하는 작업입니다. 암호를 해독할 수 있는 열쇠가 없는 상황에서 내용을 알기 위해 코드를 크래킹하는 것과 비슷하죠.

불가능하지 않나요?

일반적으로 그렇습니다. 하지만 어떤 인코딩은 특정 언어에 최적화 되어 있고 언어에는 특징이 있지요. 어떤 문자열은 자주 쓰이는 반면, 어떤 문자열은 전혀 말이 되지 않습니다. 신문을 펼쳤을 때 "txzqJv 2!dasd0a QqdKjvz"가 나오면, 영어를 좀 하는 사람은 (모든 문자가 영어의 알파벳임에도 불구하고) 이 문장이 영어가 아니라는 것을 금방 알 겁니다. "일반" 텍스트를 많이 다루다 보면 컴퓨터 알고리듬도 그런 능력을 어느 정도 모사해서 문장의 언어를 짐작할 수 있습니다.

다시 말해, 인코딩 감지는 사실 언어를 감지하는 것이고, 언어를 감지하고 나면 그 언어에서 많이 쓰이는 문자 인코딩의 정보를 이용해 인코딩까지 추측해볼 수 있습니다.

그런 알고리듬이 존재하나요?

대답은 "네"입니다. 모든 주된 브라우저는 문자 인코딩을 자동으로 감지하는 기능을 가지고 있습니다. 웹은 인코딩 정보가 없는 페이지로 가득 차 있기 때문이죠. 모질라의 파이어폭스(Mozilla Firefox)는 오픈 소스 인코딩 자동 감지 라이브러리를 사용합니다. 저는 그 라이브러리를 파이썬 2로 이식해서 chardet 모듈이라는 이름을 붙였습니다. 이 챕터에서는 chardet 라이브러리를 파이썬 2에서 파이썬 3로 한 번 더 이식하는 과정을 차례 차례 살펴보겠습니다.

chardet 모듈 소개

이식을 시작하기 전에 코드가 돌아가는 방식을 이해하는 것이 좋을 것 같습니다. 코드 자체를 해석하는 연습을 해본다고 생각하면 일석이조네요. chardet 라이브러리는 꽤 방대하므로 코드를 여기에 일일이 적을 수는 없습니다. 전체 코드가 필요하면 chardet.feedparser.org에서 받으실 수 있습니다.

감지 알고리듬의 시작 지점은 UniversalDetector라는 하나의 클래스를 가진 universaldetector.py입니다. (시작 지점이 chardet/__init__.py에 있는 detect 함수라고 생각할 수 있지만, 사실은 UniversalDetector 객체를 생성하고, 호출하고, 그 결과를 반환하는 편의 함수일 뿐입니다.)

UniversalDetector는 크게 다섯 가지 종류의 인코딩을 다룰 수 있습니다.

  • 바이트 순서 표시(Byte Order Mark, BOM)를 포함한 UTF-N. UTF-8, Big-Endian과 Little-Endian을 포함한 UTF-16, 4바이트 크기으 UTF-32를 모두 다룰 수 있습니다..
  • 7비트 ASCII 호환 escaped된 인코딩. ASCII가 아닌 문자는 escape 문자로 시작합니다. 이에 해당하는 예는 ISO-2022-JP(일본)와 HZ-GB-2312(중국)가 있습니다.
  • 한 문자가 여러 바이트를 사용하는 인코딩. 이에 해당하는 예는 BIG5(중국), SHIFT_JIS(일본), EUC-KR(한국), BOM이 없는 UTF-8 등이 있습니다.
  • 한 문자가 한 바이트를 차지하는 인코딩. 이에 해당하는 예로는 KOI8-R(러시아), WINDOWS-1255(히브루), TIS-620(타이)가 있습니다.
  • WINDOWS-1252. 마이크로소프트 윈도우즈 사용자 중에서 인코딩은 전혀 들어보지도 못한 중간 관리자들이 많이 사용합니다.

BOM을 가지고 있는 UTF-N

문자가 BOM으로 시작한다면, 우리는 그 문자가 UTF-8, UTF-16, UTF-32 중의 하나로 인코딩 되어 있다고 생각할 수 있습니다. (그리고 BOM을 살펴보면 세 개 중 어떤 인코딩인지도 알 수 있지요.) 이에 대한 처리는 UniversalDetector에서 직접 처리됩니다. 별다른 일을 하지 않고 바로 결과를 반환해주지요.

Escape된 인코딩

만약 텍스트가 알아볼 수 있는 escape 문자를 가지고 있다면 escape된 인코딩을 사용한다고 볼 수 있습니다. 이 경우 UniversalDetector는 EscChrSetProber(escprober.py에 정의되어 있습니다)를 생성하면서 텍스트를 넘겨줍니다.

EscCharSetProber는 HZ-GB-2312, ISO-2022-CN, ISO-2022-JP, and ISO-2022-KR의 모델(escsm.py에 정의)에 기반해 각 인코딩 별로 state machine이라는 것을 정의합니다. EscCharSetProber는 각 state machine에 텍스트를 한 번에 한 바이트씩 넘겨주고, state machine에서는 넘어오는 문자가 인코딩에 잘 맞는지 확인합니다. 일치하는 것 같은 인코딩이 최종적으로 하나가 남으면 EscCharSetProber는 즉시 UniversalDetector에 그 사실을 알려주고, UniversalDetector는 호출한 쪽에 이어 알립니다. 넘어온 문자가 한 state machine이 검사하는 인코딩에 전혀 맞지 않으면, 그 state machine은 바로 작동을 멈추게 되고 나머지 state machine만으로 검사를 계속 진행합니다.

여러 바이트 인코딩

BOM이 없으면 UniversalDetector는 텍스트가 높은 비트 문자를 포함하고 있는지 검사합니다. 만약 그렇다면 여러 바이트 인코딩, 한 바이트 인코딩, WINDOWS-1252 인코딩인지 확인할 수 있는 각각의 "검출기(probers)"를 만듭니다.

mbcsgroupprober.py에 정의된 MBCSGroupProber라 불리는 여러 바이트 인코딩 검출기는 사실 다른 개별 검출기(BIG5, GB2312, EUC-TW, EUC-KR, EUC-JP, SHIFT_JIS, UTF-8에 해당하는 검출기)를 관리하는 쉘입니다. MBCSGroupProber는 텍스트를 각 인코딩 검출기에 넘겨주고 결과를 확인합니다. 만약 검출기가 (해당 인코딩에서) 잘못된 바이트 배열을 발견했다고 보고하면, 이후 처리에서 그 검출기는 제외됩니다. (즉, 이후 발생하는 UniversalDetector.feed() 호출에서 그 검출기는 건너뜁니다.) 만약 어떤 검출기가 텍스트의 인코딩을 검출한 것 같다고 보고하면, MBCSGroupProber는 이 소식을 UniversalDetector에게 알려서 호출한 쪽에 결과를 돌려줍니다.

대부분의 여러 바이트 인코딩 검출기는 MultiByteCharSetProber(mbcharsetprober.py에 정의되어 있습니다)를 상속하고 있고, state machine과 distribution analyzer를 적절하게 재정의합니다. 나머지 일은 모두 MultiByteCharSetProber에게 맡기죠. MultiByteCharSetProber는 각 인코딩별 state machine에 텍스트를 한 바이트씩 넘겨주고, 주어진 바이트 배열이 인코딩과 일치하거나 혹은 일치하지 않는다는 결론이 나기를 기다립니다. 동시에 MultiByteCharSetProber는 텍스트를 각 인코딩별 distribution analyzer에도 넘겨줍니다.

Distribution analyzer(chardistribution.py에 정의)는 언어별로 가장 많이 쓰이는 문자에 대한 모델을 사용합니다. MultiByteCharSetProber가 충분한 양의 문자를 distribution analyzer에 제공하면, 자주 쓰이는 문자의 수, 사용된 총 문자 수, 언어별 문자 분포 정도에 대한 정보를 기반으로 신뢰도를 계산합니다. 신뢰도가 충분히 높게 나오면 MultiByteCharSetProber는 그 결과를 MBCSGroupProber에 되돌려주고, MBCSGroupProber는 UniversalDetector에게, UniversalDetector는 함수 호출자에게 이어서 반환합니다.

일본어의 경우는 좀 더 어렵습니다. 개별 문자의 분포를 통한 해석만으로 EUC-JP와 SHIFT_JIS를 구별하기는 충분치 않으므로, SJISProber(sjisprober.py에 정의)는 두 개 문자의 분포를 이용한 해석도 수행합니다. SJISContextAnalysis와 EUCJPContextAnalysis(둘 모두 jpcntx.py에 정의되어 있고 JapaneseContextAnalysis 클래스를 상속합니다)는 텍스트 내 히라가나 음절 문자의 빈도를 확인합니다. 충분한 텍스트가 처리되면 SJISProber에게 신뢰도를 반환하고, SJISProber는 두 해석결과를 비교하여 더 높은 신뢰도 값을 가진 결과를 MBCSGroupProber에 반환합니다.

한 바이트 인코딩

한 바이트 인코딩 검출기인 SBCSGroupProber(sbcsgroupprober.py에 정의) 역시

다른 개별 검출기(WINDOWS-1251, KOI8-R, ISO-8859-5, MacCyrillic, IBM855, IBM866(러시아); ISO-8859-7, WINDOWS-1253(그리스); ISO-8859-5, WINDOWS-1251(불가리아); ISO-8859-2, WINDOWS-1250(헝가리); TIS-620(타이); WINDOWS-1255, ISO-8859-8 (히브리))를 관리하는 쉘입니다.

SBCSGroupProber는 각 인코딩+언어별 검출기에 텍스트를 넘겨주고 결과를 확인합니다. 이들 검출기는 모두 SingleByteCharSetProber(sbcharsetprober.py에 정의)라는 하나의 클래스 내에 구현되어 있고, 이 클래스는 언어 모델을 인자로 받아들입니다. 언어 모델에는 일반적인 텍스트에서 서로 다른 두 문자의 배열이 어떤 빈도로 나오는지 정의되어 있습니다. SingleByteCharSetProber은 텍스트를 분석해서 가장 많이 사용되는 두 문자 배열에 순위를 메깁니다. 충분한 텍스트가 처리되면 자주 사용된 배열의 숫자, 사용된 총 문자, 언어별 분포 비율을 기반으로 해 신뢰도를 계산하게 됩니다.

히브리어는 특별하게 취급됩니다. 두 개의 문자 분포를 살펴봐서 텍스트가 히브리어인 것 같다고 판단하면, HebrewProber(hebrewprober.py에 정의)가 Visual 히브리어(원본 텍스트의 각 줄이 "역순"으로 저장/표시되어 별다른 처리 없이 오른쪽에서 왼 쪽으로 읽을 수 있는 방식)인지 Logical 히브리어(원본 텍스트의 각 줄이 읽는 순서대로 저장되어 클라이언트에서 오른쪽-왼쪽 순으로 읽을 수 있게 처리해주는 방식)인지 판단합니다. 어떤 문자는 단어 중간에 오는지 끝에 오는지에 따라 다르게 인코딩 되는 경우도 있으므로, 원본 텍스트의 순서를 적절하게 추측해야 하고 그에 맞는 인코딩을 반환해야 합니다(Logical 히브리어는 WINDOWS-1255, Visual Hebrew는 ISO-8859-8).

WINDOWS-1252

텍스트에서 높은 비트 문자를 발견했음에도 불구하고 여러 바이트나 한 바이트 인코딩 검출기에서 신뢰성 있는 결과를 반환하지 않으면, UniversalDetector는 Latin1Prober(latin1prober.py에 정의)를 생성해서 WINDOWS-1252으로 인코딩 된 영어 문서인지 확인해봅니다. 서로 다른 인코딩이어도 영어 문자는 같은 코드로 인코딩 하는 경우가 많으므로, 이 검출 방법의 신뢰성은 그다지 높지 않습니다. WINDOWS-1252를 구별하는 유일한 방법은 자주 사용되는 심볼(smart quotes, curly apostrophes, copyright 등) 확인하는 것 뿐입니다. Latin1Prober는 자신의 신뢰도 값을 자동으로 좀 줄이는데, 이는 가능하면 보다 정확한 검출기가 더 높은 신뢰도 값을 가지게 하기 위해서입니다.

2to3 돌리기

우리는 chardet 모듈을 파이썬 2에서 파이썬 3로 이식할 것입니다. 파이썬 3에는 2to3라는 스크립트가 포함되어 있습니다. 파이썬 2 소스 코드를 입력 받아 최대한 자동으로 파이썬 3에 맞는 문법으로 변환해 주는 도구이지요. 어떤 경우에는 쉽습니다. 함수의 이름이 좀 바뀌거나 다른 모듈로 옮겨지는 게 다입니다. 하지만 어떤 경우에는 아주 복잡할 수 있습니다. 이 스크립트가 할 수 있는 일에 대한 감을 잡으려면 부록에 있는 2to3로 파이썬 3 이식하기기를 참고하십시오. 이 챕터는 chardet 패키지에 2to3을 바로 적용하는 것으로 시작하겠습니다. 곧 알게 되겠지만, 이 자동화 도구가 몇 가지 마법을 부리고 난 뒤에도 상당히 많은 양의 할 일이 남게 됩니다.

chardet 패키지는 한 디렉토리 내에 여러 개의 서로 파일로 나뉘어서 구성되어 있습니다. 2to3 스크립트는 여러 파일을 한 번에 변환할 수 있게 작성되었습니다. 패키지가 들어 있는 디렉토리 이름을 커맨드 라인 인자로 넣어주면, 2to3는 그 안의 모든 파일을 변환합니다.

이제 2to3 스크립트를 테스트 목적으로 만들어진 test.py에 적용합시다.

뭐 그렇게 어렵지는 않습니다(앞에 -가 붙은 줄은 삭제된 것이고, +가 붙은 줄은 추가된 줄입니다). 그저 몇 개의 import 문과 print 문을 좀 바꿔주네요. 이야기가 나와서 말인데, 저 import 문에 무슨 문제가 있는 것일까요? 이에 답하려면 chardet 모듈이 어떤 식으로 여러 파일에 나뉘어 있는지를 먼저 이해해야 합니다.

여러 파일 모듈에 대한 이야기로 잠시 샙시다

chardet은 여러 파일로 구성된 모듈입니다. 모든 코드를 하나의 파일(예를 들어 chardet.py)에 몰아넣을 수도 있지만 그렇게 하지 않았습니다. 대신, 저는 chardet이라는 디렉토리를 하나 만들고 그 디렉토리에 __init__.py 파일 하나를 생성했습니다. 파이썬은 디렉토리에 __init__.py라는 파일이 있으면 그 디렉토리에 있는 모든 파일이 같은 모듈의 일부분이라고 인식합니다. 모듈의 이름은 디렉토리 이름이 됩니다. 디렉토리 내의 파일은 같은 디렉토리 내, 혹은 그 하위 디렉토리 내에 있는 다른 파일을 참조할 수 있습니다(이에 대해서는 곧 다루겠습니다). 하지만 파이썬의 다른 코드에서는 이 파일들 전체가 하나의 모듈로 보입니다. 모든 함수와 클래스가 하나의 .py 파일에 담긴 것처럼요.

__init__.py 파일에는 뭐가 있을까요? 아무 것도 없을 수도, 모든 것이 있을 수도, 아니면 그 중간 어딘가가 될 수도 있습니다. __init__.py 파일에 무언가를 정의할 필요는 없습니다. 말 그대로 빈 파일로 존재할 수도 있습니다. 아니면 여러분 모듈의 시작 지점을 정의해줄 수도 있습니다. 그것도 아니라면 여러분의 모든 함수를 다 집어넣어도 됩니다. 하나만 빼고다 넣어도 되지요.

__init__.py 파일이 들어있는 디렉토리는 항상 여러 .py 파일로 이루어진 하나의 모듈로 취급됩니다. __init__.py 파일이 없으면 그 디렉토리는 그냥 일반적인 디렉토리이고, 그 안에 든 .py 파일은 서로 독립적으로 존재합니다.

실제로 어떻게 작동하는지 봅시다.

  1. 일반적인 클래스 속성을 제외하면 chardet 모듈에 있는 유일한 것은 detect() 함수입니다.
  2. 여기 chardet 모듈이 단지 하나의 파일 이상의 무언가라는 사실을 짐작할 수 있는 첫 번째 단서가 나왔습니다. 이 "모듈"은 chardet/ 디렉토리의 __init__.py 파일에서 나왔다고 되어 있습니다.

  1. __init__.py 파일은 chardet 라이브러리로 들어가는 시작 지정인 detect() 함수를 정의하고 있습니다.
  2. 하지만 detect() 함수에는 코드가 거의 없습니다! 사실, 이 함수가 하는 일은 universaldetector 모듈을 import 하고사용하기 시작하는 것 뿐입니다. 그런데 universaldetector은 어디에 정의되어 있을까요?

답은 이상하게 보이는 import 문에 나와 있습니다.

영어로 해석하자면 "universaldetector 모듈을 import해라. 그것은 내가 있는 곳과 같은 디렉토리에 있다" 정도가 되겠네요. 여기서 "나"는 chardet/__init__.py 파일입니다. 이런 형식으로 import 하느 것을 상대(relative) import라고 합니다. 여러 파일로 이루어진 모듈 내의 파일끼리 서로를 참조할 수 있는 방법으로, 여러분이 import 검색 경로에 설치한 다른 모듈과 이름이 겹칠 염려가 없습니다. import 문은 오직 chardet/ 디렉토리 내에 있는 universaldetector 모듈만을 찾게 됩니다.

이 두 가지 개념(__init__.py와 상대 import)을 이용하면 여러분의 모듈을 얼마든지 원하는 만큼 나눌 수 있습니다. chardet 모듈은 총 36개의 .py 파일로 이루어져 있습니다. 36개요! 하지만 여러분은 그냥 import chardet 한 줄만 써주고 chardet.detect() 함수를 호출하면 모듈을 이용할 수 있습니다. 여러분의 코드에 알려지지는 않았지만, detect() 함수는 사실 chardet/__init__.py 파일에 위치하고 있습니다. detect() 함수는 상대 import를 통해 chardet/universaldetector.py에 정의된 클래스를 참조하고, 이 클래스도 상대 import를 이용해 chardet/ 디렉토리에 있는 다섯 개의 다른 파일을 참조한다는 사실 또한 알려지지 않습니다.

여러분이 큰 파이썬 라이브러리를 작성한다면(아니면 작게 시작했다가 점점 자라 커지게 되면) 코드를 여러 파일이 담긴 모듈로 나눠 다시 작성하는 것을 고려해보세요. 파이썬이 훌륭하게 지원하는 많은 기능 중 하나이니 잘 이용해 먹어야지요.

2to3가 하지 못한 부분을 고치기

False는 유효하지 않은 문법입니다

이제 테스트를 시작해봅시다. 테스트 케이스는 코드 작동의 가능한 모든 부분을 테스트하고 있으므로, 이식된 코드의 테스트를 실행시켜서 별다른 버그가 없는지 확인해보는 것은 좋은 방법입니다.

음, 작은 장애물이 있네요. 파이썬 3에서는 False가 예약어이므로 변수 이름으로 사용할 수 없습니다. constants.py를 한 번 찾아보죠. 2to3 스크립트가 변경하기 전의 원래 버전을 봅시다.

이 코드는 오래된 파이썬 2 버전에서 코드가 돌아가도록 하기 위해 작성되었습니다. 2.3 이전 버전의 파이썬은 내장된 boolean 형을 가지고 있지 않았습니다. 이 코드에서 내장된 True와 False 상수가 있는지 확인하고 없으면 따로 정의해줍니다.

하지만 파이썬 3는 언제나 boolean 형을 가지고 있으므로 이 코드 전체가 불필요합니다. 가장 간단한 해법은 constants.True와 constants.False가 등장하는 부분을 모두 True, False로 바꾸고, constants.py에서 위의 쓸모없는 코드를 삭제하는 것입니다.

예를 들어, universaldetector.py의

로 바꿉니다. 만족스럽네요. 코드가 짧아졌고 읽기도 쉽습니다.

constants라는 이름의 모듈이 없습니다

test.py를 다시 실행시켜서 어디까지 가나 확인해 볼 시간입니다.

아니, constants라는 이름의 모듈이 없다고요? constants라는 이름의 모듈은 chardet/constants.py에 분명히 존재합니다.

2to3 스크립트가 import 문을 왕창 수정했던 것 기억나세요? 이 라이브러리는 수많은 상대 import(즉, 같은 라이브러리 내에서 다른 모듈을 import 하는 것)를 사용하고 있는데, 파이썬 3로 오면서 상대 import를 처리하는 방식이 좀 달라졌습니다. 파이썬 2에서는 그냥 import constants라고 쓰면 chardet/ 디렉토리를 먼저 검색했습니다. 파이썬 3에서는 모든 import 문은 기본적으로 절대(absolute) import입니다. 만약 파이썬 3에서 상대 import를 사용하고 싶다면 명시적으로 표현을 해주어야 합니다.

그런데 2to3 스크립트가 이 변화를 처리해주는 것 아닌가요? 맞습니다. 하지만 위의 경우 한 줄에 서로 다른 두 종류의 import를 하고 있습니다. 하나는 라이브러리 내에 있는 constants 모듈을 상대 import 하는 것이고, 다른 하나는 파이썬 표준 라이브러리에 이미 내장되어 있는 sys 모듈을 절대 import 하는 것입니다. 파이썬 2에서는 import 문을 이런 식으로 섞어 쓸 수 있었습니다. 파이썬 3에서는 그럴 수 없습니다. 그리고 2to3 스크립트는 이 import 문을 두 줄로 나눠줄만큼 똑똑하지는 못합니다.

해법은 import 문을 직접 나눠주는 겁니다. 그래서 아래 import 문은

다음과 같이 두 줄로 나뉘어야 합니다.

chardet 라이브러리에는 이런 식의 import 문제가 곳곳에 퍼져 있습니다. 어떤 곳에서는 "import constants, sys"라고 되어 있고, 또 다른 곳에서는 "import constants, re"라고 쓰여 있죠. 고키는 법은 같습니다. 수동으로 import 문을 두 줄로 나눠주십시오. 하나는 상대 import고 다른 하나는 절대 import 입니다.

계속 갑시다!

'file'이라는 이름이 정의되지 않았습니다.

다시 test.py를 돌려서 테스트 케이스를 실행해봅시다...

이런 식의 파일 처리를 워낙 오래 해왔기에, 저는 이런 오류를 처음 보고 좀 놀랐습니다. 파이썬 2에서 전역 file() 함수는 open() 함수의 별명(alias)이었고, 텍스트 파일을 읽기 위해 열 때 사용하는 일반적인 함수였습니다. 파이썬 3에서는 전역 file() 함수는 더 이상 존재하지 않고 open() 함수만 남았습니다.

이 변화에 대처하는 가장 쉬운 방법은 없어진 file() 함수 대신 open() 함수를 쓰는 것입니다.

이 문제에 대해 제가 할 말은 이게 다입니다.

바이트 같은 객체에 문자열 패턴을 사용할 수 없습니다

이제 문제가 재미있어집니다. "재미있다"는 것은 "미치도록 헷갈린다"는 의미입니다.

이 문제를 바로잡기 위해 self._highBitDetector가 무엇인지 알아봅시다. 이 속성은 UniversalDetector 클래스의 __init__ 메소드에 정의되어 있습니다.

128-255 (0x80–0xFF) 사이의 값을 가지는 비(non)-ASCII 문자를 찾기 위해 정규표현식 패턴을 미리 컴파일 해두고 있습니다. 아니, 이건 더 이상 사실이 아닙니다. 보다 정확한 용어를 써야겠습니다. 이 패턴은 128-255 사이의 값을 가지는 비-ASCII 바이트를 찾도록 설계되었습니다.

그리고 거기에 문제가 있죠.

파이썬 2에서 문자열은 바이트 배열이었고 문자의 인코딩은 별도로 추적했습니다. 만약 파이썬 2에서 문자 인코딩을 계속 추적하길 원한다면 대신 유니코드 문자열(u'')을 사용해야 했습니다. 하지만 파이썬 3의 문자열은 파이썬 2에서 유니코드 문자(한 문자의 크기가 여러 바이트일 수 있습니다)열이라 불리던 것으로 바뀌었습니다. 이 정규표현식은 문자열 패턴으로 정의되었기에 문자열(문자열 배열)을 찾는 데만 쓸 수 있습니다. 하지만 우리가 찾고 있는 것은 문자열이 아닌 바이트 배열입니다. traceback을 보면 이 오류는 universaldetector.py에서 일어났다는 것을 알 수 있습니다.

aBuf는 무엇일까요? 좀 더 추적해서 UniversalDetector.feed()를 호출한 부분으로 가봅시다. 그런 장소 중 하나는 test.py에서 찾아볼 수 있습니다.

여기서 우리는 답을 찾을 수 있습니다. UniversalDetector.feed() 메소드에서 aBuf는 디스크에 있는 파일로부터 읽어온 줄(line)입니다. 파일을 열 때 사용한 인자를 자세히 보세요. 'rb'군요. 'r'은 "읽기"라는 의미인데, 우리는 파일을 읽어들이고 있으므로 문제가 없습니다. 아, 하지만 'b'는 "이진"을 뜻하죠. 'b' 옵션이 없으면 이 for 문은 파일을 줄 별로 읽은 뒤 시스템의 기본 인코딩에 따라 문자열(유니코드 문자의 배열)로 변환합니다. 하지만 'b'가 있음으로 인해 이 for 문은 파일을 줄 별로 읽은 뒤 정확히 파일에 저장된 그 형태, 즉 바이트의 배열로 저장합니다. 이 바이트 배열은 UniversalDetector.feed()로 넘어가게 되고 결국 미리 컴파일 된 정규표현식인 self._highBitDetector까지 전달되어 높은 비트... 문자를 검색하게 됩니다. 하지만 문자가 아니라 바이트가 넘어왔죠. 아이고.

이 정규표현식이 검색해야 할 대상은 문자의 배열이 아니라 바이트의 배열입니다.

이 사실을 깨닫고 나면 문제를 해결하는 것은 그리 어렵지 않습니다. 문자열로 정의된 정규표현식은 문자열을 찾고, 바이트 배열로 정의된 정규표현식은 바이트 배열을 찾습니다. 바이트 배열 패턴을 정의하기 위해서는 정규표현식에 인자의 형태를 바이트 배열 형태로 바꿔주기만 하면 됩니다. (바로 다음 줄에도 똑같은 문제를 가진 코드가 있군요.)

전체 코드를 뒤져 re 모듈을 사용하는 경우를 찾아보니 charsetprober.py에 두 가지 경우를 더 발견할 수 있었습니다. 여기서도 문자열로 정의된 정규표현식을 바이트 배열인 aBuf에 적용하고 있습니다. 해법은 똑같습니다. 정규표현식 패턴을 바이트 배열로 바꾸는 것입니다.

'바이트' 객체를 문자열로 암시적 변환할 수 없습니다

점점 더 신기해집니다.

불행하게도 코딩 스타일과 파이썬 인터프리터 사이의 충돌이 발생했습니다. TypeError 오류가 발생하긴 했는데 traceback을 봐도 정확히 어디가 문제인지 알려주지 않습니다. 첫 번째 조건문에 오류가 있을 수도 있고 두 번째 조건문에 있을 수도 있지만, 두 경우 모두 같은 traceback 정보를 출력할겁니다. 범위를 좁히기 위해 오류가 난 줄을 아래처럼 두 줄로 나눠봅시다.

다시 테스트를 돌려보죠.

아하! 문제는 첫 번째 조건문(self._mInputState == ePureAscii)이 아니라 두 번째 조건문에 있었습니다. 여기서 TypeError가 발생하는 이유는 뭘까요? search() 메소드가 기대하는 타입과는 다는 인자를 받았을 것이라 생각할 수도 있지만, 그 경우에는 이런 traceback이 출력되지 않습니다. 파이썬 함수는 인자의 수가 일치하는 한 어떤 값이든 받아들여 실행됩니다. 기대하지 않은 타입의 인자를 넘겼을 때 오류가 발생할 수 있지만, 그런 경우에는 traceback이 그 함수(여기서는 search()) 내부의 어딘가를 지적하게 됩니다. 하지만 여기 traceback은 search() 메소드를 호출하는 곳까지 가지도 못했지요. 따라서 문제는 분명 search() 메소드에 인자를 넘기기 바로 전에 실행되는 + 연산에 있을 것입니다.

이전 디버깅 경험을 통해 aBuf가 바이트 배열이라는 사실은 알고 있습니다. 그러면 self._mLastChar는 무엇일까요? reset() 메소드에 정의된 개체 변수로 __init__() 메소드에서 호출되고 있습니다.

이제 답을 알았습니다. 보이세요? self._mLastChar는 문자열인데 aBuf는 바이트 배열입니다. 그리고 문자열을 바이트 배열과 합칠 수는 없습니다. 문자열의 길이가 0이어도 마찬가지입니다.

어쨋거나 self._mLastChar에는 무슨 내용이 담기는걸까요? feed() 메소드에서 traceback이 가리키는 곳 아래쪽으로 몇 줄만 가봅시다.

호출 함수는 한 번에 몇 바이트씩을 인자로 주면서 feed() 메소드를 계속 호출합니다. 이 메소드는 인자로 받은 바이트(aBuf에 담겨서 넘어왔습니다)를 이용해서 일을 처리하고, 다음 번 호출되었을 때 필요할 경우에 대비해 마지막 바이트는 self._mLastChar에 저장해둡니다. (여러 바이트 인코딩인 경우 feed() 메소드에 문자 반 개 만큼의 인자가 넘어왔을 수도 있습니다. 다음 번 호출 때는 나머지 반의 정보를 담고 있는 인자가 넘겨지겠지요.) 하지만 aBuf는 이제 문자열이 아니라 바이트 배열이므로, self._mLastChar도 바이트 배열이 되어야 합니다. 그래서 아래처럼 바꾸었습니다.

코드 전체에서 "mLastChar"가 사용되는 경우를 찾아보았더니 mbcharsetprober.py 파일에서 하나 발견했습니다. 이번 경우는 마지막 문자 하나가 아니라 마지막 두 개의 문자를 저장했더군요. MultiByteCharSetProber 클래스는 마지막 두 문자를 저장해두기 위해 문자 한 개를 항목으로 하는 크기가 2인 리스트를 사용하고 있습니다. 파이썬 3에서는 정수를 항목으로 하는 리스트로 바뀌어야겠습니다. 문자를 저장하는 것이 아니라 바이트를 저장해야 하기 때문이죠. (바이트는 0-255 사이의 정수 아니겠습니까.)

+ 연산에서 지원하지 않는 피연산자: '정수'와 '바이트'

좋은 소식과 나쁜 소식이 있습니다. 좋은 소식은 우리 일에 진전이 있다는 것입니다...

나쁜 소식은 진전은 언제나 진전같이 느껴지지 않는다는 점이죠.

하지만 이번에는 정말 진전이 있었습니다! 비록 traceback이 이전과 같은 줄의 코드를 지적하고 있지만, 아까 본 것과는 다른 종류의 오류입니다. 진전이지요! 그래서 이번 문제는 무엇일까요? 조금 전 까지만 해도 이 줄에서 정수와 바이트 배열을 합치려고 하는 경우는 확인하지 못했습니다. 사실 self._mLastChar가 바이트 배열이었다는 사실을 알아내는 데 상당한 시간을 쏟아부었지요. 이게 어떻게 정수로 변했을까요.

해답은 이전 코드가 아니라 그 아래쪽에 따라나오는 코드에서 발견할 수 있습니다.

이 오류는 feed() 메소드가 처음으로 호출되었을 때 발생한 것이 아닙니다. self._mLastChar에 aBuf의 마지막 바이트가 할당된 이후 두 번째로 호출할 때 문제가 발생했습니다. 어떤 문제일까요? 바이트 배열에서 한 항목만 빼내어 할당할 경우, 그 값은 바이트 배열이 아니라 정수가 됩니다. 차이를 보기 위해 파이썬 대화형 쉘로 가보시죠.

  1. 길이가 3인 바이트 배열을 정의했습니다.
  2. 바이트 배열의 마지막 항목은 191입니다.
  3. 이건 정수군요.
  4. 정수를 바이트 배열과 합할 수는 없습니다. 좀 전에 universaldetector.py 파일에서 발생했던 오류를 여기서 다시 재현했습니다.
  5. 이렇게 수정할 수 있습니다. 바이트 배열의 마지막 항목을 가져오는 대신, 마지막 항목만 포함하는 새로운 바이트 배열을 생성하기 위해 리스트 쪼개기를 사용했습니다. 즉, 마지막 항목에서 시작해서 바이트 배열의 끝까지 자르는 것이지요. 이제 mLastChar는 길이가 1인 바이트 배열이 되었습니다.
  6. 길이가 1인 바이트 배열과 길이가 3인 바이트 배열을 합하니 길이가 4인 새로운 바이트 배열이 반환되었습니다.

universaldetector.py의 feed() 메소드가 반복적으로 호출되더라도 잘 작동하게 만드려면, self._mLastChar를 길이가 0인 바이트 배열로 초기화 하면 됩니다. 그러면 self._mLastChar는 항상 바이트 배열로 유지가 될 것입니다.

ord()는 길이가 1인 문자열을 기대했지만 정수가 발견되었습니다

피곤하시죠? 이제 거의 다 왔습니다...

네, c는 정수인데 ord() 함수는 길이가 1인 문자열을 기대하고 있었군요. 그러시겠죠. c는 어디에 정의되어 있을까요?

도움이 되지 않습니다. c는 그냥 이 함수의 인자로 들어왔다는 것만 알 수 있습니다. 좀 더 뒤로 가보죠.

보이시죠? 파이썬 2에서 aBuf는 문자열이었습니다. 그래서 c는 문자 하나로 구성된 문자열이었지요. (문자열을 순환하면 문자열을 구성하는 모든 문자를 순서대로 한 번에 하나씩 얻을 수 있지요.) 하지만 파이썬 3로 오면 aBuf는 바이트 배열이므로 c는 길이가 1인 문자열이 아니라 정수가 됩니다. 다시 말해, ord() 함수를 호출할 필요가 없다는 것이지요. c는 이미 정수거든요!

그러니 아래와 같이 바꾸었습니다.

다른 곳에도 이런 부분이 있는지 또 찾아봐야죠. sbcharsetprober.py 파일에서 "ord(c)"가 사용된 곳을 찾았습니다...

... latin1prober.py에도 있네요...

c는 aBuf를 순회하므로 길이가 1인 문자열이 아니라 정수 값을 가집니다. 해법은 같습니다. ord()를 그냥 c로 바꿔주세요.

순서를 메길 수 없는 타입: int() >= str()

지구는 둥그니까 자꾸 걸어 나갑시다.

이건 또 뭔가요? "순서를 메길 수 없는 타입"? 이번에도 바이트 배열과 문자열 간의 차이점이 그 더러운 마수를 뻗쳤습니다. 코드 한 번 봅시다.

aStr은 어디서 오는걸까요? 거슬러 올라갑시다.

아, 여기 있습니다. 우리의 절친 aBuf군요. 이 챕터에서 맞닥뜨린 모든 문제들을 통해 배웠듯이 aBuf는 바이트 배열입니다. 이 feed() 메소드는 aBuf 전체가 아니라 그걸 잘라서 넘기고 있습니다. 하지만 이 챕터의 앞 부분에서 보았듯이, 바이트 배열을 자르면 바이트 배열을 반환합니다. 따라서 get_order() 메소드의 인자로 전달되는 aStr은 여전히 바이트 배열입니다.

이 코드는 aStr을 가지고 뭘 하려고 하는걸까요? 바이트 배열의 첫 번째 항목을 빼서 길이가 1인 문자열과 비교하고 있습니다. 파이썬 2에서는 작동합니다. aStr과 aBuf가 문자열이므로 aStr[0]도 문자열이 되어, 문자열끼리 부등호로 비교하는 것이 가능하기 때문입니다. 하지만 파이썬 3에서는 aStr과 aBuf가 바이트 배열이므로 aStr[0]은 정수가 됩니다. 명시적인 형 변환 없이 정수와 문자열을 부등호로 비교할 수는 없습니다.

이 경우 강제로 형변환 하는 부분을 추가해 코드를 더 복잡하게 만들 필요는 없습니다. aStr[0]은 정수이고, aStr[0]과 비교하는 것들은 모두 상수지요. 이들을 길이가 1인 문자열에서 정수로 바꿔줍시다. 동시에 aStr도 aBuf로 바꿔줍시다. 더이상 문자열이 아니잖아요.

코드의 다른 부분에서 같은 문제가 있는지 한 번 찾아봤습니다. chardistribution.py 파일에 있더군요. (구체적으로는 EUCTWDistributionAnalysis, EUCKRDistributionAnalysis, GB2312DistributionAnalysis, Big5DistributionAnalysis, SJISDistributionAnalysis, EUCJPDistributionAnalysis 클래스에 있습니다.) 해법은 jpcntx.py 파일에서 EUCJPContextAnalysis와 SJISContextAnalysis 클래스를 고칠 때 사용했던 방법과 같습니다.

전역 이름 'reduce'가 정의되지 않았습니다

한 번만 더 가봅시다.

공식 문서인 파이썬 3.0의 새로운 점에 따르면 reduce() 함수는 전역 네임스페이스에서 functools 모듈로 이동했습니다. 문서를 인용하면, "정말 필요하다면 functools.reduce()를 사용하세요. 하지만 명시적인 for 문의 가독성이 더 좋을 확률이 99%입니다." 이 결정에 대해 더 궁금하신 분은 귀도 반 로썸(Guido van Rossum)의 블로그를 읽으시면 됩니다.

reduce() 함수는 두 개의 인자(함수 하나와 리스트(보다 엄격하게 말한다면 반복자 객체) 하나)를 받은 뒤, 함수를 리스트의 각 항목에 누진적(cumulative)으로 적용합니다. 다시 말하자면, 리스트의 모든 항목을 다 더해서 반환하는 일을 좀 있어보이게 하는 것입니다.

이런 코드가 너무 흔해서 파이썬은 전역 sum() 함수를 추가했습니다.

더이상 operator 모듈을 사용하지 않으므로 파일 상단부에서 import operator 부분을 제개할 수 있습니다.

이제 테스트를 통과할 수 있을까요?

감사합니다, 이제 작동하는군요! 춤이라도 춥시다.

요약

뭘 배웠습니까?

  1. 간단하지 않은 코드를 파이썬 2에서 파이썬 3로 이식하는 일은 정말 고통스럽습니다. 하지만 다른 방법이 없습니다. 어려워요.
  2. 2to3를 이용한 자동화는 쓸만하긴 하지만 쉬운 부분만 골라서 해줄 뿐입니다. 함수 이름 바꾸기, 모듈 이름 바꾸기, 문법 바꾸기 등이요. 인상적인 기술이긴 하지만, 결국 가서 보면 찾아서 바꾸는 것(search and replace)이 다입니다.
  3. 이 라이브러리를 이식하는 데 가장 큰 문제는 문자열과 바이트의 차이로 인해 발생했습니다. chardet 라이브러리가 하는 일이 바이트 데이터를 문자열로 바꾸는 작업이었으므로 그래도 그런 사실을 비교적 분명하게 알 수 있었지요. 하지만 "바이트 데이터"는 여러분 생각보다 더 자주 쓰입니다. "이진" 모드로 파일을 연다? 여러분은 바이트 데이터를 얻게 됩니다. 웹피에지를 가져온다? 웹 API를 호출한다? 이런 일도 바이트 데이터를 가져오지요.
  4. 여러분의 프로그램을 이해해야 합니다. 철저하게요. 여러분이 작성했기 때문이기도 하지만, 최소한 이상하고 축축한 구석에 있는 코드가 낯설지는 않아야 합니다. 버그는 온천지에 널렸습니다.
  5. 테스트 케이스는 필수입니다. 테스트 케이스 없이는 아무 것도 이식하지 마세요. 파이썬 3에서 chardet이 그래도 작동한다고 자신있게 말할 수 있는 유일한 이유는, 제가 코드의 거의 모든 기능을 점검할 수 있는 테스트 케이스를 작성해 두었기 때문입니다. 테스트를 아직 작성하지 않았다면 파이썬 3로 이식하기 전에 먼저 작성하세요. 몇 가지 테스트는 이미 있다면 좀 더 작성하세요. 아주 많은 테스트를 가지게 되었으면 이제 정말 재미있는 일을 할 수 있습니다.

이 강의는 영어로 된 원문을 기초로 작성되었으며, Creative Commons Attribution Share-Alike 라이센스하에 자유로운 변경, 배포가 가능합니다

4431 읽음
이전 챕터 14. HTTP 웹서비스
다음 챕터 16. 파이썬 라이브러리 패키징하기

저자

토론이 없습니다

Please log in to leave a comment

16.5.11618.20190612.allo
문제 보고 · 사용조건 · 개인정보보호
래블업 주식회사 · 대한민국 서울 테헤란로 145 · 대표: 신정규 · 사업자번호: 864-88-00080 · +82 70-8200-2587

거절 확인

닫기
좋아요 책갈피 토론

아래 주소를 복사하세요