
이 글은 실무를 하면서 마주한 이슈 중 하나인 'group_concat' 설정에 대해서 정리하는 목적으로 작성된 것이다.
프로젝트에서 사용된 기술 스택은 다음과 같다.
springboot 3.2.2
kotlin 1.9.21
JDK 21
mariaDB
hibernate 60:2.20.0
이슈 상황
N:M 관계 테이블 데이터를 querydsl을 이용해 페이징 처리해서 리스트로 반환해야 하는데, 연계 테이블을 기준으로 size가 잘리기 때문에 의도한 수 만큼 element가 반환되지 않는다.
/api/v1/contents?page=0&size=50
이렇게 요청을 보냈다고 가정하자.
1개의 contents가 여러 개의 카테고리를 갖고 있고, 응답 DTO에 카테고리명을 함께 담아야 한다고 하자.
select 시, contents_category 테이블을 기준으로 50개의 element가 추출되고 이를 contents_id로 groupBy 한 결과가 페이징 응답으로 돌아간다.
따라서 contents의 개수가 50개 아닌 그보다 적은 수가 돌아가는 경우가 생긴다.
querydsl은 from절에 서브쿼리를 지원하지 않기에 하나의 쿼리로 이를 해결할 수 없었다.
위의 요구사항을 충족하기 위해서 어떻게 하는 것이 좋을까?
1. contents 테이블을 기준으로 50개를 추출한 후, 각 contents가 갖는 카테고리를 추가로 조회하기 ( N+1 문제 )
2. GUI에서 이벤트 추가로 해결하기 ( 콘텐츠 row를 클릭하면, contents가 갖는 카테고리만 모달로 띄우기 )
3. querydsl + group_concat 조합으로 쿼리 후 카테고리를 List<String>으로 가공하기
위의 3가지 방안 중 group_concat으로 M 쪽 데이터를 하나의 string으로 받아서 slice 하는 걸 택했다.
1번은 성능적으로 안좋다고 생각했고, 2번은 사용자가 원하지 않았기에 3번을 택했다.
문제 해결
group_concat은 mySQL 내장 함수이므로, Querydsl에서 이 함수를 사용하기 위해서는 등록해주는 과정이 필요하다.
1) CustomConfig 클래스를 아래와 같이 정의하자.
필자의 경우, 아래 클래스를 /global/config 폴더에 추가하였다.
import org.hibernate.boot.model.FunctionContributions
import org.hibernate.boot.model.FunctionContributor
import org.hibernate.dialect.function.StandardSQLFunction
import org.hibernate.type.StandardBasicTypes
class MariaDBCustomConfig : FunctionContributor {
private val FUNCTION_NAME: String = "group_concat" // 사용하고자 하는 내장함수명
override fun contributeFunctions(functionContributors: FunctionContributions) {
functionContributors.functionRegistry.register(
FUNCTION_NAME,
StandardSQLFunction(FUNCTION_NAME, StandardBasicTypes.STRING)
)
}
}
2) src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor 파일 추가
src/main/resources/META-INF/services 경로에 org.hibernate.boot.model.FunctionContributor 파일명으로 파일을 생성한다.
com.xx.xx.global.config.MariaDBCustomConfig # MariaDBCustomConfig가 위치한 경로 지정
3) querydsl에서 사용하기
fun getContents(pageable: Pageable) : Page<ContentsDto.GetList> {
val contents : QContents = QContents.contents
val category : QCategory = QCategory.category
val contentsCategory : QContentsCategory = QContentsCategory.contentsCategory
val query = jpaQueryFactroy
.select(
Projections.constructor(
ContentsDto.Companion.GetList::class.java,
contents.id.`as`(ContentsDto.Companion.GetList::id.name),
contents.title.`as`(ContentsDto.Companion.GetList::title.name),
Expressions.stringTemplate("group_concat({0})", category.name).`as`("category")
)
.from(contents)
.leftJoin(contentsCategory).on(contentsCategory.contentsId.eq(contents.id))
.groupBy(contents.id, contents.createdAt)
.orderBy(contents.createdAt.desc())
.offset(pageable.offset)
.limit(pageable.size)
.fetch()
val countQuery = jpaQueryFactory
.select(contents.id.count())
.from(contents)
.leftJoin(contentsCategory).on(contentsCategory.contentsId.eq(contents.id))
.fetchOne() ?: 0L
return PageImpl(mapToList(query), pagable, countQuery)
}
마무리
hibernate 6에서는 Dialect를 통한 함수 등록이 불가능해서 위와 같이 등록해주는 과정이 필요하다. 추후에 같은 상황에 놓이면, 참고하기 위해 등록 방법에만 초점을 두어 글을 작성했다.
고맙습니다. 덕분에 잘 해결했습니다~