Spring/기술 레시피

jOOQ multiset 일대다 관계를 중첩 컬렉션으로 매핑 / 내부 동작 이해로 성능 이슈 파악하기

Chipmunks 2025. 3. 20.
728x90

레시피 목표

  • jOOQ multiset 연산으로 일대다 관계를 컬렉션 필드로 매핑시키기
  • multiset 함수 내부 동작 이해로 성능 이슈 고려하기

 

TL; DR;

  • JOOQ multiset 연산으로 일대다 관계를 컬렉션 필드로 매핑할 수 있습니다.
  • 서브 쿼리로 구현되기에 성능 병목 위험성 파악을 위해, 드리븐 테이블에서 매핑될 레코드 증가율을 유심히 살펴볼 필요가 있습니다.
  • 서브쿼리로 연결되는 드리븐 테이블의 매핑될 레코드 수가 적을 것으로 예상된다면, 성능 걱정 없이 간편하게 필드를 매핑시킬 수 있습니다.

 

실습 자료 소개

blogRepository/jooq-multiset at main · kor-Chipmunk/blogRepository

Contribute to kor-Chipmunk/blogRepository development by creating an account on GitHub.

github.com

 
ERD는 다음과 같습니다.
도커 이미지 테이블과 태그 테이블이 FK 관계로 연결되어 있습니다.
'nginx' 도커 이미지에 'latest' / 'stable-perl' 이름의 태그가 있다고 생각해 주세요.

 

MULTISET 배경

jOOQ는 강한 타입 검증으로 쿼리를 안전하게 작성하도록 DSL(도메인 특화 언어)로 제공하는 자바 라이브러리입니다.
데이터베이스 스키마 필드를 본따 자바 타입으로 매핑해주는 코드 제너레이터 기능도 플러그인으로 제공해줍니다.
직관적인 DSL 인터페이스로 복잡한 고급 쿼리 작성이 가능합니다.
 
Join 쿼리를 작성한다고 생각해 볼까요. (출처 : jOOQ multiset 릴리즈 블로그 글)
아래처럼 일대다 관계에서 불필요한 중복된 레코드를 추출하게 됩니다.
 

+----------------+----------+---------+-----------+
|title           |first_name|last_name|name       |
+----------------+----------+---------+-----------+
|ACADEMY DINOSAUR|CHRISTIAN |GABLE    |Documentary|
|ACADEMY DINOSAUR|JOHNNY    |CAGE     |Documentary|
|ACADEMY DINOSAUR|LUCILLE   |TRACY    |Documentary|
|ACADEMY DINOSAUR|MARY      |KEITEL   |Documentary|
|ACADEMY DINOSAUR|MENA      |TEMPLE   |Documentary|
|ACADEMY DINOSAUR|OPRAH     |KILMER   |Documentary|
|ACADEMY DINOSAUR|PENELOPE  |GUINESS  |Documentary|
|ACADEMY DINOSAUR|ROCK      |DUKAKIS  |Documentary|
|ACADEMY DINOSAUR|SANDRA    |PECK     |Documentary|
|ACADEMY DINOSAUR|WARREN    |NOLTE    |Documentary|
|ACE GOLDFINGER  |BOB       |FAWCETT  |Horror     |
|ACE GOLDFINGER  |CHRIS     |DEPP     |Horror     |
|ACE GOLDFINGER  |MINNIE    |ZELLWEGER|Horror     |
 ...

 
높은 확률로 위 결과를 그대로 쓰지 않을 거예요.
그룹핑하고 연결해주는, 주요 로직과 큰 관련이 없는 행사 코드가 들어가겠죠.
일대다 관계처럼 중첩된 구조가 두 개 이상이라면 어떻게 될까요?
더욱 복잡한 코드가 들어갈 겁니다.
 
jOOQ에선 이 문제를 표준 SQL 중 multiset 연산을 지원하는 연산자로 해결했습니다.
데이터베이스 종류 또는 데이터베이스 버전마다 매핑 전략을 다르게 가져갑니다.
 
아래처럼 한 DTO에 컬렉션 필드 매핑을 지원합니다.

public class DockerImageModel {

    private Long id;
    private String name;
    private String description;

    private List<DockerImageTagModel> tags;

    ...

}

 

MULTISET 매핑 전략

기본 전략으로 NestedCollectionEmulation.DEFAULT 설정을 가져갑니다.
연결된 데이터베이스에 따라 자동으로 선택해줍니다.
MySQL / MariaDB 기준 서브쿼리 + JSON 함수로 추출하는 전략을 가져갑니다.
 
