jOOQ 쿼리 SELECT와 JOIN

jOOQ 쿼리 작성의 시작점 – DSLContext

모든 jOOQ 쿼리는 DSLContext로부터 시작된다. Spring Boot에서는 spring-boot-starter-jooq 의존성을 추가하면 자도응로 빈으로 등록되어 주입받아 사용할 수 있다.

@Repository
@RequiredArgsConstructor
public class FilmRepository {
    private final DSLContext dslContext;
    private final JFilm FILM = JFilm.FILM;
    
    // 모든 쿼리는 dslContext로부터 시작
}

jOOQ의 SQL Dialect 지원

jOOQ는 JPA처럼 데이터베이스 방언(Dialect)을 지원한다. 같은 기능이라도 DB 벤더마다 다른 SQL 문법을 사용하는 경우, jOOQ가 자동으로 적절한 SQL을 생성해준다

pagination 예시

MySQL

SELECT * FROM film
LIMIT 10 OFFSET 20

PostgreSQL

SELECT * FROM film
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY

jOOQ 코드 (공통)

dslContext.selectFrom(FILM)
    .limit(10)
    .offset(20)
    .fetch();

GROUP_CONCAT / STRING_AGG 예시

그룹화 시 문자열을 연결하는 함수도 DB마다 다르다

MySQL

SELECT category_id, GROUP_CONCAT(name) 
FROM category 
GROUP BY category_id

PostgreSQL

SELECT category_id, STRING_AGG(name, ',') 
FROM category 
GROUP BY category_id

jOOQ 코드 (공통)

dslContext.select(
        CATEGORY.CATEGORY_ID,
        DSL.groupConcat(CATEGORY.NAME)
    )
    .from(CATEGORY)
    .groupBy(CATEGORY.CATEGORY_ID)
    .fetch();

설정된 Dialect에 따라 jOOQ가 자동으로 적절한 함수를 선택한다

기본 조회 – 전체 컬럼 조회

가장 기본적인 단일 레코드 조회

@Repository
@RequiredArgsConstructor
public class FilmRepository {
    private final DSLContext dslContext;
    private final JFilm FILM = JFilm.FILM;

    public Film findById(Long id) {
        return dslContext
                .select(FILM.fields())           // 모든 필드 선택
                .from(FILM)
                .where(FILM.FILM_ID.eq(id))
                .fetchOneInto(Film.class);       // Film POJO로 변환
    }
}

코드 분석

  • select(FILM.fields()): 테이블의 모든 컬럼을 선택한다. SELECT * 와 동일하지만 타입 안전하다
  • fetchOneInto(Film.class): 결과를 Film POJO로 매핑한다

실행 결과

생성된 SQL

SELECT `film`.`film_id`, 
       `film`.`title`, 
       `film`.`description`,
       `film`.`release_year`,
       -- ... 모든 컬럼
FROM `film`
WHERE `film`.`film_id` = 1

jOOQ의 장점

// 컬럼이 추가되어도 코드 수정 불필요
select(FILM.fields())  // 항상 최신 컬럼 정보 반영

MyBatis라면 XML에 컬럼을 직접 나열해야 하고, 컬럼 추가/삭제 시 모든 쿼리를 찾아 수정해야 한다. jOOQ는 DSL 재생성만으로 자동 반영된다

특정 컬럼만 조회하기

필요한 컬럼만 선택하여 별도 DTO로 받아올 수 있다

DTO 정의

@Getter
public class SimpleFilmInfo {
    private Long filmId;
    private String title;
    private String description;
}

주의: Setter가 없어도 jOOQ는 Reflection을 사용하여 필드에 값을 주입할 수 있다

Repository 구현

public SimpleFilmInfo findSimpleFilmInfoById(Long id) {
    return dslContext
            .select(
                FILM.FILM_ID,
                FILM.TITLE,
                FILM.DESCRIPTION
            )
            .from(FILM)
            .where(FILM.FILM_ID.eq(id))
            .fetchOneInto(SimpleFilmInfo.class);
}

테스트 코드

@Test
@DisplayName("영화 정보 일부 조회")
void test() {
    SimpleFilmInfo info = filmRepository.findSimpleFilmInfoById(1L);
    
    assertThat(info).hasNoNullFieldsOrProperties();
    assertThat(info.getFilmId()).isEqualTo(1L);
}

실행 결과

생성된 SQL

SELECT `film`.`film_id`,
       `film`.`title`,
       `film`.`description`
FROM `film`
WHERE `film`.`film_id` = 1

복잡한 조인 쿼리

실전 예제로 영화와 출연 배우 정보를 페이징하여 조회하는 기능을 구현한다

ERD 구조

Film (1) ---- (N) FilmActor (N) ---- (1) Actor
  • film: 영화 정보
  • film_actor: 영화-배우 매핑 테이블
  • actor: 배우 정보

도메인 모델 설계

자동 생성된 POJO들을 조합한 도메인 모델을 만든다

