Querydsl/JPA에서 Exposed DSL로 옮기며 kapt를 걷어낸 이유
Querydsl/JPA 기반 persistence를 Exposed DSL로 옮긴 작업은 단순한 ORM 교체가 아니었습니다. Kotlin 빌드에서 kapt와 생성 코드를 걷어내고, persistence 경계를 더 명시적으로 만든 전환이었습니다.
Sample codes-class-backend의 persistence 계층을 Querydsl/JPA 기반에서 Exposed DSL 기반으로 옮겼습니다. 겉으로 보면 ORM을 바꾼 작업처럼 보이지만, 실제로 더 크게 느껴진 변화는 kapt 의존성이 사라진 점입니다.
이건 의존성 한두 줄을 줄인 정도의 변화가 아니었습니다. Kotlin 코드가 데이터 접근을 표현하는 방식, Gradle 빌드가 소스를 준비하는 방식, persistence 계층이 도메인과 관계 맺는 방식이 같이 바뀐 작업에 가까웠습니다.
Querydsl/JPA에서 따라오던 것
기존 구조는 Spring Data JPA와 Querydsl을 같이 사용했습니다. 복잡한 조회를 문자열 JPQL로 두지 않고 타입 안전하게 작성한다는 점에서 Querydsl은 충분히 좋은 선택이었습니다.
문제는 Kotlin 프로젝트에서 Querydsl을 쓰려면 보통 annotation processing 단계가 필요하다는 점입니다. Q 타입을 생성해야 하고, Kotlin에서는 이를 위해 kapt가 빌드 파이프라인에 들어옵니다.
plugins {
kotlin("plugin.jpa")
kotlin("kapt")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
kapt("com.querydsl:querydsl-apt:5.1.0:jakarta")
}
이 구조에서는 repository 코드가 직접 작성한 Kotlin 코드만으로 끝나지 않습니다. 컴파일 전에 Q 타입이 생성되어야 하고, 실제 쿼리 코드는 그 생성물을 기준으로 작성합니다.
graph LR
Source["Kotlin source"]
Kapt["kapt annotation processing"]
QTypes["Generated Q-types"]
Compile["Kotlin compile"]
Test["Test / CI"]
Source --> Kapt
Kapt --> QTypes
Source --> Compile
QTypes --> Compile
Compile --> Test
이 흐름 자체가 잘못된 것은 아닙니다. 다만 프로젝트가 커질수록 생성 코드도 관리 대상이 됩니다. IDE 인덱싱, CI 캐시, 커버리지 제외 규칙, annotation processing 실패 같은 것들이 persistence와 직접 관련 없는 비용으로 따라옵니다.
Exposed DSL로 옮기며 바뀐 빌드 경로
Exposed DSL로 옮긴 뒤 persistence 모듈의 의존성은 JDBC와 Exposed 중심으로 바뀌었습니다.
dependencies {
api("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.jetbrains.exposed:exposed-core")
implementation("org.jetbrains.exposed:exposed-java-time")
implementation("org.jetbrains.exposed:exposed-jdbc")
implementation("org.jetbrains.exposed:exposed-json")
implementation("org.jetbrains.exposed:spring7-transaction")
}
동시에 root Gradle 설정에서 kotlin("plugin.jpa")와 kotlin("kapt")를 제거했습니다.
graph LR
Source["Kotlin source"]
Tables["Exposed Table definitions"]
Compile["Kotlin compile"]
Test["Test / CI"]
Source --> Compile
Tables --> Compile
Compile --> Test
이제 persistence 코드는 컴파일 전에 생성되어야 하는 Q 타입에 기대지 않습니다. 테이블 정의, mapper, repository 구현이 모두 Kotlin source 안에 있습니다. 빌드 관점에서는 단계가 줄었고, 코드 관점에서는 데이터 접근 경계가 더 눈에 보이게 됐습니다.
JPA entity에서 Table과 Mapper로
JPA 기반에서는 entity가 persistence model이면서 동시에 ORM의 관리 대상입니다. lazy loading, dirty checking, cascade 같은 기능이 있지만, 그만큼 객체 상태와 DB 상태 사이에 프레임워크가 개입합니다.
Exposed DSL로 옮긴 뒤에는 테이블과 매핑을 더 직접적으로 작성합니다.
object Lessons : Table("lessons") {
val id = varchar("id", 26)
val courseId = varchar("course_id", 26)
val title = varchar("title", 120)
val sequence = integer("sequence")
val createdAt = datetime("created_at")
}
class LessonExposedRepository {
fun findByCourseId(courseId: String): List<Lesson> =
Lessons
.selectAll()
.where { Lessons.courseId eq courseId }
.orderBy(Lessons.sequence)
.map { row -> row.toDomain() }
}
여기서 본 건 persistence code의 길이가 아니었습니다. 어떤 테이블을 읽고, 어떤 조건을 걸고, 어떤 domain model로 복원하는지가 명시적으로 드러나는지를 봤습니다.
JPA entity를 없애면 공짜로 얻는 것도 사라집니다. 변경 감지, 관계 탐색, cascade 처리를 직접 설계해야 합니다. 대신 이 프로젝트에서는 그 방향이 더 맞았습니다. 이미 도메인 모델과 persistence model을 분리하려는 흐름이 있었고, repository adapter가 port를 구현하는 구조로 경계를 잡고 있었기 때문입니다.
kapt 제거가 의미하는 것
kapt 제거는 빌드가 조금 가벼워졌다는 말보다 넓은 의미를 가집니다.
- Querydsl Q 타입 생성 단계가 사라집니다.
- 생성 코드에 맞춘 커버리지/정적 분석 예외가 줄어듭니다.
- Kotlin compile 전에 annotation processing 결과를 기다리는 구조가 사라집니다.
- repository 구현이 생성물보다 명시적인 Table 정의와 DSL에 기대게 됩니다.
- JPA plugin이 만드는 entity 보정과 ORM 생명주기에 덜 의존하게 됩니다.
특히 마지막이 중요합니다. persistence 계층이 더 이상 JPA가 관리하는 객체 그래프를 중심으로 움직이지 않습니다. 필요한 조회는 SQL에 가까운 DSL로 표현하고, 도메인으로 복원하는 지점을 mapper로 고정합니다.
graph LR
Domain["Domain model"]
Port["Persistence port"]
Adapter["Persistence adapter"]
Table["Exposed table"]
DB["Database"]
Domain --> Port
Adapter --> Port
Adapter --> Table
Table --> DB
이 구조에서는 도메인이 Exposed를 알 필요가 없습니다. Exposed는 persistence adapter 내부 구현입니다. 그래서 라이브러리 교체의 영향이 도메인 규칙으로 번지지 않습니다.
숫자로 확인한 것
이런 전환은 숫자도 같이 봐야 합니다. 다만 아래 수치를 전부 Exposed 덕분이라고 읽으면 곤란합니다. 같은 시기에 application module 제거, batch runner 정리, infrastructure module 분리 같은 후속 변경도 이어졌기 때문입니다.
제가 나눈 기준은 두 가지였습니다.
- Exposed 전환 전후에 바로 비교되는 운영 cold start 로그
- GitHub Actions에서 보이는 CI/CD shard 시간
운영 cold start는 ECS CloudWatch의 Started ... in ... seconds 로그를 기준으로 봤습니다. 전환 전 샘플은 2026-06-06~07 prod 로그, 전환 후 샘플은 2026-06-14 prod 로그입니다.
| 대상 | Querydsl/JPA 구간 | Exposed 전환 후 | 변화 |
|---|---|---|---|
| Backoffice API | 평균 65.6초, n=3 | 평균 38.4초, n=4 | 약 41% 감소 |
| Notification app | 평균 29.8초, n=3 | 평균 20.7초, n=3 | 약 31% 감소 |
Batch recover-pending-payment | 평균 237.9초, n=14 | 평균 208.0초, n=9 | 약 13% 감소 |
Backoffice와 Notification은 JPA repository scanning과 Hibernate 초기화 부담이 빠진 효과가 비교적 잘 보였습니다. Batch는 다르게 봤습니다. Exposed 전환 직후에는 237.9초에서 208.0초 정도로만 줄었습니다. 이후 Spring Batch Job/Step 기반 정리까지 들어간 현재 구간에서는 같은 batch cold start가 평균 121.4초, n=8까지 내려갔지만, 이 값은 Exposed 단독 효과로 보지 않습니다.
CI 시간은 더 보수적으로 봐야 했습니다. PR #676 merge commit 기준으로 GitHub Actions CD run을 비교하면 전체 시간은 9분 45초에서 9분 53초로 거의 같았습니다.
| CI shard | 전환 전 | 전환 후 |
|---|---|---|
| Backoffice | 3분 20초 | 3분 10초 |
| Supporters | 3분 31초 | 3분 34초 |
| Etc | 4분 13초 | 4분 00초 |
| Domain | 1분 37초 | 1분 56초 |
이 글에서는 빌드 시간이 크게 줄었다고 말하지 않습니다. 대신 확실한 건 빌드 경로에서 kapt와 Q 타입 생성 단계가 사라졌다는 점입니다. CI wall time은 캐시, 병렬 shard, 배포 단계 영향을 같이 받습니다. 그래도 컴파일 전에 생성 코드를 기다려야 하는 구조는 없어졌습니다.
PR 규모도 작지 않았습니다. refactor: persistence를 Exposed DSL로 전환 PR은 438개 파일을 바꿨고, +13,697 / -16,298 라인으로 최종적으로 약 2,600라인이 줄었습니다. 단순히 줄 수를 줄인 작업이라기보다, JPA entity와 Querydsl custom repository가 있던 자리를 Table, Mapper, Exposed repository로 바꾸며 persistence 책임을 더 직접적으로 드러낸 작업이었습니다.
Querydsl이 틀렸다는 뜻은 아닙니다
Querydsl이나 JPA를 쓰면 안 된다는 말은 아닙니다. JPA의 변경 감지와 관계 매핑이 생산성을 크게 올려주는 구간은 분명히 있습니다. Querydsl도 복잡한 JPQL을 타입 안전하게 작성하게 해주는 좋은 도구입니다.
다만 s-class-backend가 필요로 하던 방향은 조금 달랐습니다.
- 도메인 모델과 persistence model을 분리하고 싶었습니다.
- bounded context별 경계를 더 명확하게 만들고 있었습니다.
- 복잡한 조회를 객체 그래프 탐색보다 명시적인 쿼리로 표현하는 쪽이 맞았습니다.
- Kotlin 빌드에서 생성 코드와 annotation processing 의존도를 낮추고 싶었습니다.
이 조건이 겹치면 Exposed DSL은 꽤 현실적인 선택지가 됩니다. SQL에 가까운 표현력을 유지하면서도 Kotlin 코드 안에서 타입을 잡고, kapt 없이 persistence 구현을 구성합니다.
남은 변화
이번 전환에서 가장 의미 있었던 지점은 JPA를 걷어냈다는 사실이 아니었습니다. persistence 계층이 생성 코드와 ORM 생명주기에 덜 기대게 됐다는 점이었습니다.
Querydsl/JPA 기반에서는 kapt가 Q 타입을 만들고, repository 구현은 그 생성물을 사용했습니다. Exposed DSL 기반에서는 테이블 정의와 쿼리 DSL이 직접 Kotlin source 안에 남습니다. 그래서 빌드 경로가 단순해지고, persistence 구현의 책임도 더 선명해졌습니다.
라이브러리 교체 자체는 목적이 아닙니다. 하지만 그 변경이 빌드 파이프라인과 아키텍처 경계를 같이 단순하게 만든다면, 그때는 단순한 교체 이상의 의미가 생깁니다.