Bluetape4k Exposed Part 6: BigQuery, Trino, CockroachDB, DuckDB 통합 예제

Part 4에서는 JSON, 암호화, 다이얼렉트를 다뤘고, Part 5에서는 캐시와 운영 예제를 살펴봤습니다. 이번 글은 그다음 경계로 넘어갑니다. Exposed SQL DSL을 BigQuery, Trino, CockroachDB, DuckDB 같은 생태계 통합 지점에 적용할 때 무엇을 먼저 검증해야 하는지 살펴봅니다.
여기서 핵심은 “다른 DB도 Exposed로 다룰 수 있다”가 아닙니다. 이 네 가지는 모두 “그냥 다른 JDBC DB”라고 보기 어렵습니다.
| 예제 | 먼저 검증할 계약 | 기본 실행 경계 |
|---|---|---|
| BigQuery | 실행 전 드라이런 요청과 옵션 매핑 | 모의 REST 클라이언트, 인증 정보 없음 |
| Trino | 세션 옵션과 EXPLAIN 가능한 SQL 형태 | 로컬 SQL 생성, 코디네이터 연결 없음 |
| CockroachDB | SQLSTATE 40001에서 전체 트랜잭션 재시도 | Testcontainers CockroachDB |
| DuckDB | 파일 기반 내장 분석과 트랜잭션·Flow 경계 | 로컬 DuckDB 파일, 서버 없음 |
그래서 exposed-workshop/13-ecosystem-integrations의 예제는 실제 서비스를 바로 사용하는 방식으로 만들지 않았습니다.
대부분은 로컬 실행, 가짜 객체, 모의 객체, Testcontainers 경계를 먼저 둡니다. 비용과 인증 정보, 네트워크 접근을
허용하기 전에 애플리케이션이 책임지는 계약부터 검증하려는 의도입니다.
BigQuery: 실행보다 드라이런 계약이 먼저다
섹션 제목: “BigQuery: 실행보다 드라이런 계약이 먼저다”BigQuery 예제는 실제 분석 쿼리를 실행하지 않습니다. Exposed가 만든 쿼리를 BigQuery jobs.query 요청으로 바꾸고,
dryRun=true와 옵션 매핑이 맞는지 확인합니다.

핵심 코드는 간단합니다.
fun validateRegionalRevenueDryRun( context: BigQueryContext, options: BigQueryQueryOptions = defaultDryRunOptions(), minimumRevenue: BigDecimal = BigDecimal("10.00"),): QueryResponse = context.validateQuery(buildRegionalRevenueQuery(minimumRevenue), options)defaultDryRunOptions()는 maximumBytesBilled, labels, priority, location, timeoutMs를 설정합니다.
테스트는 모의 BigQuery REST 클라이언트가 받은 요청을 검사합니다. 여기서 중요한 점은 클라우드 인증 정보를
읽지 않는다는 보장입니다. 기본 명령은 GOOGLE_APPLICATION_CREDENTIALS, ADC, 서비스 계정 파일, API 키를
읽지 않아야 합니다.
./gradlew :01-bigquery-dry-run:test이 예제는 BigQuery 성능을 측정하지 않습니다. 실행 전에 “이 쿼리를 이 옵션으로 보낼 수 있는가”를 확인하는 안전장치입니다. 드라이런으로 요청 계약을 먼저 검증하면 잘못된 운영 쿼리로 비용이 발생하는 상황을 줄일 수 있습니다.
Trino: 연결 옵션은 애플리케이션 계약이다
섹션 제목: “Trino: 연결 옵션은 애플리케이션 계약이다”Trino 예제도 실제 코디네이터에 연결하지 않습니다. 대신 애플리케이션이 다루는 프로필을
TrinoConnectionOptions로 변환하고, Exposed가 만든 쿼리를 EXPLAIN 요청으로 감쌉니다.

