트러블 슈팅 - Querydsl 조회 로직
메인 페이지에 상태가 DELETED가 아닌 모든 스터디를 조회 해주는 로직을 작성해야했는데,
처음에는 굉장히 간단하게 생각했다.
보여줘야 할 리스트는 다음과 같았다.
스터디 제목
스터디 리더
스터디 시작일 - 마감일
댓글 카운트
조회수
북마크 카운트
스터디 회원 수
최대 맴버
기술스택의 리스트
이렇게 한 row씩, 전체 스터디를 반환해주면 된다고 생각해서 다음과 같이 조회로직을 짰다.
@Override
public List<GetAllStudiesStudyDto> getAllStudies(){
return queryFactory
.select(
constructor(
GetAllStudiesStudyDto.class,
QStudy.study.id,
QStudy.study.name,
QStudy.study.period,
QStudy.study.leader.name,
ExpressionUtils.as(
JPAExpressions.select(count(QStudyComment.studyComment.id))
.from(QStudyComment.studyComment)
.where(QStudyComment.studyComment.study.id.eq(QStudy.study.id)),
"commentCount"
),
ExpressionUtils.as(
JPAExpressions.select(count(QStudyView.studyView.id))
.from(QStudyView.studyView)
.where(QStudyView.studyView.study.id.eq(QStudy.study.id)),
"viewCount"
),
ExpressionUtils.as(
JPAExpressions.select(count(QBookmark.bookmark.id))
.from(QBookmark.bookmark)
.where(QBookmark.bookmark.study.id.eq(QStudy.study.id)),
"bookmarkCount"
),
QStudy.study.members.size(),
QStudy.study.maxMembersSize,
list(QStudy.study.techStackList)
)
)
.from(QStudy.study)
.where(QStudy.study.status.ne(DELETED))
.fetch();
}
복잡해보이지만 생각외로 별거 없다. Study 도메인에서 아이디, 이름, 시작일 - 종료일, 리더의 이름 등을 가져와주고, 조회수나 북마크 수는 Select절 서브쿼리로 가져온 것이다.
여기까지는 아무런 문제가 없었지만 중요한건 마지막에 기술스택의 리스트를 가져오는 부분이다.. !
ORM을 너무 맹신했던건지, Querydsl을 너무 맹신했던건지 모르겠지만, 한 row에 리스트가 전부 가져와지겠지? 라는 생각에 저런식으로 짰던 것 같다.
조회결과는 참담했다.
{
"id" : 1,
"name" : "팀1",
"period" : "com.codelap.common.study.domain.StudyPeriod@5c658163",
"leader" : "name",
"commentCount" : 0,
"viewCount" : 0,
"bookmarkCount" : 0,
"memberCount ": 1,
"maxMembersSize" : 4,
"techStackList" : [
"Spring",
"Java"
]
}
위가 내가 예상한 조회결과고,
아래가 실제 결과이다.
{
"id" : 1,
"name" : "팀1",
"period" : "com.codelap.common.study.domain.StudyPeriod@5c658163",
"leader" : "name",
"commentCount" : 0,
"viewCount" : 0,
"bookmarkCount" : 0,
"memberCount ": 1,
"maxMembersSize" : 4,
"techStackList" : [
"Spring"
],
"id" : 1,
"name" : "팀1",
"period" : "com.codelap.common.study.domain.StudyPeriod@5c658163",
"leader" : "name",
"commentCount" : 0,
"viewCount" : 0,
"bookmarkCount" : 0,
"memberCount ": 1,
"maxMembersSize" : 4,
"techStackList" : [
"Java"
]
}
기술스택의 리스트가, 한 row에 반환되는게 아니라 하나씩 중복된 row를 생성해서 가져왔던 것이다!
어떻게 해결해야할지, 곰곰히 생각해보다가 스터디 도메인을 한번 살펴봤다.
@ElementCollection
@Enumerated(STRING)
private List<TechStack> techStackList;
기술스택은 따로 도메인을 생성하지않고, Enum 타입의 List ElementCollection으로 만들어줬다.
이렇게 하면 부모와 함께 생태가 관리되기 때문에, 연관관계를 굳이 설정해주지 않아도 되는 장점이 있다.
public enum TechStack {
JavaScript,
TypeScript,
React,
Vue,
Svelte,
Nextjs,
Nodejs,
Java,
Spring,
Go,
Nestjs,
Kotlin,
Express,
MySQL,
MongoDB,
Python,
Django,
php,
GraphQL,
Firebase,
Flutter,
Swift,
ReactNative,
Unity,
AWS,
Kubernetes,
Docker,
Git,
Figma,
Zeplin,
Jest
}
TechStack enum 클래스는 이렇게 구성되어있다. 기술스택들의 경우는 따로 api를 통해 제공받을수가 없기때문에 하드코딩을 했다. 좋은 방법이 아니라는 건 알지만, 공통코드를 만들어서 DB에서 관리하기보다 오히려 직관성이 뛰어나게 관리하는게 더 낫다고 판단했다.
어찌됐건, enum 타입을 ElementCollection으로 관리를 했었는데, enum 타입의 클래스를 ElementCollection으로 등록했을 떄, QClass로 등록되지 않는다는 사실을 발견했다.
스터디에 귀속된 기술스택 리스트는 사용할 일이 많을 것 같아 QClass를 등록해두는게 좋을것이라고 판단했다.
그래서, enum을 필드로 가지고 있는 embedded 객체를 ElementCollection로 매핑하여 QClass를 구성했다.
@Getter
@Embeddable
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
public class StudyTechStack {
@Enumerated(STRING)
private TechStack techStack;
}
이렇게 QClass가 생성된 것을 확인했고, 그 다음은 조회로직을 어떻게 고쳐야할지 고민했다.
그러다가 한 방법이 떠올랐는데, Sql 함수인 group_concat을 활용해서 id가 같은 row에 기술스택 리스트를 ", "로 구분해줘서 조회하는 방법이었다.
객체 자체를 받을 수 있는게 가장 좋은 것 같았지만, ElementCollection의 한계인지 Querydsl의 한계인지 아무리 구글링을 해봐도, 정보가 좀처럼 나오지가 않았었다 ...
그렇게 해서, QueryDsl에서 group_concat을 사용하기 위해, DB dialect를 상속받은 support 클래스를 만들어서 yml 파일에 등록해주게 되었다.
이 과정에서도 곤혹을 겪었는데, 하이버네이트6 이후로는 registerFunction을 지원하지가 않는다는 점을 알게되었다.
public class MySqlCustomDialect implements MetadataBuilderContributor{
@Override
public void contribute(MetadataBuilder metadataBuilder) {
metadataBuilder.applySqlFunction(
"group_concat",
new StandardSQLFunction("group_concat", StandardBasicTypes.STRING)
);
}
}
그래서 metadataBuilderContributor를 구현해서 contribute 메서드를 오버라이딩 하는 방법으로 했는데, group_concat을 등록해준 뒤에 yml 설정파일에도 등록해줬다.
Expressions.stringTemplate("GROUP_CONCAT({0} SEPARATOR ', ')", QStudy.study.techStackList)
list를 조회하는 부분을 저렇게 group_concat을 사용하게 끔 넣어줬고, 테스트를 돌려봤다 ...
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.SemanticException: A query exception occurred [select study.id, study.name, study.period, study.leader.name, (select count(studyComment.id)
from StudyComment studyComment
where studyComment.study.id = study.id) as commentCount, (select count(studyView.id)
from StudyView studyView
where studyView.study.id = study.id) as viewCount, (select count(bookmark.id)
from Bookmark bookmark
where bookmark.study.id = study.id) as bookmarkCount, size(study.members), study.maxMembersSize, GROUP_CONCAT(study.techStackList SEPARATOR ', ')
from Study study
where study.status <> ?1]
이게 그 결과다. 처참하게 실패했고 group_concat 함수를 사용함에 있어서 문제가 되는건지 코드를 변경해보기도 하고, 다른 코드를 참고해봤지만 전부다 결과는 에러로 귀결되었다.
정말, native query로 짜면 금방 끝낼 수 있을 문제인 것 같은데, 왜 Querydsl을 사용해서 이 고생을 하고있나..
이런 생각이 안 들 수가 없었다.
하지만 우리 프로젝트 컨벤션은 Querydsl을 사용하기로 했었고, 나는 어떻게든 오류를 해결해야만 한다.
그래서 좀 더 나은 방법이 없나 고민해봤다.
그러다가 든 생각이, 어차피 메인페이지 스터디 조회는 무한스크롤로 구현해줄거라 페이징 로직이 들어가게 될 텐데, 여러개의 row를 리턴받고 하나의 아이디를 가진 객체로 합쳐주면 되지 않을까? 라는 생각이 들어서 당장 시도했다.
우선 모든 스터디를 가져오고, 스트림을 사용해서 스터디의 리스트를 생성한다.
그 리스트를 스터디 ID로 그룹화 한 다음 그룹화 된 값들의 리스트에서 각 그룹의 첫번째 요소를 취하여 DTO에 할당한 후, 각 그룹의 기술 스택 리스트를 조합하여 하나의 유일한 리스트를 만들고 DTO에 할당한다.
복잡하다..!! 몇시간에 걸쳐서 로직을 짠게 해당 코드이다.
List<GetAllStudiesStudyDto> allStudies = studyQueryAppService.getAllStudies()
.stream()
.collect(Collectors.groupingBy(GetAllStudiesStudyDto::getId))
.values()
.stream()
.map(list -> {
GetAllStudiesStudyDto dto = list.get(0);
dto.setTechStackList(list.stream()
.flatMap(s -> s.getTechStackList().stream())
.distinct()
.collect(Collectors.toList()));
return dto;
})
.collect(Collectors.toList());
.collect(Collectors.groupingBy(GetAllStudiesStudyDto::getId))를 사용해서 스터디 ID로 그룹화된 맵을 생성한다.
이 결과가 Map<Long, List<GetAllStudiesStudyDto>>로 반환된다.
이후에 .values()로 값의 컬렉션을 가져온 후에 stream.map()으로 각각의 dto 리스트에서 기술 스택 리스트를 하나로 조합하고, 첫 번째 dto에서 각각 필요한 정보를 가져와서 dto를 하나씩 만들어준다.
이후에 리스트를 allStudies에 담아준다.
말도안되게 복잡한 로직이지만 결과는 성공적이었다.
{
"id" : 1,
"name" : "팀1",
"period" : "com.codelap.common.study.domain.StudyPeriod@5c658163",
"leader" : "name",
"commentCount" : 0,
"viewCount" : 0,
"bookmarkCount" : 0,
"memberCount ": 1,
"maxMembersSize" : 4,
"techStackList" : [
"Spring",
"Java"
]
}
내가 생각했던대로 조회로직이 잘 작동했다.
이후에 테스트코드를 작성하고 마무리했다.
내일은 이 쿼리를 동적쿼리로 만들고, 페이징을 적용해 볼 생각이다.