우테코 프로젝트에서는 Spring Data JPA를 사용하고 있었다.
Spring Data JPA를 이용하면 인터페이스 생성 만으로도 SimpleJpaRepository에 내장된 다양한 CRUD 메서드를 활용할 수 있고, 추가로 커스텀 메서드도 편리하게 만들 수 있다는 장점이 존재한다. 그러나 Spring Data JPA는 동적 쿼리를 사용하기 어렵고 복잡한 쿼리는 @Query 어노테이션을 이용하여 JPQL을 직접 작성해주어야 한다는 문제점이 존재한다.
따라서 이번 기회에 queryDSL을 적용하여 컴파일 시점에 문법 오류를 잡아낼 수 있게 하고, 복잡한 쿼리를 리팩터링하는 데에 도전해보았다.
특히 위처럼 DTO를 반환하는 repository method의 경우 @Query로 작성하면 DTO 패키지명을 모두 적어줘야 한다는 번거로움이 존재한다. queryDSL의 Projections를 이용하면 DTO를 간편하게 반환할 수 있다.
queryDSL 적용 후기 및 트러블슈팅을 간단하게 남겨보도록 하겠다.
해당 관련 PR은 여기서 확인할 수 있다.
https://github.com/woowacourse-teams/2022-nae-pyeon/pull/442
queryDSL 환경설정
스프링부터 2.6 이전과 이후는 환경세팅 스크립트가 달라졌으므로 유의하면서 작성해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
plugins {
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
dependencies {
implementation "com.querydsl:querydsl-jpa:5.0.0"
annotationProcessor "com.querydsl:querydsl-apt:5.0.0"
}
// script for querydsl
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
|
cs |
2022년 9월 16일 기준, dependencies의 querydsl 의존성 부분의 버전을 5.0.0으로 명시해주지 않으면 에러가 발생한다.
따라서 버전을 5.0.0으로 명시해주도록 하자.
queryDSL을 사용하기 위해선 Entity 기반으로 하는 쿼리용 클래스를 생성해주는 디렉토리 세팅을 해주어야 한다.
script for querydsl 에 해당되는 부분이 바로 그 부분이며, 위와 같이 세팅하면 터미널에 ./gradlew compileQuerydsl을 입력하면 쿼리용 클래스를 생성해주는 컴파일 작업을 수행해준다.
compileQuerydsl은 스프링을 실행할 때, 테스트를 실행할 때는 자동으로 진행이 되지만, 그 외의 경우(ex. 별도 작업 없이 프로덕션 코드를 작성하는 경우) 실행되지 않는다. 따라서 엔티티 생성 후에 ./gradlew compileQuerydsl을 입력해주어 쿼리용 클래스를 생성해주자.
queryDSL 적용 Repository 구조
Spring Data JPA의 기능과 queryDSL을 적용한 인터페이스를 모두 사용하기 위해 아래의 구조를 채택했다.
Custom 인터페이스 및 Impl 클래스는 queryDSL 적용 메서드를 가지고 있다.
따라서 Repository는 JpaRepository와 RepositoryCustom 모두를 extends하므로 Spring Data JPA, queryDSL 기능을 모두 사용할 수 있게 된다.
그렇지만 난 대부분의 조회 메서드는 custom 인터페이스로 queryDSL을 사용하는 방법을 사용했다.
그 이유는 아래의 queryDSL 적용 성능 개선 부분에 작성하도록 하겠다.
queryDSL config 세팅
queryDSL을 사용하기 위해선 JPAQueryFactory를 이용해야 한다. queryDSL을 적용하는 RepositoryImpl 클래스에서 직접 JPAQueryFactory를 생성해줄 수도 있지만, 별도의 Config 클래스를 만들어 JPAQueryFactory를 빈으로 등록하여 외부에서 주입해주는 방법도 존재한다. 우리는 후자의 방법을 선택했다. 조금이나마 변경에 유연한 설계를 하기 위해서이다.
QueryDslConfig.class
1
2
3
4
5
6
7
8
9
10
11
12
|
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory init() {
return new JPAQueryFactory(em);
}
}
|
cs |
해당 클래스에 @Configuration 어노테이션을 붙여주고, JPAQueryFactory를 빈으로 등록하게 해 스프링에서 관리할 수 있도록 해주었다.
위와 같이 하면 RepositoryImpl 클래스에선 아래와 같이 @RequiredArgsConstructor 만으로 JPAQueryFactory를 주입받을 수 있게 된다.
MemberRepositoryImpl.class
1
2
3
4
5
6
7
8
|
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
// queryDSL
}
|
cs |
트러블슈팅
주의사항으로는, @DataJpaTest로 repository 테스트를 진행하고 있었다면 아래와 같이 에러가 발생할 수 있다.
1
2
3
4
5
6
7
8
|
Failed to load ApplicationContext
java.lang.IllegalStateException: Failed to load ApplicationContext
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'memberRepositoryImpl' defined in file [/Users/kth990303/Desktop/woowacourse/2022-nae-pyeon/backend/build/classes/java/main/com/woowacourse/naepyeon/repository/member/MemberRepositoryImpl.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.querydsl.jpa.impl.JPAQueryFactory' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
at app//org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:800)
... 86 more
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.querydsl.jpa.impl.JPAQueryFactory' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:1801)
... 104 more
|
cs |
Failed to load ApplicationContext
Error creating bean with name 'RepositoryImpl' defined in file ...
Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.querydsl.jpa.JPAQueryFactory' available
이는 @DataJpaTest 어노테이션은 스프링이 관리하는 모든 빈을 가져오는 것이 아닌, 엔티티와 entitymanager 정도만 등록해주기 때문에 JPAQueryFactory 빈은 인식하지 못하게 되는 것이다.
따라서 아래와 같이 test 클래스에 QueryDslConfig.class를 Import 해주어야 한다.
MemberRepositoryTest
@Import(QueryDslConfig.class)
// 복수 개를 import할 경우 중괄호로 감싸주자
@Import({JpaAuditingConfig.class, QueryDslConfig.class})
queryDSL 적용으로 인한 성능 개선
Spring Data JPA의 경우 연관관계가 있을 때 특정 엔티티의 id로 조회하면 left outer join절이 나간다.
XXX를 엔티티라고 할 때, findByXXX 쿼리는 where절로 찾아오지만, findByXXXId는 left outer join절로 찾아온다.
Spring Data JPA의 findByTeamId
List<TeamParticipation> findByTeamId(final Long teamId);
이는 연관관계로 묶인 엔티티는 타 엔티티를 저장하고 타 엔티티의 식별자를 저장하는 것이 아니기 때문이다. 따라서 타 엔티티는 Join절을 이용하여 식별자 필드를 찾아와야 하기 때문에 join이 나가게 된다.
left outer join을 하면 탐색 대상이 많아지기 때문에 성능 상으로 좋지 않다.
queryDSL을 이용하면 이러한 쿼리 성능을 개선할 수 있다.
위 성질은 Spring Data JPA의 find 메서드 기능 특징을 사용하면서 의도치 않은 JPQL을 호출한 것이지, JPQL 특징이 아니기 때문이다. JPQL에서 `where p.team = :team`과 `where p.team.id = :teamId`는 동일한 JPQL로 동작하고, 따라서 queryDSL을 사용하면 불필요하게 join을 치지 않게 된다.
자세한 내용은 아래 블로그를 참고하자.
queryDSL을 적용한 코드는 아래와 같다.
queryDSL의 findByTeamId
@Override
public List<TeamParticipation> findByTeamId(final Long teamId) {
return queryFactory
.selectFrom(teamParticipation)
.where(isTeamIdEq(teamId))
.fetch();
}
참고. Java8을 이용한 BooleanBuilder 적용
위 코드에서 where(isTeamIdEq(teamId)) 라는 queryDSL 제공 문법이 아닌 코드를 확인할 수 있다.
해당 문법은 아래와 같다.
// AS-IS
where(teamParticipation.team.id.eq(teamId))
// TO-DO
.where(isTeamIdEq(teamId))
where절에서 eq로 비교하는 부분을 BooleanBuilder를 이용하여 유연하게 사용할 수 있다.
private BooleanBuilder isTeamIdEq(final Long teamId) {
return nullSafeBuilder(() -> teamParticipation.team.id.eq(teamId));
}
private BooleanBuilder nullSafeBuilder(Supplier<BooleanExpression> f) {
try {
return new BooleanBuilder(f.get());
} catch (final IllegalArgumentException | NullPointerException e) {
return new BooleanBuilder();
}
}
위와 같이 메서드로 분리해두면, 해당 메서드를 적절한 때에 재사용하거나 체이닝하기 편리하다는 장점이 존재한다.
queryDSL에서 Null을 체이닝하면 NPE가 발생하므로 위와 같이 NullPointerException이 발생하면 new BooleanBuilder()를 반환하게 해주었다.
다만, eq로 비교하는 값이 ""와 같은 경우는 빈 BooleanBuilder를 반환하는 결과가 아닌 실제로 ""와 같은지를 비교한다. 따라서 ""도 빈 BooleanBuilder를 반환하게 하고 싶다면 적절히 case work를 해주어야 한다.
queryDSL 적용 후 복잡한 쿼리 리팩터링
아래 쿼리를 개선해보자.
특정 회원이 작성한 메시지를 찾는 메서드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@Query(value = "select distinct new com.woowacourse.naepyeon.service.dto.WrittenMessageResponseDto"
+ "(m.id, r.id, r.title, t.id, t.name, m.content, m.color, "
+ "case when r.recipient = com.woowacourse.naepyeon.domain.rollingpaper.Recipient.MEMBER then p.nickname "
+ "when r.recipient = com.woowacourse.naepyeon.domain.rollingpaper.Recipient.TEAM then t.name "
+ "else '' end) "
+ "from Message m"
+ ", Rollingpaper r"
+ ", Team t"
+ ", TeamParticipation p "
+ "where m.rollingpaper.id = r.id "
+ "and r.team.id = t.id "
+ "and p.team.id = t.id "
+ "and m.author.id = :authorId "
+ "and (p.member.id = r.member.id or r.member.id is null)")
Page<WrittenMessageResponseDto> findAllByAuthorId(@Param("authorId") final Long authorId,
final Pageable pageRequest);
|
cs |
위 쿼리가 복잡한 이유는 아래와 같다.
- 해당 메서드 사용 api 반환값으로 다양한 엔티티의 정보를 요구한다. (ex. 메시지 정보, 메시지 작성자 정보, 메시지가 존재하는 롤링페이퍼 위치, 해당 롤링페이퍼가 존재하는 모임 정보 등)
- 쿼리에서 DTO를 바로 반환하도록 한다.
- 쿼리 내에서 특정 조건에 따라 찾아오는(select) 값이 달라져서 case, when 절이 필요하다.
특히 DTO를 반환하는 부분에서 @Query (JPQL)을 직접 사용하면 패키지명을 일일이 적어줘야 하는 번거로움이 존재한다.
또한, 위 쿼리는 상당히 길이가 길기 때문에 공백이나 문법오류가 존재할 확률이 높으며, sql 실수는 컴파일 시점에 잡아주기 어려우므로 개발자들이 많이 고통받게 된다.
심지어 위 쿼리 실행 결과로 엄청나게 많은 join절이 발생하게 된다.
queryDSL을 적용해보자.
일단 위 쿼리를 그대로 queryDSL로 옮겨와보자.
먼저 아래 코드를 보자. 참고로 이 코드는 total 개수를 구하는 부분이 틀린 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
@Override
public Page<WrittenMessageResponseDto> findAllByAuthorId(final Long authorId, final Pageable pageRequest) {
final List<WrittenMessageResponseDto> content = queryFactory
.select(Projections.constructor(WrittenMessageResponseDto.class,
message.id,
message.rollingpaper.id,
message.rollingpaper.title,
message.rollingpaper.team.id,
message.rollingpaper.team.name,
message.content,
message.color,
new CaseBuilder()
.when(message.rollingpaper.recipient.eq(Recipient.MEMBER))
.then(teamParticipation.nickname)
.when(message.rollingpaper.recipient.eq(Recipient.TEAM))
.then(message.rollingpaper.team.name)
.otherwise("")
))
.from(message)
.leftJoin(message.rollingpaper, rollingpaper)
.leftJoin(rollingpaper.team, team)
.join(teamParticipation).on(message.rollingpaper.team.id.eq(teamParticipation.team.id))
.where(isAuthorIdEq(authorId)
.and(message.rollingpaper.id.eq(rollingpaper.id))
.and(rollingpaper.team.id.eq(team.id))
.and(message.rollingpaper.member.id.isNull()
.or(message.rollingpaper.member.id.eq(teamParticipation.member.id))
)
)
.distinct()
.offset(pageRequest.getOffset())
.limit(pageRequest.getPageSize())
.fetch();
final long total = queryFactory
.select(Projections.constructor(WrittenMessageResponseDto.class,
message.id,
message.rollingpaper.id,
message.rollingpaper.title,
message.rollingpaper.team.id,
message.rollingpaper.team.name,
message.content,
message.color,
new CaseBuilder()
.when(message.rollingpaper.recipient.eq(Recipient.MEMBER))
.then(teamParticipation.nickname)
.when(message.rollingpaper.recipient.eq(Recipient.TEAM))
.then(message.rollingpaper.team.name)
.otherwise("")
))
.from(message)
.leftJoin(message.rollingpaper, rollingpaper)
.leftJoin(rollingpaper.team, team)
.join(teamParticipation).on(message.rollingpaper.team.id.eq(teamParticipation.team.id))
.where(isAuthorIdEq(authorId)
.and(message.rollingpaper.id.eq(rollingpaper.id))
.and(rollingpaper.team.id.eq(team.id))
.and(message.rollingpaper.member.id.isNull()
.or(message.rollingpaper.member.id.eq(teamParticipation.member.id))
)
)
.distinct()
.stream()
.count();
return new PageImpl<>(content, pageRequest, total);
}
|
cs |
페이지네이션을 적용해야 되기 때문에 굉~장히 길다.
하지만 Projections를 활용하여 DTO 패키지명을 모두 적어주어야 한다는 번거로움이 없어졌고, 컴파일 시점에서 문법 오류를 캐치해낼 수 있다는 장점이 생기게 됐다. CaseBuilder를 이용하여 복잡한 case when 절을 처리해주었다.
queryDSL은 체이닝이 가능하기 때문에 생산성이 확실히 좋다는 장점이 있는 듯하다.
트러블슈팅 1
Projections.constructor에서는 select 절에서 count를 사용할 수 없어 PageableExecutionUtils.getPage의 세 번째 인자에 longSupplier 타입을 넣어줄 방법을 찾기가 어렵다. DTO를 페이지네이션을 이용하여 반환하기 위해선 Projections를 이용해야 하는데, Projections.constructor().count() 기능이 존재하지 않아 페이지네이션을 적용하는 데에 애를 먹었다.
DTO 클래스에 @QueryProjection 어노테이션을 붙여줄 수도 있다.
하지만, 해당 방법은 DTO가 queryDSL에 의존적이게 돼서 변경에 유연하지 못한 설계가 돼버린다.
DTO는 DTO의 역할에 충실해야 한다고 생각하기 때문에 위 방법은 사용하지 않았다.
-> 따라서 위의 35~64번째 라인과 같이 java의 스트림 기능을 활용하여 반환값 타입을 PageImpl<>로 해주어 longSupplier 타입이 아닌 long 타입을 넣을 수 있도록 해주었다.
-> 위와 같이 하면 전체 totalElements 개수가 아닌 페이징 카운트 개수만 가져온다. 따라서 의도한대로 메시지 전체 개수를 가져오지 않는다. 그럼 어떻게 해야될까? 어차피 count(개수)를 가져오는 것이므로 Projections를 이용하지 않고 Entity.count() 또는 Entity.countDistinct()를 이용하면 된다.
트러블슈팅 2
queryDSL에서는 eq를 이용하여 특정 값과 동일한지 확인할 수 있다. 하지만 eq에는 null을 넣을 수 없다.
이러한 경우를 위해 queryDSL에서는 isNull(), isNotNull()을 제공해준다.
// AS-IS: 불가능
.where(isAuthorIdEq(authorId)
.and(message.rollingpaper.member.id.eq(null)
.or(message.rollingpaper.member.id.eq(teamParticipation.member.id))
)
)
// TO-DO: 가능
.where(isAuthorIdEq(authorId)
.and(message.rollingpaper.member.id.isNull()
.or(message.rollingpaper.member.id.eq(teamParticipation.member.id))
)
)
쿼리 실행 결과
연관관계에 포함된 모든 엔티티의 catesian product 연산 결과를 출력해주는 cross join에서
left에 해당되는 엔티티에 존재하는 경우만 출력해주는 left outer join으로 성능이 개선된 모습을 확인할 수 있다.
조금 더 리팩터링해보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
@Override
public Page<WrittenMessageResponseDto> findAllByAuthorId(final Long authorId, final Pageable pageRequest) {
final List<WrittenMessageResponseDto> content = getQueryWhenFindAllByAuthorId(authorId)
.offset(pageRequest.getOffset())
.limit(pageRequest.getPageSize())
.fetch();
return PageableExecutionUtils.getPage(
content, pageRequest, getCountQueryWhenFindAllByAuthorId(authorId)::fetchOne
);
}
private JPAQuery<WrittenMessageResponseDto> getQueryWhenFindAllByAuthorId(final Long authorId) {
return queryFactory
.select(makeProjections())
.distinct()
.from(message)
.join(teamParticipation).on(message.rollingpaper.team.id.eq(teamParticipation.team.id))
.where(isAuthorIdEq(authorId)
.and(message.rollingpaper.member.id.isNull()
.or(message.rollingpaper.member.id.eq(teamParticipation.member.id))
)
);
}
private JPAQuery<Long> getCountQueryWhenFindAllByAuthorId(final Long authorId) {
return queryFactory
.select(message.countDistinct())
.from(message)
.join(teamParticipation).on(message.rollingpaper.team.id.eq(teamParticipation.team.id))
.where(isAuthorIdEq(authorId)
.and(message.rollingpaper.member.id.isNull()
.or(message.rollingpaper.member.id.eq(teamParticipation.member.id))
)
);
}
private ConstructorExpression<WrittenMessageResponseDto> makeProjections() {
return Projections.constructor(WrittenMessageResponseDto.class,
message.id,
message.rollingpaper.id,
message.rollingpaper.title,
message.rollingpaper.team.id,
message.rollingpaper.team.name,
message.content,
message.color,
new CaseBuilder()
.when(message.rollingpaper.recipient.eq(Recipient.MEMBER))
.then(teamParticipation.nickname)
.when(message.rollingpaper.recipient.eq(Recipient.TEAM))
.then(message.rollingpaper.team.name)
.otherwise("")
);
}
|
cs |
리팩터링 내용은 아래와 같다.
- 메서드 분리를 통해 가독성을 높임.
- queryDSL은 연관관계에 해당되는 엔티티의 필드를 theta join 없이 접근 가능하므로 불필요한 leftJoin을 제거
참고로 countDistinct()와 count()를 select한 후 distinct()의 결과는 다르다. count() 자체는 특정 엔티티의 개수, 즉 수를 반환하기 때문에 애초에 원소가 하나이다. 따라서 distinct()가 적용되지 않는다.
쿼리 실행 결과
연관관계에 해당되는 특정 엔티티에 해당되는 모든 출력을 해주는 left outer join에서
두 엔티티의 교집합에 해당되는 출력만 해주는 inner join으로 성능이 개선된 모습을 확인할 수 있다.
또한, 메서드 분리 및 쿼리 길이 감소로 가독성이 상승된 것을 확인할 수 있다.
느낀 점
queryDSL을 적용하고 나니 성능상으로 개선된 모습을 확인할 수 있어서 뿌듯했다.
또한, 개발할 때에 컴파일 시점에서 문법 오류를 확인할 수 있고 적절한 체이닝과 자동완성이 빠르게 제공된다는 편리성 면에서 확실히 좋다고 생각된다.
다만 queryDsl을 적용하기 위한 환경세팅이 번거롭고, 코드량은 오히려 늘어날 수 있다는 단점이 존재한다.
그렇지만 장점이 단점을 충분히 커버할 수 있다고 생각한다.
복잡한 쿼리가 존재하거나 JPQL 빈도가 높다면 queryDSL 적용을 추천한다.
참고
- findByXXXId 관련 우아한테크코스 4기 오찌 블로그: https://velog.io/@ohzzi/Data-Jpa-findByXXXId-%EB%8A%94-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-join%EC%9D%84-%EC%9C%A0%EB%B0%9C%ED%95%9C%EB%8B%A4
- @DataJpaTest와 QueryDslConfig: https://www.inflearn.com/questions/23063
- BooleanBuilder를 활용한 nullSafeBuilder: https://www.inflearn.com/questions/94056
- queryDSL에서 ""를 Null 처리하는 방법: https://www.inflearn.com/questions/545806
- 프로젝트 적용 트러블슈팅 및 이슈: https://github.com/woowacourse-teams/2022-nae-pyeon/pull/421
'JAVA > JPA 학습기록' 카테고리의 다른 글
[ERROR] JPA initializationError 해결 방법 모음 및 대처법 (0) | 2023.02.10 |
---|---|
[JPA] 프로젝트 동시성 이슈 해결을 위해 낙관적 락을 걸어보았다 (3) | 2022.10.29 |
[JPA] 벌크 Update, Delete 연산과 영속성 컨텍스트 (2) | 2022.09.15 |
[JPA] 쓰기 지연으로 인한 쿼리 실행 시점과 예외처리 및 기본키 생성 전략 (2) | 2022.09.10 |
[JPA] Spring Data JPA 페이징 기법을 적용해보자 (0) | 2022.08.04 |