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()으로 유연한 조건 제어
- 메서드 체이닝: 읽기 쉽고 직관적인 코드
- 유틸리티 추출: 재사용 가능한 조건 로직