Bluetape4k Exposed Part 3: R2DBC, 코루틴, 가상 스레드

Part 2에서 JDBC repository를 봤습니다. 여기서 자연스럽게 다음 질문이 나옵니다. WebFlux나 Ktor처럼 비동기 runtime을 쓰는 서비스라면 JDBC + Virtual Threads만으로 충분할까요? 아니면 R2DBC까지 가야 할까요?
저는 둘 중 하나만 정답이라고 보지 않았습니다. 그래서 R2DBC + Coroutines와 JDBC + Virtual Threads를 둘 다 남겨두고, workload에 따라 선택하도록 했습니다.
exposed-r2dbc는 Exposed의 R2DBC driver를 coroutine-first로 쓰려고 만든 R2DBC 쪽 확장입니다. 반대쪽에는
exposed-jdbc와 Virtual Threads가 있습니다. 둘 다 “요청이 기다릴 때 비싼 자원을 붙잡지 않게 하자”는 문제를
다루지만, 접근 방식이 다릅니다.

R2DBC 방식
섹션 제목: “R2DBC 방식”R2DBC를 넣은 이유는 분명합니다. event-loop 기반 runtime에서 DB I/O까지 blocking으로 남겨두면 구조가 반쪽짜리로
남습니다. Spring WebFlux를 도입했는데 내부에서 JDBC가 event loop를 오래 붙잡는다면, 응답성 개선을 기대했던 구조가
오히려 위험해집니다. R2DBC는 이 지점을 해결하기 위한 Reactive Streams 기반 non-blocking database access이고,
exposed-r2dbc는 이를 Kotlin coroutine API로 감쌉니다.
suspend fun findById(id: Long): ResultRow? = suspendTransaction(database) { UserTable .selectAll() .where { UserTable.id eq id } .singleOrNull()}Repository에서는 suspend 단건 연산과 Flow 조회를 씁니다.
interface UserRepository : ExposedR2dbcRepository<User, Long> { suspend fun findByName(name: String): List<User> suspend fun countByAge(age: Int): Long}
suspend fun processAllUsers() { userRepository.findAll() .collect { user -> // streaming 처리 }}R2DBC를 선택하는 편이 좋은 경우는 다음과 같습니다.
- WebFlux/Netty/Ktor처럼 event-loop 기반 runtime을 이미 쓰고 있다.
- row stream과 backpressure가 실제 domain 요구사항이다.
- 긴 결과를 한 번에 메모리에 올리지 않고 단계적으로 처리해야 한다.
- Reactor/Coroutine context 전파와 observability가 팀에 익숙하다.
JDBC + Virtual Threads 방식
섹션 제목: “JDBC + Virtual Threads 방식”그렇다고 모든 서비스를 R2DBC로 밀어야 한다고 보지는 않습니다. JDBC는 blocking API지만, Virtual Threads를 쓰면 blocking call이 platform thread를 오래 붙잡지 않도록 만들 수 있습니다.
val count = newVirtualThreadJdbcTransaction { UserTable.selectAll().count()}그래도 JDBC + Virtual Threads를 남겨둔 이유는 단순합니다.
| 장점 | 설명 |
|---|---|
| driver maturity | JDBC driver의 batch 처리와 pooling 경험이 오래 쌓여 있습니다 |
| call stack | stack trace와 profiler가 익숙한 모양입니다 |
| migration cost | 기존 JDBC/transaction 코드를 크게 바꾸지 않아도 됩니다 |
| SQL DSL 유지 | Exposed DSL은 그대로 씁니다 |
단점도 있습니다. non-blocking I/O가 아니므로 DB connection과 server capacity는 그대로 한계입니다. Virtual Threads는 스레드 대기 비용을 낮추는 기술이지, DB round-trip을 없애는 기술이 아닙니다. 이 점을 착각하면 “스레드는 싸졌는데 DB는 그대로 느린” 상태가 됩니다.
배치 벤치마크가 말한 것
섹션 제목: “배치 벤치마크가 말한 것”그래서 숫자를 따로 봤습니다. bluetape4k-exposed/utils/batch/benchmark에서는 H2, MySQL, PostgreSQL에서 JDBC +
Virtual Threads와 R2DBC + Coroutines 방식을 같은 batch workload로 비교했습니다.
| 축 | 값 |
|---|---|
| DB | H2, PostgreSQL, MySQL |
| JDBC 방식 | JDBC + Virtual Threads |
| R2DBC 방식 | R2DBC + Coroutines |
| 시나리오 | seedBenchmark, endToEndBatchJobBenchmark |
| 파라미터 | dataSize=1000/10000/100000, poolSize=10/30/60, parallelism=1/4/8 |
대형 end-to-end batch job에서 dataSize=100000, parallelism=8 조건은 이렇게 나왔습니다.

| DB | poolSize | JDBC ops/sec | R2DBC ops/sec | JDBC/R2DBC |
|---|---|---|---|---|
| MySQL | 10 | 1.596 | 0.181 | 8.8x |
| MySQL | 30 | 1.561 | 0.182 | 8.6x |
| MySQL | 60 | 1.525 | 0.182 | 8.4x |
| PostgreSQL | 10 | 0.972 | 0.192 | 5.1x |
| PostgreSQL | 30 | 0.990 | 0.192 | 5.2x |
| PostgreSQL | 60 | 0.951 | 0.193 | 4.9x |
제가 이 결과에서 읽은 결론은 “JDBC가 항상 이긴다”가 아닙니다. 이 benchmark는 batch insert와 end-to-end batch job을 측정한 결과입니다. streaming response나 backpressure가 핵심인 API라면 R2DBC의 가치가 달라집니다.
하지만 batch workload에서는 JDBC driver의 성숙한 처리 방식, 단순한 transaction 경계, Virtual Threads의 대기 비용 절감이 강하게 작동했습니다. 이름은 blocking이어도 결과는 꽤 현실적입니다.
R2DBC 풀도 측정해야 한다
섹션 제목: “R2DBC 풀도 측정해야 한다”R2DBC를 고르더라도 pool은 따로 봐야 합니다. non-blocking이라고 해서 DB connection이 무한히 생기는 것은 아닙니다.
그래서 예제에서는 bluetape4k-r2dbc의 R2dbcPoolConfig.highThroughput()을 기준 설정으로 두고 다음 지점을
확인했습니다.
- 순수 acquire/close 처리량은 driver마다 다릅니다.
- 요청이 connection을
1-5 ms점유하면 처리량은 점유 시간에 지배됩니다. - 동시 요청 수가 pool size를 넘는 64 threads contention benchmark에서는
maxSize영향이 뚜렷합니다. - 과부하 보호가 필요하면 bounded pending acquire, warmup, finite timeout을 우선합니다.
즉 R2DBC도 “non-blocking이니 pool 걱정 끝”이 아닙니다. DB connection은 여전히 제한된 자원입니다. 이 부분을 잊으면 reactive pipeline이 아니라 예쁜 대기열을 만든 셈입니다.
선택 규칙
섹션 제목: “선택 규칙”| 상황 | 먼저 볼 방향 |
|---|---|
| 일반 CRUD, batch job, 기존 JDBC driver 성숙도가 중요 | JDBC + Virtual Threads |
| streaming response, backpressure, event-loop runtime 일관성이 중요 | R2DBC + Coroutines |
| Spring MVC 기반 서비스, blocking library가 많음 | JDBC + Virtual Threads |
| WebFlux/Ktor coroutine-first 서비스 | R2DBC 또는 suspend JDBC 격리 |
| driver별 batch 성능이 불확실 | 둘 다 benchmark |
제가 선택 기준에 꼭 넣는 것은 팀의 관찰성 경험입니다. 장애가 났을 때 thread dump, JFR, JDBC pool metric으로 보는 팀이라면 JDBC + Virtual Threads가 더 쉽게 운영될 수 있습니다. 반대로 Reactor/Coroutine context와 backpressure metric을 이미 잘 본다면 R2DBC 쪽이 더 편합니다.
맺음말
섹션 제목: “맺음말”R2DBC와 Virtual Threads는 서로 완전히 대체재가 아닙니다. 둘은 같은 문제를 다른 층에서 풉니다.
- R2DBC는 driver I/O 자체를 non-blocking으로 만듭니다.
- Coroutines는 Kotlin에서 그 비동기 방식을 읽기 쉽게 만듭니다.
- Virtual Threads는 blocking JDBC call의 대기 비용을 낮춥니다.
- Exposed DSL은 두 방식 모두에서 SQL을 Kotlin 코드로 안전하게 유지합니다.
다음 글에서는 JSON, 암호화, 측정 단위 컬럼, dialect 모듈을 보겠습니다. 제가 그 모듈들을 따로 둔 이유는 성능보다 “DB별 지저분한 세부사항을 어디에 격리할 것인가”에 가깝습니다.
참고 링크
섹션 제목: “참고 링크”- R2DBC README: exposed/exposed-r2dbc/README.ko.md
- Batch benchmark hub: utils/batch/benchmark/README.ko.md
- MySQL benchmark details: mysql.md
- PostgreSQL benchmark details: postgresql.md
- R2DBC workshop: exposed-r2dbc-workshop
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.