본문 바로가기
DB

[jOOQ] ad-hoc 방식을 통한 One-to-One, One-to-Many 조회

by soro.k 2025. 2. 2.

 

들어가기 전에

지난 번에 jOOQ가 어떻게 동작하는지를 설명하고 간단한 CRUD 예제 및 테스트 코드를 정리한 글을 올렸었다.

 

[jOOQ] jOOQ는 처음이라

들어가기 전에이번 회사에서 처음으로 jOOQ를 사용하게 됐다. MyBatis와 JPA만 사용했기 때문에 jOOQ를 어떤 이유에서 사용하는지 정확히 파악하기 위해 여러 글들을 봤다. 그래서 이번 글에서는 jOOQ

justsora.tistory.com

 

이번에는 위의 글처럼 단건 혹은 목록 조회일 때가 아니라 일대일, 일대다 관계일 때 jOOQ를 어떻게 활용할 수 있는지 정리해 보고자 한다. 아래의 예제 코드처럼 Nested DTO를 포함해서 조회해야 할 때 참고할 수 있겠다.

public record SampleDto ( // jOOQ를 통해 조회해야 할 class
    int id,
    String title,
    List<Nested1Dto> nested1List, // List 형태의 Nested class (1:N)
    Nested2Dto nested2Dto // Nested class (1:1)
) {
}

 

 

ad-hoc (ad hoc)

제목에 적혀있던 ad-hoc 방식은 무엇일까? 일반적으로 ad-hoc특정 상황을 해결하기 위한 어떠한 것(for a particular purpose)을 의미한다. 예를 들어, 특정 상황에서만 적용되는 수학적 풀이법에 대해서는 ad hoc solution이라고 표현할 수 있다.

 

그렇다면 jOOQ에서 항상 ad-hoc 방식으로 매핑이 이루어진다고 한 것은 어떤 의미일까?

In jOOQ, such a mapping is always ad-hoc, on a per query basis, so it does not need to reflect your actual database model. 

출처 : https://www.jooq.org/doc/latest/manual/coming-from-jpa/from-jpa-manytoone/

 

 

간단하게 일대일 관계를 가진 book 테이블과 book_detail 테이블을 통해 jOOQ의 매핑 방법을 설명해 보려고 한다. JPA의 매핑 방식과 비교하면 더 이해하기 쉽기 때문에 JPA부터 어떻게 처리하는지 알아보자.

 

[1] JPA

엔티티 기반, 연관 관계 설정, 자동 매핑

 

JPA는 엔티티를 기반으로 연관 관계를 지정하면 자동으로 매핑이 이루어진다. 그리고 데이터 로딩 시점을 정해 이 설정에 따라 각 엔티티가 조회된다. 아래 코드를 보면 @OneToOne 어노테이션을 통해 일대일 관계를 정의하면서 지연 로딩 방식으로 해당 엔티티를 필요한 시점에 조회함을 알 수 있다.

 

@Entity
public class Book {

    // 생략

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "book_detail_id", unique = true)
    private BookDetail bookDetail;

}

 

 

[2] jOOQ

쿼리 단위, 동적 매핑

 

반면 jOOQ는 JPA처럼 관계를 미리 정의하고 자동으로 매핑하지 않는다. 조회하고자 하는 데이터에 맞춰 원하는 대로 직접 쿼리를 작성해서 동적으로 매핑하는 방식이다. 그래서 아래처럼 중첩 구조를 활용할 수 있다.

public record BookDto(int id, String title, String author, BookDetailDto detailDto) {}
public record BookDetailDto(int pageSize, String isbn) {}
dslContext
    .select(
        // 생략
        DSL.row(
            BOOK_DETAIL.PAGE_COUNT,
            BOOK_DETAIL.ISBN
        ).mapping(BookDetailDto::new)
    )
    .from(BOOK)
    .join(BOOK_DETAIL).on(BOOK_DETAIL.BOOK_ID.eq(BOOK.ID))
    .fetchOne(Records.mapping(BookDto::new));

 

 

'조회하려는 데이터에 맞춰 원하는 대로 쿼리를 작성하고 동적으로 매핑한다'는 말에서 기시감이 들었다면 그게 맞다. 바로 이것이 ad-hoc 방식의 핵심 개념이기 때문이다.

 

