jOOQ Testcontainers + Flyway

JPA Entity 기반 DSL 생성 방식은 좋지만 멀티모듈 강제, 일부 DB 객체 지원 불가 등의 제약이 있었다. 이번에는 Testcontainers와 Flyway를 결합하여 DDL 파일로 jOOQ DSL을 생성하는 방식을 살펴보자

DDL Database 방식의 한계

순수 DSL 파일만으로 DSL을 생성하는 방식(DDL Database)를 살펴보자

장점

  • 별다른 종속성 없이 가볍게 실행 가능
  • 모든 개발자가 영향받지 않고 개발 가능
  • GitOps를 통해 스키마의 버전 관리 가능

단점

  • H2 데이터베이스의 DDL만 사용 가능하다. 즉, 운영 환경에서 사용하는 MySQL, PostgreSQL 등의 실제 DDL이 아닌 H2용 DDL을 별도로 관리해야 한다
  • 이는 운영 DB와 다른 스키마를 따로 관리해야 한다는 뜻이며, 근본적으로 얻을 수 있는 이점이 거의 없다

Testcontainers + Flyway 조합

Testcontainers

  • Docker 컨테이너를 사용해 테스트 환경에 필요한 인프라를 간편하게 설정하는 라이브러리

Flyway

  • 데이터베이스의 버전 관리와 일관된 마이그레이션을 지원하는 도구
  • Liquibase 등 유사한 도구로 대체 가능

동작 과정

1. MySQL DDL 파일 작성 (실제 사용하는 DB의 DDL)
   ↓
2. Flyway를 통해 Testcontainers의 MySQL 컨테이너로 마이그레이션
   ↓
3. jOOQ Code Generator가 컨테이너의 스키마를 스캔
   ↓
4. jOOQ DSL 생성

해당 방식의 장점

GitOps로 스키마의 원본을 코드로 관리
  • “스키마의 원본은 운영 DB 아닌가요?”라는 질문이 있을 수 있다. 하지만 다음과 같은 상황을 생각해 보자
최초 운영 DB (v1.5) ──────┐
                       │
고객사 1 운영 DB (v1.8) ──┼── 어느 것이 원본인가?
                       │
고객사 2 운영 DB (v1.9) ──┘

솔루션처럼 동일한 DB를 여러 고객사에 운영하는 경우, 운영 DB가 여러 개 존재하게 된다. 시간이 지나 각 DB의 버전이 달라지면 어느 것이 원본일지 단번에 알 수 없다. 게다가 망분리로 인해 운영 DB 스키마를 자주 확인하기도 어렵다. 따라서 코드로 스키마의 원본을 관리하는 것이 더 합리적이다

도메인별 스키마 분리 및 ERD 다이어그램
src/main/resources/db/migration/
├── 1.0.01_영화/
│   └── V1.0.01__영화_도메인.sql
├── 1.0.02_비디오가게_도메인/
│   └── V1.0.02__비디오가게_도메인.sql
└── 1.0.03_지역_도메인/
    └── V1.0.03__지역_도메인.sql

IntelliJ에서 DDL 파일 기반으로 도메인별 ERD 다이어그램을 확인할 수 있다

설계상 외래키를 코드로 유지

설계 단계에서는 외래키를 포함하지만, 성능 등의 이유로 실제 개발/운영 DB에는 물리적 외래키를 걸지 않을 경우가 있다. 이렇게 되면 나중에 새로운 인원이 참여할 때 연관 관계 파악이 어렵다.
이 방식을 사용하면 설계도에만 존재했던 외래키를 DDL 코드로 유지할 수 있어, jOOQ DSL에서도 해당 관계 정보를 활용할 수 있다

DDL 관리가 강제됨

원본 DDL이 jOOQ DSL 생성에 직접 관여한다

  • DDL이 없으면 컴파일 에러가 발생
  • 쿼리 자체를 만들 수 없다
  • 모든 개발자가 필수적으로 DDL을 수정하고 관리하게 된다

“팀에서 이런 식으로 관리하자”는 규칙만으로 구멍이 생기기 쉽지만, 이 방식을 개발 자체에 영향을 끼치므로 관리가 자연스럽게 강제된다

Gradle 플러그인 사용

