jOOQ path-based Join

jOOQ를 사용하다 보면 매번 JOIN할 때마다 ON 절을 명시해야 하는 번거로움이 있다. JPA – QueryDSL에서는 Entity의 연관관계 덕분에 ON 절 없이도 간단하게 JOIN을 작성할 수 있는데, jOOQ에서도 외래키(Foreign Key) 정보를 기반으로 테이블 간 연관관계 Path를 자동 생성해서 활용하면 ON 절 없이도 깔끔한 JOIN 쿼리를 작성할 수 있다

문제 상황 – 반복되는 ON 절

기존 jOOQ 코드에서는 JOIN마다 ON 절을 명시해야 한다

public List<FilmWithActor> findFilmWithActorsList(Long page, Long pageSize) {
    final JFilmActor FILM_ACTOR = JFilmActor.FILM_ACTOR;
    final JActor ACTOR = JActor.ACTOR;
    
    return dslContext.select(
                    DSL.row(FILM.fields()),
                    DSL.row(FILM_ACTOR.fields()),
                    DSL.row(ACTOR.fields())
            )
            .from(FILM)
            .join(FILM_ACTOR)
                .on(FILM.FILM_ID.eq(FILM_ACTOR.FILM_ID))      // ON 절 필수
            .join(ACTOR)
                .on(FILM_ACTOR.ACTOR_ID.eq(ACTOR.ACTOR_ID))   // ON 절 필수
            .limit(pageSize)
            .offset((page - 1) * pageSize)
            .fetchInto(FilmWithActor.class);
}

반면 JPA + QueryDSL 에서는 연관관계가 명시되어 있어 훨씬 간결하다

public List<Film> findWithPage(Long page, Long pageSize) {
    return jpaQueryFactory.selectFrom()
            .from(FILM)
            .join(FILM.filmActor, FILM_ACTOR)  // ON 절 생략
            .join(FILM_ACTOR.actor, ACTOR)     // ON 절 생략
            .limit(pageSize)
            .offset((page - 1) * pageSize)
            .fetch();
}

해결책 – 외래키 기반 Path

jOOQ는 JPA처럼 Java 클래스에 연관관계를 명시할 수 없지만, 데이터베이스의 외래키 정보를 활용할 수 있다. 관계형 데이터베이스는 이미 외래키라는 연관관계를 가지고 있을 것이다. jOOQ는 외래키 기반으로 TableDSL에 Path를 자동 생성한다

// JFilm 테이블 DSL에 생성된 Path
public class JFilm extends TableImpl<FilmRecord> {
    // ...
    public FilmActorPath filmActor() { ... }  // FILM -> FILM_ACTOR로 가는 Path
}

// JFilmActor 테이블 DSL에 생성된 Path
public class JFilmActor extends TableImpl<FilmActorRecord> {
    // ...
    public ActorPath actor() { ... }  // FILM_ACTOR -> ACTOR로 가는 Path
}

외래키가 없다면?

실무에서는 성능 등의 이유로 외래키를 설계만 하고 물리적으로 걸지 않는 경우도 있다. 이런 경우 두 가지 방법이 있다

Synthetic Foreign Key 사용 (상용 라이센스 전용)

  • jOOQ 상용 라이센스 구매 시 사용 가능
  • DSL 생성 설정에서 외래키가 없어도 있는 것처럼 지정 가능

DSL 생성 시에만 외래키 구성 (오픈소스 버전)

  • Testcontainers + Flyway: DDL 파일에 외래키 명시
  • JPA Entity: @JoinColumn으로 외래키 정보 명시

예제 스키마

글에서 사용할 테이블 구조

FILM ──(1:N)──> FILM_ACTOR ──(N:1)──> ACTOR
  • FILE → FILM_ACTOR: one-to-many 관계
  • FILM_ACTOR → ACTOR: many-to-one 관계

Path-based Join의 두 가지 방식

Implicit Path Join (비추천)

SELECT 절에서 Path를 통해 하위 테이블의 컬럼에 암시적으로 접근한다. JOIN 절에는 조인 테이블이 명시되지 않는다