이제 jOOQ의 매핑 방식을 이해했다면 실제 예제를 통해 일대일과 일대다 관계에서의 조회 방법을 살펴보자.

 

 

@OneToOne

앞서 예제로 다뤘던 테이블 정보로 직접 데이터를 조회해 보려고 한다. 각 record 클래스에서는 데이터베이스 모델을 그대로 담지 않고 조회하고자 하는 데이터들로 필드를 구성했다.

 

public record BookDto(int id, String title, String author, BookDetailDto detailDto) {}
public record BookDetailDto(int pageSize, String isbn) {}

 

 

[1] Records.mapping()을 통한 자동 매핑을 활용하는 방법

➟ DSL.row()를 통해 여러 컬럼을 묶어 mapping()으로 하나의 객체로 변환한다.
➟ fetchOne()에서 하나의 객체로 반환한다.
➟ Records.mapping()으로 쿼리 결과로 반환된 컬럼 값들을 자동으로 지정된 DTO 클래스의 필드에 맞춰 매핑해준다.

 

public BookDto getBookById(int id) {
    return dslContext
        .select(
            BOOK.ID,
            BOOK.TITLE,
            BOOK.AUTHOR,
            DSL.row(
                BOOK_DETAIL.PAGE_COUNT,
                BOOK_DETAIL.ISBN
            ).mapping(BookDetailDto::new)
        )
        .from(BOOK)
        .join(BOOK_DETAIL).on(BOOK_DETAIL.BOOK_ID.eq(BOOK.ID))
        .where(BOOK.ID.eq(id))
        .fetchOne(Records.mapping(BookDto::new));
}

 

  • 코드가 2번 방법에 비해 더 직관적이며 간결하다.
  • DSL.row()에서 컬럼을 관리하기 때문에 유지보수하기 쉽다.

 

 

[2] fetchOne() 내부에서 수동으로 매핑하는 방법

➟ fetchOne() 내부에서 직접 BookDto와 BookDetailDto를 생성한다.
➟ record.get()을 통해 각 필드를 직접 매핑한다.

 

public BookDto getBookById(int id) {
    return dslContext
        .select(
            BOOK.ID,
            BOOK.TITLE,
            BOOK.AUTHOR,
            BOOK_DETAIL.PAGE_COUNT,
            BOOK_DETAIL.ISBN
        )
        .from(BOOK)
        .join(BOOK_DETAIL).on(BOOK_DETAIL.BOOK_ID.eq(BOOK.ID))
        .where(BOOK.ID.eq(id))
        .fetchOne(record -> new BookDto(
            record.get(BOOK.ID),
            record.get(BOOK.TITLE),
            record.get(BOOK.AUTHOR),
            new BookDetailDto(
                record.get(BOOK_DETAIL.PAGE_COUNT),
                record.get(BOOK_DETAIL.ISBN)
            )
        ));
}

 

  • 개발자가 직접 매핑 로직을 작성하기 때문에 명확하다.
  • 코드가 길어지며 중복 코드가 발생한다.

 

 

@OneToMany

이번에는 일대다 관계인 book과 review 테이블로 원하는 데이터를 조회하는 방법이다.

 

public record BookDto(int id,String title,String author,List<ReviewDto> reviews) {}
public record ReviewDto(String reviewerName, String comment) {}

 

 

[1] multiset을 활용하는 방법

jOOQ에서 지원하는 multiset 기능을 사용하면 편하게 목록 데이터를 중첩시켜 조회할 수 있다.

➟ DSL.multiset()으로 쿼리 내부의 또 다른 목록 데이터를 조회한다.
➟ convertFrom()으로 결과 값을 원하는 객체로 변환한다.

 

public Book1Dto getBookReviewById(Integer id) {
    return dslContext
        .select(
            BOOK.ID,
            BOOK.TITLE,
            BOOK.AUTHOR,
            DSL.multiset(
                DSL.select(
                    REVIEW.REVIEWER_NAME,
                    REVIEW.COMMENT
                )
                    .from(REVIEW)
                    .where(REVIEW.BOOK_ID.eq(BOOK.ID))
            )
            .as("reviews")
            .convertFrom(record -> record.map(mapping(ReviewDto::new)))
        )
        .from(BOOK)
        .where(BOOK.ID.eq(id))
        .fetchOne(mapping(Book1Dto::new));
}

 

 