Settings 클래스의 emulateMultiset 설정으로 직접 변경할 수 있습니다.
DSLContext 빈을 만들때 직접 등록할 수도 있으며, jOOQ Codegen 설정으로도 넣을 수 있습니다.
 
1) DSLContext Bean 설정

import javax.sql.DataSource;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.conf.NestedCollectionEmulation;
import org.jooq.conf.Settings;
import org.jooq.impl.DSL;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class jOOQConfig {

    @Bean
    public DSLContext dslContext(DataSource dataSource) {
        Settings settings = new Settings()
                .withEmulateMultiset(NestedCollectionEmulation.JSONB); // Here

        return DSL.using(dataSource, SQLDialect.MYSQL, settings);
    }

}

 
2) Gradle jOOQ Codegen 설정

import org.jooq.conf.NestedCollectionEmulation
jooq {
	configuration {
		generator {
			database {
				name = 'org.jooq.meta.mysql.MySQLDatabase'
			}
			generate {
				emulateMultiset = NestedCollectionEmulation.JSONB
			}
		}
	}
}

 
대체로 직접 변경할 일은 없습니다.
성능 최적화를 위해서 빠른 시간 안에 설정만 바꿔 시험해볼 수 있지만,
서브 쿼리를 쓰지 않고 애플리케이션 로직으로 매핑하는 방법 자체를 변경하는 게 더 나은 선택지일 가능성이 높습니다.

설정으로 변경한다면 모든 시스템에 영향을 끼치지 않도록, include / exclude 를 조심히 설정해야 합니다.
이는 히스토리가 필연적이라 후환이 없는 해결법으로는 추천하지는 않습니다.

 

multiset 조회 쿼리 작성 예시

도커 이미지를 조회하는 메소드인 getDockerImages() 메소드를 살펴볼까요?

import static com.tistory.itchipmunk.jooq_multiset.Tables.DOCKER_IMAGES;
import static com.tistory.itchipmunk.jooq_multiset.Tables.DOCKER_IMAGE_TAGS;
import static org.jooq.impl.DSL.multiset;

import com.tistory.itchipmunk.jooq_multiset.DockerImageModel.DockerImageTagModel;
import java.util.List;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.jooq.DSLContext;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class DockerImageRepository {

    private final DSLContext dsl;

    public List<DockerImageModel> getDockerImages() {
        var fetched = dsl.select(
                        DOCKER_IMAGES.asterisk(),

                        multiset(
                                dsl.select(DOCKER_IMAGE_TAGS.asterisk())
                                        .from(DOCKER_IMAGE_TAGS)
                                        .where(DOCKER_IMAGE_TAGS.DOCKER_IMAGE_ID.eq(DOCKER_IMAGES.ID))
                        )
                                .as("tags")
                                .convertFrom(records -> records.map(
                                        record -> record.into(DockerImageTagModel.class))
                                )
                )
                .from(DOCKER_IMAGES)
                .fetchInto(DockerImageModel.class);

        return fetched;
    }

}

 
DSL.multiset(...) 메소드로 일대다 연관관계를 가져오는 코드를 확인할 수 있습니다.
데이터베이스 레코드를 DockerImageModel 클래스 타입으로 매핑합니다.

...

private List<DockerImageTagModel> tags;

...

 
해당 클래스의 tags 필드명을 매핑해주기 위해서 multiset 함수 결과로 나온
Field 객체의 이름을 as (alias) 함수로 'tags' 으로 지정했습니다.
 
조회한 jOOQ 레코드 타입을 다시 DockerImageTagModel 클래스로 변환합니다.
여러 건을 조회했기에 각 레코드를 태그 모델 클레스로 변환합니다.
 
자바 레코드(jOOQ 레코드 타입과 다름)를 사용한다면, 아래처럼 mapping() 함수로 매핑시킬 수 있습니다.
다만 이경우 정확한 필드 매핑만 지원하므로, Asterisk 사용은 불가능합니다.

multiset(
        dsl.select(DOCKER_IMAGE_TAGS.ID, DOCKER_IMAGE_TAGS.NAME)
                .from(DOCKER_IMAGE_TAGS)
                .where(DOCKER_IMAGE_TAGS.DOCKER_IMAGE_ID.eq(DOCKER_IMAGES.ID))
)
        .as("tags")
        .convertFrom(r -> r.map(Records.mapping(DockerImageTagModel::new)))

 

일대다 관계 매핑 결과 확인

도커 이미지 DTO의 로그를 살펴보겠습니다.

// JooqMultisetApplication.java

