CSV Writer가 모래주머니를 벗었을 때

지난 CSV 글은 읽기 이야기였다. bluetape4k-csv는 UTF-8 필드를 character-first lexer로 끌고 다니지
않고 Okio segment를 직접 스캔하면서 allocation을 줄였다.
그러면 자연스럽게 다음 질문이 나온다. 읽기는 byte와 segment를 존중해서 빨라졌는데, 쓰기는 왜 아직도
파일 export 전체를 OutputStreamWriter에게 공손하게 맡기고 있을까?
이번 writer 최적화는 그 찝찝한 부분을 정리한 작업이다. 다행히 public API는 이미 좋은 모양이었다.
suspend fun writeFile( path: Path, encoding: Charset = Charsets.UTF_8, append: Boolean = false, skipHeaders: Boolean = false, headers: List<String> = emptyList(), rows: Flow<Iterable<*>>,): Long맞다. FlowCsvWriter는 coroutine 기반 API다. 이게 중요하다. writer가 export 전체를 메모리에 모을
필요가 없다. Flow에서 row 하나를 받고, 그 row를 쓰고, cancellation을 확인한 뒤 다음 row로 가면 된다.

수상한 부분은 Flow가 아니었다
섹션 제목: “수상한 부분은 Flow가 아니었다”streaming 모양은 이미 있었다. 비싼 냄새는 그 아래에서 났다.
OutputStreamWriter(FileOutputStream(path.toFile(), append), encoding).use { fw -> val fileWriter = DelimitedWriter(fw, delimiter, quote, settings.quoteEscape, lineSeparator) rows.collect { row -> currentCoroutineContext().ensureActive() writeRowToDelimited(fileWriter, fw, row) count++ }}이 코드는 나쁜 코드가 아니다. 정확하고, portable하고, charset fallback도 단순하다. 다만 가장 흔한 UTF-8 파일 export에서도 delimiter, quote, line separator, payload text가 모두 character writer를 거쳐 간다. 목적지는 결국 byte인데 말이다.
그래서 새 규칙은 단순하게 잡았다.
| Output encoding | Writer path |
|---|---|
| UTF-8 | Okio BufferedSink fast path |
| Non-UTF-8 | 기존 Writer fallback |
API는 그대로 둔다. UTF-8 lane만 지름길을 탄다.
Fast Path
섹션 제목: “Fast Path”coroutine boundary는 여전히 파일 export를 감싼다. 바뀐 것은 그 안의 sink다.
private suspend fun writeUtf8FileWithOkio( path: Path, append: Boolean, skipHeaders: Boolean, headers: List<String>, rows: Flow<Iterable<*>>,): Long { var count = 0L FileOutputStream(path.toFile(), append).sink().buffer().use { sink -> val fileWriter = OkioDelimitedWriter(sink, delimiter, quote, settings.quoteEscape, lineSeparator) if (!skipHeaders && headers.isNotEmpty()) { writeRowToOkio(fileWriter, headers) } rows.collect { row -> currentCoroutineContext().ensureActive() writeRowToOkio(fileWriter, row) count++ } } return count}중요한 줄은 Okio import 자체가 아니다. 주변 구조가 중요하다. rows.collect, ensureActive(),
그리고 row 하나를 buffered sink로 밀어 넣는 구조다. 이렇게 하면 export pipeline은 cancellation에
반응하면서도 UTF-8 파일 경로에서는 Writer.write(...) 우회를 피한다.
CSV는 그렇게 순하지 않다
섹션 제목: “CSV는 그렇게 순하지 않다”CSV 쓰기는 지루해 보이지만, empty string, 앞뒤 공백, embedded quote, CRLF field, TSV variant가 들어오는 순간 바로 까다로워진다. fast path가 “comma로 join하면 되겠지”가 될 수 없는 이유다.
내부 writer는 기존 DelimitedWriter 의미를 그대로 지킨다.
private fun writeQuoted(s: String) { sink.writeUtf8Char(quote) var start = 0 for (index in s.indices) { if (s[index] == quote) { if (start < index) { sink.writeUtf8(s, start, index) } sink.writeUtf8Char(quoteEscape) sink.writeUtf8Char(quote) start = index + 1 } } if (start < s.length) { sink.writeUtf8(s, start, s.length) } sink.writeUtf8Char(quote)}작은 요령은 chunked write다. 대부분의 field text는 slice 단위로 sink에 들어간다. quote 문자만 doubled quote byte로 바뀐다. 정상 문자를 하나씩 작은 호출로 보내지 않는다.
동작은 테스트로 잠갔다.
| Case | Expected behavior |
|---|---|
null | 인용 없는 빈 field |
"" | quoted empty string |
| 앞뒤 공백 | quoted |
| delimiter 포함 | quoted |
| quote 포함 | doubled quote |
| CR/LF 포함 | quoted |
quoteAll | non-null field는 모두 quoted |
| TSV mode | tab delimiter 유지 |
Benchmark Result
섹션 제목: “Benchmark Result”benchmark command:
./gradlew :bluetape4k-csv:testBenchmarkwriter workload 결과는 다음과 같다.
| Dataset | Existing Writer | Okio writer | Speedup |
|---|---|---|---|
writerBaseline_small vs okioWriter_small | 11,741.418 ops/s | 16,355.656 ops/s | 1.39x |
writerBaseline_medium vs okioWriter_medium | 676.110 ops/s | 2,068.696 ops/s | 3.06x |
writerBaseline_large vs okioWriter_large | 83.802 ops/s | 272.157 ops/s | 3.25x |
small workload도 빨라졌지만 setup과 coroutine collection overhead가 아직 보인다. 진짜 이야기는 medium, large에서 나온다. row가 계속 흘러 들어오면 character-layer write와 작은 호출을 줄인 효과가 눈에 띈다.
Reader에서 배운 것을 Writer에 다시 썼다
섹션 제목: “Reader에서 배운 것을 Writer에 다시 썼다”reader 최적화에서는 BufferedSource와 UnsafeCursor가 핵심이었다. writer에는 UnsafeCursor가
필요 없다. 이미 있는 segment를 뒤지는 일이 아니라 byte를 만들어 내는 일이기 때문이다.
그래서 재사용된 교훈은 “항상 가장 날카로운 Okio API를 쓰자”가 아니었다. 더 좁고 실용적이었다.
| Direction | Useful Okio layer |
|---|---|
| Read | BufferedSource, Buffer, read-only UnsafeCursor로 structural byte scanning |
| Write | BufferedSink로 UTF-8 row를 바로 출력 |
양쪽 모두 같은 설계 원칙을 따른다. public API는 유지하고, 지원하지 않는 경로에는 fallback을 남기고, 까다로운 CSV 데이터를 포함한 테스트로 semantics를 잠근다.
Source Links
섹션 제목: “Source Links”- Writer implementation:
OkioDelimitedWriter - Flow writer integration:
FlowCsvWriterImpl - Benchmarks:
CsvParserBenchmark - Tests:
FlowCsvWriterTest - Previous article: Okio Segment로 CSV 파서 Allocation 줄이기
Closing
섹션 제목: “Closing”좋은 최적화는 사용자가 외워야 할 개념을 늘리지 않는다. 새 public API도 없고, CSV를 쓰는 새 방법도
없다. FlowCsvWriter는 이미 streaming pipeline을 약속하고 있었다. 이번 패치는 UTF-8 파일 경로에서
그 pipeline에 달려 있던 모래주머니 하나를 빼낸 것이다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.