Spring Boot 데이터 접근 기술(4/6)
Kotlin/Spring

JOOQ - 타입 세이프 SQL

JOOQ가 무엇인지, DB 스키마에서 코드를 생성하는 원리, MyBatis/QueryDSL과의 차이, 라이선스 제약을 정리한다.

2026-03-30
8 min read
#JOOQ#SQL#Spring Boot#Kotlin#타입세이프

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

특징QueryDSLMyBatisJOOQ
코드 기반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, DerbyApache 2.0무료
PostgreSQL, MySQL, MariaDBApache 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

시리즈: Spring Boot 데이터 접근 기술

  1. JPA 심화 - N+1, 지연 로딩, Dirty Checking
  2. QueryDSL - 타입 세이프 동적 쿼리
  3. MyBatis - SQL Mapper
  4. JOOQ - 타입 세이프 SQL ← 현재 글
  5. Kotlin Exposed - Kotlin ORM
  6. 어떤 걸 선택해야 할까?