파일에서 한 글자씩 스캔하는 방법

네이버 지식인에서 줏어든 문제 중에 이런게 있었다. 한글과 영문, 기호가 섞여 있는 내용으로 된 매우 큰 텍스트 파일이 있는데, 여기서 세 글자씩 가져와서 처리하고 싶다는 것이다.

이런 경우라면 텍스트 파일을 통째로 읽어들여서 큰 문자열로 로드한 후 앞에서부터 세글자씩 subscript해서 사용하면 된다. 그런데 문제는 이 파일이 매우 크다는 것이다. 그래서 이진 파일로 조금씩 읽어서 어떻게 한글/영문 관계 없이 세 글자씩 묶어서 처리할 수 있을까? 해당 텍스트 파일의 인코딩이 UTF8이라는 전제에서 접근해보자.

접근

UTF8의 앞부분은 아스키코드와 일치하는데 그 범위는 0x00 ~ 0x7F 에 해당한다. 만약 특정 바이트가 저 값 사이의 범위에 있다면 해당 바이트는 싱글바이트 글자로 전환할 수 있다.

UTF8에서 멀티바이트는 첫 바이트의 앞 비트가 특정한 모양을 가지며, 해당 글자가 몇 바이트로 구성되어 있는지를 알려준다. 2바이트 문자의 경우, 110x xxxx 의 모양이며, 이후 10xx xxxx 의 모양으로 된 바이트 1개를 더 사용해서 한 글자를 구성한다. 비슷하게 3바이트 문자의 첫 바이트는 1110 xxxx 이며, 4 바이트 문자에서는 1111 0xxx 의 꼴로 되어 있다. (바이트수 = 처음 1인 비트 수)

이를 사용해서 다음과 같은 절차로 텍스트 파일에서 한 글자씩을 읽어내는 스캐너를 작성해보자.

  1. 스캐너는 제너레이터로 작성하며, 파일의 경로를 인자로 받는다.
  2. 미리 정해진 버퍼 크기만큼 파일을 읽어 버퍼에 추가한다. 만약 더 읽어 들일 수 있는 내용이 없으면 리턴한다.
  3. 버퍼의 첫 바이트를 검사한다. 아스키코드 영역이면 해당 코드 값으로 문자를 만든다.
  4. 4글자, 3글자, 2글자의 바이트인지 검사한다. 매칭된 결과에 따라서 해당 길이의 바이트들을 사용해서 문자를 생성한다. 만약, 버퍼에 남은 바이트가 모자르다면 2로 돌아간다.
  5. 글자를 만들어 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을 호출하는 것이 좀 마음에 들지 않았다.
  • 여기서도 forelse 가 신박하게 쓰였다.
  • 이 예에서 버퍼 크기는 고작 1kB로 잡혀있는데, _buffer_size 값을 충분히 늘리면 IO 횟수가 줄어들 것이기 때문에 성능이 향상될 수 있다.