왜 jOOQ인가?
JPA는 객체 중심 ORM이다. 단순 CRUD에는 강력하지만, 복잡한 조인, 서브쿼리, 윈도우 함수, CTE를 작성할 때는 JPQL/QueryDSL의 한계에 부딪힌다. jOOQ(Java Object Oriented Querying)는 반대 철학을 취한다. SQL을 그대로 쓰되, 타입 안전한 DSL로 감싸서 컴파일 타임에 검증한다.
핵심 차이는 이렇다: JPA는 “객체를 저장하면 SQL이 생성된다”는 추상화인 반면, jOOQ는 “SQL을 Java 코드로 작성한다”는 접근이다. DB 스키마에서 코드를 자동 생성하므로, 테이블명·컬럼명·타입이 모두 컴파일 타임에 검증된다.
의존성 설정
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.3.0"
id("nu.studer.jooq") version "9.0" // jOOQ 코드 생성 플러그인
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
implementation("org.jooq:jooq:3.19.6")
runtimeOnly("com.mysql:mysql-connector-j")
}
// jOOQ 코드 생성 설정
jooq {
version.set("3.19.6")
configurations {
create("main") {
jooqConfiguration.apply {
jdbc.apply {
driver = "com.mysql.cj.jdbc.Driver"
url = "jdbc:mysql://localhost:3306/mydb"
user = "root"
password = "password"
}
generator.apply {
name = "org.jooq.codegen.KotlinGenerator" // 또는 JavaGenerator
database.apply {
name = "org.jooq.meta.mysql.MySQLDatabase"
inputSchema = "mydb"
includes = ".*"
excludes = "flyway_schema_history"
}
target.apply {
packageName = "com.example.jooq.generated"
directory = "src/generated/jooq"
}
generate.apply {
isDeprecated = false
isRecords = true
isPojos = true
isFluentSetters = true
isDaos = true
}
}
}
}
}
}
코드 생성 실행
# DB 스키마 기반 코드 자동 생성
./gradlew generateJooq
# 생성되는 파일:
# src/generated/jooq/
# ├── tables/
# │ ├── Orders.java ← 테이블 메타데이터
# │ ├── Products.java
# │ └── records/
# │ ├── OrdersRecord.java ← 레코드 클래스
# │ └── ProductsRecord.java
# ├── Keys.java ← PK, FK 정보
# └── Indexes.java ← 인덱스 정보
기본 CRUD: DSL 맛보기
import static com.example.jooq.generated.Tables.*;
@Repository
public class OrderRepository {
private final DSLContext dsl;
public OrderRepository(DSLContext dsl) {
this.dsl = dsl; // Spring Boot가 자동 주입
}
// SELECT
public List<OrdersRecord> findByUserId(Long userId) {
return dsl.selectFrom(ORDERS)
.where(ORDERS.USER_ID.eq(userId))
.and(ORDERS.STATUS.ne("CANCELLED"))
.orderBy(ORDERS.CREATED_AT.desc())
.limit(20)
.fetchInto(OrdersRecord.class);
}
// INSERT
public OrdersRecord create(CreateOrderRequest req) {
return dsl.insertInto(ORDERS)
.set(ORDERS.PRODUCT_ID, req.productId())
.set(ORDERS.USER_ID, req.userId())
.set(ORDERS.AMOUNT, req.amount())
.set(ORDERS.STATUS, "PENDING")
.returning() // INSERT 후 생성된 레코드 반환
.fetchOne();
}
// UPDATE
public int updateStatus(Long orderId, String status) {
return dsl.update(ORDERS)
.set(ORDERS.STATUS, status)
.set(ORDERS.UPDATED_AT, LocalDateTime.now())
.where(ORDERS.ID.eq(orderId))
.execute();
}
// DELETE
public int softDelete(Long orderId) {
return dsl.update(ORDERS)
.set(ORDERS.DELETED_AT, LocalDateTime.now())
.where(ORDERS.ID.eq(orderId))
.and(ORDERS.DELETED_AT.isNull())
.execute();
}
}
복잡한 조인: jOOQ의 진가
// 3개 테이블 조인 + 집계
public List<OrderSummaryDto> getOrderSummary(Long userId) {
return dsl.select(
ORDERS.ID,
ORDERS.CREATED_AT,
PRODUCTS.NAME.as("productName"),
PRODUCTS.PRICE,
USERS.EMAIL,
// 서브쿼리: 해당 사용자의 총 주문 수
DSL.field(
dsl.selectCount()
.from(ORDERS)
.where(ORDERS.USER_ID.eq(userId))
).as("totalOrders")
)
.from(ORDERS)
.join(PRODUCTS).on(ORDERS.PRODUCT_ID.eq(PRODUCTS.ID))
.join(USERS).on(ORDERS.USER_ID.eq(USERS.ID))
.where(ORDERS.USER_ID.eq(userId))
.and(ORDERS.DELETED_AT.isNull())
.orderBy(ORDERS.CREATED_AT.desc())
.fetchInto(OrderSummaryDto.class);
}
// DTO — jOOQ가 자동 매핑
public record OrderSummaryDto(
Long id,
LocalDateTime createdAt,
String productName,
BigDecimal price,
String email,
Integer totalOrders
) {}
윈도우 함수: JPA로는 불가능한 쿼리
// 사용자별 주문 순위 + 누적 금액
public List<OrderRankDto> getOrderRanks() {
return dsl.select(
ORDERS.ID,
ORDERS.USER_ID,
ORDERS.AMOUNT,
// ROW_NUMBER: 사용자별 주문 순번
DSL.rowNumber()
.over(DSL.partitionBy(ORDERS.USER_ID)
.orderBy(ORDERS.CREATED_AT))
.as("orderSequence"),
// RANK: 금액 기준 순위
DSL.rank()
.over(DSL.orderBy(ORDERS.AMOUNT.desc()))
.as("amountRank"),
// SUM OVER: 사용자별 누적 금액
DSL.sum(ORDERS.AMOUNT)
.over(DSL.partitionBy(ORDERS.USER_ID)
.orderBy(ORDERS.CREATED_AT)
.rowsBetweenUnboundedPreceding()
.andCurrentRow())
.as("cumulativeAmount"),
// LAG: 이전 주문 금액
DSL.lag(ORDERS.AMOUNT, 1)
.over(DSL.partitionBy(ORDERS.USER_ID)
.orderBy(ORDERS.CREATED_AT))
.as("previousAmount")
)
.from(ORDERS)
.where(ORDERS.STATUS.eq("CONFIRMED"))
.fetchInto(OrderRankDto.class);
}
CTE(Common Table Expression)
// WITH 절을 활용한 계층 쿼리
public List<CategoryTreeDto> getCategoryTree(Long rootId) {
// CTE 정의
var tree = DSL.name("tree").as(
// 앵커: 루트 카테고리
dsl.select(
CATEGORIES.ID,
CATEGORIES.NAME,
CATEGORIES.PARENT_ID,
DSL.val(0).as("depth")
)
.from(CATEGORIES)
.where(CATEGORIES.ID.eq(rootId))
.unionAll(
// 재귀: 자식 카테고리
dsl.select(
CATEGORIES.ID,
CATEGORIES.NAME,
CATEGORIES.PARENT_ID,
DSL.field(DSL.name("tree", "depth"), Integer.class).add(1)
)
.from(CATEGORIES)
.join(DSL.table(DSL.name("tree")))
.on(CATEGORIES.PARENT_ID.eq(
DSL.field(DSL.name("tree", "id"), Long.class)))
)
);
return dsl.withRecursive(tree)
.select()
.from(tree)
.orderBy(DSL.field(DSL.name("depth")))
.fetchInto(CategoryTreeDto.class);
}
동적 쿼리: 조건부 WHERE
// 검색 필터 동적 구성
public List<OrdersRecord> search(OrderSearchFilter filter) {
// 조건을 동적으로 빌드
var conditions = DSL.noCondition(); // 빈 조건 시작
if (filter.userId() != null) {
conditions = conditions.and(ORDERS.USER_ID.eq(filter.userId()));
}
if (filter.status() != null) {
conditions = conditions.and(ORDERS.STATUS.eq(filter.status()));
}
if (filter.minAmount() != null) {
conditions = conditions.and(ORDERS.AMOUNT.ge(filter.minAmount()));
}
if (filter.fromDate() != null) {
conditions = conditions.and(
ORDERS.CREATED_AT.ge(filter.fromDate().atStartOfDay()));
}
if (filter.productName() != null) {
conditions = conditions.and(
PRODUCTS.NAME.likeIgnoreCase("%" + filter.productName() + "%"));
}
// 동적 정렬
var orderField = switch (filter.sortBy()) {
case "amount" -> ORDERS.AMOUNT;
case "date" -> ORDERS.CREATED_AT;
default -> ORDERS.ID;
};
var sortField = filter.sortDesc()
? orderField.desc() : orderField.asc();
return dsl.selectFrom(ORDERS)
.join(PRODUCTS).on(ORDERS.PRODUCT_ID.eq(PRODUCTS.ID))
.where(conditions)
.and(ORDERS.DELETED_AT.isNull())
.orderBy(sortField)
.limit(filter.limit())
.offset(filter.offset())
.fetchInto(OrdersRecord.class);
}
JPA + jOOQ 공존 전략
JPA를 완전히 버릴 필요는 없다. CRUD는 JPA, 복잡한 조회는 jOOQ로 역할을 분담하는 것이 가장 실용적이다.
// JPA Entity — 쓰기(CUD) 담당
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
private Long userId;
private BigDecimal amount;
private String status;
// ...
}
// JPA Repository — 단순 CRUD
public interface OrderJpaRepository extends JpaRepository<Order, Long> {
List<Order> findByUserId(Long userId);
}
// jOOQ Repository — 복잡한 읽기(R) 담당
@Repository
public class OrderQueryRepository {
private final DSLContext dsl;
// 복잡한 보고서, 집계, 윈도우 함수 등
public List<SalesReportDto> getMonthlySalesReport(int year) {
return dsl.select(
DSL.month(ORDERS.CREATED_AT).as("month"),
DSL.count().as("orderCount"),
DSL.sum(ORDERS.AMOUNT).as("totalSales"),
DSL.avg(ORDERS.AMOUNT).as("avgOrderValue")
)
.from(ORDERS)
.where(DSL.year(ORDERS.CREATED_AT).eq(year))
.and(ORDERS.STATUS.eq("CONFIRMED"))
.groupBy(DSL.month(ORDERS.CREATED_AT))
.orderBy(DSL.month(ORDERS.CREATED_AT))
.fetchInto(SalesReportDto.class);
}
}
// Service에서 둘 다 사용
@Service
public class OrderService {
private final OrderJpaRepository jpaRepo; // CUD
private final OrderQueryRepository queryRepo; // R
public Order createOrder(CreateOrderRequest req) {
return jpaRepo.save(new Order(req)); // JPA로 저장
}
public List<SalesReportDto> getSalesReport(int year) {
return queryRepo.getMonthlySalesReport(year); // jOOQ로 조회
}
}
배치 처리: 대량 INSERT/UPDATE
// 배치 INSERT (1000건씩)
public void bulkInsert(List<CreateOrderRequest> requests) {
var inserts = requests.stream()
.map(req -> dsl.insertInto(ORDERS)
.set(ORDERS.USER_ID, req.userId())
.set(ORDERS.PRODUCT_ID, req.productId())
.set(ORDERS.AMOUNT, req.amount())
.set(ORDERS.STATUS, "PENDING"))
.toList();
dsl.batch(inserts).execute();
// 단일 라운드트립으로 1000건 INSERT
}
// UPSERT (INSERT ... ON DUPLICATE KEY UPDATE)
public void upsertProduct(Product product) {
dsl.insertInto(PRODUCTS)
.set(PRODUCTS.SKU, product.sku())
.set(PRODUCTS.NAME, product.name())
.set(PRODUCTS.PRICE, product.price())
.set(PRODUCTS.STOCK, product.stock())
.onDuplicateKeyUpdate()
.set(PRODUCTS.NAME, product.name())
.set(PRODUCTS.PRICE, product.price())
.set(PRODUCTS.STOCK, product.stock())
.set(PRODUCTS.UPDATED_AT, LocalDateTime.now())
.execute();
}
트랜잭션: Spring @Transactional 통합
@Service
public class TransferService {
private final DSLContext dsl;
@Transactional // Spring 트랜잭션 그대로 사용
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// jOOQ 쿼리들이 Spring 트랜잭션 안에서 실행
int debited = dsl.update(ACCOUNTS)
.set(ACCOUNTS.BALANCE, ACCOUNTS.BALANCE.sub(amount))
.where(ACCOUNTS.ID.eq(fromId))
.and(ACCOUNTS.BALANCE.ge(amount))
.execute();
if (debited == 0) {
throw new InsufficientBalanceException();
}
dsl.update(ACCOUNTS)
.set(ACCOUNTS.BALANCE, ACCOUNTS.BALANCE.add(amount))
.where(ACCOUNTS.ID.eq(toId))
.execute();
dsl.insertInto(TRANSACTIONS)
.set(TRANSACTIONS.FROM_ACCOUNT, fromId)
.set(TRANSACTIONS.TO_ACCOUNT, toId)
.set(TRANSACTIONS.AMOUNT, amount)
.execute();
}
}
jOOQ는 “SQL을 포기하지 않으면서 타입 안전성을 확보하는” 최적의 도구다. JPA가 객체 매핑의 편리함을 제공한다면, jOOQ는 SQL의 표현력을 100% 살리면서 컴파일 타임 검증을 더한다. 두 기술을 함께 사용하면 CRUD는 간결하게, 복잡한 쿼리는 강력하게 처리할 수 있다.