Testcontainers와 Flyway를 결합하여 DDL 파일로 jOOQ DSL을 생성하는 방식의 문제점은 설정이 어렵다는 것이다. Testcontainers + Flyway 조합을 맨땅에 헤딩하며 설정하기는 매우 어렵다. 다행히 이를 간편하게 해주는 오픈소스 플러그인들이 있다

사용 가능한 플러그인

  • Gradle: https://github.com/monosoul/jooq-gradle-plugin
  • Maven: https://github.com/testcontainers/testcontainers-jooq-codegen-maven-plugin

플러그인 교체

// 기존
id 'nu.studer.jooq' version '9.0'

// 변경
id 'dev.monosoul.jooq-docker' version '6.0.16'

build.gradle 수정

buildscript {
    ext {
        jooqVersion = '3.19.5'
    }
}

dependencies {
    // jOOQ 관련 의존성
    implementation "org.jooq:jooq:${jooqVersion}"
    
    // 플러그인은 jooqCodegen으로 변경됨
    jooqCodegen project(':jooq-custom')
    jooqCodegen "org.jooq:jooq:${jooqVersion}"
    jooqCodegen "org.jooq:jooq-meta:${jooqVersion}"
    jooqCodegen "org.jooq:jooq-codegen:${jooqVersion}"
    
    // Flyway 의존성
    jooqCodegen 'org.flywaydb:flyway-core:10.8.1'
    jooqCodegen 'org.flywaydb:flyway-mysql:10.8.1'
    
    // Testcontainers는 플러그인에 포함되어 있어 별도 추가 불필요
}

jOOQ 설정

import org.jooq.meta.jaxb.*

jooq {
    version = "${jooqVersion}"
    
    // Testcontainers 설정
    withContainer {
        image {
            name = "mysql:8.0.29"
            envVars = [
                MYSQL_ROOT_PASSWORD: "passwd",
                MYSQL_DATABASE     : "sakila"
            ]
        }

        db {
            username = "root"
            password = "passwd"
            name = "sakila"
            port = 3306
            jdbc {
                schema = "jdbc:mysql"  // JDBC URL 스키마
                driverClassName = "com.mysql.cj.jdbc.Driver"
            }
        }
    }
}

tasks {
    generateJooqClasses {
        schemas.set(["sakila"])
        outputDirectory.set(project.layout.projectDirectory.dir("src/generated"))
        includeFlywayTable.set(false)  // Flyway 버전 관리 테이블 제외

        // Java Configuration 방식
        usingJavaConfig {
            generate = new Generate()
                    .withJavaTimeTypes(true)
                    .withDeprecated(false)
                    .withDaos(true)
                    .withFluentSetters(true)
                    .withRecords(true)

            withStrategy(
                    new Strategy().withName("jooq.custom.generator.JPrefixGeneratorStrategy")
            )

            database.withForcedTypes(
                    new ForcedType()
                            .withUserType("java.lang.Long")
                            .withTypes("int unsigned"),
                    new ForcedType()
                            .withUserType("java.lang.Integer")
                            .withTypes("tinyint unsigned"),
                    new ForcedType()
                            .withUserType("java.lang.Integer")
                            .withTypes("smallint unsigned")
            )
        }
    }
}

주요 변경사항

  • 기본 Groovy DSL 방식에서 Java Configuration 방식으로 변경
  • Testcontainers 라이프사이클은 플러그인이 자동 관리

Flyway Migration 파일 추가

src/main/resources/db/migration/
└── V1__init_tables.sql
파일명 규칙
  • V{버전번호}__{설명}.sql – 형식을 반드시 따른다
  • 예: V1__init_tables.sql, V2__add_indexes.sql
실제 MySQL DDL 작성 내용
-- DROP 문은 제외 (컨테이너가 매번 새로 생성되므로 불필요)
CREATE TABLE actor (
    actor_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    first_name VARCHAR(45) NOT NULL,
    last_name VARCHAR(45) NOT NULL,
    last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) CHARSET=utf8mb4;

-- 외래키 포함 가능
CREATE INDEX idx_actor_last_name ON actor (last_name);

-- 나머지 테이블들...

DSL 생성 및 검증

./gradlew generateJooqClasses
  • Docker Desktop에서 MySQL Testcontainer 생성 확인
  • Flyway 마이그레이션 실행
  • jOOQ DSL 생성
  • 컨테이너 자동 종료
생성된 DSL을 테스트
./gradlew test

출처 – 실전 jOOQ! Type Safe SQL with Java