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가 생성된다