예제: Many-To-One (기본 지원)
public List<FilmWithActor> findFilmWithActorsListImplicitPathJoin(Long page, Long pageSize) {
    final JFilmActor FILM_ACTOR = JFilmActor.FILM_ACTOR;
    
    return dslContext.select(
                    DSL.row(FILM.fields()),
                    DSL.row(FILM_ACTOR.fields()),
                    DSL.row(FILM_ACTOR.actor().fields())  // Path로 ACTOR 접근
            )
            .from(FILM)
            .join(FILM_ACTOR)
                .on(FILM.FILM_ID.eq(FILM_ACTOR.FILM_ID))
            // .join(ACTOR) 생략!
            .limit(pageSize)
            .offset((page - 1) * pageSize)
            .fetchInto(FilmWithActor.class);
}
생성된 SQL
SELECT ... 
FROM `film` 
JOIN (`film_actor` 
      JOIN `actor` AS `alias_92235250` 
      ON `film_actor`.`actor_id` = `alias_92235250`.`actor_id`) 
ON `film`.`film_id` = `film_actor`.`film_id`
예제: One-to-many (설정 필요)

One-to-many 관계는 기본적으로 비활성화되어 있어 설정이 필요

// JooqConfig.java
@Configuration
public class JooqConfig {
    @Bean
    public DefaultConfigurationCustomizer jooqDefaultConfigurationCustomizer() {
        return c -> {
            c.set(PerformanceListener::new);
            c.settings()
                    .withExecuteDeleteWithoutWhere(ExecuteWithoutWhere.THROW)
                    .withExecuteUpdateWithoutWhere(ExecuteWithoutWhere.THROW)
                    .withRenderSchema(false)
                    // implicit path join to-many는 기본적으로 에러를 발생시키므로 수동으로 JOIN을 지정해야 한다
                    .withRenderImplicitJoinToManyType(RenderImplicitJoinType.INNER_JOIN);
        };
    }
}
public List<FilmWithActors> findFilmWithActorsListImplicitPathManyToOneJoin(Long page, Long pageSize) {
    return dslContext.select(
                    DSL.row(FILM.fields()),
                    DSL.row(FILM.filmActor().fields()),           // Path 사용
                    DSL.row(FILM.filmActor().actor().fields())   // 중첩 Path 사용
            )
            .from(FILM)
            // JOIN 절 전부 생략!
            .limit(pageSize)
            .offset((page - 1) * pageSize)
            .fetchInto(FilmWithActors.class);
}
비추천 이유
  • 가독성 문제: 어떤 테이블이 JOIN되는지 한 눈에 파악하기 어렵다
  • 복잡한 설정: One-to-many 방향은 수동 설정 필요
  • 실수 가능성: Many-to-one인지 One-to-many인지 개발자가 인지하고 있어야 한다
  • 암시적 동작: JOIN절에 명시되지 않아 예상치 못한 쿼리 생성 가능
Implicit Join 완전히 막기

실수를 방지하기 위해 implicit join을 아예 비활성화할 수 있다

