Testcontainers로 jOOQ와 Flyway 함께 사용하기

Testcontainers로 jOOQ와 Flyway 함께 사용하기

이 가이드에서는 Flyway 마이그레이션으로 관리하는 실제 PostgreSQL 데이터베이스에서 타입 안전한 jOOQ 코드를 생성하고, Testcontainers로 리포지토리를 테스트하는 방법을 배워요.

출처: 문서

본문

이 가이드를 통해 다음 내용을 배울 수 있어요.

  • jOOQ 지원이 포함된 Spring Boot 애플리케이션 만들기
  • Testcontainers, Flyway, Maven 플러그인으로 jOOQ 코드 생성하기
  • jOOQ를 사용해 기본 데이터베이스 연산 구현하기
  • jOOQ의 MULTISET 기능으로 복잡한 객체 그래프 로드하기
  • Testcontainers로 jOOQ 영속성 계층 테스트하기

사전 준비 (Prerequisites)

  • Java 17 이상
  • Maven
  • Testcontainers가 지원하는 Docker 환경

참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.

Spring Boot 프로젝트 만들기

Spring Initializr에서 빌드 도구로 Maven을 선택하고 JOOQ Access Layer, Flyway Migration, Spring Boot DevTools, PostgreSQL Driver, Testcontainers 스타터를 추가해 Spring Boot 프로젝트를 만들어요. 또는 가이드 저장소를 클론해도 돼요.

jOOQ(jOOQ Object Oriented Querying)는 타입 안전한 SQL 쿼리를 만들기 위한 유연한 API(fluent API)를 제공해요. 이 타입 안전한 DSL의 장점을 최대한 활용하려면 데이터베이스 테이블, 뷰, 기타 객체들에서 Java 코드를 생성해야 해요.

팁: jOOQ 코드 생성기가 어떤 도움이 되는지 더 자세히 알고 싶다면 "Why You Should Use jOOQ With Code Generation"을 읽어보세요.

jOOQ 코드 생성으로 애플리케이션을 빌드하고 테스트하는 일반적인 과정은 다음과 같아요.

  1. Testcontainers로 데이터베이스 인스턴스를 만든다.
  2. Flyway 데이터베이스 마이그레이션을 적용한다.
  3. 데이터베이스 객체에서 Java 코드를 생성하도록 jOOQ 코드 생성기를 실행한다.
  4. 통합 테스트를 실행한다.

testcontainers-jooq-codegen-maven-plugin이 이 과정을 Maven 빌드의 일부로 자동화해요.

Flyway 마이그레이션 스크립트 만들기

예제 애플리케이션은 users, posts, comments 테이블을 가져요. Flyway 명명 규칙을 따라 첫 번째 마이그레이션 스크립트를 만들어요. src/main/resources/db/migration/V1__create_tables.sql을 만들어요.

create table users (
    id bigserial not null,
    name varchar not null,
    email varchar not null,
    created_at timestamp,
    updated_at timestamp,
    primary key (id),
    constraint user_email_unique unique (email)
);

create table posts (
    id bigserial not null,
    title varchar not null,
    content varchar not null,
    created_by bigint references users(id) not null,
    created_at timestamp,
    updated_at timestamp,
    primary key (id)
);

create table comments (
    id bigserial not null,
    name varchar not null,
    content varchar not null,
    post_id bigint references posts(id) not null,
    created_at timestamp,
    updated_at timestamp,
    primary key (id)
);

ALTER SEQUENCE users_id_seq RESTART WITH 101;
ALTER SEQUENCE posts_id_seq RESTART WITH 101;
ALTER SEQUENCE comments_id_seq RESTART WITH 101;

시퀀스 값이 101부터 다시 시작하는 이유는, 테스트용으로 명시적 기본 키 값을 가진 샘플 데이터를 삽입할 수 있게 하기 위해서예요.

jOOQ 코드 생성 구성하기

pom.xml에 testcontainers-jooq-codegen-maven-plugin을 추가해요.

<properties>
    <testcontainers.version>2.0.4</testcontainers.version>
    <testcontainers-jooq-codegen-maven-plugin.version>0.0.4</testcontainers-jooq-codegen-maven-plugin.version>
</properties>

