Spring jOOQ 타입 안전 SQL

왜 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는 간결하게, 복잡한 쿼리는 강력하게 처리할 수 있다.

관련 글: Spring JPA N+1 해결 전략 | Spring JPA Specification 동적 쿼리

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux