jOOQ 조건절과 동적 쿼리

SQL의 핵심은 조건 절이다. jOOQ는 타입 안전한 방식으로 조건을 구성하고, 동적으로 쿼리를 생성할 수 있는 강력한 기능을 제공한다.

jOOQ의 모든 조건은 org.jooq.Condition 인터페이스를 구현한다. 이 조건들은 메서드 체이닝 방식으로 조합할 수 있어 직관적이고 읽기 쉬운 코드를 작성할 수 있다

// 모든 조건은 Condition 타입
Condition nameCondition = ACTOR.FIRST_NAME.eq("PENELOPE");
Condition lastNameCondition = ACTOR.LAST_NAME.eq("GUINESS");

// 메서드 체이닝으로 조합
Condition combined = nameCondition.and(lastNameCondition);

AND 조건

방법 1 – 메서드 체이닝

@Repository
@RequiredArgsConstructor
public class ActorRepository {
    private final DSLContext dslContext;
    private final JActor ACTOR = JActor.ACTOR;

    public List<Actor> findByFirstNameAndLastName(String firstName, String lastName) {
        return dslContext
                .selectFrom(ACTOR)
                .where(
                    ACTOR.FIRST_NAME.eq(firstName)
                        .and(ACTOR.LAST_NAME.eq(lastName))
                )
                .fetchInto(Actor.class);
    }
}

방법 2 – 쉼표로 구분 (권장)

public List<Actor> findByFirstNameAndLastName(String firstName, String lastName) {
    return dslContext
            .selectFrom(ACTOR)
            .where(
                ACTOR.FIRST_NAME.eq(firstName),  // 쉼표로 구분
                ACTOR.LAST_NAME.eq(lastName)
            )
            .fetchInto(Actor.class);
}

생성된 SQL

SELECT `actor`.`actor_id`, 
       `actor`.`first_name`,
       `actor`.`last_name`,
       `actor`.`last_update` 
FROM `actor` 
WHERE (`actor`.`first_name` = ? AND `actor`.`last_name` = ?)

테스트

@Test
@DisplayName("AND 조건 검색 - firstName과 lastName이 일치하는 배우 조회")
void AND조건_검색() {
    // given
    String firstName = "ED";
    String lastName = "CHASE";

    // when
    List<Actor> actorList = actorRepository
        .findByFirstNameAndLastName(firstName, lastName);

    // then
    assertThat(actorList).hasSize(1);
    assertThat(actorList.get(0).getFirstName()).isEqualTo(firstName);
    assertThat(actorList.get(0).getLastName()).isEqualTo(lastName);
}

두 방식의 차이

// 메서드 체이닝: 명시적이지만 장황함
.where(ACTOR.FIRST_NAME.eq(firstName).and(ACTOR.LAST_NAME.eq(lastName)))

// 쉼표 구분: 간결하고 읽기 쉬움 (권장)
.where(
    ACTOR.FIRST_NAME.eq(firstName),
    ACTOR.LAST_NAME.eq(lastName)
)

두 방식 모두 동일한 SQL을 생성하지만, 쉼표로 구분하는 방식이 가독성이 좋아 권장된다

OR 조건

OR 조건은 명시적으로 .or() 메서드를 사용해야 한다

구현

public List<Actor> findByFirstNameOrLastName(String firstName, String lastName) {
    return dslContext
            .selectFrom(ACTOR)
            .where(
                ACTOR.FIRST_NAME.eq(firstName)
                    .or(ACTOR.LAST_NAME.eq(lastName))
            )
            .fetchInto(Actor.class);
}

생성된 SQL

SELECT `actor`.`actor_id`, 
       `actor`.`first_name`,
       `actor`.`last_name`,
       `actor`.`last_update`
FROM `actor`
WHERE (`actor`.`first_name` = ? OR `actor`.`last_name` = ?)

테스트

@Test
@DisplayName("OR 조건 검색 - firstName 또는 lastName이 일치하는 배우 조회")
void OR조건_검색() {
    // given
    String firstName = "ED";
    String lastName = "CHASE";

    // when
    List<Actor> actorList = actorRepository
        .findByFirstNameOrLastName(firstName, lastName);

    // then
    assertThat(actorList).hasSizeGreaterThan(1);  // ED이거나 CHASE인 배우 여러 명
}

AND와 OR 혼합 사용

AND와 OR을 함께 사용할 때 괄호 우선순위가 자동으로 처리된다

public List<Actor> findByComplexCondition(
    String firstName, 
    String lastName
) {
    return dslContext
            .selectFrom(ACTOR)
            .where(
                ACTOR.FIRST_NAME.eq(firstName).or(ACTOR.LAST_NAME.eq(lastName)),
                ACTOR.LAST_NAME.eq(lastName)  // 추가 AND 조건
            )
            .fetchInto(Actor.class);
}

