CSV Writer가 불필요한 우회 경로를 걷어냈을 때

지난 CSV 글은 읽기 이야기였다. bluetape4k-csv는 UTF-8 필드를 character-first lexer로 끌고 다니지
않고 Okio segment를 직접 스캔하면서 allocation을 줄였다.
그러면 자연스럽게 다음 질문이 나온다. 읽기는 byte와 segment를 존중해서 빨라졌는데, 쓰기는 왜 아직도
파일 내보내기 전체를 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++ }}이 코드는 나쁜 코드가 아니다. 정확하고, 이식성이 좋고, charset fallback도 단순하다. 다만 가장 흔한 UTF-8 파일 export에서도 delimiter, quote, line separator, 필드 본문이 모두 character writer를 거쳐 간다. 목적지는 결국 byte인데 말이다.
그래서 새 규칙은 단순하게 잡았다.
| Output encoding | Writer path |
|---|---|
| UTF-8 | Okio BufferedSink fast path |
| Non-UTF-8 | 기존 Writer fallback |
API는 그대로 둔다. UTF-8 경로만 더 짧은 처리 경로를 탄다.
고속 경로
섹션 제목: “고속 경로”coroutine 경계는 여전히 파일 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 파이프라인은 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로 바뀐다. 정상 문자를 하나씩 작은 호출로 보내지 않는다.
동작은 테스트로 검증했다.
| 사례 | 기대 동작 |
|---|---|
null | 인용 없는 빈 field |
"" | quoted empty string |
| 앞뒤 공백 | quoted |
| delimiter 포함 | quoted |
| quote 포함 | doubled quote |
| CR/LF 포함 | quoted |
quoteAll | non-null field는 모두 quoted |
| TSV mode | tab delimiter 유지 |
벤치마크 결과
섹션 제목: “벤치마크 결과”벤치마크 명령:
./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를 쓰자”가 아니었다. 더 좁고 실용적이었다.
| 방향 | 유용한 Okio 계층 |
|---|---|
| Read | BufferedSource, Buffer, read-only UnsafeCursor로 structural byte scanning |
| Write | BufferedSink로 UTF-8 row를 바로 출력 |
양쪽 모두 같은 설계 원칙을 따른다. public API는 유지하고, 지원하지 않는 경로에는 fallback을 남기고, 까다로운 CSV 데이터를 포함한 테스트로 의미를 검증한다.
- 구현:
OkioDelimitedWriter - Flow writer 통합:
FlowCsvWriterImpl - 벤치마크:
CsvParserBenchmark - 테스트:
FlowCsvWriterTest - 이전 글: Okio Segment로 CSV 파서 Allocation 줄이기
마무리
섹션 제목: “마무리”좋은 최적화는 사용자가 외워야 할 개념을 늘리지 않는다. 새 public API도 없고, CSV를 쓰는 새 방법도
없다. FlowCsvWriter는 이미 streaming 파이프라인을 약속하고 있었다. 이번 패치는 UTF-8 파일 경로에서
그 파이프라인에 남아 있던 불필요한 우회를 걷어낸 것이다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.