@Bean
    public DefaultConfigurationCustomizer jooqDefaultConfigurationCustomizer() {
        return c -> {
            c.set(PerformanceListener::new);
            c.settings()
                    .withExecuteDeleteWithoutWhere(ExecuteWithoutWhere.THROW)
                    .withExecuteUpdateWithoutWhere(ExecuteWithoutWhere.THROW)
                    .withRenderSchema(false)
                    // 모든 implicit join을 예외 발생시킴
                    .withRenderImplicitJoinType(RenderImplicitJoinType.THROW)
                    .withRenderImplicitJoinToManyType(RenderImplicitJoinType.THROW);
}

Explicit Path Join (추천)

Path를 통해 JOIN의 ON절을 자동 생성하되, JOIN 절에는 조인되는 테이블을 명시한다. 가독성이 뛰어나고 사용이 간편하다

public List<FilmWithActor> findFilmWithActorsListExplicitPathJoin(Long page, Long pageSize) {
    return dslContext.select(
                    DSL.row(FILM.fields()),
                    DSL.row(FILM.filmActor().fields()),           // Path로 필드 접근
                    DSL.row(FILM.filmActor().actor().fields())   // 중첩 Path로 필드 접근
            )
            .from(FILM)
            .join(FILM.filmActor())              // Path로 JOIN 명시
            .join(FILM.filmActor().actor())      // Path로 JOIN 명시
            .limit(pageSize)
            .offset((page - 1) * pageSize)
            .fetchInto(FilmWithActor.class);
}
생성된 SQL
SELECT ... 
FROM `film` 
JOIN `film_actor` AS `alias_128952411` 
  ON `alias_128952411`.`film_id` = `film`.`film_id` 
JOIN `actor` AS `alias_4198651` 
  ON `alias_128952411`.`actor_id` = `alias_4198651`.`actor_id`
더 간결한 SELECT 절
public List<FilmWithActor> findFilmWithActorsListExplicitPathJoin(Long page, Long pageSize) {
    return dslContext.select(
                    FILM,                        // 테이블 자체 전달
                    FILM.filmActor(),           // Path 전달
                    FILM.filmActor().actor()    // 중첩 Path 전달
            )
            .from(FILM)
            .join(FILM.filmActor())
            .join(FILM.filmActor().actor())
            .limit(pageSize)
            .offset((page - 1) * pageSize)
            .fetchInto(FilmWithActor.class);
}
추천하는 이유
  • 명확한 가독성: JOIN되는 테이블이 명시되어 한눈에 파악 가능
  • 간단한 사용: One-to-many든 Many-to-one이든 신경 쓸 필요 없다
  • 안전성: 어떤 테이블이 JOIN되는지 명확하여 실수를 방지한다
  • ON 절 생략: 외래키 기반으로 ON 절 자동 생성

테스트 코드

Implicit Path Join 테스트

@SpringBootTest
public class JooqJoinShortcutTest {

    @Autowired
    FilmRepository filmRepository;

    @Test
    @DisplayName("implicitPathJoin_테스트")
    void implicitPathJoin_테스트() {
        List<FilmWithActor> original = filmRepository.findFilmWithActorsList(1L, 10L);
        List<FilmWithActor> implicit = filmRepository.findFilmWithActorsListImplicitPathJoin(1L, 10L);

        assertThat(original)
                .usingRecursiveFieldByFieldElementComparator()
                .isEqualTo(implicit);
    }
}

Explicit Path Join 테스트

@Test
@DisplayName("explicitPathJoin_테스트")
void explicitPathJoin_테스트() {
    List<FilmWithActor> original = filmRepository.findFilmWithActorsList(1L, 10L);
    List<FilmWithActor> explicit = filmRepository.findFilmWithActorsListExplicitPathJoin(1L, 10L);

    assertThat(original)
            .usingRecursiveFieldByFieldElementComparator()
            .isEqualTo(explicit);
}

Explicit Path Join 권장 이유

외래키가 지원된다면 Explicit Path Join이 최선의 선택

  • 코드 가독성이 뛰어남
  • One-to-many든 Many-to-one이든 동일하게 사용
  • JOIN 테이블이 명시되어 SQL 이해가 쉬움
  • 실수 가능성이 낮음

Implicit Path Join을 피해야 하는 이유

  • 어떤 테이블이 JOIN되는지 불명확
  • One-to-many 설정이 복잡
  • 예상치 못한 쿼리 생성 가능
  • 유지보수 시 혼란 초래

Testcontainers + Flyway 사용 시

-- DDL에 외래키 명시
CREATE TABLE film_actor (
    actor_id INT UNSIGNED NOT NULL,
    film_id INT UNSIGNED NOT NULL,
    -- ...
    CONSTRAINT fk_film_actor_film 
        FOREIGN KEY (film_id) REFERENCES film (film_id),
    CONSTRAINT fk_film_actor_actor 
        FOREIGN KEY (actor_id) REFERENCES actor (actor_id)
);

이렇게 하면 jOOQ DSL 생성 시 자동으로 Path가 생성된다

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