생성된 SQL

SELECT * FROM `actor`
WHERE (
    (`actor`.`first_name` = 'ED' OR `actor`.`last_name` = 'CHASE')
    AND 
    `actor`.`last_name` = 'CHASE'
)

jOOQ는 자동으로 괄호를 추가하여 논리적 우선순위를 명확히 한다

동적 조건 – IN 절

가장 흔한 동적 쿼리 패턴은 리스트가 비어있을 때 조건을 제외하는 것이다

문제 상황

public List<Actor> findByActorIdIn(List<Long> idList) {
    return dslContext
            .selectFrom(ACTOR)
            .where(ACTOR.ACTOR_ID.in(idList))
            .fetchInto(Actor.class);
}

문제점

// 빈 리스트 전달 시
actorRepository.findByActorIdIn(Collections.emptyList());
// 생성된 SQL: WHERE false  (아무것도 조회되지 않음)

// null 전달 시
actorRepository.findByActorIdIn(null);
// 생성된 SQL: WHERE false  (아무것도 조회되지 않음)

해결 – DSL.noCondition()

DSL.noCondition()을 반환하면 해당 조건이 WHERE 절에서 제외된다

public List<Actor> findByActorIdIn(List<Long> idList) {
    return dslContext
            .selectFrom(ACTOR)
            .where(inIfNotEmpty(ACTOR.ACTOR_ID, idList))
            .fetchInto(Actor.class);
}

private Condition inIfNotEmpty(Field<Long> field, List<Long> idList) {
    if (CollectionUtils.isEmpty(idList)) {
        return DSL.noCondition();  // 조건 제외
    }
    return field.in(idList);
}

동작

// 빈 리스트
actorRepository.findByActorIdIn(Collections.emptyList());
// 생성된 SQL: SELECT * FROM `actor`  (조건 없음, 전체 조회)

// 값이 있는 리스트
actorRepository.findByActorIdIn(List.of(1L, 2L, 3L));
// 생성된 SQL: WHERE `actor`.`actor_id` IN (1, 2, 3)

테스트

@Test
@DisplayName("IN절 - 빈 리스트 시 조건 제외")
void in절_동적_조건_빈_리스트() {
    // when
    List<Actor> actorList = actorRepository
        .findByActorIdIn(Collections.emptyList());

    // then
    assertThat(actorList).hasSizeGreaterThan(1);  // 전체 조회
}

@Test
@DisplayName("IN절 - 값이 있을 때 정상 조회")
void in절_동적_조건_값_있음() {
    // when
    List<Actor> actorList = actorRepository
        .findByActorIdIn(List.of(1L));

    // then
    assertThat(actorList).hasSize(1);
    assertThat(actorList.get(0).getActorId()).isEqualTo(1L);
}

유틸리티 클래스로 추출

동적 조건 로직은 재사용성이 높으므로 유틸리티로 추출한다

JooqConditionUtils 생성

package org.sight.jooqstart.utils.jooq;

import org.jooq.Condition;
import org.jooq.Field;
import org.jooq.impl.DSL;
import org.springframework.util.CollectionUtils;

import java.util.List;

public class JooqConditionUtils {
    
    /**
     * 값이 null이 아닐 때만 equals 조건 추가
     */
    public static <T> Condition eqIfNotNull(Field<T> field, T value) {
        if (value == null) {
            return DSL.noCondition();
        }
        return field.eq(value);
    }

    /**
     * 리스트가 비어있지 않을 때만 IN 조건 추가
     */
    public static <T> Condition inIfNotEmpty(Field<T> field, List<T> values) {
        if (CollectionUtils.isEmpty(values)) {
            return DSL.noCondition();
        }
        return field.in(values);
    }

    /**
     * 문자열이 blank가 아닐 때만 LIKE 조건 추가
     */
    public static Condition containsIfNotBlank(Field<String> field, String value) {
        if (value == null || value.isBlank()) {
            return DSL.noCondition();
        }
        return field.like("%" + value + "%");
    }
}

사용

import static org.sight.jooqstart.utils.jooq.JooqConditionUtils.*;

@Repository
public class ActorRepository {
    private final DSLContext dslContext;
    private final JActor ACTOR = JActor.ACTOR;

    public ActorRepository(DSLContext dslContext) {
        this.dslContext = dslContext;
    }

    public List<Actor> findByActorIdIn(List<Long> idList) {
        return dslContext
                .selectFrom(ACTOR)
                .where(inIfNotEmpty(ACTOR.ACTOR_ID, idList))  // 유틸 사용
                .fetchInto(Actor.class);
    }
}

복잡한 다중 조건 검색