@Getter
@RequiredArgsConstructor
public class FilmWithActor {
    private final Film film;
    private final FilmActor filmActor;
    private final Actor actor;

    // 비즈니스 로직 메서드
    public String getTitle() {
        return film.getTitle();
    }

    public String getActorFullName() {
        return actor.getFirstName() + " " + actor.getLastName();
    }

    public Long getFilmId() {
        return film.getFilmId();
    }
}

설계 포인트

  • 자동 생성된 POJO는 수정하지 않는다
  • 비즈니스 로직은 도메인 모델(FilmWithActor)에 작성한다
  • Persistence 레이어와 Domain 레이어를 명확히 분리한다

Repository 구현

public List<FilmWithActor> findFilmWithActorList(Long page, Long pageSize) {
    JFilmActor FILM_ACTOR = JFilmActor.FILM_ACTOR;
    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))
            .join(ACTOR)
                .on(FILM_ACTOR.ACTOR_ID.eq(ACTOR.ACTOR_ID))
            .offset((page - 1) * pageSize)
            .limit(pageSize)
            .fetchInto(FilmWithActor.class);
}

코드 분석

DSL.row()의 역할

DSL.row()를 사용하면 여러 컬럼을 하나의 객체로 그룹화할 수 있다

DSL.row(FILM.fields())         // Film의 모든 필드 → Film 객체
DSL.row(FILM_ACTOR.fields())   // FilmActor의 모든 필드 → FilmActor 객체
DSL.row(ACTOR.fields())        // Actor의 모든 필드 → Actor 객체

jOOQ는 FilmWithActor의 생성자와 시그니처를 보고 자동으로 매핑한다

JOIN 타입

.join(TABLE)           // INNER JOIN (기본)
.innerJoin(TABLE)      // INNER JOIN (명시적)
.leftJoin(TABLE)       // LEFT OUTER JOIN
.rightJoin(TABLE)      // RIGHT OUTER JOIN
.fullJoin(TABLE)       // FULL OUTER JOIN

Pagination

.offset((page - 1) * pageSize)  // 시작 위치 (0-based)
.limit(pageSize)                 // 가져올 개수

생성된 SQL

SELECT `film`.`film_id`, `film`.`title`, ...,
       `film_actor`.`actor_id`, `film_actor`.`film_id`, ...,
       `actor`.`actor_id`, `actor`.`first_name`, `actor`.`last_name`, ...
FROM `film`
JOIN `film_actor` 
    ON `film`.`film_id` = `film_actor`.`film_id`
JOIN `actor` 
    ON `film_actor`.`actor_id` = `actor`.`actor_id`
OFFSET 0
LIMIT 20

Service 레이어 – 페이징 응답 구성

Repository에서 가져온 데이터를 가져온 API 응답 형태로 가공한다

응답 DTO 정의

@Getter
public class FilmWithActorPagedResponse {
    private final PagedResponse page;
    private final List<FilmActorResponse> filmWithActorList;

    public FilmWithActorPagedResponse(
        PagedResponse page,
        List<FilmWithActor> filmWithActors
    ) {
        this.page = page;
        this.filmWithActorList = filmWithActors.stream()
            .map(FilmActorResponse::new)
            .toList();
    }

    @Getter
    public static class FilmActorResponse {
        private final String filmTitle;
        private final String actorFullName;
        private final Long filmId;

        public FilmActorResponse(FilmWithActor filmWithActor) {
            this.filmTitle = filmWithActor.getTitle();
            this.actorFullName = filmWithActor.getActorFullName();
            this.filmId = filmWithActor.getFilmId();
        }
    }
}

@Getter
@NoArgsConstructor
@AllArgsConstructor
class PagedResponse {
    private long page;
    private long pageSize;
}

Service 구현

@Service
@RequiredArgsConstructor
public class FilmService {
    private final FilmRepository filmRepository;

    public FilmWithActorPagedResponse getFilmActorPageResponse(
        Long page, 
        Long pageSize
    ) {
        List<FilmWithActor> filmWithActors = 
            filmRepository.findFilmWithActorList(page, pageSize);
        
        PagedResponse pageInfo = new PagedResponse(page, pageSize);
        
        return new FilmWithActorPagedResponse(pageInfo, filmWithActors);
    }
}

테스트 코드

@Test
@DisplayName("영화와 영화에 출연한 배우 정보를 페이징하여 조회")
void test() {
    FilmWithActorPagedResponse response = 
        filmService.getFilmActorPageResponse(1L, 20L);
    
    assertThat(response.getFilmWithActorList()).hasSize(20);
    assertThat(response.getPage().getPage()).isEqualTo(1L);
    assertThat(response.getPage().getPageSize()).isEqualTo(20L);
}

MyBatis와의 비교

같은 기능을 MyBatis로 구현

MyBatis 방식