[2] JSON을 활용하는 방법

모든 데이터베이스의 Dialect가 multiset을 지원하는 것은 아니다. 예를 들어, PostgreSQL에서는 multiset을 지원하지만 MySQL에서는 제공하지 않는다. 대신 XML이나 Json 형태로 데이터를 조회할 수 있는데 이번 예제에서는 Json으로 조회하는 방법을 선택했다. 또한, 반환 데이터에 Nested DTO가 구현되어있기 때문에 Json 데이터를 DTO로 변환해주는 코드를 구현해줬다.

public BookDto getBookReviewById1(int id) {
    return dslContext
        .select(
            BOOK.ID,
            BOOK.TITLE,
            BOOK.AUTHOR,
            DSL.field(
                "JSON_ARRAYAGG(JSON_OBJECT('reviewerName', {0}, 'comment', {1}))",
                String.class,
                REVIEW.REVIEWER_NAME, REVIEW.COMMENT
            ).as("reviews")
        )
        .from(BOOK)
        .leftJoin(REVIEW).on(REVIEW.BOOK_ID.eq(BOOK.ID))
        .where(BOOK.ID.eq(id))
        .groupBy(BOOK.ID)
        .fetchOne(record -> new BookDto(
            record.get(BOOK.ID),
            record.get(BOOK.TITLE),
            record.get(BOOK.AUTHOR),
            parseJsonToReviewDtoList(record.get("reviews", String.class)) // JSON 변환
        ));
}

 

 

Json 데이터를 DTO로 변환해주는 코드

private List<ReviewDto> parseJsonToReviewDtoList(String json) {
    if (json == null || json.isEmpty()) {
        return Collections.emptyList();
    }
    
    ObjectMapper objectMapper = new ObjectMapper();
    try {
        return objectMapper.readValue(json, new TypeReference<>() {});
    } catch (JsonProcessingException e) {
        // 예외 처리
    }
}

 

 

 

마무리

이번 글에서는 jOOQ가 ad-hoc 방식으로 필요한 데이터에 맞춰 쿼리를 직접 작성하고 데이터베이스 모델을 동적으로 매핑하는 것을 배웠다. 그리고 일대일 및 일대다 관계에서 데이터를 조회하는 예제 코드를 통해 실습할 수 있었다.

 

추가로 일대다 관계 데이터를 조회하는 방법을 다룰 때 multiset을 지원하지 않는 Dialect의 경우, 더 효율적으로 코드를 작성할 수 있는 방법이 있지 않을까 찾아봤다. 쿼리 내부에 문자열로 작성했던 부분은 DSL에서 제공하는 함수 중 같은 이름을 가진 것은 있으나, 자동으로 group_concat_max_len 값을 수정해주는 쿼리가 생성되어서 그런 건지 계속해서 에러가 발생했다.  jOOQ와 MySQL의 문법 체계 혹은 버전이 잘 호환되지 않은 건가 싶어 최종적으로는 SQL 문자열을 그대로 사용하게 됐다.

"JSON_ARRAYAGG(JSON_OBJECT('reviewerName', {0}, 'comment', {1}))"
org.springframework.jdbc.BadSqlGrammarException: jOOQ; bad SQL grammar [set @t = @@group_concat_max_len; set @@group_concat_max_len = 4294967295; select `db`.`book`.`id`, `db`.`book`.`title`, `db`.`book`.`author`, json_merge_preserve('[]', concat('[', group_concat(json_object(cast(reviewerName as char(100)), cast(comment as char)) separator ','), ']')) as `reviews` from `db`.`book` left outer join `db`.`review` on `db`.`review`.`book_id` = `db`.`book`.`id` where `db`.`book`.`id` = ? group by `db`.`book`.`id`; set @@group_concat_max_len = @t;]] with root cause

 

그래서 개인적으로 이 경우는 성능에 크게 문제가 발생하지 않는다면 목록 데이터는 따로 조회하여 반환 데이터에 추가해주는 편이 훨씬 코드가 간결하고 유지보수 측면에서 유리하지 않을까 생각한다.

 

 

참고