실전에서는 여러 조건을 조합하여 검색하는 경우가 많다. 배우의 필모그래피(출연작 목록)를 검색하는 예제를 살펴보자

도메인 모델

@Getter
public class ActorFilmography {
    private final Actor actor;
    private final List<Film> filmList;

    public ActorFilmography(Actor actor, List<Film> filmList) {
        this.actor = actor;
        this.filmList = filmList;
    }
}

검색 조건 DTO

@Builder
@Getter
public class ActorFilmographySearchOption {
    private final String actorName;   // 배우 이름 (선택)
    private final String filmTitle;   // 영화 제목 (선택)
}

Repository 구현

@Repository
public class ActorRepository {
    private final DSLContext dslContext;
    private final JActor ACTOR = JActor.ACTOR;

    public List<ActorFilmography> findActorFilmography(
        ActorFilmographySearchOption searchOption
    ) {
        final JFilmActor FILM_ACTOR = JFilmActor.FILM_ACTOR;
        final JFilm FILM = JFilm.FILM;

        // 1. 조인 쿼리 실행 및 그룹화
        Map<Actor, List<Film>> actorFilmMap = dslContext
                .select(
                    DSL.row(ACTOR.fields()).as("actor"),
                    DSL.row(FILM.fields()).as("film")
                )
                .from(ACTOR)
                .join(FILM_ACTOR)
                    .on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
                .join(FILM)
                    .on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
                .where(
                    // 동적 조건 1: 배우 풀네임 LIKE 검색
                    containsIfNotBlank(
                        ACTOR.FIRST_NAME.concat(" ").concat(ACTOR.LAST_NAME),
                        searchOption.getActorName()
                    ),
                    // 동적 조건 2: 영화 제목 LIKE 검색
                    containsIfNotBlank(
                        FILM.TITLE,
                        searchOption.getFilmTitle()
                    )
                )
                .fetchGroups(
                    record -> record.get("actor", Actor.class),
                    record -> record.get("film", Film.class)
                );

        // 2. Map을 도메인 모델로 변환
        return actorFilmMap.entrySet().stream()
                .map(entry -> new ActorFilmography(
                    entry.getKey(),
                    entry.getValue()
                ))
                .toList();
    }
}

코드 분석

fetchGroups()의 동작

fetchGroups()는 1:N 관계를 Map<K, List<V>> 형태로 반환한다

.fetchGroups(
    record -> record.get("actor", Actor.class),   // Key: Actor
    record -> record.get("film", Film.class)      // Value: List<Film>
)
// 결과: Map<Actor, List<Film>>
alias 사용 이유

as(“actor”)와 as(“film”)으로 별칭을 지정하면 fetchGroups에서 명확하게 컬럼을 구분할 수 있다

DSL.row(ACTOR.fields()).as("actor")    // actor.* 에 별칭 부여
DSL.row(FILM.fields()).as("film")      // film.* 에 별칭 부여
concat()으로 풀네임 검색
ACTOR.FIRST_NAME.concat(" ").concat(ACTOR.LAST_NAME)
// SQL: CONCAT(CONCAT(`first_name`, ' '), `last_name`)
// 결과: "PENELOPE GUINESS"

생성된 SQL

조건 1개 (배우 이름만)
SELECT `actor`.`actor_id` AS `actor.actor_id`,
       `actor`.`first_name` AS `actor.first_name`,
       -- ... 생략
       `film`.`film_id` AS `film.film_id`,
       `film`.`title` AS `film.title`
       -- ... 생략
FROM `actor`
JOIN `film_actor` ON `actor`.`actor_id` = `film_actor`.`actor_id`
JOIN `film` ON `film_actor`.`film_id` = `film`.`film_id`
WHERE CONCAT(CONCAT(`actor`.`first_name`, ' '), `actor`.`last_name`) 
      LIKE '%LOLLOBRIGIDA%'
조건 2개 (배우 이름 + 영화 제목)
WHERE (
    CONCAT(CONCAT(`actor`.`first_name`, ' '), `actor`.`last_name`) 
        LIKE '%LOLLOBRIGIDA%'
    AND 
    `film`.`title` LIKE '%COMMANDMENTS EXPRESS%'
)

테스트

@Test
@DisplayName("다중 조건 검색 - 배우 이름으로 조회")
void 다중_조건_검색_배우이름() {
    // given
    var searchOption = ActorFilmographySearchOption.builder()
            .actorName("LOLLOBRIGIDA")
            .build();

    // when
    List<ActorFilmography> result = actorRepository
        .findActorFilmography(searchOption);

    // then
    assertThat(result).hasSize(1);
    assertThat(result.get(0).getActor().getLastName())
        .contains("LOLLOBRIGIDA");
}

