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
| 측면 | jOOQ | MyBatis |
| 컬럼 추가 / 삭제 | 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보다 훨씬 안전하고 생산적으로 쿼리를 작성할 수 있다.