프로필은 JDBC 드라이버 속성을 여러 곳에 흩뜨리지 않고 애플리케이션 값으로 먼저 검증합니다.
data class TrinoWorkshopConnectionProfile( val catalog: String = "hive", val schema: String = "analytics", val source: String = "exposed-workshop", val clientTags: List<String> = listOf("exposed", "analytics", "workshop"), val sessionProperties: Map<String, String> = mapOf( "join_distribution_type" to "AUTOMATIC", "query_max_execution_time" to "5m", ),): Serializable그 다음 쿼리 형태를 봅니다.
fun buildExplainRequest(sql: String): String { sql.requireNotBlank("sql") return "EXPLAIN\n$sql"}이 테스트가 커넥터별 푸시다운을 보장하지는 않습니다. Trino의 푸시다운은 커넥터와 카탈로그 설정에 따라 달라집니다. 이 예제는 프로젝션, 조건식, 정렬, top-N 신호가 유지된 SQL을 만들고 실제 클러스터에 연결하기 전에 요청 경계를 확인합니다.
CockroachDB: 마지막 SQL 문이 아니라 전체 트랜잭션을 다시 실행한다
섹션 제목: “CockroachDB: 마지막 SQL 문이 아니라 전체 트랜잭션을 다시 실행한다”CockroachDB는 PostgreSQL 와이어 프로토콜을 사용하지만, 트랜잭션 실패를 다루는 방식은 애플리케이션 코드가
알아야 합니다. SERIALIZABLE 충돌이 발생하면 SQLSTATE 40001과 restart transaction 계열 메시지가 올 수
있습니다. 이때 마지막 UPDATE만 다시 실행하면 안 됩니다. 읽기, 검증, 갱신, 원장 저장을 포함한 전체 트랜잭션을
다시 실행해야 합니다.

예제 서비스는 재시도 책임을 withCockroachTransaction에 맡깁니다.
return withCockroachTransaction(db = db, options = retryOptions) { val current = CockroachInventory .selectAll() .where { CockroachInventory.sku eq sku } .single() .toInventorySnapshot()
CockroachInventory.update({ CockroachInventory.sku eq sku }) { it[quantityOnHand] = current.quantityOnHand - quantity it[version] = current.version + 1 } CockroachLedger.insert { it[CockroachLedger.sku] = sku it[quantityDelta] = -quantity it[CockroachLedger.reason] = reason }}테스트는 CockroachDB Testcontainers를 실행해 정상 예약, 한 번 재시도해야 하는 충돌, 재시도하면 안 되는 SQL 오류를 확인합니다.
./gradlew :03-cockroachdb-retry:test여기서 예제가 보여주는 것은 CockroachDB 전용 다이얼렉트가 아닙니다. “어떤 실패만 재시도할 것인가”와 “재시도 단위를 어디에 둘 것인가”입니다. 이 경계를 서비스마다 직접 판단하기 시작하면 코드가 금방 지저분해집니다.
DuckDB: 원격 분석 플랫폼이 아니라 내장 분석 엔진이다
섹션 제목: “DuckDB: 원격 분석 플랫폼이 아니라 내장 분석 엔진이다”DuckDB는 앞의 세 예제와 성격이 다릅니다. BigQuery와 Trino가 원격 플랫폼 경계를 다루고 CockroachDB가 분산 SQL 트랜잭션 경계를 다룬다면, DuckDB는 애플리케이션 프로세스 안에서 분석 엔진을 실행합니다.

