콘텐츠로 이동

Bluetape4k Exposed Part 2: JDBC 저장소와 SQL DSL

Exposed 워크숍 작업대에서 작은 로봇 작업자들이 PostgreSQL, SQL DSL, 저장소 블록을 다루는 3D diorama
SQL을 없애려 하지 말고, 문자열 SQL이 만들던 실수를 줄이는 쪽이 더 오래 갑니다.

Part 1에서는 왜 Exposed를 선택했는지 봤습니다. 이제 JDBC 쪽 이야기를 보겠습니다. Exposed DSL만으로도 쿼리는 충분히 작성할 수 있습니다. 그런데 실제 서비스에서는 단건 조회, 페이징, 소프트 삭제, 감사 갱신 같은 코드가 테이블마다 반복됩니다.

exposed-jdbc를 만든 이유는 SQL DSL을 감추기 위해서가 아닙니다. Exposed의 장점은 SQL을 Kotlin 코드로 명시적으로 작성할 수 있다는 점입니다. JdbcRepository는 그 장점을 없애는 층이 아니라, 제가 서비스마다 반복해서 만들던 findById, findPage, 소프트 삭제, 매퍼, 가상 스레드 트랜잭션 같은 주변 코드를 줄이는 층입니다.

서비스 메서드에서 Exposed DSL, JdbcRepository, ResultRow 매퍼, 가상 스레드 트랜잭션을 거쳐 데이터베이스로 가는 JDBC 저장소 흐름
서비스는 트랜잭션 경계와 도메인 의도를 잡고, 저장소는 반복되는 쿼리 형태와 매핑을 줄입니다.

제가 Exposed JDBC에서 가장 중요하게 보는 지점은 SQL 모양이 코드에 남는다는 점입니다. SQL을 문자열로 쓰면 오탈자나 매개변수 순서 실수를 실행할 때야 알게 됩니다. Exposed DSL은 같은 SQL을 Kotlin 타입 시스템 안에서 조립합니다.

val rows = Users
.innerJoin(Orders)
.select(Users.id, Users.name, Orders.amount)
.where {
(Users.id eq userId) and (Orders.status eq "PAID")
}
.orderBy(Orders.id)
.toList()

이 코드는 SQL을 모르는 척하지 않습니다. join, select, where, orderBy가 그대로 보입니다. 대신 컬럼과 타입이 코드로 남습니다. MyBatis XML이나 문자열 SQL에서 흔히 보던 오탈자, 컬럼명 불일치, 매개변수 순서 실수는 줄어듭니다.

그래서 저는 모든 쿼리를 저장소 메서드로 숨기지 않습니다. 복잡한 쿼리는 SQL 모양이 보이는 편이 낫습니다. 저장소는 반복되는 작은 쿼리와 일관된 페이징/소프트 삭제 규칙을 맡는 정도가 좋습니다. 이 선을 넘기 시작하면 저장소가 어느 순간 또 하나의 쿼리 언어가 됩니다.

제가 저장소에 넣고 싶었던 것은 거창한 도메인 추상화가 아닙니다. 기본 형태는 단순합니다. LongJdbcRepository<UserTable, UserRecord>를 구현하고, 테이블과 ResultRow.toEntity()만 제공합니다. 이렇게 하면 단건 조회와 페이징 보조 기능을 바로 씁니다.

data class UserRecord(
val id: Long = 0L,
val name: String,
val email: String,
)
object UserTable : LongIdTable("users") {
val name = varchar("name", 100)
val email = varchar("email", 200)
}
class UserRepository : LongJdbcRepository<UserTable, UserRecord> {
override val table = UserTable
override fun ResultRow.toEntity() = UserRecord(
id = this[UserTable.id].value,
name = this[UserTable.name],
email = this[UserTable.email],
)
}

이 정도 추상화면 충분히 얇습니다. SQL DSL을 못 보게 만드는 저장소가 아니라, 같은 매핑과 페이징 코드를 반복하지 않게 하려는 장치입니다. 제가 필요하다고 느낀 것도 바로 이 정도였습니다.

기능왜 저장소에 둘 만한가
findById기본 키 단건 조회는 거의 모든 테이블에서 반복됩니다
findPage페이지 번호, 페이지 크기, 전체 개수 계산이 반복됩니다
소프트 삭제isDeleted=false 조건을 빠뜨리면 운영 사고가 됩니다
감사 갱신updatedAt, updatedBy 같은 컬럼은 규칙으로 묶는 편이 안전합니다
배치 삽입/upsertDB별 차이가 쿼리마다 흩어지는 것을 막습니다

트랜잭션 경계는 숨기지 않는다

섹션 제목: “트랜잭션 경계는 숨기지 않는다”

JDBC를 쓸 때 제가 가장 조심하는 부분은 트랜잭션 경계입니다. 저장소는 보통 애플리케이션 시작 시 만들거나 DI 컨테이너가 주입합니다. 매 트랜잭션 안에서 새로 만들 필요는 없습니다. Exposed는 transaction { ... } 블록 안에서 쿼리를 실행합니다.

val repo = UserRepository()
transaction {
val page = repo.findPage(pageNumber = 0, pageSize = 20) {
UserTable.name like "홍%"
}
page.content
}

