jOOQ는 generate { daos = true } 설정으로 각 테이블마다 기본 CRUD 메서드를 포함한 DAOImpl을 상속한 DAO 클래스를 자동 생성한다
자동 생성된 DAO란?
jOOQ는 각 테이블에 대해 DAOImpl을 상속받은 DAO 클래스를 생성한다
생성 위치
src/generated ├── tables/ │ ├── daos/ ← 자동 생성된 DAO │ │ ├── ActorDao │ │ ├── FilmDao │ │ └── ... │ ├── pojos/ │ └── records/
FilmDao 구조
public class FilmDao extends DAOImpl<FilmRecord, Film, Long> {
public FilmDao() {
super(JFilm.FILM, Film.class);
}
public FilmDao(Configuration configuration) {
super(JFilm.FILM, Film.class, configuration);
}
@Override
public Long getId(Film object) {
return object.getFilmId();
}
// ID로 조회
public Film fetchOneByJFilmId(Long value) {...}
public Optional<Film> fetchOptionalByJFilmId(Long value) {...}
// 범위 조회
public List<Film> fetchRangeOfJLength(Integer lower, Integer upper) {...}
// IN 절 조회
public List<Film> fetchByJTitle(String... values) {...}
// 기본 CRUD 메서드
public void insert(Film film) {...}
public void update(Film film) {...}
public void delete(Film film) {...}
}
DAO 활용 방법 – 상속 (Is-A)
DAO를 직접 상속받아 사용하는 방법
구현
@Repository
public class FilmRepositoryIsA extends FilmDao {
private final DSLContext dslContext;
private final JFilm FILM = JFilm.FILM;
public FilmRepositoryIsA(Configuration configuration, DSLContext dslContext) {
super(configuration); // Configuration 필수
this.dslContext = dslContext;
}
// 커스텀 쿼리도 작성 가능
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);
}
// 복잡한 조인 쿼리
public List<FilmWithActors> 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))
.limit(pageSize)
.offset((page - 1) * pageSize)
.fetchInto(FilmWithActors.class);
}
}
Configuration이 필요한 이유
// Configuration 없이 생성하면
public FilmRepositoryIsA(DSLContext dslContext) {
super(); // 기본 생성자 호출
this.dslContext = dslContext;
}
// 실행 시 오류 발생
org.jooq.exception.DetachedException:
Cannot execute query. No JDBC Connection configured
- 원인: 자동 생성된 DAO는 내부적으로 JDBC Connection을 사용하는데 Configuration이 없으면 연결 정보를 알 수 없다
- 해결: Spring Boot는 org.jooq.Configuration을 자동으로 빈으로 등록하므로, 생성자에서 주입받아 부모 클래스에 전달한다
public FilmRepositoryIsA(Configuration configuration, DSLContext dslContext) {
super(configuration); // Connection 정보 전달
this.dslContext = dslContext;
}
사용 예시
@SpringBootTest
public class JooqDaoTest {
@Autowired
FilmRepositoryIsA filmRepository;
@Test
void 상속_기본_조회() {
// 자동 생성된 메서드 사용
Film film = filmRepository.findById(10L);
assertThat(film).isNotNull();
assertThat(film.getFilmId()).isEqualTo(10L);
}
@Test
void 상속_범위_조회() {
// 영화 길이가 100~180분 사이인 영화 조회
List<Film> films = filmRepository.fetchRangeOfJLength(100, 180);
assertThat(films).allSatisfy(film ->
assertThat(film.getLength()).isBetween(100, 180)
);
}
}
생성된 SQL
SELECT `film`.`film_id`, `film`.`title`, ..., `film`.`last_update` FROM `film` WHERE `film`.`length` BETWEEN 100 AND 180
상속 방식의 문제점
불필요한 메서드 노출
// FilmRepositoryIsA가 상속받은 모든 public 메서드가 외부에 노출됨 filmRepository.fetchRangeOfJFilmId(...) // 사용하지 않음 filmRepository.fetchByJTitle(...) // 사용하지 않음 filmRepository.fetchRangeOfJReleaseYear(...) // 사용하지 않음 filmRepository.exists(...) // 사용하지 않음 // ... 수십 개의 메서드
필요한 메서드만 선별적으로 제공하기 어렵다
메서드명에 불필요한 J prefix
// 자동 생성된 메서드명 fetchRangeOfJLength(...) // J가 붙음 fetchByJFilmId(...) // J가 붙음 fetchOptionalByJFilmId(...) // J가 붙음
테이블 클래스에 J preifx를 설정하면, DAO 메서드명에도 J가 붙는 이슈가 있다 (jOOQ 3.20부터 수정 예정)
DAO 활용 방법 – 컴포지션 (Has-A) 권장
DAO를 필드로 포함하고, 필요한 메서드만 래핑하여 노출하는 방법이다
구현
@Repository
public class FilmRepositoryHasA {
private final DSLContext dslContext;
private final JFilm FILM = JFilm.FILM;
private final FilmDao dao;
public FilmRepositoryHasA(DSLContext dslContext, Configuration configuration) {
this.dao = new FilmDao(configuration); // DAO 인스턴스 생성
this.dslContext = dslContext;
}
// 필요한 메서드만 선별적으로 노출
public Film findById(Long id) {
return dao.fetchOneByJFilmId(id);
}
public List<Film> findByLengthBetween(Integer from, Integer to) {
return dao.fetchRangeOfJLength(from, to);
}
// 커스텀 쿼리
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);
}
// 복잡한 조인 쿼리
public List<FilmWithActors> 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))
.limit(pageSize)
.offset((page - 1) * pageSize)
.fetchInto(FilmWithActors.class);
}
}
사용 예시
@SpringBootTest
public class JooqDaoTest {
@Autowired
FilmRepositoryHasA filmRepository;
@Test
@DisplayName("컴포지션: 기본 조회")
void 컴포지션_기본_조회() {
Film film = filmRepository.findById(10L);
assertThat(film).isNotNull();
assertThat(film.getFilmId()).isEqualTo(10L);
}
@Test
@DisplayName("컴포지션: 범위 조회 - 영화 길이 100~180분")
void 컴포지션_범위_조회() {
var start = 100;
var end = 180;
List<Film> films = filmRepository.findByLengthBetween(start, end);
assertThat(films).allSatisfy(film ->
assertThat(film.getLength()).isBetween(start, end)
);
}
}
생성된 SQL
SELECT `film`.`film_id`, `film`.`title`, ..., `film`.`last_update` FROM `film` WHERE `film`.`length` BETWEEN 100 AND 180
컴포지션 방식의 장점
캡슐화 및 메서드 선별 노출
// 필요한 메서드만 명시적으로 제공 filmRepository.findById(10L) filmRepository.findByLengthBetween(100, 180) // 불필요한 메서드는 노출되지 않음 // filmRepository.fetchRangeOfJFilmId(...) - 접근 불가 // filmRepository.exists(...) - 접근 불가
명확한 메서드명 제공
// DAO의 복잡한 메서드명을 비즈니스 친화적으로 변경 dao.fetchRangeOfJLength(from, to) → findByLengthBetween(from, to) dao.fetchOneByJFilmId(id) → findById(id) dao.fetchOptionalByJFilmId(id) → findOptionalById(id)
유연한 확장
public List<Film> findPopularFilms(Integer minLength) {
// DAO와 DSLContext를 조합하여 복잡한 로직 구현
List<Film> candidates = dao.fetchRangeOfJLength(minLength, 200);
return candidates.stream()
.filter(this::isPopular) // 추가 비즈니스 로직
.toList();
}
private boolean isPopular(Film film) {
// 복잡한 인기도 계산 로직
return film.getRentalRate().compareTo(BigDecimal.valueOf(2.99)) > 0;
}
상속 VS 컴포지션 비교
| 측면 | 상속 (Is-A) | 컴포지션 (Has-A) |
| 메서드 노출 | 모든 DAO 메서드 노출 | 필요한 메서드만 선별 노출 |
| 메서드명 | fetchRangeOfJLength (J prefix) | findByLengthBetween (커스텀) |
| 캡슐화 | 낮음 | 높음 |
| 유연성 | 제한적 | 높음 (래핑 + 추가로직) |
| 코드량 | 적음 | 약간 많음 (래핑 메서드 작성) |
| 권장도 | 비권장 | 권장 |
실전 활용 패턴
단순 조회는 DAO 활용
@Repository
public class ActorRepository {
private final ActorDao dao;
private final DSLContext dslContext;
public ActorRepository(Configuration configuration, DSLContext dslContext) {
this.dao = new ActorDao(configuration);
this.dslContext = dslContext;
}
// 단순 조회 - DAO 활용
public Actor findById(Long id) {
return dao.fetchOneByJActorId(id);
}
public List<Actor> findByLastName(String lastName) {
return dao.fetchByJLastName(lastName);
}
// 복잡한 쿼리 - DSLContext 사용
public List<ActorWithFilmCount> findActorsWithFilmCount() {
JActor ACTOR = JActor.ACTOR;
JFilmActor FILM_ACTOR = JFilmActor.FILM_ACTOR;
return dslContext
.select(
ACTOR.ACTOR_ID,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME,
DSL.count(FILM_ACTOR.FILM_ID).as("film_count")
)
.from(ACTOR)
.leftJoin(FILM_ACTOR).on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
.groupBy(ACTOR.ACTOR_ID)
.fetchInto(ActorWithFilmCount.class);
}
}
Optional 반환 활용
public Optional<Film> findOptionalById(Long id) {
return dao.fetchOptionalByJFilmId(id);
}
// 사용
filmRepository.findOptionalById(999L)
.ifPresentOrElse(
film -> log.info("Found: {}", film.getTitle()),
() -> log.warn("Film not found")
);
IN 절 조회
public List<Film> findByIds(Long... ids) {
return dao.fetchByJFilmId(ids);
}
// 사용
List<Film> films = filmRepository.findByIds(1L, 5L, 10L, 20L);
생성된 SQL
SELECT * FROM `film` WHERE `film`.`film_id` IN (1, 5, 10, 20)
범위 조회 활용
public List<Film> findRecentFilms(Year from, Year to) {
return dao.fetchRangeOfJReleaseYear(from, to);
}
// 사용
List<Film> recentFilms = filmRepository.findRecentFilms(
Year.of(2020),
Year.of(2024)
);
자동 생성 DAO의 주요 메서드
기본 CRUD
// 삽입 dao.insert(film); // 수정 dao.update(film); // 삭제 dao.delete(film); dao.deleteById(10L); // 존재 여부 boolean exists = dao.existsById(10L); // 전체 조회 List<Film> allFilms = dao.findAll(); // 개수 int count = dao.count();
조건부 조회
// 단일 조회 Film film = dao.fetchOne(FILM.TITLE, "ACADEMY DINOSAUR"); Optional<Film> optFilm = dao.fetchOptional(FILM.FILM_ID, 10L); // 다중 조회 List<Film> films = dao.fetch(FILM.RATING, FilmRating.PG); // 범위 조회 List<Film> films = dao.fetchRange(FILM.LENGTH, 90, 120);
주의사항 및 팁
Configuration 주입 필수
// 잘못된 예
public FilmRepository(DSLContext dslContext) {
this.dao = new FilmDao(); // Configuration 없음
}
// 올바른 예
public FilmRepository(Configuration configuration, DSLContext dslContext) {
this.dao = new FilmDao(configuration);
}
트랜잭션 처리
DAO의 insert/update/delete는 자동으로 트랜잭션 경계를 관리하고 않는다
@Service
@RequiredArgsConstructor
public class FilmService {
private final FilmRepository filmRepository;
@Transactional
public void updateFilm(Film film) {
filmRepository.update(film); // 트랜잭션 필요
}
}