Spring Boot 데이터 접근 기술(4/6)
Kotlin/SpringJOOQ - 타입 세이프 SQL
JOOQ가 무엇인지, DB 스키마에서 코드를 생성하는 원리, MyBatis/QueryDSL과의 차이, 라이선스 제약을 정리한다.
2026-03-30
8 min read
#JOOQ#SQL#Spring Boot#Kotlin#타입세이프
Spring Boot 데이터 접근 기술시리즈 목차
JOOQ란?
실제 DB 스키마에서 코드를 자동 생성하고, 그 코드로 타입 세이프하게 SQL을 작성하는 라이브러리다.
// JOOQ
dslContext
.select(USER.NAME, USER.EMAIL)
.from(USER)
.where(USER.AGE.gt(20).and(USER.STATUS.eq("ACTIVE")))
.orderBy(USER.CREATED_AT.desc())
.fetch()
QueryDSL이 JPA 엔티티 기반이라면, JOOQ는 실제 DB 테이블 기반이다. JPA 없이 독립적으로 동작한다.
동작 원리
코드 생성 단계
빌드 시 JOOQ Codegen이 실제 DB에 연결해서 스키마를 읽고 Kotlin/Java 클래스를 생성한다.
DB 연결
│ INFORMATION_SCHEMA 조회 (테이블, 컬럼, 타입, 제약조건)
▼
코드 생성 (jooq-codegen)
│
├── Tables.kt - 테이블 메타데이터
├── tables/User.kt - USER 테이블 객체 (각 컬럼 타입 포함)
├── tables/records/
│ └── UserRecord.kt - 테이블의 한 행을 표현하는 레코드
└── Keys.kt - PK, FK, Index 정보
생성된 USER 객체는 실제 DB의 users 테이블과 1:1로 대응한다.
// 생성된 코드 예시 (대략 이런 구조)
object USER : Table<UserRecord>("users") {
val ID: Field<Long> = field("id", Long::class.java)
val NAME: Field<String> = field("name", String::class.java)
val EMAIL: Field<String> = field("email", String::class.java)
val AGE: Field<Int> = field("age", Int::class.java)
val STATUS: Field<String> = field("status", String::class.java)
val CREATED_AT: Field<LocalDateTime> = field("created_at", LocalDateTime::class.java)
}
컬럼 타입이 DB 스키마와 정확히 일치한다. USER.AGE.gt("twenty") 같은 코드는 컴파일 자체가 안 된다.
쿼리 실행 단계
DSL 코드
│ .selectFrom(USER).where(USER.ID.eq(1L))
▼
SQL 생성 (SQLDialect 기반)
│ SELECT id, name, email FROM users WHERE id = ?
▼
JDBC 실행
▼
ResultSet → Record 변환
▼
UserRecord / DTO 반환
JOOQ는 SQLDialect를 설정해서 PostgreSQL, MySQL, Oracle 등 각 DB에 맞는 SQL 문법을 생성한다. LIMIT/OFFSET, RETURNING, 윈도우 함수 등 DB별 차이를 추상화한다.
QueryDSL vs MyBatis vs JOOQ
| 특징 | QueryDSL | MyBatis | JOOQ |
|---|---|---|---|
| 코드 기반 | JPA 엔티티 | 없음 (SQL 직접) | DB 스키마 |
| 타입 안전성 | ✅ | ❌ | ✅ |
| SQL 표현력 | 중간 | 최고 | 높음 |
| JPA 필요 | ✅ | ❌ | ❌ |
| 코드 생성 | Q클래스 (엔티티→) | 없음 | 테이블 클래스 (DB→) |
| 페이징 | 직접 구현 | 직접 작성 | dialect 자동 처리 |
기본 사용법
설정 (build.gradle.kts)
plugins {
id("nu.studer.jooq") version "8.2"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
jooqGenerator("org.postgresql:postgresql")
}
jooq {
configurations {
create("main") {
generateSchemaSourceOnCompilation.set(true)
jooqConfiguration.apply {
jdbc.apply {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/mydb"
user = "postgres"
password = "password"
}
generator.apply {
database.apply {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
}
target.apply {
packageName = "com.example.generated"
directory = "src/main/generated"
}
generate.apply {
isKotlinNotNullPojoAttributes = true
isKotlinNotNullRecordAttributes = true
}
}
}
}
}
}
SELECT
import com.example.generated.tables.User.USER
@Repository
class UserRepository(private val dsl: DSLContext) {
fun findById(id: Long): UserRecord? =
dsl.selectFrom(USER)
.where(USER.ID.eq(id))
.fetchOne()
// 특정 컬럼만 조회
fun findNames(): List<String> =
dsl.select(USER.NAME)
.from(USER)
.orderBy(USER.CREATED_AT.desc())
.fetch(USER.NAME)
// DTO로 직접 매핑
fun findSummaries(): List<UserSummary> =
dsl.select(USER.ID, USER.NAME, USER.EMAIL)
.from(USER)
.fetchInto(UserSummary::class.java)
}
동적 쿼리
fun search(name: String?, minAge: Int?, status: String?): List<UserRecord> {
val conditions = mutableListOf<Condition>()
name?.let { conditions.add(USER.NAME.likeIgnoreCase("%$it%")) }
minAge?.let { conditions.add(USER.AGE.ge(it)) }
status?.let { conditions.add(USER.STATUS.eq(it)) }
return dsl.selectFrom(USER)
.where(conditions) // 빈 리스트면 WHERE 없음
.orderBy(USER.CREATED_AT.desc())
.fetch()
}
JOIN
fun findUsersWithOrders(): Map<UserRecord, List<OrderRecord>> =
dsl.select()
.from(USER)
.leftJoin(ORDER).on(ORDER.USER_ID.eq(USER.ID))
.where(USER.STATUS.eq("ACTIVE"))
.fetchGroups(USER, ORDER) // user를 키로 orders를 리스트로 그룹핑
// 결과를 DTO로 변환
fun findUsersWithOrderDto(): List<UserWithOrdersDto> =
dsl.select(
USER.ID, USER.NAME,
DSL.multiset(
dsl.select(ORDER.ID, ORDER.AMOUNT)
.from(ORDER)
.where(ORDER.USER_ID.eq(USER.ID))
).`as`("orders").convertFrom { it.into(OrderDto::class.java) }
)
.from(USER)
.fetchInto(UserWithOrdersDto::class.java)
페이징
fun findWithPaging(page: Int, size: Int): List<UserRecord> =
dsl.selectFrom(USER)
.orderBy(USER.CREATED_AT.desc())
.limit(size)
.offset(page * size)
.fetch()
// JOOQ가 DB dialect에 맞게 LIMIT/OFFSET SQL을 생성
INSERT / UPDATE / DELETE
// INSERT
fun create(name: String, email: String): UserRecord =
dsl.insertInto(USER)
.set(USER.NAME, name)
.set(USER.EMAIL, email)
.returning() // PostgreSQL의 RETURNING 절 활용
.fetchOne()!!
// INSERT OR UPDATE (upsert)
dsl.insertInto(USER)
.set(USER.EMAIL, email)
.set(USER.NAME, name)
.onConflict(USER.EMAIL)
.doUpdate()
.set(USER.NAME, name)
.execute()
// UPDATE
fun updateEmail(id: Long, email: String): Int =
dsl.update(USER)
.set(USER.EMAIL, email)
.where(USER.ID.eq(id))
.execute()
// DELETE
fun delete(id: Long): Int =
dsl.deleteFrom(USER)
.where(USER.ID.eq(id))
.execute()
집계 쿼리
// GROUP BY, 집계 함수
fun countByStatus(): Map<String, Int> =
dsl.select(USER.STATUS, DSL.count())
.from(USER)
.groupBy(USER.STATUS)
.fetchMap(USER.STATUS, DSL.count())
// 윈도우 함수 (PostgreSQL)
fun findWithRank(): List<Record> =
dsl.select(
USER.NAME,
USER.SCORE,
DSL.rank().over(DSL.orderBy(USER.SCORE.desc())).`as`("rank")
)
.from(USER)
.fetch()
트랜잭션
JOOQ는 Spring의 @Transactional과 통합된다.
@Transactional
fun transferOrder(fromUserId: Long, toUserId: Long, orderId: Long) {
// Spring 트랜잭션 안에서 JOOQ 실행
dsl.update(ORDER)
.set(ORDER.USER_ID, toUserId)
.where(ORDER.ID.eq(orderId).and(ORDER.USER_ID.eq(fromUserId)))
.execute()
}
또는 JOOQ 자체 트랜잭션 API:
dsl.transaction { config ->
val ctx = DSL.using(config)
ctx.insertInto(USER)...
ctx.update(ORDER)...
}
라이선스 주의사항
JOOQ는 DB에 따라 라이선스가 다르다.
| DB | 라이선스 | 비용 |
|---|---|---|
| H2, SQLite, Derby | Apache 2.0 | 무료 |
| PostgreSQL, MySQL, MariaDB | Apache 2.0 | 무료 |
| Oracle, SQL Server, DB2 | 상용 | 유료 |
PostgreSQL이나 MySQL이라면 오픈소스 버전으로 충분하다. Oracle을 쓴다면 유료 라이선스가 필요하다.
장단점
장점
- DB 스키마 기반 타입 안전성: 실제 DB와 일치하는 타입 체크
- 높은 SQL 표현력: 윈도우 함수, CTE, Upsert 등 고급 SQL 지원
- JPA 불필요: ORM 없이 독립 사용 가능
- Dialect 추상화: DB별 SQL 문법 차이를 추상화
- 안정적 유지보수: QueryDSL보다 꾸준히 업데이트됨
단점
- 코드 생성 의존: 빌드 시 DB 연결 필요 (CI/CD 설정 고려 필요)
- 스키마 변경 시 재생성: DB 변경 → 코드 재생성 → 코드 수정 사이클
- Oracle 등 상용 DB: 유료 라이선스
- 학습 곡선: 풍부한 API로 처음 익히는 데 시간이 걸림
언제 선택하는가?
- JPA 없이 SQL을 직접 제어하면서 타입 안전성도 원하는 경우
- PostgreSQL/MySQL 기반 프로젝트
- 복잡한 집계/분석 쿼리가 많은 서비스
- QueryDSL의 유지보수 불확실성이 불안한 경우
정리
| 항목 | 내용 |
|---|---|
| 코드 생성 기반 | 실제 DB 스키마 → 타입 세이프 코드 |
| 장점 | SQL 표현력, 타입 안전, JPA 불필요, 안정적 유지보수 |
| 단점 | 코드 생성 의존, Oracle 유료 |
| 적합한 경우 | SQL 직접 제어 + 타입 안전, PostgreSQL/MySQL |