콘텐츠로 건너뛰기
Home » 대용량 텍스트 파일을 한줄씩 읽기 – StreamReader를 작성하자

대용량 텍스트 파일을 한줄씩 읽기 – StreamReader를 작성하자

특정한 텍스트 파일을 읽어들여서 한 줄씩 처리하는 방법에서 가장 간단한 구현은 Stringinit(contentOfURL:encoding:)을 이용하여 텍스트 파일의 내용 전체를 하나의 문자열로 만든 다음에, 개행 문자를 이용해서 자르는 것이다.

let path = "~/Downloads/sample.txt"
let url = URL(fileURLWithPath: (NSString(string:path).expandingTildeInPath) // #1, #2
if let s = String(contentsOf: url) {
    for line in s.components(separatedBy:.newlines) {
        print(line)
    }
}

  1. NSString은 경로를 처리하는 메소드들들 몇 개 가지고 있는데, 그중 .expandingTildeInPathString으로 편입되지 않았더랬다.
  2. URL(fileURLWithPath:)는 틸드를 이용해서 축약된 경로를 제대로 해석하지 못한다.
  3. 개행문자들로 나누려고 할 때 NSString.components(separatedBy:)를 쓰는데 이 때는 문자열이나 문자세트를 줄 수 있다. 이 코드에서는 CharacterSet.newlines 를 쓴 것인데, 인자의 타입이 결정된 셈이므로 해당 타입의 타입메소드를 사용하면서 타입을 생략한 것이다.

간단한 텍스트 파일을 한줄씩 처리하는 것은 이런식으로 하면 되는데, 만약 사전 데이터같은 몇 기가짜리 큰 텍스트 파일을 이런식으로 읽어들였다가는 메모리가 넘쳐서 낭패를 겪게될 것이다. 따라서 파일의 일부만 읽어들여서 한 줄 (혹은 특정한 구분자로 쪼개지는 단위 – 예를 들어 콤마로 구분된 값 등) 단위로 읽어들여서 처리하는 방법을 살펴보자.

한 줄 씩 읽기

거대한 파일로부터 한줄 혹은 한 단위로 데이터를 읽어들이는 것은 결국 전체 길이를 알 수 없는 데이터 스트림으로부터 일정량을 읽어들여서 처리하는 과정을 의미한다. 이 과정은 모든 디테일을 떼어내면 다음의 절차들로 구성된다.

  1. 데이터가 들어오는 데이터 스트림이 존재한다.
  2. 데이터의 일부를 받아둘 버퍼를 준비한다.
  3. 데이터 스트림으로부터 일정량을 읽어와서 버퍼에 담아둔다.
  4. 버퍼의 시작 위치로부터 끊어 읽어야 하는 범위를 찾는다.
  5. 4의 범위만큼의 데이터를 사용하여 어떤 처리를 한다. (해당 데이터는 버퍼에서 소모된다.)
  6. 4의 범위만큼 버퍼의 앞쪽 데이터를 비운 후 4로 돌아간다.
  7. 만약 4에서 끊어야 하는 위치를 찾지 못했다면 데이터 스트림으로부터 데이터를 더 읽어와서 버퍼 뒤쪽에 이어 붙인다. (버퍼의 크기가 고정되어 있다면 읽어들일 수 있는 만큼만 읽는다.)
  8. 더 이상 읽어올 데이터가 없다면 버퍼의 남은 데이터가 마지막 단위가 된다.

그럼 이런 동작을 수행하는 스트림 리더를 만들어 보자.
약간의 디테일이 필요하다.

  1. 데이터스트림은 FileHandle을 이용한다.
  2. 읽어들인 데이터는 Data를 사용한다. 이는 NSMutableData와는 다른 Swift의 표준 라이브러리 클래스이며, 바이트의 시퀀스 개념으로 생각하면 된다.
  3. 텍스트 파일을 읽어들였다고 하더라도, 이진 데이터라는 점을 잊지 말자. 이를 문자열로 변환하려고하면 디코딩해야하고, 이 때 파일의 인코딩은 사용자가 알고 있다고 가정한다.
  4. 구분자(delimeter)는 문자열로 받게 되지만, 실제 데이터 상에서는 인코딩한 데이터로 대조하게 된다.
import Foundation
class StreamReader {
    let encoding: String.Encoding
    let chunkSize: Int
    let fileHandle: FileHandle
    let delimPattern: Data
    var buffer: Data
    var isAtEOF: Bool = false
    init?(url: URL, delimeter: String = "\n", encoding: String.Encoding = .utf8, chunckSize Int = 4096) {
        guard let fileHandle = try? FileHandle(forReadingFrom: url) else { return nil }
        self.fileHandle = fileHandle
        self.chunkSize = chunkSize
        self.encoding = encoding
        buffer = Data(capacity: chunkSize)
        delimPattern = delimeter.data(using: encoding)!
    }
    deinit { fileHandle.closeFile() }
    func nextLine() -> String? {
        // 파일을 이미 다 읽었으면 더 이상 나올 게 없음
        guard !isAtEOF else { return nil }
        repeat {
            if let range = buffer.range(of: delimPattern, options: [],
                            in: buffer.startIndex..<buffer.endIndex) {
                // 버퍼에 있는 데이터에서 구분자패턴을 찾았으므로
                // 구분자 패턴위치의 앞까지를 읽어서 문자열을 만든다.
                let subData = buffer.subData(in: buffer.startIndex..<range.lowerBound)
                let line = String(data: subData, encoding: encoding)
                // 읽어서 사용한 부분은 제거한다.
                buffer.replaceSubrange(buffer.startIndex..<range.upperBound, with:[])
                return line
            } else {
                // 버퍼에서 구분자패턴을 찾을 수 없다.
                // 버퍼의 내용이 너무 짧았거나, 한 줄이 너무 긴 경우
                let tempData = fileHandle.readData(ofLengh: chunkSize)
                if tempData.count == 0 { // 더 읽을 수 없음
                    isAtEOF = true
                    return (buffer.count > 0) ? String(data:buffer, encoding: encoding) : nil
                }
                // 더 읽은 데이터를 버퍼에 추가하고 다시 패턴을 찾으러 돌아간다.
                buffer.append(tempData)
            }
        } while true
    }
}

다음과 같이 써볼 수 있겠다.

let pathURL = URL(fileURLWithPath: (NSString(string: "~/Downloads/words.txt").expandingTildeInPath))
let s = StreamReader(url: pathURL)
while let line = s?.nextLine() {
    print(line)
}