[이슈] Springboot3.2.2에서 Querydsl과 MySQL group_concat 같이 쓰기

보라도리·2024년 3월 2일

springboot 이슈 정리

목록 보기
1/6
post-thumbnail

이 글은 실무를 하면서 마주한 이슈 중 하나인 '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를 통한 함수 등록이 불가능해서 위와 같이 등록해주는 과정이 필요하다. 추후에 같은 상황에 놓이면, 참고하기 위해 등록 방법에만 초점을 두어 글을 작성했다.

1개의 댓글

comment-user-thumbnail
2024년 3월 11일

고맙습니다. 덕분에 잘 해결했습니다~

답글 달기