jOOQ - 타입 안전한 SQL 작성
Java 데이터 접근 기술의 흐름
Java/Kotlin 진영에서 데이터베이스를 다루는 기술은 꾸준히 진화해왔다.
JDBC → 순수 SQL + 수동 매핑. 반복 코드가 많고 실수하기 쉽다.
val sql = "SELECT id, name, email FROM member WHERE id = ?"
val ps = conn.prepareStatement(sql)
ps.setLong(1, memberId)
val rs = ps.executeQuery()
if (rs.next()) {
val member = Member(
id = rs.getLong("id"),
name = rs.getString("name"),
email = rs.getString("email") // 컬럼명 오타? 런타임에야 알 수 있다
)
}
MyBatis → XML에 SQL을 분리. JDBC 반복 코드는 줄었지만 SQL은 여전히 문자열이다.
JPA → 객체 중심 개발. SQL을 직접 작성하지 않아도 되지만, 복잡한 쿼리에서 한계가 드러난다.
jOOQ → 다시 SQL로. 하지만 이번에는 타입 안전한 코드로 SQL을 작성한다.
이 흐름의 핵심은 단순하다. SQL을 포기하는 것이 아니라, SQL을 더 안전하게 작성하는 방향으로 진화해왔다.
Spring Data와 독립 라이브러리
이 기술들이 어디에 속하는지 혼동하기 쉽다. 정리하면:
Spring Data (Spring 공식 상위 프로젝트)
| 모듈 | 최초 릴리즈 | 설명 |
|---|---|---|
| Spring Data JPA | 2011 | JPA 기반 리포지토리 추상화 |
| Spring Data MongoDB | 2011 | MongoDB 문서 DB 접근 |
| Spring Data Neo4j | 2011 | Neo4j 그래프 DB 접근 |
| Spring Data Redis | 2012 | Redis 인메모리 저장소 접근 |
| Spring Data Elasticsearch | 2014 | Elasticsearch 검색 엔진 접근 |
| Spring Data Cassandra | 2014 | Cassandra 분산 DB 접근 |
| Spring Data JDBC | 2018 | 순수 JDBC 기반 (ORM 없이 심플하게) |
| Spring Data R2DBC | 2020 | 리액티브 논블로킹 DB 접근 |
2011년 JPA, MongoDB, Neo4j로 시작해서 다양한 데이터 저장소로 확장되어 왔다. 핵심 철학은 동일하다. Repository 인터페이스 하나로 저장소 종류에 상관없이 일관된 프로그래밍 모델을 제공하는 것이다.
독립 라이브러리 (Spring과 무관)
| 라이브러리 | 최초 릴리즈 | 출처 | 설명 |
|---|---|---|---|
| iBatis | 2002 | Clinton Begin → Apache | SQL Mapper의 원조. 2010년 MyBatis로 계승 |
| QueryDSL | 2007 | Mysema Ltd | JPA(2006~) 위에 만든 타입 안전 쿼리 빌더. Spring Data JPA보다 먼저 등장 |
| jOOQ | 2009 | Data Geekery 사 | SQL을 타입 안전한 DSL로 작성 |
| MyBatis | 2010 | iBatis에서 포크 | SQL Mapper. 국내 SI/금융권 사실상 표준 |
jOOQ와 QueryDSL 모두 Spring Boot가 통합 지원(spring-boot-starter-jooq 등)을 해주기 때문에 Spring 생태계 안에 있는 것처럼 느껴지지만, 출처가 다른 독립 라이브러리다. spring-boot-starter-jooq는 DSLContext 자동 설정(auto-configuration)을 제공해줄 뿐이다.
MyBatis vs JPA vs jOOQ
철학의 차이
| 구분 | MyBatis | JPA | jOOQ |
|---|---|---|---|
| 패러다임 | SQL Mapper | ORM (Object-Relational Mapping) | SQL Builder (DSL) |
| 핵심 철학 | “SQL을 직접 작성하되 매핑을 자동화” | “객체 중심으로 사고하고 SQL은 추상화” | “SQL을 코드로 타입 안전하게 작성” |
| SQL 제어권 | 개발자가 100% 제어 | 프레임워크가 생성 (JPQL/Criteria로 제어 가능) | 개발자가 100% 제어 |
| 쿼리 검증 시점 | 런타임 | 런타임 (Criteria는 일부 컴파일 타임) | 컴파일 타임 |
장단점 비교
MyBatis
장점
- 학습 곡선이 낮다. SQL을 아는 개발자라면 바로 사용 가능
- SQL을 완전히 제어할 수 있다
- 복잡한 쿼리, 레거시 DB에 강하다
- 국내 SI/금융권에서 사실상 표준
단점
- SQL이 XML 문자열이라 오타를 컴파일 타임에 잡을 수 없다
- 테이블 컬럼 변경 시 관련 XML을 모두 찾아 수정해야 한다
- 동적 쿼리를
<if>,<choose>,<foreach>태그로 작성해야 한다 - 객체 그래프 탐색이 불가능하다
JPA
장점
- 객체 중심 개발. 패러다임 불일치를 프레임워크가 해결
- 기본 CRUD를 자동 생성 (Spring Data JPA)
- 변경 감지(Dirty Checking)로 UPDATE를 자동 처리
- 1차 캐시, 지연 로딩 등 성능 최적화 기능 내장
- DB 방언 추상화로 DB 독립적 개발 가능
단점
- 학습 곡선이 가파르다 (영속성 컨텍스트, 프록시, N+1 등)
- 복잡한 쿼리에서 JPQL/Criteria API의 한계
- 생성되는 SQL을 예측하기 어렵다
- 통계, 집계, 리포팅 쿼리에 부적합
- 대량 데이터 처리(Bulk Insert/Update)에서 성능 이슈
jOOQ
장점
- SQL을 코드로 작성하므로 컴파일 타임에 문법 오류를 검증
- DB 스키마에서 코드를 생성하므로 컬럼명, 타입이 항상 DB와 동기화
- IDE 자동완성으로 생산성이 높다
- 복잡한 쿼리(Window Function, CTE, Recursive Query)를 자연스럽게 작성
- 동적 쿼리를 순수 코드로 조립
- 생성되는 SQL을 정확히 예측할 수 있다
단점
- 코드 생성기 설정이 필요하다 (빌드 파이프라인에 추가 단계)
- 상용 DB(Oracle, SQL Server)는 유료 라이선스
- 객체 그래프 탐색, 변경 감지 등 ORM 기능이 없다
- 국내 레퍼런스가 상대적으로 적다
- 단순 CRUD에서는 JPA 대비 코드량이 많다
어떤 상황에 무엇을 선택할까
| 상황 | 추천 |
|---|---|
| 단순 CRUD 위주 + 객체 중심 설계 | JPA |
| 복잡한 조회 쿼리 + 통계/리포팅 | jOOQ |
| 레거시 DB + 기존 SQL 자산 활용 | MyBatis |
| 타입 안전성 + SQL 제어권 둘 다 필요 | jOOQ |
| JPA로 CRUD + 복잡한 조회만 분리 | JPA + jOOQ 조합 |
| SI/금융 프로젝트 (팀 역량 고려) | MyBatis |
jOOQ란
jOOQ(Java Object Oriented Querying)는 SQL을 DSL로 작성하는 라이브러리다.
핵심 철학은 명확하다.
“SQL is a first-class citizen in jOOQ.”
jOOQ는 ORM이 아니다. 이 한 문장에 많은 것이 함축되어 있다.
ORM(JPA)이 해주는 것들을 jOOQ는 의도적으로 하지 않는다:
| ORM이 해주는 것 | jOOQ의 입장 |
|---|---|
| 영속성 컨텍스트 (1차 캐시) | 없다. 매번 SQL이 실행된다 |
| 변경 감지 (Dirty Checking) | 없다. UPDATE를 직접 작성한다 |
| 지연 로딩 (Lazy Loading) | 없다. 필요한 데이터를 JOIN으로 직접 가져온다 |
객체 그래프 탐색 (member.team.name) |
없다. SQL 결과가 곧 데이터의 전부다 |
| 엔티티 생명주기 관리 (persist, merge, detach) | 없다. 객체 상태를 추적하지 않는다 |
자동 DDL 생성 (ddl-auto) |
없다. 스키마는 개발자가 관리한다 |
이것은 단점이 아니라 설계 철학이다. ORM은 SQL을 추상화해서 객체 중심으로 사고하게 해주지만, 그 대가로 예측하기 어려운 쿼리, N+1 문제, 프록시 동작 등의 복잡성을 안게 된다. jOOQ는 반대 방향을 택했다. SQL을 숨기지 않고 그대로 존중하되, 문자열이 아닌 타입 안전한 코드로 작성할 수 있게 해준다.
// SQL
// SELECT id, name FROM member WHERE age > 20 ORDER BY name
// jOOQ
dsl.select(MEMBER.ID, MEMBER.NAME)
.from(MEMBER)
.where(MEMBER.AGE.gt(20))
.orderBy(MEMBER.NAME)
.fetch()
SQL을 아는 개발자라면 jOOQ 코드를 바로 읽을 수 있다. jOOQ의 DSL은 SQL 문법과 1:1로 대응되도록 설계되었기 때문이다.
동작 원리
DB 스키마 (테이블, 컬럼, 타입)
↓ 코드 생성기 (jooq-codegen)
Java/Kotlin 클래스 (Tables, Records, Keys)
↓ DSLContext
타입 안전한 SQL 작성 → SQL 문자열 생성 → JDBC 실행
- 코드 생성기가 DB 스키마를 읽어 클래스를 만든다
- 개발자는 생성된 클래스를 사용해 DSL로 쿼리를 작성한다
- jOOQ가 DSL을 SQL 문자열로 변환해 JDBC를 통해 실행한다
코드 생성 (Code Generation)
jOOQ의 타입 안전성은 코드 생성에서 시작된다. DB 스키마를 스캔해 테이블, 컬럼, 관계를 클래스로 만들어낸다.
코드 생성 방식
jOOQ 코드 생성기는 두 가지 방식으로 스키마를 읽을 수 있다.
1. 실제 DB 접속 방식
운영/개발 DB에 직접 접속해서 스키마를 스캔한다.
jooq {
configurations {
main {
jdbc {
url = "jdbc:postgresql://localhost:5432/mydb"
user = "username"
password = "password"
}
generator {
database {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
}
}
}
}
}
실제 DB와 항상 동기화되지만, 빌드 시 DB 접속이 필요하다는 단점이 있다.
2. DDL 파일 기반 방식
실제 DB 접속 없이 SQL 파일만으로 코드를 생성한다.
jooq {
configurations {
main {
generator {
database {
name = "org.jooq.meta.extensions.ddl.DDLDatabase"
properties {
property {
key = "scripts"
value = "src/main/resources/db/schema.sql"
}
}
}
}
}
}
}
-- src/main/resources/db/schema.sql
CREATE TABLE "member" (
"id" BIGINT AUTO_INCREMENT PRIMARY KEY,
"name" VARCHAR(100) NOT NULL,
"email" VARCHAR(255),
"age" INT
);
CREATE TABLE "orders" (
"id" BIGINT AUTO_INCREMENT PRIMARY KEY,
"member_id" BIGINT NOT NULL,
"amount" DECIMAL(10, 2),
"order_date" DATE,
FOREIGN KEY ("member_id") REFERENCES "member" ("id")
);
내부적으로 DDL 파일을 파싱해서 스키마를 스캔하는 방식이다. PostgreSQL 등 주요 DB의 DDL 문법을 지원하므로 DB별 고유 타입이나 문법도 문제없이 처리된다. DB 접속이 필요 없으므로 CI/CD 환경에서 유리하고, DDL 파일 자체가 스키마 문서 역할도 한다.
| 구분 | 실제 DB 접속 | DDL 파일 기반 |
|---|---|---|
| DB 접속 필요 | O | X |
| CI/CD 친화적 | 별도 DB 필요 | 파일만 있으면 됨 |
| 스키마 정확도 | 운영 DB와 동일 보장 | DDL 파일 관리 필요 |
| 적합한 상황 | 레거시 DB, 복잡한 스키마 | 신규 프로젝트, 스키마 버전 관리 |
생성되는 클래스 구조
generated/
├── tables/
│ ├── Member.kt // 테이블 메타데이터
│ ├── Order.kt
│ └── records/
│ ├── MemberRecord.kt // 행(Row) 표현
│ └── OrderRecord.kt
├── Keys.kt // PK, FK, Unique 제약조건
└── Indexes.kt // 인덱스 정보
테이블 클래스
// 자동 생성된 MEMBER 테이블 클래스 (Java로 생성됨)
public class Member extends TableImpl<MemberRecord> {
public static final Member MEMBER = new Member();
public final TableField<MemberRecord, Long> ID = createField("id", BIGINT);
public final TableField<MemberRecord, String> NAME = createField("name", VARCHAR(100));
public final TableField<MemberRecord, String> EMAIL = createField("email", VARCHAR(255));
public final TableField<MemberRecord, Integer> AGE = createField("age", INTEGER);
}
코드 생성기가 만드는 클래스는 Java로 생성되지만, Kotlin에서 그대로 사용할 수 있다.
이렇게 생성된 클래스 덕분에:
MEMBER.NAME은VARCHAR(100)타입 →String으로 매핑MEMBER.AGE는INTEGER타입 →Int로 매핑- 존재하지 않는 컬럼을 참조하면 컴파일 에러가 발생한다
스키마 변경 시 흐름
DB에서 member 테이블에 phone 컬럼 추가
↓ 코드 생성기 재실행
Member 클래스에 PHONE 필드 자동 추가
↓ 컴파일
PHONE을 사용하지 않는 기존 코드 → 정상
PHONE이 필요한 새 코드 → IDE 자동완성으로 바로 사용 가능
MyBatis라면? XML 파일을 하나씩 찾아 수동으로 수정해야 한다. 누락하면 런타임 에러다.
기본 CRUD
모든 예제에서 dsl은 DSLContext 인스턴스다.
SELECT
-- SQL
SELECT id, name, email
FROM member
WHERE id = 1;
// jOOQ
val member: MemberRecord? = dsl.selectFrom(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOne()
// 특정 컬럼만 조회
val result: Record2<Long, String>? = dsl.select(MEMBER.ID, MEMBER.NAME)
.from(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOne()
INSERT
-- SQL
INSERT INTO member (
name, email, age
)
VALUES (
'홍길동', 'hong@email.com', 30
);
// jOOQ
dsl.insertInto(MEMBER)
.set(MEMBER.NAME, "홍길동")
.set(MEMBER.EMAIL, "hong@email.com")
.set(MEMBER.AGE, 30)
.execute()
// Record 방식
val record = dsl.newRecord(MEMBER).apply {
name = "홍길동"
email = "hong@email.com"
age = 30
}
record.store()
UPDATE
-- SQL
UPDATE member
SET name = '김철수',
age = 25
WHERE id = 1;
// jOOQ
dsl.update(MEMBER)
.set(MEMBER.NAME, "김철수")
.set(MEMBER.AGE, 25)
.where(MEMBER.ID.eq(1L))
.execute()
DELETE
-- SQL
DELETE
FROM "member"
WHERE "id" = 1;
// jOOQ
dsl.deleteFrom(MEMBER)
.where(MEMBER.ID.eq(1L))
.execute()
결과 가져오기 (fetch)
// 단건 조회 (없으면 null)
val one: MemberRecord? = dsl.selectFrom(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOne()
// 단건 조회 (없으면 예외)
val single: MemberRecord = dsl.selectFrom(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchSingle()
// 다건 조회
val list: Result<MemberRecord> = dsl.selectFrom(MEMBER)
.fetch()
// Optional
val optional: Optional<MemberRecord> = dsl.selectFrom(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOptional()
// 단일 값
val name: String? = dsl.select(MEMBER.NAME)
.from(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOneInto(String::class.java)
// 컬럼 2개 조회 → Record2
val record: Record2<String, String>? = dsl.select(MEMBER.NAME, MEMBER.EMAIL)
.from(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOne()
val memberName = record?.value1() // 또는 record?.get(MEMBER.NAME)
val memberEmail = record?.value2() // 또는 record?.get(MEMBER.EMAIL)
select 컬럼 수에 따라 Record1 ~ Record22까지 타입이 자동 결정된다. 23개 이상이면 제네릭이 없는 Record로 반환되지만, r.get(MEMBER.NAME)처럼 필드 참조로 꺼내면 타입 안전성은 유지된다. 컬럼이 많은 경우 fetchInto(Dto::class.java)로 DTO에 바로 매핑하는 편이 낫다.
조건 조립
비교 연산자
MEMBER.AGE.eq(30) // age = 30
MEMBER.AGE.ne(30) // age != 30
MEMBER.AGE.gt(30) // age > 30
MEMBER.AGE.ge(30) // age >= 30
MEMBER.AGE.lt(30) // age < 30
MEMBER.AGE.le(30) // age <= 30
IN / BETWEEN / LIKE
MEMBER.AGE.`in`(20, 25, 30) // age IN (20, 25, 30)
MEMBER.AGE.notIn(20, 25, 30) // age NOT IN (20, 25, 30)
MEMBER.AGE.between(20, 30) // age BETWEEN 20 AND 30
MEMBER.NAME.like("김%") // name LIKE '김%'
MEMBER.NAME.notLike("김%") // name NOT LIKE '김%'
MEMBER.NAME.contains("길동") // name LIKE '%길동%'
MEMBER.NAME.startsWith("김") // name LIKE '김%'
MEMBER.NAME.endsWith("동") // name LIKE '%동'
NULL 체크
MEMBER.EMAIL.isNull // email IS NULL
MEMBER.EMAIL.isNotNull // email IS NOT NULL
AND / OR 조합
// AND
dsl.selectFrom(MEMBER)
.where(MEMBER.AGE.gt(20))
.and(MEMBER.NAME.like("김%"))
.fetch()
// OR
dsl.selectFrom(MEMBER)
.where(MEMBER.AGE.gt(30))
.or(MEMBER.NAME.eq("홍길동"))
.fetch()
// 복합 조건: (age > 20 AND name LIKE '김%') OR email IS NOT NULL
dsl.selectFrom(MEMBER)
.where(
MEMBER.AGE.gt(20).and(MEMBER.NAME.like("김%"))
)
.or(MEMBER.EMAIL.isNotNull)
.fetch()
정렬
dsl.selectFrom(MEMBER)
.orderBy(MEMBER.AGE.desc(), MEMBER.NAME.asc())
.fetch()
// NULL 정렬 제어
dsl.selectFrom(MEMBER)
.orderBy(MEMBER.AGE.desc().nullsLast())
.fetch()
JOIN
INNER JOIN
-- SQL
SELECT m.name, o.order_date, o.amount
FROM member m
INNER JOIN orders o ON m.id = o.member_id
WHERE o.amount > 10000;
// jOOQ
dsl.select(MEMBER.NAME, ORDERS.ORDER_DATE, ORDERS.AMOUNT)
.from(MEMBER)
.innerJoin(ORDERS).on(MEMBER.ID.eq(ORDERS.MEMBER_ID))
.where(ORDERS.AMOUNT.gt(10000))
.fetch()
LEFT JOIN
// 주문이 없는 회원도 포함
dsl.select(MEMBER.NAME, ORDERS.ORDER_DATE)
.from(MEMBER)
.leftJoin(ORDERS).on(MEMBER.ID.eq(ORDERS.MEMBER_ID))
.fetch()
다중 테이블 JOIN
-- SQL
SELECT "m"."name", "o"."order_date", "p"."product_name"
FROM "member" "m"
INNER JOIN "orders" "o" ON "m"."id" = "o"."member_id"
INNER JOIN "order_item" "oi" ON "o"."id" = "oi"."order_id"
INNER JOIN "product" "p" ON "oi"."product_id" = "p"."id";
// jOOQ
dsl.select(MEMBER.NAME, ORDERS.ORDER_DATE, PRODUCT.PRODUCT_NAME)
.from(MEMBER)
.innerJoin(ORDERS).on(MEMBER.ID.eq(ORDERS.MEMBER_ID))
.innerJoin(ORDER_ITEM).on(ORDERS.ID.eq(ORDER_ITEM.ORDER_ID))
.innerJoin(PRODUCT).on(ORDER_ITEM.PRODUCT_ID.eq(PRODUCT.ID))
.fetch()
CROSS JOIN
dsl.select(MEMBER.NAME, PRODUCT.PRODUCT_NAME)
.from(MEMBER)
.crossJoin(PRODUCT)
.fetch()
Self JOIN
// 같은 나이대의 다른 회원 찾기
val m1 = MEMBER.`as`("m1")
val m2 = MEMBER.`as`("m2")
dsl.select(m1.NAME, m2.NAME)
.from(m1)
.innerJoin(m2).on(m1.AGE.eq(m2.AGE).and(m1.ID.ne(m2.ID)))
.fetch()
서브쿼리
WHERE절 서브쿼리 (IN)
-- SQL
SELECT *
FROM "member"
WHERE "id" IN (
SELECT "member_id"
FROM "orders"
WHERE "amount" > 50000
);
// jOOQ
dsl.selectFrom(MEMBER)
.where(
MEMBER.ID.`in`(
select(ORDERS.MEMBER_ID)
.from(ORDERS)
.where(ORDERS.AMOUNT.gt(50000))
)
)
.fetch()
WHERE절 서브쿼리 (EXISTS)
-- SQL
SELECT *
FROM "member" "m"
WHERE EXISTS (
SELECT 1
FROM "orders" "o"
WHERE "o"."member_id" = "m"."id"
);
// jOOQ
dsl.selectFrom(MEMBER)
.whereExists(
selectOne()
.from(ORDERS)
.where(ORDERS.MEMBER_ID.eq(MEMBER.ID))
)
.fetch()
스칼라 서브쿼리 (SELECT절)
-- SQL
SELECT
"name",
(
SELECT COUNT(*)
FROM "orders" "o"
WHERE "o"."member_id" = "m"."id"
) AS "order_count"
FROM "member" "m";
// jOOQ
val orderCount: Field<Int> = select(count())
.from(ORDERS)
.where(ORDERS.MEMBER_ID.eq(MEMBER.ID))
.asField("order_count")
dsl.select(MEMBER.NAME, orderCount)
.from(MEMBER)
.fetch()
FROM절 서브쿼리 (인라인 뷰)
// 나이대별 회원 수를 구한 후, 10명 이상인 나이대만 조회
val ageStats = dsl.select(
MEMBER.AGE,
count().`as`("cnt")
)
.from(MEMBER)
.groupBy(MEMBER.AGE)
.asTable("age_stats")
dsl.select()
.from(ageStats)
.where(ageStats.field("cnt", Int::class.java)!!.ge(10))
.fetch()
집계와 그룹핑
GROUP BY / HAVING
-- SQL
SELECT "age", COUNT(*) AS "cnt", AVG("age") AS "avg_age"
FROM "member"
GROUP BY
"age"
HAVING COUNT(*) > 5;
// jOOQ
dsl.select(MEMBER.AGE, count().`as`("cnt"), avg(MEMBER.AGE).`as`("avg_age"))
.from(MEMBER)
.groupBy(MEMBER.AGE)
.having(count().gt(5))
.fetch()
집계 함수
count() // COUNT(*)
count(MEMBER.ID) // COUNT(id)
countDistinct(MEMBER.AGE) // COUNT(DISTINCT age)
sum(ORDERS.AMOUNT) // SUM(amount)
avg(MEMBER.AGE) // AVG(age)
max(ORDERS.AMOUNT) // MAX(amount)
min(ORDERS.AMOUNT) // MIN(amount)
Window Function
-- SQL
SELECT
"name", "age",
ROW_NUMBER() OVER (ORDER BY "age" DESC) AS "rn",
RANK() OVER (ORDER BY "age" DESC) AS "rnk",
DENSE_RANK() OVER (ORDER BY "age" DESC) AS "dense_rnk"
FROM "member";
// jOOQ
dsl.select(
MEMBER.NAME,
MEMBER.AGE,
rowNumber().over(orderBy(MEMBER.AGE.desc())).`as`("rn"),
rank().over(orderBy(MEMBER.AGE.desc())).`as`("rnk"),
denseRank().over(orderBy(MEMBER.AGE.desc())).`as`("dense_rnk")
)
.from(MEMBER)
.fetch()
PARTITION BY
-- SQL
SELECT
"name", "department",
SUM("salary") OVER (PARTITION BY "department") AS "dept_total",
"salary" * 100.0 / SUM("salary") OVER (PARTITION BY "department") AS "pct"
FROM "employee";
// jOOQ
val w = name("w")
.`as`(partitionBy(EMPLOYEE.DEPARTMENT))
dsl.select(
EMPLOYEE.NAME,
EMPLOYEE.DEPARTMENT,
sum(EMPLOYEE.SALARY).over(w).`as`("dept_total"),
EMPLOYEE.SALARY.mul(100.0)
.div(sum(EMPLOYEE.SALARY).over(w)).`as`("pct")
)
.from(EMPLOYEE)
.window(w)
.fetch()
동적 쿼리
jOOQ의 가장 강력한 기능 중 하나. MyBatis의 <if>, <choose> 태그를 순수 코드로 대체한다.
MyBatis 동적 쿼리
<!-- MyBatis -->
<select id="searchMember" resultType="Member">
SELECT * FROM member
<where>
<if test="name != null">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
<if test="maxAge != null">
AND age<= #{maxAge}
</if>
<if test="email != null">
AND email = #{email}
</if>
</where>
ORDER BY id DESC
</select>
jOOQ 동적 쿼리
// jOOQ - 같은 로직을 순수 Kotlin으로
fun searchMember(
name: String? = null,
minAge: Int? = null,
maxAge: Int? = null,
email: String? = null
): List<MemberRecord> {
var condition = DSL.noCondition() // 항상 true
name?.let { condition = condition.and(MEMBER.NAME.contains(it)) }
minAge?.let { condition = condition.and(MEMBER.AGE.ge(it)) }
maxAge?.let { condition = condition.and(MEMBER.AGE.le(it)) }
email?.let { condition = condition.and(MEMBER.EMAIL.eq(it)) }
return dsl.selectFrom(MEMBER)
.where(condition)
.orderBy(MEMBER.ID.desc())
.fetchInto(MemberRecord::class.java)
}
장점이 명확하다:
- 컴파일 타임 검증: 컬럼명 오타 → 컴파일 에러
- 리팩토링 안전: 컬럼명 변경 시 IDE에서 일괄 변경
- 디버깅 용이: 브레이크포인트를 걸어 조건 조립 과정을 추적 가능
- 테스트 용이: 순수 함수이므로 단위 테스트 가능
동적 SELECT 컬럼
fun selectMembers(includeEmail: Boolean): Result<*> {
val fields = mutableListOf<SelectFieldOrAsterisk>(MEMBER.ID, MEMBER.NAME)
if (includeEmail) {
fields.add(MEMBER.EMAIL)
}
return dsl.select(fields)
.from(MEMBER)
.fetch()
}
동적 ORDER BY
fun findAll(sortField: String, ascending: Boolean): Result<MemberRecord> {
val sort = when (sortField) {
"name" -> if (ascending) MEMBER.NAME.asc() else MEMBER.NAME.desc()
"age" -> if (ascending) MEMBER.AGE.asc() else MEMBER.AGE.desc()
else -> MEMBER.ID.desc()
}
return dsl.selectFrom(MEMBER)
.orderBy(sort)
.fetch()
}
CTE와 고급 문법
CTE (Common Table Expression)
-- SQL
WITH "high_value_orders" AS (
SELECT "member_id", SUM("amount") AS "total"
FROM "orders"
GROUP BY
"member_id"
HAVING SUM("amount") > 100000
)
SELECT "m"."name", "h"."total"
FROM "member" "m"
INNER JOIN "high_value_orders" "h" ON "m"."id" = "h"."member_id";
// jOOQ
val highValueOrders = name("high_value_orders").fields("member_id", "total")
.`as`(
select(ORDERS.MEMBER_ID, sum(ORDERS.AMOUNT))
.from(ORDERS)
.groupBy(ORDERS.MEMBER_ID)
.having(sum(ORDERS.AMOUNT).gt(100000))
)
dsl.with(highValueOrders)
.select(MEMBER.NAME, highValueOrders.field("total"))
.from(MEMBER)
.innerJoin(highValueOrders)
.on(MEMBER.ID.eq(highValueOrders.field("member_id", Long::class.java)))
.fetch()
UNION / UNION ALL
dsl.select(MEMBER.NAME, MEMBER.EMAIL)
.from(MEMBER)
.where(MEMBER.AGE.gt(30))
.union(
select(ADMIN.NAME, ADMIN.EMAIL)
.from(ADMIN)
)
.fetch()
// UNION ALL (중복 허용)
dsl.select(MEMBER.NAME)
.from(MEMBER)
.unionAll(
select(ADMIN.NAME)
.from(ADMIN)
)
.fetch()
CASE WHEN
dsl.select(
MEMBER.NAME,
case_()
.`when`(MEMBER.AGE.lt(20), "미성년자")
.`when`(MEMBER.AGE.lt(30), "20대")
.`when`(MEMBER.AGE.lt(40), "30대")
.otherwise("40대 이상")
.`as`("age_group")
)
.from(MEMBER)
.fetch()
페이징
// LIMIT / OFFSET
dsl.selectFrom(MEMBER)
.orderBy(MEMBER.ID.desc())
.limit(10)
.offset(20)
.fetch()
// Cursor 기반 페이징 (대용량에 적합)
dsl.selectFrom(MEMBER)
.where(MEMBER.ID.lt(lastId))
.orderBy(MEMBER.ID.desc())
.limit(10)
.fetch()
CAST / 타입 변환
// 문자열로 변환
dsl.select(MEMBER.AGE.cast(VARCHAR(10)))
.from(MEMBER)
.fetch()
// 날짜 함수
dsl.select(
ORDERS.ORDER_DATE,
year(ORDERS.ORDER_DATE).`as`("year"),
month(ORDERS.ORDER_DATE).`as`("month")
)
.from(ORDERS)
.fetch()
Record에서 DTO 변환
jOOQ는 ORM이 아니므로 엔티티 자동 매핑이 없다. 대신 다양한 DTO 변환 방법을 제공한다.
fetchInto() - 가장 간단한 방법
data class MemberDto(
val id: Long,
val name: String,
val email: String?
)
val members: List<MemberDto> = dsl.select(MEMBER.ID, MEMBER.NAME, MEMBER.EMAIL)
.from(MEMBER)
.fetchInto(MemberDto::class.java)
수동 매핑
val members: List<MemberDto> = dsl.select(MEMBER.ID, MEMBER.NAME, MEMBER.EMAIL)
.from(MEMBER)
.fetch { r ->
MemberDto(
id = r.get(MEMBER.ID)!!,
name = r.get(MEMBER.NAME)!!,
email = r.get(MEMBER.EMAIL)
)
}
into() - 단건 변환
val dto: MemberDto = dsl.selectFrom(MEMBER)
.where(MEMBER.ID.eq(1L))
.fetchOne()!!
.into(MemberDto::class.java)
RecordMapper - 재사용 가능한 매퍼
val mapper = RecordMapper<Record, MemberDto> { r ->
MemberDto(
id = r.get(MEMBER.ID)!!,
name = r.get(MEMBER.NAME)!!,
email = r.get(MEMBER.EMAIL)
)
}
// 여러 곳에서 재사용
val list1: List<MemberDto> = dsl.selectFrom(MEMBER).fetch(mapper)
val list2: List<MemberDto> = dsl.selectFrom(MEMBER)
.where(MEMBER.AGE.gt(20))
.fetch(mapper)
복합 DTO (JOIN 결과 매핑)
data class OrderWithMember(
val memberName: String,
val orderDate: LocalDate,
val amount: BigDecimal
)
val result: List<OrderWithMember> = dsl
.select(MEMBER.NAME, ORDERS.ORDER_DATE, ORDERS.AMOUNT)
.from(ORDERS)
.innerJoin(MEMBER).on(ORDERS.MEMBER_ID.eq(MEMBER.ID))
.fetch { r ->
OrderWithMember(
memberName = r.get(MEMBER.NAME)!!,
orderDate = r.get(ORDERS.ORDER_DATE)!!,
amount = r.get(ORDERS.AMOUNT)!!
)
}
Spring Boot 통합
DSLContext 주입
Spring Boot에서 spring-boot-starter-jooq를 사용하면 DSLContext가 자동으로 빈에 등록된다.
@Repository
class MemberRepository(
private val dsl: DSLContext
) {
fun findByAge(minAge: Int): List<MemberDto> =
dsl.selectFrom(MEMBER)
.where(MEMBER.AGE.ge(minAge))
.fetchInto(MemberDto::class.java)
}
트랜잭션 관리
Spring의 @Transactional이 그대로 동작한다.
@Service
class MemberService(
private val dsl: DSLContext
) {
@Transactional
fun transfer(fromId: Long, toId: Long, amount: BigDecimal) {
dsl.update(ACCOUNT)
.set(ACCOUNT.BALANCE, ACCOUNT.BALANCE.sub(amount))
.where(ACCOUNT.MEMBER_ID.eq(fromId))
.execute()
dsl.update(ACCOUNT)
.set(ACCOUNT.BALANCE, ACCOUNT.BALANCE.add(amount))
.where(ACCOUNT.MEMBER_ID.eq(toId))
.execute()
}
}
Batch 처리
// Batch Insert
dsl.batch(
dsl.insertInto(MEMBER, MEMBER.NAME, MEMBER.EMAIL, MEMBER.AGE)
.values(null as String?, null, null)
).bind("홍길동", "hong@email.com", 30)
.bind("김철수", "kim@email.com", 25)
.bind("이영희", "lee@email.com", 28)
.execute()
// Loader API (CSV/JSON 대량 삽입)
dsl.loadInto(MEMBER)
.loadCSV(csvInputStream)
.fields(MEMBER.NAME, MEMBER.EMAIL, MEMBER.AGE)
.execute()
JPA와 함께 사용하기
복잡한 조회는 jOOQ, 단순 CRUD는 JPA로 역할을 분리할 수 있다.
// jOOQ - 복잡한 조회용
@Repository
class MemberQueryRepository(
private val dsl: DSLContext
) {
fun getMemberStats(): List<MemberStatsDto> =
dsl.select(
MEMBER.AGE,
count().`as`("cnt"),
avg(MEMBER.AGE).`as`("avg_age")
)
.from(MEMBER)
.groupBy(MEMBER.AGE)
.having(count().gt(5))
.fetchInto(MemberStatsDto::class.java)
}
// JPA - 단순 CRUD용
interface MemberJpaRepository : JpaRepository<MemberEntity, Long>
Command(CUD)는 JPA, Query(R)는 jOOQ. CQRS 패턴과 자연스럽게 어울린다.
댓글