Bluetape4k Graph Part 5: Virtual Threads benchmark 읽기

bluetape4k-graph에는 sync API와 virtual-thread API가 같이 있습니다. API를 둘 다 제공하면 질문이 바로 따라옵니다.
graph database 호출은 I/O-bound처럼 보이는데, 그럼 Virtual Threads를 기본으로 쓰면 되나?
결론부터 말하면, 아닙니다. TinkerGraph처럼 in-memory에서 몇 µs 안에 끝나는 작업은 sync가 더 빠릅니다. Virtual
Thread로 감싸는 순간 CompletableFuture 생성, virtual thread scheduling, join() 대기 비용이 붙습니다. 작은 작업에는
그 비용이 그대로 드러납니다. 반대로 Neo4j, Memgraph, AGE처럼 network I/O와 server wait가 들어가는 backend에서는 판단이
달라집니다. 그때는 몇 µs의 wrapping 비용보다 blocking wait를 어떻게 처리하느냐가 더 중요해집니다.
이 글은 “Virtual Threads가 빠르다”는 홍보가 아닙니다. “언제 굳이 감싸면 손해고, 언제 감싸는 편이 코드와 운영을 단순하게 만드는가”를 benchmark 숫자로 읽어보는 글입니다. 성능 이야기는 늘 그렇습니다. 숫자 하나만 들고 뛰면 금방 발목을 잡힙니다.
같은 graph 작업, 다른 실행 경로
섹션 제목: “같은 graph 작업, 다른 실행 경로”GraphOperations는 동기 API입니다. virtual-thread facade는 backend를 새로 구현하지 않습니다. 같은 동기 작업을
Virtual Thread 위에서 실행하고 CompletableFuture<T>로 돌려줍니다.

실제 adapter도 구조는 단순합니다.
class VirtualThreadVertexAdapter( private val delegate: GraphVertexRepository,) : GraphVirtualThreadVertexRepository {
override fun findVertexByIdAsync( label: String, id: GraphElementId, ): CompletableFuture<GraphVertex?> = virtualFutureOfNullable { delegate.findVertexById(label, id) }}여기서 봐야 할 부분은 delegate.findVertexById() 자체가 동기 호출이라는 사실입니다. Virtual Thread는 이 동기 호출을
다른 실행 경로에 올립니다. 그러니 실제 backend가 TinkerGraph in-memory인지, network DB인지에 따라 결과가 달라집니다.
도구가 같아도 작업장이 다르면 삽질 포인트도 달라집니다.
Benchmark 조건부터 읽어야 합니다
섹션 제목: “Benchmark 조건부터 읽어야 합니다”이번 benchmark는 graph-benchmark 모듈의 TinkerGraph fixture를 사용합니다.
| 항목 | 값 |
|---|---|
| Backend | TinkerGraph in-memory |
| JVM | Java 25 |
| 측정 | JMH AverageTime, µs/op |
| Warmup / measurement | 3 × 2초 / 5 × 3초 |
| 비교 | sync 호출 vs virtual-thread adapter + CompletableFuture.join() |
fixture는 GraphBenchmarkState에서 Person vertex 4개와 KNOWS/FOLLOWS edge를 만들고, 같은 graph 위에서 sync와
virtual-thread 호출을 비교합니다.
@Benchmarkfun syncFindVertexById(): Boolean = syncOps.findVertexById("Person", aliceId) != null
@Benchmarkfun vtFindVertexById(): Boolean = vtOps.findVertexByIdAsync("Person", aliceId).join() != null이 조건은 일부러 network noise를 뺀 조건입니다. 그래서 결과는 “실제 Neo4j 운영 latency”가 아니라 “API model을 감쌌을 때 생기는 비용”을 보여줍니다. benchmark를 읽을 때 이 구분을 놓치면 결론이 엉뚱한 데로 갑니다. TinkerGraph 숫자로 “Virtual Threads는 느리다”라고 말하면 너무 성급하고, network DB 이야기를 하면서 이 overhead를 아예 무시해도 곤란합니다.
숫자는 꽤 정직합니다
섹션 제목: “숫자는 꽤 정직합니다”TinkerGraph in-memory 기준에서는 sync가 모든 단일 호출에서 앞섭니다.