Spring @Transactional을 쓰는 서비스라면 Spring 트랜잭션 경계와 Exposed Database 연결이 어떻게 묶이는지 명확해야 합니다. 트랜잭션이 어디서 시작되고, 어떤 커넥션을 잡고, 스키마/라우팅이 언제 결정되는지 모르면 멀티테넌시나 읽기/쓰기 라우팅에서 바로 흔들립니다.

블로킹 JDBC를 계속 쓰려면 대기 비용도 봐야 합니다. 그래서 exposed-jdbc에는 JDK 21+ 가상 스레드 기반 트랜잭션 보조 기능을 넣었습니다.

val count = newVirtualThreadJdbcTransaction {
UserTable.selectAll().count()
}
val futures = List(10) { index ->
virtualThreadJdbcTransactionAsync {
UserTable.insert { it[name] = "user-$index" }
index
}
}
val results = futures.awaitAll()

이 패턴은 JDBC 드라이버를 그대로 씁니다. 블로킹 호출은 남아 있지만, 대기하는 작업이 비싼 플랫폼 스레드를 오래 붙잡지 않게 합니다. Part 1에서 본 벤치마크처럼, I/O 대기가 많은 JDBC 워크로드에서 효과가 큽니다.

단, 가상 스레드가 트랜잭션 설계를 대신해 주지는 않습니다. 커넥션 풀 크기, DB 서버 처리 용량, 인덱스, 잠금 경합은 그대로 봐야 합니다. 실제 병목은 오히려 이쪽에서 더 자주 나옵니다. 스레드가 싸졌다고 DB 처리량이 자동으로 늘어나는 것은 아닙니다.

JdbcRepository 예제를 조금 더 보면

섹션 제목: “JdbcRepository 예제를 조금 더 보면”

제가 실제 서비스 코드에서 더 자주 필요했던 것은 CTE보다 단건 조회, 페이징, 배치 저장, 소프트 삭제, 감사 보조 기능이었습니다. 아래 예제처럼 저장소는 트랜잭션 안에서 사용하고, 저장소 객체 자체는 바깥에서 준비해 둡니다.

val repo = UserRepository()
transaction {
val user = repo.save(UserRecord(name = "홍길동", email = "hong@example.com"))
val found = repo.findByIdOrNull(user.id)
val page = repo.findPage(
pageNumber = 0,
pageSize = 20,
sortOrder = SortOrder.ASC,
) { UserTable.name like "홍%" }
val inserted = repo.batchInsert(users) { item ->
this[UserTable.name] = item.name
this[UserTable.email] = item.email
}
}

소프트 삭제 저장소에서는 삭제를 DELETE가 아니라 상태 전환으로 처리합니다.

val repo = PostRepository()
transaction {
repo.softDeleteById(postId)
val activePosts = repo.findActive()
val deletedPosts = repo.findDeleted()
repo.restoreById(postId)
}

감사 저장소는 수정 시점과 수정자를 쿼리마다 직접 넣지 않게 해줍니다.

val repo = ArticleRepository()
transaction {
UserContext.withUser("editor@example.com") {
repo.auditedUpdateById(articleId) {
it[ArticleTable.title] = "Updated Title"
}
}
}

물론 exposed-jdbc에는 PostgreSQL/MySQL WITH, WITH RECURSIVE를 위한 withCte()도 넣었습니다. 다만 일반 서비스 코드에서는 CTE를 저장소 메서드명으로 숨기기보다 SQL DSL을 그대로 보여주는 편이 낫습니다. 복잡한 쿼리 이름이 설명문처럼 길어지는 순간, 저장소는 SQL을 다시 발명하고 있는 중입니다.

JDBC 저장소는 작은 예제로 보면 너무 쉬워 보입니다. 제가 워크숍에 여러 실행 모델을 둔 이유는 저장소가 Spring 트랜잭션, 가상 스레드, 캐시, 라우팅과 만났을 때 어디까지 경계를 지키는지 보려는 것입니다.

예제코드에서 볼 것
exposed-workshop/03-exposed-basicDSL, DAO, 트랜잭션 기본 모양
exposed-workshop/08-coroutines/02-virtualthreads-basicJDBC + 가상 스레드 실행 모델
exposed-workshop/09-springSpring 트랜잭션과 저장소 연결
exposed-workshop/10-multi-tenant스키마 전환과 트랜잭션 커넥션 경계
exposed-workshop/11-high-performance/03-routing-datasource읽기/쓰기/테넌트 라우팅 키 계산

JDBC 방식을 고를 때 저는 이렇게 봅니다.

  • 쿼리 모양을 명시적으로 보고 싶다.
  • 문자열 SQL보다 Kotlin DSL의 타입 안전성이 필요하다.
  • JPA의 큰 ORM 기능보다 트랜잭션/쿼리 제어가 중요하다.
  • 블로킹 JDBC를 쓰되 가상 스레드로 대기 비용을 낮추고 싶다.
  • 반복 저장소 코드는 줄이되 SQL DSL까지 숨기고 싶지는 않다.

다음 글에서는 R2DBC, 코루틴, 가상 스레드를 같이 보겠습니다. 제가 이 둘을 나눠 둔 이유는 이름이 더 현대적으로 보이는 쪽을 고르기 위해서가 아니라, 워크로드에 따라 실제로 유리한 방식이 달라졌기 때문입니다.

댓글

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