일반적으로 필요한 Repository를 만들면 Spring Data JPA 기능을 이용하기 위해 JpaRepository를 상속받고, 또 Querydsl-JPA를 위해 사용자 정의 레포지토리를 만들고 이를 상속 받는다. 그리고 이를 구현한 실제 구현 객체가 필요하다.
아니면 QuerydslRepositorySupport클래스를 통해 이를 사용자 정의 클래스에서 상속 받도록 하는 방법도 있다.
근데 이렇게 매번 상속 받는 것이 불편하기도 하고 사실 JPAQueryFactory만 있다면 상관없기 때문에 상속 받는 구조보단, 이것만 주입받도록 하는게 가장 깔끔하다.
Querydsl 사용하는 방법
// 기존 방법 1.
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {}
// 기존 방법2
public class MemberRepositoryCustom extends QuerydslRepositorySupport {}
// 추천하는 방법
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustom {
private final JpaQueryFactory queryFactory; // 물론 이를 위해서는 빈으로 등록을 해줘야 한다.
}
JPAQueryFactory 빈으로 등록하는 방법
@Configuration
public class QuerydslConfiguration {
@Autowired
EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
동적쿼리를 작성하는 방법에는 BooleanBuilder를 작성하는 방법과 Where절과 파라미터로 Predicate를 이용하는 방법 그리고 where절과 파라미터로 Predicate를 상속한 BooleanExpression을 사용하는 방법 이렇게 있다.
예제를 보면 알겠지만.. BooleanBuilder를 사용하는 방법은 어떤 쿼리가 나가는지 예측하기 힘들다는 단점이 있다.
그리고 Predicate보다는 BooleanExpression을 사용하는 이유는 BooleanExpression은 and와 or같은 메서더들을 이용해서 BooleanExpression을 조합해서 새로운 BooleanExpression을 만들 수 있다는 장점이 있다. 그러므로 재사용성이 높다. 그리고 BooleanExpression은 null을 반환하게 되면, Where절에서 조건이 무시되기 때문에 안전하다.
BooleanBuilder를 이용하는 예제
public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition){
BooleanBuilder builder = new BooleanBuilder();
if (hasText(condition.getUsername())) {
builder.and(member.username.eq(condition.getUsername()));
}
if(hasText(condition.getTeamName())){
builder.and(team.name.eq(condition.getTeamName()));
}
if(condition.getAgeGoe() != null) {
builder.and(member.age.goe(condition.getAgeGoe()));
}
if(condition.getAgeLoe() != null){
builder.and(member.age.loe(condition.getAgeLoe()));
}
return queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(builder)
.fetch();
}
Where절과 BooleanExpression을 이용하는 예제
public List<MemberTeamDto> searchByWhere(MemberSearchCondition condition){
return queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetch();
}
private BooleanExpression usernameEq(String username) {
return hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
private BooleanExpression ageBetween(Integer ageLoe, Integer ageGoe) {
return ageLoe(ageLoe).and(ageGoe(ageGoe));
}
Querydsl에서 exist는 사용하지 않는 것이 좋다. 왜냐하면 Querydsl에 있는 exist는 count 쿼리를 사용하기 때문.
(count 쿼리는 SQL에서 exist와 같은 역할을 해줄 수 있기 때문)
SQL exist 쿼리의 경우에는 첫번째로 조건에 맞는 값을 찾는다면 바로 반환하도록 하지만, count 쿼리는 전체 행을 모두 조회하도록 해서 성능이 떨어진다. → 이 차이는 스캔 대상이 앞에 있을수록 성능 차이가 심해짐
즉 Querydsl에서는 이 메서드를 사용하지 않고 우회하도록 해야 하는데 이를 위해서는 fetchFirst()를 사용하면 된다.
fetchFirst()의 내부 구현에는 limit(1)이 있어서 결과를 한개만 가져오도록 하기 때문에 SQL exist문과 큰 차이가 없다.
fetchFirst를 이용한 exist 구현하기
public Boolean exist(Long memberId) {
Integer fetchOne = queryFactory
.selectOne()
.from(member)
.where(member.id.eq(memberId))
.fetchFirst();
return fetchOne != null;
}
묵시적 조인이라고 하는 조인을 명시하지 않고, 엔티티에서 다른 엔티티를 조회해서 비교하는 경우 JPA가 알아서 크로스 조인을 하게 된다.
크로스 조인을 하게 되면 나올 수 있는 데이터가 그냥 조인들보다 많아지기 때문에 성능 상에 단점이 있다.
그러므로 크로스 조인을 피하기 위해서는 쿼리를 보고 크로스 조인이 나간다면 명시적 조인을 이용해서 해결하도록 하자.
Cross Join 발생 예제
public List<Member> crossJoin() {
return queryFactory
.selectFrom(member)
.where(member.team.id.gt(member.team.leader.id))
.fetch();
}
Cross Join을 Inner Join으로 변경
public List<Member> crossJoinToInnerJoin() {
return queryFactory
.selectFrom(member)
.innerJoin(member.team, team)
.where(member.team.id.gt(member.team.leader.id))
.fetch();
}
많은 분들이 데이터베이스에서 Entity를 가지고 오는 걸 먼저 생각하지만, Entity를 가지고 오면 영속성 컨텍스트의 1차 캐시 기능을 사용하게 되기도 하고 불필요한 컬럼을 조회하기도 한다.
그리고 OneToOne 관계에서는 N+1 문제가 생기기도 한다.
OneToOne N+1 문제는 외래 키를 가지고 있는 주인 테이블에서는 지연 로딩이 제대로 동작하지만, mappedBy로 연결된 반대편 테이블에서는 지연로딩이 동작하지 않고 N+1 쿼리가 터지는 문제다.
OneToOne 문제 자세히 알아보기 →
JPA 도입 — OneToOne 관계에서의 LazyLoading 이슈 #1
(나중에 꼭 정리하자 .. !)
즉, 여기까지 얘기를 종합하면 Entity를 조회하면 성능 이슈가 될만한 사항이 많다는 것
그치만 Entity를 조회가 필요한 경우도 있으므로 아래와 같은 기준을 둬서 조회하자 !!
Dto 조회를 할 때 좀 더 성능상으로 이득을 보기 위해서는 조회 컬럼을 최소화 하는 방법이 있다.
Dto 조회할 때 필요한 컬럼만 가져오기
public List<MemberTeamDto> findSameTeamMember(Long teamId){
return queryFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
Expressions.asNumber(teamId),
team.name
))
.from(member)
.innerJoin(member.team, team)
.where(member.team.id.eq(teamId))
.fetch();
}
다음과 같이 select 절 안에 Entity를 넣도록 하면 Entity에 있는 모든 칼럼들이 조회가 된다.
위에서 얘기한 것처럼 필요한 컬럼만 조회하도록 하고, Entity를 조회하면 그에 따른 OneToOne 관계에서는 N+1 문제가 발생할 수 있으니까 조심하자.
Select절에 Entity가 있는 경우
public List<MemberTeamDto2> entityInSelect(Long teamId){
return queryFactory
.select(
new QMemberTeamDto2(
member.id,
member.username,
member.age,
member.team // team 에 있는 모든 칼럼을 가지고오게 된다.
)
)
.from(member)
.innerJoin(member.team, team)
.where(member.team.id.eq(teamId))
.fetch();
}
**select
member0_.member_id as col_0_0_,
member0_.username as col_1_0_,
member0_.age as col_2_0_,
member0_.team_id as col_3_0_,
team1_.team_id as team_id1_1_,
team1_.member_id as member_i3_1_,
team1_.name as name2_1_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
where
member0_.team_id=?**
Select절에 필요한 컬럼만 넣기
public List<MemberTeamDto2> entityInSelect(Long teamId){
return queryFactory
.select(
new QMemberTeamDto2(
member.id,
member.username,
member.age,
member.team.id // 꼭 필요한 칼럼만 가지고오자.
)
)
.from(member)
.innerJoin(member.team, team)
.where(member.team.id.eq(teamId))
.fetch();
}
select
member0_.member_id as col_0_0_,
member0_.username as col_1_0_,
member0_.age as col_2_0_,
member0_.team_id as col_3_0_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
where
member0_.team_id=?
일반적으로 MySQL에서는 Group By를 실행하면 Group By column에 의한 Filesort라는 정렬 알고리즘이 추가적으로 실행된다.
물론 이 쿼리는 index가 없다면 발생한다.
이 해결책으로 MySQL 레퍼런스를 보면..
If you use GROUP BY, output rows are sorted according to the GROUP BY columns as if you had an ORDER BY for the same columns. To avoid the overhead of sorting that GROUP BY produces, add ORDER BY NULL:
Relying on implicit GROUP BY sorting (that is, sorting in the absence of ASC or DESC designators) or explicit sorting for GROUP BY (that is, by using explicit ASC or DESC designators for GROUP BY columns) is deprecated. To produce a given sort order, provide an ORDER BY clause.
이 말은 Filesort가 발생하면 상대적으로 더 느려지므로 이 경우를 막으려면 order by null을 사용하면 된다.
하지만 Querydsl에서는 order by null을 지원하지 않음.
→ 우아한 형제들에서는 ORderByNull이라는 클래스를 만들고 이를 통해서 OrderByNull을 지원하도록 했다.
OrderByNull 클래스
public class OrderByNull extends OrderSpecifier {
public static final OrderByNull DEFAULT = new OrderByNull();
private OrderByNull(){
super(Order.ASC, NullExpression.DEFAULT, NullHandling.Default);
}
}
Group By와 OrderByNull 사용 예제
public List<Integer> useOrderByNull() {
return queryFactory
.select(member.age.sum())
.from(member)
.innerJoin(member.team, team)
.groupBy(member.team)
.orderBy(OrderByNull.DEFAULT)
.fetch();
}
select
sum(member0_.age) as col_0_0_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
group by
member0_.team_id
order by
null asc
주의할 점은 OrderByNull은 페이징 쿼리인 경우에는 사용하지 못한다.
추가적으로 정보로 정렬의 경우에는 100건 이하라면 애플리케이션 메모리로 가져와서 정렬하는 걸 추천 → 일반적으로 DB 자원보다는 애플리케이션 자원이 더 싸기 때문에 효율적
커버링 인덱스는 쿼리를 충족시키는데 필요한 모든 컬럼을 가지고 있는 인덱스이다.
select / where / order by / group by 등에서 사용되는 모든 컬럼이 인덱스에 포함된 상태로 No Offset방식과 더불어 페이징 조회 성능을 향상시키는 가장 보편적인 방법이다.
성능 향상을 위한 SQL 작성법
MySQL에서 커버링 인덱스
MySQL에서 커버링 인덱스로 쿼리 성능을 높여보자!!
지금은 인덱스를 이용해서 질의를 한다면 select 절을 비롯해 order by, where 등 쿼리 내 모든 항목이 인덱스 컬럼으로만 이루어지게 되서 인덱스 내부에서 쿼리가 완성되므로 DB 데이터 블록을 가지고 오기 보다 DB 인덱스 페이지 I/O 만으로 이뤄지기 때문에 성능이 올라간다는 점만 알자.
즉 인덱스 검색으로 빠르게 처리하고 걸러진 항목에 대해서만 데이터 블록에 접근하기 때문에 성능의 이점을 얻게 된다.
커버링 인덱스를 사용할 땐 from 절에 subQuery에서 커버링 인덱스를 통해 필터를 하도록 하는게 보편적인데 아쉽게도 Querydsl에선 JPQL은 from절에 서브쿼리를 지원하지 않는다.
→ 이를 우회하는 방법으로는 두번의 select 절을 이용하는건데 ..
Querydsl 에서 커버링 인덱스를 사용한 예제
public List<MemberDto> useCoveringIndex(int offset, int limit){
List<Long> ids = queryFactory
.select(member.id)
.from(member)
.where(member.username.like("member%"))
.orderBy(member.id.desc())
.limit(limit)
.offset(offset)
.fetch();
if(ids.isEmpty()){
return new ArrayList<>();
}
return queryFactory
.select(new QMemberDto(
member.username,
member.age
))
.from(member)
.where(member.id.in(ids))
.orderBy(member.id.desc())
.fetch();
}
이 방식의 단점으로는 너무 많은 인덱스가 생길 수 있다는 점.
결국 쿼리의 모든 항목이 인덱스로 필요하기 때문에 느린 쿼리가 발생할 때마다 새로운 신규 인덱스가 생성된 수 있다.
인덱스의 크기도 점점 커질 수 있는데, 인덱스도 결국 데이터이기 때문에 들어가는 항목이 점점 많아진다면 인덱스가 비대해진다는 단점이 있다.
기존 페이징 방식인 offset과 limit을 이용한 방식은 서비스가 커짐에 따라서 장애를 유발할 수도 있음.
이유로는 초기엔 데이터가 적어서 문제가 없지만 데이터가 점점 많아지면 느려지기 때문에 결국에는 offset을 이용하면 offset + limit 만큼의 데이터를 읽어야 하기 때문.
일단 offset을 이용하는 기존 페이징 쿼리는 아래와 같아.
SELECT *
FROM items
WHERE 조건문
ORDER BY id desc
OFFSET 페이지 번호
LIMIT 페이지 사이즈
이와 같은 형태는 페이지 번호가 뒤로 갈수록 앞에서 읽었떤 행을 다시 읽어야 한다.
이 말은 offset이 10000이고 limit이 20이라면, 10020행을 읽어야 한다는 것이고, 그러고 나서 10000개의 행을 버리는 것..
→ 그렇기 때문에 성능 상에 안좋다는 점인데, No Offset 방식은 시작 지점을 인덱스로 빠르게 찾아 첫 페이지부터 읽도록 하는 방식이다.
No Offset을 이용하는 SQL문은 다음과 같다.
SELECT *
FROM items
WHERE 조건문
AND id < 마지막 조회 ID
ORDER BY id desc
LIMIT 페이지 사이즈
No Offset 예제 코드
public List<MemberDto> noOffset(Long lastMemberId, int limit){
return queryFactory
.select(new QMemberDto(
member.username,
member.age
))
.from(member)
.where(member.username.contains("member")
.and(memberIdLt(lastMemberId)))
.orderBy(member.id.desc())
.limit(limit)
.fetch();
}
private BooleanExpression memberIdLt(Long lastMemberId) {
return lastMemberId != null ? member.id.lt(lastMemberId): null;
}
JPA를 사용하면 영속성 컨텍스트가 Dirty Checking 기능을 지원해주는데 이를 이용하면 엄청나게 많은 양의 데이터에 대해서 업데이트 쿼리가 나갈수도 있다.
💥 JPA의 Dirty Checking(더티 체킹)은 영속성 컨텍스트에 관리되는 엔티티 객체의 상태를 변경 감지하여 자동으로 데이터베이스에 반영하는 기능.
영속성 컨텍스트에 의해 관리되는 엔티티 객체의 상태가 변경되면, 트랜잭션이 종료될 때(커밋될 때) 데이터베이스에 자동으로 업데이트 쿼리가 실행된다 ! 이를 통해 개발자는 엔티티 객체의 변경만으로 데이터베이스 업데이트를 간편하게 처리할 수 있음.
🌈 중요한 포인트 : 더티 체킹은 트랜잭션 종료 시점에 발생. 엔티티 객체의 상태 변경은 영속성 컨텍스트에 의해 추적되지만, 실제 데이터베이스 업데이트는 트랜잭션이 커밋될 때 발생한다.
👍 장단점
하나의 트랜잭션 내에서 영속성 컨텍스트는 각 엔티티를 개별적으로 관리한다. 트랜잭션이 종료될 때, 영속성 컨텍스트는 변경된 엔티티를 감지하고, 각 변경된 엔티티에 대해서 개별적으로 쿼리를 실행한다. → 이 과정이 JPA의 더티 체킹 메커니즘
이렇게 일괄 Update 하는 것보다 확실히 성능이 낮다.
JPA Dirty Checking을 이용한 예제
private void dirtyChecking(){
List<Member> result = queryFactory
.selectFrom(member)
.fetch();
for (Member member : result){
member.setUsername(member.getUsername() + "+");
}
}
위 예제는 아래 단계를 거친다.
member.setUsername() 메서드 호출은 영속성 컨텍스트에 의해 관리되는 엔티티 객체의 상태를 변경하는 작업 → 이 작업은 트랜잭션이 종료될 때 일괄적으로 데이터베이스에 반영된다. 트랜잭션이 종료되기 전에는 데이터베이스 업데이트가 발생하지 않으며, 트랜잭션 종료 시점에 JPA의 더티 체킹이 일어나서 변경된 엔티티에 대해 업데이트 쿼리가 실행.
👍 트랜잭션 범위 : 트랜잭션 범위 내에서 여러 엔티티 객체를 변경할 수 있으며, 이 모든 변경 사항은 트랜잭션이 종료될 때 일괄적 처리.
즉, 트랜잭션 범위는 @Transactioal이 적용된 메서드의 시작부터 끝까지. (어노테이션이 붙은 메서드가 정상적으로 종료될 때 커밋된다. → 트랜잭션 종료 시점) 그리고 더티체킹 시점은 트랜잭션 종료 시점에 엔티티 객체의 변경 사항을 더티 체킹을 통해 데이터베이스에 반영한다.
☃️ 효율성 : 더티 체킹 방식은 개별 객체의 변경을 관리할 수 있어 실시간 비즈니스 로직에 적합하지만, 대량의 데이터 업데이트에는 비효율적이다. 대량 업데이트는 일괄 업데이트(batch update)를 통해 성능을 최적화하자.
Querydsl 일괄 업데이트를 이용한 예제
public void batchUpdate(){
queryFactory
.update(member)
.set(member.username, member.username + "+")
.execute();
}
Querydsl을 사용한 일괄 업데이트는 한 번의 SQL 업데이트 쿼리로 다수의 레코드를 업데이트 하는 방법이다. 이는 데이터베이스 레벨에서 일괄 처리를 하기 때문에 성능이 뛰어나다.
위 예제의 동작 방식은
queryFactory.update(member)
를 사용하여 Member
엔티티의 username
필드를 업데이트하는 쿼리를 작성한다..set(member.username, member.username + "+")
를 통해 username
필드를 업데이트한다..execute()
를 호출하여 쿼리를 실행하고, 데이터베이스에서 한 번의 쿼리로 모든 Member
엔티티의 username
필드를 갱신.🧐 일괄 업데이트는 영속성 컨텍스트를 무시하고, 직접 데이터베이스에 쿼리를 실행하기 때문에 아래와 같은 특성을 가진다.
👍 영속성 컨텍스트와 1차 캐시
문제 상황 예시
@Transactional
public void batchUpdate() {
// 일괄 업데이트
queryFactory
.update(member)
.set(member.username, member.username + "+")
.execute();
// 영속성 컨텍스트를 초기화하지 않음
}
@Transactional
public void updateMemberAgain() {
// 이전 트랜잭션에서 일괄 업데이트된 엔티티를 다시 가져와서 변경
List<Member> members = queryFactory.selectFrom(member).fetch();
for (Member member : members) {
member.setAge(member.getAge() + 1); // 다른 필드를 변경
}
}
해결방법
일괄 업데이트 후 영속성 컨텍스트와 데이터베이스 간의 일관성을 유지하기 위해 영속성 컨텍스트를 적절히 갱신해야 한다. → 가장 간단한 것은 영속성 컨텍스트를 초기화 하는 것 !
영속성 컨텍스트 초기화
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void batchUpdate() {
queryFactory
.update(member)
.set(member.username, member.username + "+")
.execute();
entityManager.clear(); // 영속성 컨텍스트 초기화
}
이렇게 하면, 영속성 컨텍스트가 초기화되어, 이후 불일치가 발생하지 않음.
정리
JPA의 더티 체킹은 영속성 컨텍스트가 관리하는 엔티티 객체의 상태를 변경하고, 트랜잭션이 종료될 때 변경된 엔티티를 자동으로 감지하여 데이터베이스에 반영하는 기능이다.
즉, 트랜잭션 범위 내에서 여러 엔티티의 상태 변경을 감지하고, 트랜잭션이 종료되면(커밋되면) 변경된 엔티티에 대해 자동으로 업데이트 쿼리가 실행된다.
→ 이는 실시간으로 엔티티 상태를 관리하고 업데이트가 필요할 때(실시간 비즈니스 처리)에 유리하다.
일괄 업데이트(Querydsl을 활용한 일괄 처리)
Querydsl을 사용한 일괄 업데이트는 한번의 SQL 쿼리로 다수의 레코드를 갱신하는 방법이다. 데이터베이스 레벨에서 일괄처리를 하기 때문에 성능이 매우 뛰어나다.
한 번의 쿼리로 다수의 레코드를 업데이트하고나 삭제할 수 있다. 하지만 일괄 업데이트는 영속성 컨텍스트를 무시하고 직접 데이터베이스에 쿼리를 실행하므로 영속성 컨텍스트의 1차 캐시는 갱신되지 않는다. → 이에 대해 일괄 업데이트 후 영속성 컨텍스트를 초기화하여 데이터베이스와 일관성을 유지해야 한다.
결국, 대량 데이터를 처리해야 하는 경우에는 일괄 처리 후 영속성 컨텍스트 초기화를 생각하자. (특정 조건을 만족하는 모든 레코드를 업데이트 하거나 삭제해야 할 때.)
그러므로 실시간 비즈니스 처리나 실시간 단건 처리가 필요하다면 Dirty Checking 기능을 본격적으로 이용하고 대량의 데이터를 일괄 업데이트가 필요하면 위의 방식을 사용하자.
JDBC에는 rewriteBatchedStatements로 Insert 합치기라는 옵션이 있다. 이를 통해 여러 Insert문을 하나의 Insert로 작업하도록 하는 것을 말한다.
예를 들면 아래 쿼리들이
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at);
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at);
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at);
// ...
아래 쿼리로 대체될 수 있는 것이다.
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at)
, (:content, :status, :created_by, :created_at, :last_modified_at)
, (:content, :status, :created_by, :created_at, :last_modified_at)
, ...;
하지만 JPA에는 auto_increment일 때 insert 합치기가 적용되지 않는다. 그러므로 이 기능이 필요하다면 → JdbcTemplate를 사용하자.
물론 이렇게 하면 type-safe하진 않지만 어쩔 수 없다.