네이버 지식인에서 줏어든 문제 중에 이런게 있었다. 한글과 영문, 기호가 섞여 있는 내용으로 된 매우 큰 텍스트 파일이 있는데, 여기서 세 글자씩 가져와서 처리하고 싶다는 것이다.
이런 경우라면 텍스트 파일을 통째로 읽어들여서 큰 문자열로 로드한 후 앞에서부터 세글자씩 subscript해서 사용하면 된다. 그런데 문제는 이 파일이 매우 크다는 것이다. 그래서 이진 파일로 조금씩 읽어서 어떻게 한글/영문 관계 없이 세 글자씩 묶어서 처리할 수 있을까? 해당 텍스트 파일의 인코딩이 UTF8이라는 전제에서 접근해보자.
접근
UTF8의 앞부분은 아스키코드와 일치하는데 그 범위는 0x00
~ 0x7F
에 해당한다. 만약 특정 바이트가 저 값 사이의 범위에 있다면 해당 바이트는 싱글바이트 글자로 전환할 수 있다.
UTF8에서 멀티바이트는 첫 바이트의 앞 비트가 특정한 모양을 가지며, 해당 글자가 몇 바이트로 구성되어 있는지를 알려준다. 2바이트 문자의 경우, 110x xxxx
의 모양이며, 이후 10xx xxxx
의 모양으로 된 바이트 1개를 더 사용해서 한 글자를 구성한다. 비슷하게 3바이트 문자의 첫 바이트는 1110 xxxx
이며, 4 바이트 문자에서는 1111 0xxx
의 꼴로 되어 있다. (바이트수 = 처음 1인 비트 수)
이를 사용해서 다음과 같은 절차로 텍스트 파일에서 한 글자씩을 읽어내는 스캐너를 작성해보자.
- 스캐너는 제너레이터로 작성하며, 파일의 경로를 인자로 받는다.
- 미리 정해진 버퍼 크기만큼 파일을 읽어 버퍼에 추가한다. 만약 더 읽어 들일 수 있는 내용이 없으면 리턴한다.
- 버퍼의 첫 바이트를 검사한다. 아스키코드 영역이면 해당 코드 값으로 문자를 만든다.
- 4글자, 3글자, 2글자의 바이트인지 검사한다. 매칭된 결과에 따라서 해당 길이의 바이트들을 사용해서 문자를 생성한다. 만약, 버퍼에 남은 바이트가 모자르다면 2로 돌아간다.
- 글자를 만들어 yield 하고, 사용한 버퍼의 앞부분은 버린다.
이 때, 몇 바이트짜리 글자인지를 검사하는 것은 4바이트, 3바이트, 2바이트 순으로 한다. 1111 0000 AND B
로 검사한 결과가 1111 00000
로 나오면 4바이트 글자가 되는데, 이 때 B는 1111 0xxx
일 것이다. 이 값은 1110 0000
과 AND 연산했을 때에도 1110 0000
이 되기 때문에 작은 바이트 규칙을 먼저 검사하면 잘못 판정하게 된다.
코드
_buffer_size = 1024
# 예외
class ScanError(Exception):
pass
def getChars(filepath):
buffer = bytearray()
cookie, l = 0, 1
pred = (0b_1111_0000, 0b_1110_0000, 0b_1100_0000)
with open(filepath, 'rb') as f:
while True:
# 파일에서 버퍼를 읽어들인다.
chunk = f.read(_buffer_size)
if not chunk:
# 버퍼에 남은 내용이 있다면 처리가 불가능한 코드를 만난 것이므로
# 여기서 예외를 일으키도록 하는것이 좋다.
raise ScanError("Incomplete Byte Sequence Error")
return
# 읽어들인 바이트를 버퍼에 추가하고,
# 남은 길이값은 버퍼 길이로 초기화한다.
buffer.extend(chunk)
cookie = len(buffer)
while cookie > 0:
c = buffer[0]
# 싱글 바이트, 멀티바이트인지 검사하여
# 필요한 바이트 길이를 정한다.
if 0x00 <= c <= 0x7f:
l = 1
else:
for i, p in enumerate(pred):
if p & c == p:
l = 4 - i
break
else:
# 여기에 진입한 경우는 4,3,2 바이트 글자의
# 첫바이트 매칭에 실패한 경우로, 예외상황이다.
raise ScanError("Incorrect Multibyte Sequence")
# 버퍼의 앞에서 판정된 길이 l만큼 가져와 글자를 만든다.
# 이때, 필요한 길이보다 남은 내용이 짧다면 다시 로딩한다.
cookie -= l
if cookie < 0:
break
yield buffer[0:l].decode('utf8')
buffer[0:l] = []
if __name__ == '__main__':
for c in getChar('sample.txt'):
print(c)
부연
bytearray
는 가변 바이트 배열이다. 우리는 버퍼를 추가하거나 잘라내야 하므로 이 타입을 사용한다. 이에 대응하는 불변바이트 배열은bytes
타입을 사용하면 된다.cookie
는 버퍼에 남은 바이트의 수를 체크하기 위해 사용하는 값이다. 매번 len을 호출하는 것이 좀 마음에 들지 않았다.- 여기서도
for
…else
가 신박하게 쓰였다. - 이 예에서 버퍼 크기는 고작 1kB로 잡혀있는데,
_buffer_size
값을 충분히 늘리면 IO 횟수가 줄어들 것이기 때문에 성능이 향상될 수 있다.