콘텐츠로 이동

Bluetape4k Graph Part 5: Virtual Threads benchmark 읽기

로봇 작업자들이 Sync와 Virtual Threads 실행 경로를 나눠 graph benchmark 작업대를 살펴보는 3D 일러스트
Virtual Threads의 장점은 계산이 빠른 코드보다 기다리는 코드에서 더 잘 드러납니다.

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 숫자로 읽어보는 글입니다. 성능 이야기는 늘 그렇습니다. 숫자 하나만 들고 뛰면 금방 발목을 잡힙니다.

GraphOperations는 동기 API입니다. virtual-thread facade는 backend를 새로 구현하지 않습니다. 같은 동기 작업을 Virtual Thread 위에서 실행하고 CompletableFuture<T>로 돌려줍니다.

Sync path and Virtual Thread path for the same graph backend operation
Virtual Thread 경로는 backend 작업을 바꾸지 않습니다. 기다리는 위치와 비용이 달라집니다.

실제 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를 사용합니다.

항목
BackendTinkerGraph in-memory
JVMJava 25
측정JMH AverageTime, µs/op
Warmup / measurement3 × 2초 / 5 × 3초
비교sync 호출 vs virtual-thread adapter + CompletableFuture.join()

fixture는 GraphBenchmarkState에서 Person vertex 4개와 KNOWS/FOLLOWS edge를 만들고, 같은 graph 위에서 sync와 virtual-thread 호출을 비교합니다.

@Benchmark
fun syncFindVertexById(): Boolean =
syncOps.findVertexById("Person", aliceId) != null
@Benchmark
fun 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가 모든 단일 호출에서 앞섭니다.

TinkerGraph benchmark chart comparing Sync and Virtual Threads latency in microseconds per operation
작업 자체가 µs 단위로 끝나면 virtual-thread wrapping 비용이 결과에 크게 드러납니다.

대표 수치는 이렇습니다. AverageTime이므로 낮을수록 빠릅니다.

BenchmarkSync µs/opVirtual Thread µs/op해석
findVertexById1.7588.055단건 lookup에서는 wrapping 비용이 더 큼
findVerticesByLabel2.4858.679query 자체가 작아서 sync 우세
neighbors2.98510.595traversal이 작으면 VT 비용이 그대로 보임
bfs4.21612.588알고리즘 호출도 fixture가 작으면 sync 우세
pageRank7.27015.257작업이 조금 커져도 overhead가 남음
shortestPath22.03131.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가 더 자연스럽습니다.

상황이유
TinkerGraph 같은 in-memory graph호출 자체가 매우 짧아 wrapping 비용이 크게 보임
단발성 단순 lookupCompletableFuture까지 만들 이유가 적음
이미 호출량이 낮고 thread blocking 문제가 없음실행 모델을 늘려도 얻는 것이 작음
benchmark fixture나 unit test가장 작은 비용으로 결과를 확인하는 편이 좋음

테스트나 작은 in-memory graph에서는 sync를 쓰는 편이 단순합니다. 빠르고, stack trace도 바로 보이고, 불필요한 join()도 없습니다. 작은 못 하나 박는데 공구함 전체를 펼치면 작업보다 정리가 더 오래 걸립니다.

다음 조건이면 virtual-thread API가 의미를 갖습니다.

상황이유
Neo4j, Memgraph, AGE처럼 network I/O가 있는 backendblocking wait가 platform thread를 오래 붙잡지 않음
Spring MVC나 기존 blocking service codesuspend 전파 없이 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실행 모델을 읽는 법
TinkerGraphin-memory fixture, prototype, test에는 sync가 보통 충분
Neo4jnetwork I/O와 ACID transaction이 있으므로 VT가 service concurrency에 도움
Memgraphlow-latency backend라도 network wait가 있으면 VT 검토 가치 있음
Apache AGEPostgreSQL 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라도 성격이 다릅니다.

성능 선택은 멋진 이름으로 하는 일이 아닙니다. “어디서 기다리는가”를 찾고, 그 기다림을 어떤 실행 모델이 가장 덜 지저분하게 다루는지 고르는 일입니다.

댓글

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