<!-- FilmMapper.xml -->
<select id="findFilmWithActorList" resultMap="FilmWithActorMap">
    SELECT 
        f.film_id, f.title, f.description, f.release_year, ...,
        fa.actor_id, fa.film_id,
        a.actor_id, a.first_name, a.last_name, ...
    FROM film f
    JOIN film_actor fa ON f.film_id = fa.film_id
    JOIN actor a ON fa.actor_id = a.actor_id
    LIMIT #{limit} OFFSET #{offset}
</select>

<resultMap id="FilmWithActorMap" type="FilmWithActor">
    <association property="film" javaType="Film">
        <id property="filmId" column="film_id"/>
        <result property="title" column="title"/>
        <!-- 모든 필드 수동 매핑 -->
    </association>
    <association property="filmActor" javaType="FilmActor">
        <!-- 모든 필드 수동 매핑 -->
    </association>
    <association property="actor" javaType="Actor">
        <!-- 모든 필드 수동 매핑 -->
    </association>
</resultMap>

jOOQ vs MyBatis

측면jOOQMyBatis
컬럼 추가 / 삭제DSL 재생성만 하면 자동 반영XML에서 모든 쿼리 수동 수정
타입 안전성컴파일 타임에 오류 검증런타임에 오류 발견
리팩토링IDE의 리팩토링 도구 사용 가능문자열 검색으로 수동 수정
코드량상대적으로 간결XML + Mapper 인터페이스 필요
학습 곡선초기 설정 복잡, 사용은 직관적상대적으로 평이

Fetch 메서드 종류

jOOQ는 다양한 fetch 메서드를 제공한다

// 1. 단일 레코드
Film film = dslContext.select()...fetchOne();          // Record 반환
Film film = dslContext.select()...fetchOneInto(Film.class);  // POJO 반환

// 2. 여러 레코드
List<Film> films = dslContext.select()...fetch();                // List<Record> 반환
List<Film> films = dslContext.select()...fetchInto(Film.class);  // List<Film> 반환

// 3. Optional 반환
Optional<Film> film = dslContext.select()...fetchOptional();
Optional<Film> film = dslContext.select()...fetchOptionalInto(Film.class);

// 4. 스트림 처리
dslContext.select()...fetchStream()
    .map(record -> ...)
    .collect(Collectors.toList());

// 5. 특정 컬럼만 추출
List<String> titles = dslContext.select(FILM.TITLE)
    .from(FILM)
    .fetch(FILM.TITLE);  // List<String> 반환

자동 생성된 DAO 활용

단순 CRUD는 자동 생성된 DAO를 사용하면 더 간편해진다

@Repository
@RequiredArgsConstructor
public class FilmRepository {
    private final DSLContext dslContext;
    private final FilmDao filmDao;  // 자동 생성된 DAO

    // DAO를 사용한 단순 조회
    public Film findById(Long id) {
        return filmDao.fetchOneByFilmId(id);  // 자동 생성된 메서드
    }

    // 복잡한 쿼리는 DSLContext 사용
    public List<FilmWithActor> findFilmWithActorList(Long page, Long pageSize) {
        // 위에서 작성한 복잡한 조인 쿼리
    }
}

자동 생성된 DAO 메서드

generate { daos = true } 설정 시 생성된다

public class FilmDao extends DAOImpl<FilmRecord, Film, Long> {
    // 자동 생성된 메서드들
    public void insert(Film film) {...}
    public void update(Film film) {...}
    public void delete(Film film) {...}
    public void deleteById(Long id) {...}
    public Film fetchOneByFilmId(Long id) {...}
    public List<Film> fetch() {...}
    // ... 더 많은 편의 메서드들
}

실전 팁

테이블 상수 선언

Repository에서 사용하는 테이블은 상수로 선언하면 편리하다

@Repository
@RequiredArgsConstructor
public class FilmRepository {
    private final DSLContext dslContext;
    
    // 테이블 상수 선언
    private final JFilm FILM = JFilm.FILM;
    private final JFilmActor FILM_ACTOR = JFilmActor.FILM_ACTOR;
    private final JActor ACTOR = JActor.ACTOR;
}

forcedTypes 활용

MySQL의 UNSIGNED INT를 Java의 Long으로 매핑할 수 있다

database {
    forcedTypes {
        forcedType {
            userType = 'java.lang.Long'
            includeTypes = 'int unsigned'
        }
        forcedType {
            userType = 'java.lang.Integer'
            includeTypes = 'tinyint unsigned'
        }
    }
}

쿼리 로깅 활성화

application.yml

logging:
  level:
    org.jooq.tools.LoggerListener: DEBUG

jOOQ를 사용하면

  • 타입 안전성: 컴파일 타임에 SQL 오류를 잡아낸다
  • 생산성: 복잡한 조인도 간결하게 작성할 수 있다
  • 유지보수성: 스키마 변경 시 컴파일 에러로 즉시 알 수 있다
  • DB 독립성: Dialect 지원으로 DB 변경 시 코드 수정이 최소화된다

초기 설정은 복잡하지만, 한 번 구성하면 MyBatis보다 훨씬 안전하고 생산적으로 쿼리를 작성할 수 있다.

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