@Test
@DisplayName("다중 조건 검색 - 배우 이름과 영화 제목으로 조회")
void 다중_조건_검색_배우이름_영화제목() {
    // given
    var searchOption = ActorFilmographySearchOption.builder()
            .actorName("LOLLOBRIGIDA")
            .filmTitle("COMMANDMENTS EXPRESS")
            .build();

    // when
    List<ActorFilmography> result = actorRepository
        .findActorFilmography(searchOption);

    // then
    assertThat(result).hasSize(1);
    assertThat(result.get(0).getFilmList()).hasSize(1);
    assertThat(result.get(0).getFilmList().get(0).getTitle())
        .isEqualTo("COMMANDMENTS EXPRESS");
}

jOOQ의 1:N 매핑 한계

위 예제에서 보듯이 jOOQ는 1:N 관계 매핑이 JPA만큼 직관적이지 않다

JPA vs jOOQ

JPA
@Entity
public class Actor {
    @OneToMany(mappedBy = "actor")
    private List<Film> films;  // 자동 매핑
}

// 사용
List<Actor> actors = actorRepository.findAll();
actors.get(0).getFilms();  // 자동으로 조회됨
jOOQ
// 1. fetchGroups()로 Map 생성
Map<Actor, List<Film>> map = dslContext.select(...)
    .fetchGroups(...);

// 2. 수동으로 도메인 모델 변환
return map.entrySet().stream()
    .map(e -> new ActorFilmography(e.getKey(), e.getValue()))
    .toList();

권장 패턴

복잡한 1:N 관계는 다음과 같이 처리한다

  • 단순 조회: fetchGroups() 사용
  • 복잡한 비즈니스 로직: 별도 쿼리로 분리
// Actor 조회
   List<Actor> actors = findActors(...);
   
   // Film 별도 조회 후 조합
   actors.forEach(actor -> {
       List<Film> films = findFilmsByActorId(actor.getId());
       // 조합 로직
   });

동적 조건 패턴 정리

기본 equals 조건

public static <T> Condition eqIfNotNull(Field<T> field, T value) {
    return value == null ? DSL.noCondition() : field.eq(value);
}

// 사용
.where(eqIfNotNull(ACTOR.FIRST_NAME, firstName))

IN 절

public static <T> Condition inIfNotEmpty(Field<T> field, List<T> values) {
    return CollectionUtils.isEmpty(values) 
        ? DSL.noCondition() 
        : field.in(values);
}

// 사용
.where(inIfNotEmpty(ACTOR.ACTOR_ID, idList))

LIKE 검색

public static Condition containsIfNotBlank(Field<String> field, String value) {
    if (value == null || value.isBlank()) {
        return DSL.noCondition();
    }
    return field.like("%" + value + "%");
}

// 사용
.where(containsIfNotBlank(ACTOR.FIRST_NAME, searchTerm))

범위 검색

public static <T> Condition betweenIfNotNull(
    Field<T> field, 
    T min, 
    T max
) {
    if (min == null && max == null) {
        return DSL.noCondition();
    }
    if (min == null) {
        return field.le(max);  // <= max
    }
    if (max == null) {
        return field.ge(min);  // >= min
    }
    return field.between(min, max);
}

// 사용
.where(betweenIfNotNull(FILM.LENGTH, minLength, maxLength))

실전 팁

조건 조합의 우선 순위

// 의도와 다를 수 있음
.where(A.or(B), C)  
// SQL: (A OR B) AND C

// 명확한 의도 표현
.where(A.or(B).and(C))
// SQL: (A OR B) AND C

.where(A.and(B).or(C))
// SQL: (A AND B) OR C

null 안전성

// NPE 위험
public static Condition like(Field<String> field, String value) {
    return field.like("%" + value + "%");  // value가 null이면 NPE
}

// null 안전
public static Condition likeIfNotBlank(Field<String> field, String value) {
    if (value == null || value.isBlank()) {
        return DSL.noCondition();
    }
    return field.like("%" + value + "%");
}

빈 조건 처리

// 모든 조건이 noCondition()이면?
.where(
    eqIfNotNull(ACTOR.FIRST_NAME, null),    // noCondition()
    eqIfNotNull(ACTOR.LAST_NAME, null)      // noCondition()
)
// 생성된 SQL: SELECT * FROM actor (WHERE 절 없음)

jOOQ는 모든 조건이 noCondition()일 때 where 절 자체를 생략한다

jOOQ의 조건절 처리

  • 타입 안전성: 컴파일 타임에 오류 검증
  • 동적 쿼리: DSL.noCondition()으로 유연한 조건 제어
  • 메서드 체이닝: 읽기 쉽고 직관적인 코드
  • 유틸리티 추출: 재사용 가능한 조건 로직

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