콘텐츠로 이동

Bluetape4k Graph Part 3: graph I/O와 benchmark 읽기

로봇 작업자들이 CSV, NDJSON, GraphML, OkIO 파일을 graph import/export 컨베이어로 옮기는 3D 작업대 일러스트
bulk I/O는 migration만의 문제가 아닙니다. 테스트 재현성과 운영 분석에도 바로 필요합니다.

서비스에서 만든 그래프를 항상 database 안에서만 다루지는 않습니다. migration, snapshot, analytics, 재현 가능한 test fixture를 만들려면 vertex와 edge를 파일로 내보내고 다시 불러와야 합니다. 운영에서 한 번쯤은 “이 graph를 파일로 빼서 다시 재현해 볼 수 있나?”라는 질문을 만나게 됩니다. 그때부터 콘솔 복붙은 급격히 덜 귀여워집니다. bluetape4k-graphgraph-io는 이 작업을 포맷별 모듈과 I/O 조합 레이어로 나눕니다.

Graph I/O pipeline from GraphOperations to graph-io-core, data formats, I/O decorators, operational uses, and benchmark gate
format adapter는 graph service 코드와 파일 형식을 분리하고, OkIO는 I/O 경로를 조합합니다.

현재 graph-io에서 구현되어 있는 포맷은 CSV, Jackson2 NDJSON, Jackson3 NDJSON, GraphML입니다.

Format잘 맞는 용도
CSV사람이 열어보고 수정하기 쉬운 작은 fixture, 데이터 확인
Jackson2 NDJSONJackson2를 쓰는 기존 서비스와 호환
Jackson3 NDJSON신규 Kotlin/JVM 서비스의 line-delimited interchange
GraphML다른 graph tool과 교환하거나 표준 XML 형식이 필요할 때

NDJSON은 포맷이고, Jackson2/Jackson3는 그 NDJSON을 읽고 쓰는 serializer 구현입니다. Fastjson2도 같은 NDJSON 계열로 확장하기 좋은 후보지만, 현재 bluetape4k-graph 소스에는 Jackson2/Jackson3 모듈만 들어 있습니다. 이 글에서는 실제로 구현된 모듈만 기준으로 보겠습니다.

OkIO는 포맷이 아닙니다. bluetape4k-io, bluetape4k-okio는 CSV/NDJSON/GraphML 경로 위에 buffering, compression, encryption, async I/O 작업을 decorator 방식으로 붙일 수 있게 해줍니다. 쉽게 말하면 “어떤 파일 형식인가”와 “그 파일을 어떤 I/O 경로로 읽고 쓸 것인가”를 분리하는 구조입니다. 포맷과 I/O 경로를 섞어 두면 나중에 압축 하나 붙이려고 코드가 의외로 크게 흔들립니다.

공통 contract는 graph-io-core에 있고, 각 format 모듈이 sync, virtual-thread, suspend 변형을 제공합니다. 여기서도 실행 모델 선택은 “무조건 coroutine”이 아닙니다. 파일 I/O와 parser 비용, graph storage write 비용, graph 크기에 따라 결과가 달라집니다.

아래 결과는 docs/benchmark/2026-04-18-graph-io-bulk-results.md의 quick-run을 기준으로 합니다. 구현은 BulkGraphIoBenchmarkBulkGraphIoBenchmarkState에 있습니다.

@State(Scope.Benchmark)
class BulkGraphIoBenchmarkState {
@Param("small", "medium")
lateinit var size: String
lateinit var ops: TinkerGraphOperations
lateinit var tempDir: Path
}

benchmark state는 임시 디렉터리를 만들고, TinkerGraph in-memory graph에 데이터를 넣습니다. small dataset은 Person vertex 1,000개와 KNOWS edge 2,000개입니다. random seed는 고정되어 있어 같은 fixture를 반복해서 만들 수 있습니다. benchmark method는 이 graph를 CSV/NDJSON/GraphML 파일로 export하거나, 파일을 다시 TinkerGraph로 import합니다.

@Benchmark
fun csvSyncExport(state: BulkGraphIoBenchmarkState) {
state.csvExporter.exportGraph(state.ops, state.csvPath, state.exportOptions)
}