대표 수치는 이렇습니다. AverageTime이므로 낮을수록 빠릅니다.
| Benchmark | Sync µs/op | Virtual Thread µs/op | 해석 |
|---|---|---|---|
findVertexById | 1.758 | 8.055 | 단건 lookup에서는 wrapping 비용이 더 큼 |
findVerticesByLabel | 2.485 | 8.679 | query 자체가 작아서 sync 우세 |
neighbors | 2.985 | 10.595 | traversal이 작으면 VT 비용이 그대로 보임 |
bfs | 4.216 | 12.588 | 알고리즘 호출도 fixture가 작으면 sync 우세 |
pageRank | 7.270 | 15.257 | 작업이 조금 커져도 overhead가 남음 |
shortestPath | 22.031 | 31.540 | 작업이 커질수록 배율 차이는 줄어듦 |
이 숫자에서 볼 부분은 “VT가 몇 배 느리다”가 아닙니다. 더 중요한 것은 절대 overhead입니다. benchmark 문서에서는
Virtual Thread 경로의 추가 비용을 대략 6-8µs 수준으로 봅니다. findVertexById가 1.8µs면 6-8µs는 큽니다. 하지만
실제 graph DB가 network를 타고 수 ms, 수십 ms를 기다린다면 이야기가 바뀝니다. 8µs 때문에 뚜껑이 열릴 일은 별로
없고, connection pool과 server wait, query plan이 먼저 사람을 괴롭힐 가능성이 큽니다.
그럼 언제 Sync를 쓰나
섹션 제목: “그럼 언제 Sync를 쓰나”다음 조건이면 sync가 더 자연스럽습니다.
| 상황 | 이유 |
|---|---|
| TinkerGraph 같은 in-memory graph | 호출 자체가 매우 짧아 wrapping 비용이 크게 보임 |
| 단발성 단순 lookup | CompletableFuture까지 만들 이유가 적음 |
| 이미 호출량이 낮고 thread blocking 문제가 없음 | 실행 모델을 늘려도 얻는 것이 작음 |
| benchmark fixture나 unit test | 가장 작은 비용으로 결과를 확인하는 편이 좋음 |
테스트나 작은 in-memory graph에서는 sync를 쓰는 편이 단순합니다. 빠르고, stack trace도 바로 보이고, 불필요한
join()도 없습니다. 작은 못 하나 박는데 공구함 전체를 펼치면 작업보다 정리가 더 오래 걸립니다.
그럼 언제 Virtual Threads를 쓰나
섹션 제목: “그럼 언제 Virtual Threads를 쓰나”다음 조건이면 virtual-thread API가 의미를 갖습니다.
| 상황 | 이유 |
|---|---|
| Neo4j, Memgraph, AGE처럼 network I/O가 있는 backend | blocking wait가 platform thread를 오래 붙잡지 않음 |
| Spring MVC나 기존 blocking service code | suspend 전파 없이 concurrency를 늘릴 수 있음 |
| 여러 graph request를 동시에 처리 | 요청별 blocking을 virtual thread로 격리하기 쉬움 |
Java 소비자가 CompletableFuture pipeline을 원함 | coroutine 없이 async 조합 가능 |
여기서도 “무조건 Virtual Threads”는 아닙니다. Graph DB server가 버틸 수 있는 connection 수, query plan, transaction 범위가 먼저입니다. Virtual Threads는 DB를 빠르게 만들어주지 않습니다. 기다리는 동안 비싼 platform thread를 덜 붙잡게 해주는 쪽에 가깝습니다.
val vtOps = graphOperations.asVirtualThread()
val friends = vtOps .neighborsAsync(userId, NeighborOptions(edgeLabel = "FOLLOWS")) .thenApply { vertices -> vertices.map { it.id } }이런 코드는 Java나 Spring MVC 쪽에서 특히 다루기 쉽습니다. 기존 동기 repository를 크게 바꾸지 않고도 기다리는 작업을
분리할 수 있습니다. 다만 join()을 바로 호출하면 결국 현재 흐름에서 기다립니다. 여러 작업을 조합할 때는
CompletableFuture.allOf(...), thenCompose, thenApply 같은 조합으로 대기 지점을 뒤로 미루는 편이 낫습니다.
graph database 선택 가이드와 연결하면
섹션 제목: “graph database 선택 가이드와 연결하면”Part 1에서 본 graph database 선택 기준과도 이어집니다.
| Backend | 실행 모델을 읽는 법 |
|---|---|
| TinkerGraph | in-memory fixture, prototype, test에는 sync가 보통 충분 |
| Neo4j | network I/O와 ACID transaction이 있으므로 VT가 service concurrency에 도움 |
| Memgraph | low-latency backend라도 network wait가 있으면 VT 검토 가치 있음 |
| Apache AGE | PostgreSQL JDBC 경로를 타므로 blocking call을 VT로 감싸기 좋음 |
벤치마크가 알려주는 것은 API의 순위표가 아닙니다. “내 backend의 대기 시간이 wrapping 비용보다 충분히 큰가?”라는 질문을 먼저 하라는 신호입니다. 이 질문을 건너뛰면 나중에 “왜 좋아질 줄 알았는데 더 느리지?”라는 대화를 하게 됩니다. 보통 그 대화는 회의실 공기를 빠르게 차갑게 만듭니다.
bluetape4k-graph의 virtual-thread API는 sync API를 대체하려고 만든 것이 아닙니다. 같은 graph abstraction 위에서
서비스 실행 모델을 고를 수 있게 만든 선택지입니다.
- in-memory, µs 단위, 단건 호출이면 sync가 가장 단순하고 빠릅니다.
- network graph DB, blocking I/O, 높은 동시성이면 Virtual Threads가 의미를 갖습니다.
- benchmark 숫자는 backend latency와 harness 조건을 같이 읽어야 합니다.
join()을 바로 때리는 코드와 future를 조합하는 코드는 같은 virtual-thread API라도 성격이 다릅니다.
성능 선택은 멋진 이름으로 하는 일이 아닙니다. “어디서 기다리는가”를 찾고, 그 기다림을 어떤 실행 모델이 가장 덜 지저분하게 다루는지 고르는 일입니다.
참고한 소스
섹션 제목: “참고한 소스”- Virtual Thread vs Sync benchmark result
- Graph DB tradeoffs
- Virtual Threads expansion design
- Virtual Threads expansion plan
- GraphBenchmarkState
- VertexOperationsBenchmark
- TraversalBenchmark
- AlgorithmBenchmark
- ApiModelBenchmark
- VirtualThreadVertexAdapter
- VirtualThreadOperationsAdapter
시리즈
섹션 제목: “시리즈”- Part 1: 그래프 데이터베이스 선택 지도
- Part 2: core API와 실행 모델
- Part 3: graph I/O와 benchmark 읽기
- Part 4: workshop 시나리오와 서비스 통합
- Part 5: Virtual Threads benchmark 읽기
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.