jOOQ DAO

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);  // 트랜잭션 필요
    }
}

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