<build>
    <plugins>
        <plugin>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-jooq-codegen-maven-plugin</artifactId>
            <version>${testcontainers-jooq-codegen-maven-plugin.version}</version>
            <dependencies>
                <dependency>
                    <groupId>org.testcontainers</groupId>
                    <artifactId>testcontainers-postgresql</artifactId>
                    <version>${testcontainers.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.postgresql</groupId>
                    <artifactId>postgresql</artifactId>
                    <version>${postgresql.version}</version>
                </dependency>
            </dependencies>
            <executions>
                <execution>
                    <id>generate-jooq-sources</id>
                    <goals>
                        <goal>generate</goal>
                    </goals>
                    <phase>generate-sources</phase>
                    <configuration>
                        <database>
                            <type>POSTGRES</type>
                            <containerImage>postgres:16-alpine</containerImage>
                        </database>
                        <flyway>
                            <locations>filesystem:src/main/resources/db/migration</locations>
                        </flyway>
                        <jooq>
                            <generator>
                                <database>
                                    <includes>.*</includes>
                                    <excludes>flyway_schema_history</excludes>
                                    <inputSchema>public</inputSchema>
                                </database>
                                <target>
                                    <packageName>com.testcontainers.demo.jooq</packageName>
                                    <directory>target/generated-sources/jooq</directory>
                                </target>
                            </generator>
                        </jooq>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

플러그인 구성이 하는 일을 살펴보면,

  • <configuration>/<database> 섹션은 데이터베이스 유형을 POSTGRES, Docker 이미지를 postgres:16-alpine으로 설정해요.
  • <configuration>/<flyway> 섹션은 Flyway 마이그레이션 스크립트를 가리켜요.
  • <configuration>/<jooq> 섹션은 생성된 코드의 패키지 이름과 출력 디렉터리를 구성해요.

공식 jooq-code-generator 플러그인이 지원하는 어떤 구성 옵션이든 사용할 수 있어요. ./mvnw clean package를 실행하면, 플러그인이 Testcontainers로 PostgreSQL 컨테이너를 띄우고 Flyway 마이그레이션을 적용한 뒤 target/generated-sources/jooq 아래에 Java 코드를 생성해요.

모델 클래스 만들기

다양한 사용 사례의 데이터 구조를 나타내는 모델 클래스를 만들어요. 이 레코드들은 테이블의 컬럼 값 일부를 보관해요.

User.java:

package com.testcontainers.demo.domain;

public record User(Long id, String name, String email) {}

Post.java:

package com.testcontainers.demo.domain;

import java.time.LocalDateTime;
import java.util.List;

public record Post(
        Long id,
        String title,
        String content,
        User createdBy,
        List<Comment> comments,
        LocalDateTime createdAt,
        LocalDateTime updatedAt) {}

Comment.java:

package com.testcontainers.demo.domain;

import java.time.LocalDateTime;

public record Comment(
        Long id,
        String name,
        String content,
        LocalDateTime createdAt,
        LocalDateTime updatedAt) {}

jOOQ로 리포지토리 구현하기

UserRepository.java를 만들어 사용자 생성과 이메일 조회 메서드를 추가해요.

package com.testcontainers.demo.domain;

import static com.testcontainers.demo.jooq.tables.Users.USERS;
import static org.jooq.Records.mapping;

import java.time.LocalDateTime;
import java.util.Optional;

import org.jooq.DSLContext;
import org.springframework.stereotype.Repository;

@Repository
class UserRepository {
    private final DSLContext dsl;

    UserRepository(DSLContext dsl) {
        this.dsl = dsl;
    }

    public User createUser(User user) {
        return this.dsl.insertInto(USERS)
            .set(USERS.NAME, user.name())
            .set(USERS.EMAIL, user.email())
            .set(USERS.CREATED_AT, LocalDateTime.now())
            .returningResult(USERS.ID, USERS.NAME, USERS.EMAIL)
            .fetchOne(mapping(User::new));
    }

    public Optional<User> getUserByEmail(String email) {
        return this.dsl.select(USERS.ID, USERS.NAME, USERS.EMAIL)
            .from(USERS)
            .where(USERS.EMAIL.equalIgnoreCase(email))
            .fetchOptional(mapping(User::new));
    }
}

jOOQ DSL은 SQL과 비슷해 보이지만 Java로 작성돼요. 코드가 데이터베이스 스키마에서 생성되기 때문에 데이터베이스 구조와 항상 동기화되고 타입 안전성을 보장해요. 예를 들어 where(USERS.EMAIL.equalIgnoreCase(email))는 String 값을 기대해요. 123 같은 문자열이 아닌 값을 전달하면 컴파일러 오류가 나요.

복잡한 객체 그래프 가져오기

jOOQ는 복잡한 쿼리에서 특히 빛나요. 데이터베이스는 Post에서 User로의 다대일 관계와 Post에서 Comment로의 일대다 관계를 가져요. jOOQ의 MULTISET 기능으로 생성자와 댓글을 단일 쿼리로 로드하는 PostRepository.java를 만들어요.

package com.testcontainers.demo.domain;

import static com.testcontainers.demo.jooq.Tables.COMMENTS;
import static com.testcontainers.demo.jooq.tables.Posts.POSTS;
import static org.jooq.Records.mapping;
import static org.jooq.impl.DSL.multiset;
import static org.jooq.impl.DSL.row;
import static org.jooq.impl.DSL.select;

import java.util.Optional;

import org.jooq.DSLContext;
import org.springframework.stereotype.Repository;

@Repository
class PostRepository {
    private final DSLContext dsl;

    PostRepository(DSLContext dsl) {
        this.dsl = dsl;
    }

    public Optional<Post> getPostById(Long id) {
        return this.dsl.select(
                POSTS.ID,
                POSTS.TITLE,
                POSTS.CONTENT,
                row(POSTS.users().ID, POSTS.users().NAME, POSTS.users().EMAIL)
                    .mapping(User::new)
                    .as("createdBy"),
                multiset(
                        select(COMMENTS.ID, COMMENTS.NAME, COMMENTS.CONTENT,
                               COMMENTS.CREATED_AT, COMMENTS.UPDATED_AT)
                        .from(COMMENTS)
                        .where(POSTS.ID.eq(COMMENTS.POST_ID))
                ).as("comments")
                 .convertFrom(r -> r.map(mapping(Comment::new))),
                POSTS.CREATED_AT,
                POSTS.UPDATED_AT
            ) .from(POSTS)
              .where(POSTS.ID.eq(id))
              .fetchOptional(mapping(Post::new));
    }
}

이 코드는 다대일 Post-User 연관에 jOOQ의 중첩 레코드를, 일대다 Post-Comment 연관에 MULTISET을 사용해요.

Testcontainers로 테스트 작성하기

테스트를 작성하기 전에 테스트 데이터를 시딩할 SQL 스크립트를 src/test/resources/test-data.sql에 만들어요.

DELETE FROM comments;
DELETE FROM posts;
DELETE FROM users;

INSERT INTO users (id, name, email) VALUES
    (1, 'Siva', '[email protected]'),
    (2, 'Oleg', '[email protected]');

INSERT INTO posts (id, title, content, created_by, created_at) VALUES
    (1, 'Post 1 Title', 'Post 1 content', 1, CURRENT_TIMESTAMP),
    (2, 'Post 2 Title', 'Post 2 content', 2, CURRENT_TIMESTAMP);

INSERT INTO comments (id, name, content, post_id, created_at) VALUES
    (1, 'Ron', 'Comment 1', 1, CURRENT_TIMESTAMP),
    (2, 'James', 'Comment 2', 1, CURRENT_TIMESTAMP),
    (3, 'Robert', 'Comment 3', 2, CURRENT_TIMESTAMP);

@JooqTest 슬라이스로 테스트하기

@JooqTest 어노테이션은 영속성 계층 컴포넌트만 로드하고 jOOQ의 DSLContext를 자동 구성해요. Testcontainers 특수 JDBC URL을 사용해 Postgres 컨테이너를 시작해요. UserRepositoryJooqTest.java를 만들어요.

package com.testcontainers.demo.domain;

import static org.assertj.core.api.Assertions.assertThat;

import org.jooq.DSLContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jooq.JooqTest;
import org.springframework.test.context.jdbc.Sql;

@JooqTest(properties = {
    "spring.test.database.replace=none",
    "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db",
})
@Sql("/test-data.sql")
class UserRepositoryJooqTest {
    @Autowired
    DSLContext dsl;

    UserRepository repository;

    @BeforeEach
    void setUp() {
        this.repository = new UserRepository(dsl);
    }

    @Test
    void shouldCreateUserSuccessfully() {
        User user = new User(null, "John", "[email protected]");
        User savedUser = repository.createUser(user);

        assertThat(savedUser.id()).isNotNull();
        assertThat(savedUser.name()).isEqualTo("John");
        assertThat(savedUser.email()).isEqualTo("[email protected]");
    }

    @Test
    void shouldGetUserByEmail() {
        User user = repository.getUserByEmail("[email protected]").orElseThrow();

        assertThat(user.id()).isEqualTo(1L);
        assertThat(user.name()).isEqualTo("Siva");
        assertThat(user.email()).isEqualTo("[email protected]");
    }
}

테스트가 하는 일을 살펴보면,

  • @JooqTest는 영속성 계층만 로드하고 DSLContext를 자동 구성해요.
  • Testcontainers 특수 JDBC URL(jdbc:tc:postgresql:16-alpine:///db)이 PostgreSQL 컨테이너를 자동으로 시작해요.
  • flyway-core가 classpath에 있으므로 Spring Boot는 시작 시 src/main/resources/db/migration의 Flyway 마이그레이션을 실행해요.
  • @Sql("/test-data.sql")은 각 테스트 전에 테스트 데이터를 로드해요.
  • UserRepository는 주입된 DSLContext로 수동으로 인스턴스화돼요.

@SpringBootTest로 통합 테스트하기

전체 통합 테스트를 위해 Spring Boot 3.1에서 도입된 Testcontainers @ServiceConnection 지원과 함께 @SpringBootTest를 사용해요. UserRepositoryTest.java를 만들어요.

package com.testcontainers.demo.domain;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.jdbc.Sql;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Sql("/test-data.sql")
@Testcontainers
class UserRepositoryTest {
    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine");

    @Autowired
    UserRepository repository;

    @Test
    void shouldCreateUserSuccessfully() {
        User user = new User(null, "John", "[email protected]");
        User savedUser = repository.createUser(user);

        assertThat(savedUser.id()).isNotNull();
        assertThat(savedUser.name()).isEqualTo("John");
        assertThat(savedUser.email()).isEqualTo("[email protected]");
    }

    @Test
    void shouldGetUserByEmail() {
        User user = repository.getUserByEmail("[email protected]").orElseThrow();

        assertThat(user.id()).isEqualTo(1L);
        assertThat(user.name()).isEqualTo("Siva");
        assertThat(user.email()).isEqualTo("[email protected]");
    }
}

테스트가 하는 일을 살펴보면,

  • @SpringBootTest는 전체 애플리케이션 컨텍스트를 로드하므로 UserRepository가 직접 주입돼요.
  • @Testcontainers와 @Container가 PostgreSQL 컨테이너 수명주기를 관리해요.
  • @ServiceConnection은 실행 중인 컨테이너에서 데이터소스 속성을 자동 구성해서 @DynamicPropertySource의 필요성을 없애줘요.
  • @Sql("/test-data.sql")은 테스트 데이터를 초기화해요.

PostRepository 테스트하기

Testcontainers 특수 JDBC URL을 사용해 복잡한 객체 그래프를 가져오는 PostRepository를 테스트해요. PostRepositoryTest.java를 만들어요.

package com.testcontainers.demo.domain;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;

@SpringBootTest(properties = {
    "spring.test.database.replace=none",
    "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db",
})
@Sql("/test-data.sql")
class PostRepositoryTest {
    @Autowired
    PostRepository repository;

    @Test
    void shouldGetPostById() {
        Post post = repository.getPostById(1L).orElseThrow();

        assertThat(post.id()).isEqualTo(1L);
        assertThat(post.title()).isEqualTo("Post 1 Title");
        assertThat(post.content()).isEqualTo("Post 1 content");
        assertThat(post.createdBy().id()).isEqualTo(1L);
        assertThat(post.createdBy().name()).isEqualTo("Siva");
        assertThat(post.createdBy().email()).isEqualTo("[email protected]");
        assertThat(post.comments()).hasSize(2);
    }
}

이 테스트는 getPostById가 jOOQ의 MULTISET 기능을 사용해 게시물과 그 생성자, 댓글을 단일 쿼리로 로드하는지 검증해요.

테스트 실행과 다음 단계

테스트를 실행해요.

$ ./mvnw test

PostgreSQL Docker 컨테이너가 시작되고, jOOQ 코드 생성이 완료되며, 모든 테스트가 통과하는 걸 볼 수 있어요. 테스트가 끝나면 컨테이너는 자동으로 중지되고 제거돼요.

요약 (Summary)

Testcontainers 라이브러리는 jOOQ 코드 생성기로 데이터베이스에서 Java 코드를 생성하고, 목이나 인메모리 데이터베이스 대신 프로덕션에서 쓰는 것과 같은 종류의 데이터베이스(PostgreSQL)로 영속성 계층을 테스트할 수 있게 도와줘요. 코드가 항상 데이터베이스의 현재 상태에서 생성되기 때문에 코드가 데이터베이스 변경과 동기화를 유지한다고 확신할 수 있어요. 리팩터링을 해도 애플리케이션이 예상대로 동작하는지 검증할 수 있답니다.

Testcontainers에 대해 더 알아보고 싶다면 Testcontainers 개요를 방문해요.

더 읽어보기 (Further reading)

  • jOOQ 문서
  • jOOQ 코드 생성
  • Spring Boot Testcontainers 지원
  • 테스트를 위해 H2를 실제 데이터베이스로 교체하기

더 알아보기 (Learn more)