특정한 텍스트 파일을 읽어들여서 한 줄씩 처리하는 방법에서 가장 간단한 구현은 String
의 init(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)
}
}
NSString
은 경로를 처리하는 메소드들들 몇 개 가지고 있는데, 그중.expandingTildeInPath
는String
으로 편입되지 않았더랬다.URL(fileURLWithPath:)
는 틸드를 이용해서 축약된 경로를 제대로 해석하지 못한다.- 개행문자들로 나누려고 할 때
NSString.components(separatedBy:)
를 쓰는데 이 때는 문자열이나 문자세트를 줄 수 있다. 이 코드에서는CharacterSet.newlines
를 쓴 것인데, 인자의 타입이 결정된 셈이므로 해당 타입의 타입메소드를 사용하면서 타입을 생략한 것이다.
간단한 텍스트 파일을 한줄씩 처리하는 것은 이런식으로 하면 되는데, 만약 사전 데이터같은 몇 기가짜리 큰 텍스트 파일을 이런식으로 읽어들였다가는 메모리가 넘쳐서 낭패를 겪게될 것이다. 따라서 파일의 일부만 읽어들여서 한 줄 (혹은 특정한 구분자로 쪼개지는 단위 – 예를 들어 콤마로 구분된 값 등) 단위로 읽어들여서 처리하는 방법을 살펴보자.
한 줄 씩 읽기
거대한 파일로부터 한줄 혹은 한 단위로 데이터를 읽어들이는 것은 결국 전체 길이를 알 수 없는 데이터 스트림으로부터 일정량을 읽어들여서 처리하는 과정을 의미한다. 이 과정은 모든 디테일을 떼어내면 다음의 절차들로 구성된다.
- 데이터가 들어오는 데이터 스트림이 존재한다.
- 데이터의 일부를 받아둘 버퍼를 준비한다.
- 데이터 스트림으로부터 일정량을 읽어와서 버퍼에 담아둔다.
- 버퍼의 시작 위치로부터 끊어 읽어야 하는 범위를 찾는다.
- 4의 범위만큼의 데이터를 사용하여 어떤 처리를 한다. (해당 데이터는 버퍼에서 소모된다.)
- 4의 범위만큼 버퍼의 앞쪽 데이터를 비운 후 4로 돌아간다.
- 만약 4에서 끊어야 하는 위치를 찾지 못했다면 데이터 스트림으로부터 데이터를 더 읽어와서 버퍼 뒤쪽에 이어 붙인다. (버퍼의 크기가 고정되어 있다면 읽어들일 수 있는 만큼만 읽는다.)
- 더 이상 읽어올 데이터가 없다면 버퍼의 남은 데이터가 마지막 단위가 된다.
그럼 이런 동작을 수행하는 스트림 리더를 만들어 보자.
약간의 디테일이 필요하다.
- 데이터스트림은
FileHandle
을 이용한다. - 읽어들인 데이터는
Data
를 사용한다. 이는NSMutableData
와는 다른 Swift의 표준 라이브러리 클래스이며, 바이트의 시퀀스 개념으로 생각하면 된다. - 텍스트 파일을 읽어들였다고 하더라도, 이진 데이터라는 점을 잊지 말자. 이를 문자열로 변환하려고하면 디코딩해야하고, 이 때 파일의 인코딩은 사용자가 알고 있다고 가정한다.
- 구분자(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)
}