Steady - 개발 스터디 및 프로젝트 인원 모집 사이트
[ 배포 링크 ] [ 프로젝트 깃허브 ]
자체적으로 제공하는 폼과 유저 평가를 통해 외부 서비스에 의존하지 않고 검증된 스터디 인원을 모집할 수 있는 서비스입니다.
개요
이전 포스팅에서 MySQL 슬로우 쿼리를 최적화하는 과정을 진행하였습니다. 이번 포스팅에서는 최적화 된 쿼리를 바탕으로 QueryDsl을 통해 Pagination을 구현하는 과정을 진행하였습니다.
아래는 Pagination에 사용될 SQL 쿼리입니다.
SELECT DISTINCT s.*
FROM steadies s
JOIN (SELECT DISTINCT s.id,
s.promoted_at
FROM steadies s
LEFT JOIN steady_likes sl
ON s.id = sl.steady_id
ORDER BY s.promoted_at DESC
LIMIT 100, 10) AS s2
ON s.id = s2.id
JOIN steady_stacks ss
ON s.id = ss.steady_id
JOIN steady_positions sp
ON s.id = sp.steady_id;
구현과정
우선 JPQL에서 `FROM` 절에서 서브 쿼리를 사용하는 방법부터 찾아보았습니다. FROM절에서 사용되는 서브 쿼리는 `인라인 뷰`라 불리며 Hibernate 공식 문서에 따르면 6.1 버전부터 인라인 뷰 사용이 가능토록 변경되었다고 합니다. [ 공식 문서 링크 ]
하지만, QueryDsl : JPA에는 Hibernate 6.1 버전에 적용된 사항이 반영되지 않아 인라인 뷰를 사용할 수 없었습니다. JPQL에서는 가능한데 QueryDsl에서는 불가능한 상황이었습니다.. 다른 방법을 찾던 도중 `QueryDsl : SQL`을 발견하게 되었습니다. QueryDsl로 작성된 코드를 JPQL이 아닌 Native SQL로 변경해주는 라이브러리입니다.
사용을 위해서 아래와 같이 의존성을 추가해줍니다.
implementation 'com.querydsl:querydsl-sql'
다음과 같이 Configuration 클래스를 작성하여 JPASQLQuery를 주입받아 사용할 수 있도록 합니다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public SQLTemplates mysqlTemplates() {
return MySQLTemplates.builder().build();
}
@Bean
public JPASQLQuery<?> jpaSqlQuery(EntityManager em) {
return new JPASQLQuery<>(em, mysqlTemplates());
}
}
그리고 작성된 SQL 쿼리를 기반으로 다음과 같이 메서드를 작성하고 실행시켜 보았습니다.
QueryDsl : SQL 을 활용한 코드
@Override
public Page<Steady> findAllByFilterCondition(UserInfo userInfo, FilterConditionDto condition, Pageable pageable) {
StringPath subQueryAlias = Expressions.stringPath("sub_query_steady");
List<Long> steadies = jpaSqlQuery
.select(steady.id)
.distinct()
.from(steady)
.innerJoin(SQLExpressions.select(steady.id, steady.promotedAt).distinct()
.from(steady)
.leftJoin(steadyLike).on(steady.id.eq(steadyLike.steadyId))
.where(isLikedSteady(condition.like(), userInfo)),
subQueryAlias)
.on(steady.id.eq(
Expressions.numberPath(Long.class, subQueryAlias, "id")
))
.innerJoin(steadyStack)
.on(steady.id.eq(steadyStack.steady.id)
.innerJoin(steadyPosition)
.on(steady.id.eq(steadyPosition.steady.id)
.fetch();
}
실행 결과 위와 같은 오류를 만나게 되었습니다. 이는 QueryDsl : SQL 에서는 QueryDsl : JPA를 통해 생성된 QClass의 객체 그래프 탐색이 동작하지 않기 때문이었습니다. 이를 해결하기 위해서는 QClass를 이용하지 않고 직접 `Expressions` 를 통해 직접 컬럼의 경로를 지정해주어야 했습니다.
Expressions 적용 코드
@Override
public Page<Steady> findAllByFilterCondition(UserInfo userInfo, FilterConditionDto condition, Pageable pageable) {
StringPath subQueryAlias = Expressions.stringPath("sub_query_steady");
NumberPath<Long> subQueryId = Expressions.numberPath(Long.class, subQueryAlias, "id");
DateTimePath<LocalDateTime> promotedAt = Expressions.dateTimePath(LocalDateTime.class, steady, "promoted_at");
NumberPath<Long> steadyLikeSteadyId = Expressions.numberPath(Long.class, steadyLike, "steady_id");
NumberPath<Long> steadyStackSteadyId = Expressions.numberPath(Long.class, steadyStack, "steady_id");
NumberPath<Long> steadyPositionSteadyId = Expressions.numberPath(Long.class, steadyPosition, "steady_id");
List<Steady> steadies = jpaSqlQuery
.select(steady)
.distinct()
.from(steady)
.innerJoin(
SQLExpressions.select(steady.id, promotedAt).distinct()
.from(steady)
.leftJoin(steadyLike).on(steady.id.eq(steadyLikeSteadyId))
.where(isLikedSteady(condition.like(), userInfo))
.orderBy(promotedAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize()),
subQueryAlias
)
.on(steady.id.eq(subQueryId))
.innerJoin(steadyStack)
.on(steady.id.eq(steadyStackSteadyId))
.innerJoin(steadyPosition)
.on(steady.id.eq(steadyPositionSteadyId))
.fetch();
}
// 실행 결과
select
distinct steady.id id1_9_0_,
steady.bio bio4_9_0_,
steady.contact contact5_9_0_,
steady.content content6_9_0_,
steady.created_at created_at2_9_0_,
steady.deadline deadline7_9_0_,
steady.finished_at finished_at8_9_0_,
steady.like_count like_count9_9_0_,
steady.name name10_9_0_,
steady.number_of_participants number_of_participants11_9_0_,
steady.participant_limit participant_limit12_9_0_,
steady.promoted_at promoted_at13_9_0_,
steady.promotion_count promotion_count14_9_0_,
steady.scheduled_period scheduled_period15_9_0_,
steady.status status16_9_0_,
steady.steady_mode steady_mode17_9_0_,
steady.title title18_9_0_,
steady.type type19_9_0_,
steady.updated_at updated_at3_9_0_,
steady.version version20_9_0_,
steady.view_count view_count21_9_0_
from
steadies steady
inner join
(select
distinct steady.id, steady.promoted_at
from
steadies steady
left join
steady_likes steadyLike
on steady.id = steadyLike.steady_id
order by
steady.promoted_at desc limit ? offset ?) as sub_query_steady
on steady.id = sub_query_steady.id
inner join
steady_stacks steadyStack
on steady.id = steadyStack.steady_id
inner join
steady_positions steadyPosition
on steady.id = steadyPosition.steady_id
위와 같이 직접 객체 경로를 지정해줌으로써 의도한 쿼리를 얻어낼 수 있었습니다.
트러블 슈팅
QueryDsl : SQL을 통한 서브 쿼리 기능 구현 자체는 정상적으로 동작하였으나, 최초 요청 이후에 쿼리가 중복으로 JOIN되는 문제가 발생하였습니다.
잘못 생성되는 쿼리
보시는 바와 같이 JPASQLQuery 내부에 생성된 쿼리에서 서브 쿼리가 반복적으로 JOIN되고 있는 것을 확인할 수 있었습니다. 즉, 이전에 사용된 쿼리가 초기화되지 않고 객체 내부에 남아있어 다시 객체가 사용될 때 남아있던 쿼리와 현재 실행시킬 쿼리가 결합되어버리는 것이었습니다.
사용한 자원을 초기화 시키는 동작이 작동하지 않는다고 생각하여 이와 관련된 로직들을 찾아보았습니다.
위 이미지는 `AbstractJPASQLQuery` 클래스의 fetch() 함수입니다. 데이터를 최종적으로 가져오는 곳인데 finally 절에서 reset() 함수를 호출하고 있는 것을 볼 수 있습니다.
하지만, 정작 reset() 함수에는 로직이 존재하지 않습니다. 이러한 이유로 초기화가 되지 않았던 걸까요? 일단, `JPAqueryFactory`를 사용하는 경우에는 쿼리 중복 문제가 발생하지 않았기 때문에 `AbstractJPAQuery` 클래스의 fetch() 함수를 확인하면 확실한 답을 얻을 수 있을 것이라 생각했습니다.
그런데 마찬가지로 AbstractJPAQuery의 reset() 함수에도 로직이 존재하지 않았습니다. reset() 과 관련된 클래스들을 모두 살펴보았지만 해답을 얻을 수는 없었습니다. 다시 처음부터 두 클래스를 비교하며 분석을 해보기로 하였고 허무하게도 클래스 이름에서 해답을 얻을 수 있었습니다.
JPAQueryFactory는 말 그대로 Factory 클래스입니다. 가장 먼저 실행되는 select 메서드 동작 시에 private 메서드 query()를 통해 JPAQuery를 생성하고 해당 클래스를 통해 메서드를 실행하는 것을 볼 수 있습니다. 이 JPAQuery가 행위의 주체라고 할 수 있겠습니다.
호출되는 생성자를 보면 `new DefaultQueryMetadta()` 를 확인할 수 있는데, 해당 클래스는 QueryMetaData의 구현체로 QueryDsl 코드로 작성된 대상을 쿼리로 변한하기 위한 메타 정보들이 담기게 됩니다. 즉, JPAQueryFactory는 여러 번 요청을 받더라도 내부에서 JPAQuery도 새로 생성하며, QueryMetaData도 새로 생성되기 때문에 문제가 발생하지 않았던 것입니다.
반면에 JPASQLQuery의 경우 Factory 클래스가 아니며 앞서 @Configuration 에서 정의한 것을 주입 받아 싱글톤으로 사용하고있어 초기화가 일어나지 않으며, 당연히 내부에 QueryMetaData 역시 그대로 남아있어 쿼리가 결합되는 문제가 발생하는 것이었습니다.
따라서 위와 같이 Factory 클래스를 만들고 이를 주입받아 사용함으로써 문제를 해결할 수 있었습니다.
성과
QueryDsl에 대한 이해
Hibernate 6.1 버전부터 JPQL 에서 from 절에 서브 쿼리를 작성할 수 있음에도 기존의 QueryDsl : JPA에서는 위 방식을 지원하지 않아 사용할 수 없었지만, QueryDsl : SQL 을 사용해 JPQL이 아닌 Native SQL로 쿼리를 생성하도록 변경함으로써 QueryDsl을 통해서도 from 절에 서브 쿼리를 작성할 수 있게 되었습니다.
후기
QueryDsl : SQL의 불편함
기존의 QClass를 그대로 활용할 수 없으며, 객체 탐색을 위해 일일이 Expressions를 통해 Path를 지정해주어야 한다는 불편함이 있습니다. 개인적으로 직접 Path를 지정해서 쿼리를 작성하는 과정이 상당히 번거롭고 가독성에도 좋지 않다는 느낌을 받았습니다.
추가적으로 조사해보니 EntityQL이라는 라이브러리를 통해 보다 편리하게 QueryDsl-SQL을 위한 QClass를 생성하는 방법도 존재하였습니만, primitive type 필드 사용불가, @Embedded 미지원 등의 제약사항이 존재하여 선택지에서 제외하였습니다.
관리되고 있지 않는 QueryDsl
QueryDsl을 사용하면 JAVA 코드로 쿼리를 작성할 수 있기 때문에 컴파일 시점에 문법 오류를 확인할 수 있으며 복잡한 쿼리나 동적 쿼리를 작성하기 편하다는 장점이 존재하지만, 가장 최신 버전인 QueryDsl 5.0 ( 2022년 7월 출시 ) 이후로 현재까지 추가적인 업데이트가 이루어지고 있지 않는 상황입니다.
앞으로도 추가적인 관리가 이루어지지 않는다면 해당 라이브러리를 지속해서 사용할 수 있을까라는 의문이 듭니다. 물론, 지금까지 이용하면서 “이거 진짜 안되겠는데..?”와 같은 심각한 문제는 없었지만, 가끔은 라이브러리에 대한 무조건적인 신뢰보다는 비판적인 시선도 가져보는 것이 성장하는 데에 더 도움이 되지 않을까 생각합니다.
자아성찰
이번 구현을 진행하면서 어려움을 겪었던 부분들이 단순히 QueryDsl을 사용하면서 오게되는 어려움인가에 대해서도 생각해볼 수 있는 시간이 되었던 것 같습니다.
본문에서 다룬 내용 이외에도 구현 과정에서 많은 어려움이 있었기 때문에 애초에 도메인 설계부터 잘못된 것은 아닐지 잘못된 접근법으로 문제를 해결하려고 하고 있는 것은 아닌지라는 의문이 들기 시작하였습니다.
위와 같은 의문을 해결하기 위해서는 스스로의 코드에서 문제점을 파악해야 하는데, 좁은 시선으로 스스로의 코드에서 문제점을 파악하기가 정말 어려운 것 같습니다. 때문에 스스로 개선할 점을 찾아나가기 위해서는 더욱 많은 공부와 경험이 필요하겠구나를 통감하는 계기가 되었습니다.
'Spring' 카테고리의 다른 글
이벤트를 좀 더 제대로 다뤄보자 (feat. MSA & EDA) (2) | 2024.04.22 |
---|---|
동시성 테스트를 통한 성능 개선하기 (2) - Spring Event (2) | 2024.04.04 |
조회 성능 최적화 (1) - 쿼리 발생 줄이기 (1) | 2024.02.13 |
테스트 객체 생성 라이브러리 Instancio, Fixture Monkey (feat. Test Fixture) (0) | 2024.02.05 |