예제는 파일 기반 데이터베이스를 사용합니다. 이 선택이 중요합니다. 단순 jdbc:duckdb: 메모리 데이터베이스는
연결마다 독립된 카탈로그를 만들 수 있습니다. 여러 Exposed 트랜잭션에서 같은 행을 조회해야 하는 워크숍에는
적합하지 않습니다.
class DuckDbAnalyticsSession private constructor( val db: Database, private val rootConnection: DuckDBConnection,): AutoCloseable { companion object { fun file(path: Path): DuckDbAnalyticsSession { val rootConnection = DriverManager .getConnection("jdbc:duckdb:${path}") as DuckDBConnection val db = Database.connect( getNewConnection = { WorkshopDuckDbConnectionWrapper(rootConnection.duplicate()) }, ) return DuckDbAnalyticsSession(db, rootConnection) } }}집계 쿼리는 Exposed DSL로 만들고 DuckDB가 로컬에서 실행합니다.
fun buildDailyCategorySalesQuery(): Query = DuckDbOrderEvents .select( DuckDbOrderEvents.region, DuckDbOrderEvents.category, DuckDbOrderEvents.eventDate, DuckDbOrderEvents.orderId.count(), DuckDbOrderEvents.amount.sum(), ) .groupBy( DuckDbOrderEvents.region, DuckDbOrderEvents.category, DuckDbOrderEvents.eventDate, )여기서 혼동하기 쉬운 부분이 queryFlow입니다. streamDailyCategorySales는 Flow를 반환하지만 JDBC 행을
트랜잭션 밖에서 한 줄씩 전달한다는 뜻은 아닙니다. 대표 패턴은 아래처럼 트랜잭션 안에서 쿼리 결과를 먼저
List<DailyCategorySales>로 구체화하고, 이미 읽은 행을 코루틴 처리 흐름의 입력으로 넘기는 방식입니다.
fun streamDailyCategorySales( db: Database, dispatcher: CoroutineDispatcher = Dispatchers.IO,): Flow<DailyCategorySales> = queryFlow(db, dispatcher) { buildDailyCategorySalesQuery().map(::toDailyCategorySales) }즉 Flow 경계는 “DB 커서를 오래 열어둔다”가 아니라 “트랜잭션 안에서 읽은 분석 결과를 이후 처리 흐름에서
다루기 쉽게 만든다”는 뜻입니다. 전체 구현과 테스트는 아래 참고 링크에서 확인할 수 있습니다. 이름이 Flow라고 해서
DB 커서 생명주기까지 코루틴 파이프라인 밖으로 확장하면 진단하기 어려운 오류가 발생합니다.
선택 기준
섹션 제목: “선택 기준”해결할 문제 상황에 따라 4가지 예제를 중심으로 선택 기준을 본다면 다음과 같습니다.
| 상황 | 먼저 볼 예제 | 가져갈 규칙 |
|---|---|---|
| 분석 쿼리를 실행 전에 검증하고 싶다 | BigQuery dry-run | 비용과 인증 정보보다 요청 계약을 먼저 테스트 |
| 페더레이션 쿼리를 애플리케이션 설정으로 제어하고 싶다 | Trino 세션 옵션 | 카탈로그·스키마·소스·세션 속성을 타입이 있는 설정 객체로 관리 |
분산 SQL에서 SERIALIZABLE 충돌을 다룬다 | CockroachDB 재시도 | 재시도 단위는 마지막 SQL 문이 아니라 전체 트랜잭션 |
| 로컬·CI·오프라인 작업에서 분석 SQL을 실행하고 싶다 | DuckDB 내장 분석 | 파일 기반 세션과 이미 읽은 행을 Flow로 넘기는 경계를 명확히 설정 |
이 글의 결론은 “Exposed로 모든 DB를 똑같이 다루자”가 아닙니다. 오히려 반대입니다. DB와 플랫폼이 달라지는 지점에서는 같게 보이는 API보다 서로 다른 계약을 먼저 드러내야 합니다. BigQuery는 드라이런 요청, Trino는 세션 옵션, CockroachDB는 재시도 분류, DuckDB는 파일 기반 내장 세션이 그 계약입니다.
Exposed DSL은 이 경계에서 SQL을 만들고 트랜잭션 블록을 표현하는 공통 언어가 됩니다. 하지만 실행 모델, 인증 정보, 재시도 의미론, 커서 생명주기까지 동일하다고 가정해서는 안 됩니다. 작은 예제에서 경계를 정확히 설정해야 운영 코드가 커져도 설계 원칙을 유지할 수 있습니다.
참고 링크
섹션 제목: “참고 링크”- Chapter 13: Ecosystem Integrations
- BigQuery dry-run: 01-bigquery-dry-run/README.ko.md
- Trino session options: 02-trino-session-options/README.ko.md
- CockroachDB retry: 03-cockroachdb-retry/README.ko.md
- DuckDB embedded analytics: 09-duckdb-embedded-analytics/README.ko.md
- DuckDB workshop source: DuckDbEmbeddedAnalyticsWorkshop.kt
- BigQuery 지원 모듈: exposed/bigquery
- Trino 지원 모듈: exposed/trino
- CockroachDB 지원 모듈: exposed/cockroachdb
- DuckDB 지원 모듈: exposed/duckdb
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.