@Slf4j
@SpringBootApplication
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class JooqMultisetApplication implements CommandLineRunner {

    private final DockerImageRepository repository;

    public static void main(String[] args) {
        SpringApplication.run(JooqMultisetApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        repository.getDockerImages().stream()
                .map(DockerImageModel::toString)
                .forEach(log::info);
    }

}

// DockerImageModel.java

public class DockerImageModel {

    private Long id;
    private String name;
    private String description;

    private List<DockerImageTagModel> tags;

    static class DockerImageTagModel {
        private Long id;
        private String name;

        @Override
        public String toString() {
            return "DockerImageTagModel{" +
                    "id=" + id +
                    ", name='" + name + '\'' +
                    '}';
        }
    }

    @Override
    public String toString() {
        return "DockerImageModel{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", description='" + description + '\'' +
                ", tags=" + tags +
                '}';
    }
}

 

DockerImageModel{
  id=1, 
  name='mysql', 
  description='MySQL is a widely used, open-source relational database management system (RDBMS).', 
  tags=[
    DockerImageTagModel{id=1, name='latest'}, 
    DockerImageTagModel{id=2, name='9.2.0'}, 
    DockerImageTagModel{id=3, name='8.4.4'}, 
    DockerImageTagModel{id=4, name='8.0.41'}
  ]
 }
 
DockerImageModel{
  id=2, 
  name='nginx', 
  description='Official build of Nginx.', 
  tags=[
    DockerImageTagModel{id=5, name='stable-perl'}, 
    DockerImageTagModel{id=6, name='stable-otel'}, 
    DockerImageTagModel{id=7, name='1.26.3-perl'}
  ]
}

 

MULTISET 쿼리 분석

실제 쿼리문은 다음과 같습니다.
application.yml 에서 로그 레벨을 조절할 수 있습니다.
DEBUG 레벨이면 쿼리 실행 내역을 알려줍니다.

# application.yml

# jOOQ 실행 트레이스 보기 설정
logging:
  level:
    org:
      jooq:
        tools:
          LoggerListener: DEBUG

 
추출한 SQL은 아래와 같습니다.

select
	`docker-hub`.`docker_images` .*,
	(
	select
		coalesce(json_merge_preserve('[]',
		concat('[',
		group_concat(json_array(t.`v0`,
		t.`v1`,
		t.`v2`) separator ','),
		']')),
		json_array())
	from
		(
		select
			`docker-hub`.`docker_image_tags`.`id` as `v0`,
			`docker-hub`.`docker_image_tags`.`docker_image_id` as `v1`,
			`docker-hub`.`docker_image_tags`.`name` as `v2`
		from
			`docker-hub`.`docker_image_tags`
		where
			`docker-hub`.`docker_image_tags`.`docker_image_id` = `docker-hub`.`docker_images`.`id`) as t) as `tags`
from
	`docker-hub`.`docker_images`;

 
MySQL 9.2.0 환경에선 서브 쿼리와 json 함수를 활용하는 걸 확인할 수 있습니다. (MySQL / MariaDB 8 이상도 같음)
단순 서브쿼리를 활용하는 것이 아니라, JSON 형태로 변환해주는 게 특이한 지점입니다.
jOOQ 코어에서 Json 결과를 파싱해 다시 객체로 매핑(없으면 jOOQ Field 클래스)해주는 식으로 내부 구현을 한 것으로 예상합니다.
 

MULTISET 함수 주의점

위 쿼리에서 알 수 있듯이 한 레코드마다 서브쿼리가 반복됩니다.
많은 레코드를 조회하거나, 서브쿼리 대상 테이블에서 조회되는 레코드 수가 많을수록 성능이 급격히 저하됩니다.

예를 들어, 한 게시글당 평균 1,000개의 댓글이 있다고 가정해 봅시다.
페이지네이션을 통해 20개의 게시글을 가져올 때, MULTISET 연산을 사용하면 20개의 게시글을 먼저 조회한 후,
각 게시글에 대해 1,000개씩 총 20,000개의 댓글을 조회하게 됩니다.
이는 인덱스를 활용하더라도 20번의 인덱스 탐색이 발생할 수 있으며, 데이터 양이 많아질수록 I/O 비용이 증가합니다.

만약 두 테이블 모두 정렬된 인덱스를 사용하여 조회한다면,
지역성(Spatial Locality)에 의해 같은 메모리 페이지에 저장된 근처 데이터까지 버퍼 풀(Buffer Pool)로 함께 불러와 빠르게 접근할 수도 있습니다.

그러나 레코드 수가 많아지고, 인덱스가 파편화되었거나 랜덤한 조회 패턴이 발생하는 경우 병목점이 될 수 있습니다.
하드웨어 단에서 메모리 지역성이 충분히 활용되지 못해, 같은 메모리 페이지에 필요한 데이터가 포함될 확률이 낮아집니다.
소프트웨어 단의 버퍼 풀에 캐싱되지 않은 데이터를 디스크에서 다시 읽어와야 하며,
이로 인해 디스크 I/O가 증가할 가능성이 높습니다.

또한, MULTISET 연산은 결과를 JSON 문자 형식으로 변환하기 때문에
데이터베이스 설정에 따라 단일 필드 크기 제한이나 통신 버퍼 크기 제한을 초과할 수도 있습니다.
결과가 객체로 변환되더라도 불필요한 메모리 할당이 발생할 수 있으므로,
데이터 크기가 커지는 경우 성능 최적화가 필요합니다.
 
즉 Multiset 함수가 적용되는 레코드의 수가 많거나, 많아질 가능성이 높을 때는 주의해서 사용해야 합니다.
 
경험상 평균 100~200개가 매핑되고, 소수로 20,000 ~ 30,000 개까지 매핑되는 페이지네이션에서 성능 이슈가 크게 발생했습니다.
multiset 을 제거하고 조회 쿼리를 분리해서 애플리케이션 단에서 가벼운 쿼리로 매핑했습니다.
정말 수 백개의 매핑된 데이터를 한 번에 조회해야 할 정도로 필요한 경우는 극히 드물지 않을까 싶네요.
 
로컬 DB에서도 20개 조회시 각각 평균 5,000개씩 서브 쿼리로 조회하는 동작만, 33초 정도 걸렸습니다.
( 드라이빙 테이블(이미지 테이블) 1,000,000 개 / 드리븐 테이블(태그 테이블) 8,900,000 개 상황 )
네트워크망이 분리되었거나 통신 속도에 지연이 있거나, 객체를 모두 Json 으로 변환하는 CPU 연산이 들어가는 등
실제로는 1분 가까이 작업 시간이 될 것입니다.
이는 대부분의 상용 API의 기대 응답 속도로는 적절치 않습니다.
 
 

의심했으나 고려하지 않은 것

서브 쿼리와 LIMIT / OFFSET 절의 실행 순서에 따라 성능이 달라질 수 있습니다.
모든 레코드의 서브 쿼리를 먼저 계산 한다면, 풀 테이블 서브 쿼리로 인한 병목이 크게 발생할 것으로 예상했어요.
다만 LIMIT / OFFSET 이 적용한 레코드만 서브 쿼리 연산을 수행하도록, 옵티마이저가 실행 비용을 계산할 터라 본문에서 삭제했습니다.
multiset 성능이 급격하게 느려진다면, EXPLAIN 옵티마이저 실행 계획으로 의심할 만한 포인트입니다.
 
아래처럼 LIMIT / OFFSET 쿼리를 먼저 수행한 FROM 절 서브쿼리로 애플리케이션 단에서 강제할 수도 있습니다.
약간의 aliasing 으로 인한 불편함은 있습니다.

// LIMIT / OFFSET 절 먼저 수행하는 쿼리 메소드
public List<DockerImageModel> getDockerImagesPagination(int page, int pageSize) {
        var baseTable = dsl.select(
                        DOCKER_IMAGES.asterisk()
                ).from(DOCKER_IMAGES)
                .orderBy(DOCKER_IMAGES.ID.asc())
                .limit(pageSize)
                .offset((page - 1) * pageSize)
                .asTable(DOCKER_IMAGES);

        var fetched = dsl.select(
                        baseTable.asterisk(),

                        multiset(
                                select(DOCKER_IMAGE_TAGS.asterisk())
                                        .from(DOCKER_IMAGE_TAGS)
                                        .where(DOCKER_IMAGE_TAGS.DOCKER_IMAGE_ID.eq(baseTable.field("id").cast(Long.class)))
                        )
                                .as("tags")
                                .convertFrom(records -> records.map(
                                        record -> record.into(DockerImageTagModel.class))
                                )
                )
                .from(baseTable)
                .fetchInto(DockerImageModel.class);

        return fetched;
}

 

마무리

일대다 관계를 DTO 필드에 매핑시키는 것까진 좋으나,
불필요한 행사 코드(as, convertFrom 등...)가 등장하기도 합니다.
 
내부 구현 과정이 서브 쿼리와 JSON 변환으로 동작하다보니,
운영 도중 빠른 시간 안에 성능 이슈를 경험할 가능성이 높습니다.
 
매핑될 레코드 증가율이 예상 가능하고, 서브쿼리 적용 범위의 레코드 수가 적다면
빠르게 컬렉션 필드에 매핑시킬 수 있는 multiset 연산을 추천드립니다.
 
 

댓글