실행 환경은 Mac mini Pro M4, memory 48GB, Java 25입니다. 이 Graph I/O quick-run 자체는 TinkerGraph in-memory와 임시 파일을 사용하므로 별도 graph DB Docker container를 띄우지 않습니다. Neo4j/Memgraph/AGE/FalkorDB 비교 benchmark나 통합 테스트에서는 Docker/Testcontainers를 사용합니다.

quick-run 설정은 @Fork(0), warmup 1×1초, measurement 2×1초입니다. 빠르게 regression을 확인하기 위한 숫자이지, 발표용 정밀 benchmark는 아닙니다. 숫자가 너무 예쁘게 나오면 오히려 한 번 더 의심하는 편이 좋습니다. 정밀하게 보려면 @Fork(1), warmup 3×3초, measurement 5×3초 이상으로 다시 돌리는 편이 좋습니다.

Terminal window
./gradlew :graph-io-benchmark:smokeBenchmark

아래 차트는 작은 graph 1,000 vertices / 2,000 edges 기준의 평균 latency를 요약한 것입니다. JMH mode는 AverageTime이고 단위는 ms/op입니다. 이 quick-run은 빠른 회귀 확인용으로 평균값만 보고, 분산까지 확인해야 하는 정밀 비교는 raw JSON을 남기는 긴 실행으로 따로 돌리는 편이 낫습니다.

Graph I/O quick-run benchmark chart in milliseconds per operation
GraphML factory caching처럼 구현 세부가 결과를 크게 바꾸는 지점은 숫자와 측정 조건을 함께 봐야 합니다.

대표 숫자는 이렇습니다.

작업평균 ms/op
CSV export1.017
Jackson2 NDJSON export1.194
Jackson3 NDJSON export1.275
GraphML export2.582
CSV import17.854
Jackson2 NDJSON import18.831
Jackson3 NDJSON import19.852
GraphML import21.111

GraphML은 factory caching과 buffered stream 적용 전후 차이가 특히 컸습니다. 이전에는 export가 약 413ms 수준까지 튀었고, 개선 후에는 약 2.5ms 수준으로 내려왔습니다. 이 결과는 “GraphML은 느리다”로 끝낼 이야기가 아닙니다. 문제는 포맷 이름이 아니라 XMLInputFactory/XMLOutputFactory 생성, buffering, parser reuse 같은 I/O 기본기였습니다. 성능 문제를 잡다 보면 가끔 아주 평범한 코드가 범인입니다. 억울하지만 자주 그렇습니다.

Suspend import에서 Jackson2/Jackson3가 약 150ms까지 튄 결과도 있습니다. benchmark 문서에서는 runBlocking 안에서 Dispatchers.IO 초기화 비용이 함께 잡힌 것으로 분석합니다. 실제 coroutine 서비스에서는 dispatcher가 이미 준비되어 있을 가능성이 높지만, benchmark에서는 이런 초기화 비용까지 숫자에 섞일 수 있습니다. 그래서 benchmark는 표와 차트만 보지 말고 harness도 같이 읽어야 합니다.

테스트 fixture는 CSV나 NDJSON으로 시작하는 편이 편합니다. 사람이 파일을 열어보고 vertex/edge를 확인해야 하면 CSV가 좋고, 서비스 간 교환이나 snapshot replay를 생각하면 NDJSON이 더 자연스럽습니다. GraphML은 graph tool과의 교환이 필요할 때 쓰는 선택지입니다. “나중에 사람이 열어봐야 하나?”라는 질문 하나만 해도 포맷 선택이 꽤 빨리 좁혀집니다.

대용량 파일을 다루기 시작하면 포맷보다 I/O 경로가 더 크게 느껴질 때가 있습니다. 그때 OkIO 기반 adapter로 buffering, compression, encryption, async 경로를 조합합니다. 포맷 선택과 I/O decorator 선택을 분리해 두면, CSV에서 NDJSON으로 바꾸거나 GraphML export만 별도 경로로 빼는 작업이 훨씬 단순해집니다.

다음 글에서는 bluetape4k-workshop의 abuser detection, recommendation, knowledge graph, social network 예제로 서비스에서 graph API를 어떻게 적용하는지 보겠습니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.