현재 구현된 서비스의 검색 기능들 중 대부분은 조건이 한 개이다. 물론 다양한 테이블을 join하는 것은 있을수 있지만 검색의 조건이 다양한 것은 조금 다르다고 생각했다
기존의 특정 단어로만 검색하는 것이 아닌 여러가지 조건이 담긴 검색의 성능 향상을 위해 작성해본다
💬 쿼리 튜닝이 필요한 작업과 설명
검색의 조건은 카테고리와 장르를 기준으로 그곳에 해당하는 작품들을 검색해서 전달하는 것이다
1. 카테고리와 장르와 연관된 작품id까지 저장되어 있는 중간 테이블을 제작
검색의 성능을 향상시키는 방법은 중간 테이블을 활용하는 것이라고 생각했다. 그렇기 때문에 아래와 같이 카테고리와 장르가 연결된 작품들을 작성하는 중간 테이블을 제작했다.
이렇게 해서 좋아지는 것을 짧게라도 생각해본다면 만약 중간 테이블이 없었다면
- 카테고리 별로 작품을 분류해서 작품을 검색
- 해당 작품의 장르 검색해서 그중 원하는 장르가 포함 된다면 출력
이렇게 생각보다 복잡해진다 하지만 중간 테이블이 있다면
- 카테고리와 장르를 기준으로 중간테이블을 검색하고 거기에 담긴 작품의 정보를 가져온다
이렇게 쉽게 가져올 수 있는 것이다.
2. 로직을 작성해서 실제로 데이터가 저장된 값을 불러와보기
그렇다면 이제 실제로 데이터를 가져와보자.
나는 JPA를 이용해서 프로젝트를 진행하고 있기 때문에 우선 역시 가장 먼저 해보는것은 내가 따로 쿼리를 작성하지 않고 지원하는 ORM으로 알아서 작성되는 방식으로 진행을 해보았다
- category와 genre를 검색하여 return으로 받는 workCategoryGenre값을 받아 response값을 설정해주는 코드
@Override
public List<WorkResponseDto> getWorkByCategoryAndGenre(Long categoryId, Long genreId) {
List<WorkCategoryGenre> works = workCategoryGenreService.findWorks(categoryId, genreId);
return works.stream().map(workCategoryGenre -> WorkResponseDto.builder()
.workId(workCategoryGenre.getWork().getId())
.title(workCategoryGenre.getWork().getTitle())
.build()
).toList();
}
- 실제로 WorkCategoryGenre 를 return해주는 로직
@Override
public List<WorkCategoryGenre> findWorks(Long categoryId, Long genreId) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new BusinessExceptionHandler(ErrorCode.CATEGORY_NOT_FOUND));
Genre genre = genreRepository.findById(genreId)
.orElseThrow(() -> new BusinessExceptionHandler(ErrorCode.GENRE_NOT_FOUND));
return workCategoryGenreRepository.findWorkCategoryGenreByCategoryAndGenre(category, genre);
}
우선 카테고리와 장르를 확인하고 해당하는 카테고리와 장르를 기준으로 가져오는 것인데 여기서 바로 문제가 발생했다
💫 N + 1 문제 발생
앞서서 먼저 JPA가 자동적으로 생성해서 만드는 쿼리에 굉장히 큰 문제가 있었다.
만약 where문에서 category와 genre가 겹치는 행을 모두 검색해서 만약 해당되면 바로 select 쿼리를 발생하는 것이다.
Hibernate:
select
w1_0.work_id,
c1_0.category_id,
c1_0.name,
w1_0.content,
w1_0.created_date,
w1_0.image_url,
m1_0.member_id,
m1_0.name,
w1_0.publisher_date,
w1_0.publisher_name,
w1_0.title,
w1_0.updated_date,
w1_0.view
from
work w1_0
left join
category c1_0
on c1_0.category_id=w1_0.category_id
left join
member m1_0
on m1_0.member_id=w1_0.member_id
where
w1_0.work_id=?
그 쿼리가 위의 쿼리인데 해당 작품이 있을때마다 저 쿼리가 발생한다. 즉, 만약 해당 작품이 5천개라면? 1만개라면? 그만큼의 쿼리가 발생한다 그러면 성능은 굳이 말하지 않아도 알것이라고 예상된다.
그래서 이를 해결하기 위해 JPA에서 직접 작성해주는 것이 아닌 직접 쿼리를 튜닝해서 빠르게 가져오는 것으로 변경해보고자 한다.
❗ 해결을 위한 QueryDSL의 도입
우선 쿼리를 튜닝하기 전에 JPQL을 직접 사용하지 않고 QueryDSL을 도입한 이유를 먼저 적어보려고 한다
JPQL의 특징
장점
- 엔티티 객체를 대상으로 쿼리를 작성하기 때문에 객체 중심의 쿼리문
- JPA 표준의 일부로 모든 JPA 구현체에서 동일하게 사용된다
단점
- String 타입으로 작성이 되다보니 복잡하게 쿼리를 작성하는 것이 어렵다.
- 쿼리 오류를 컴파일 타임에 검출 할 수 없고 런타임에서만 발생한다
QueryDSL의 특징
장점
- 컴파일 단계에서 쿼리의 오류를 검출할 수 있어서 안전하고 비교적 쉽게 코딩을 할 수 있다
- 다양한 조건을 조합하여 동적 쿼리를 쉽게 작성할 수 있다
- 자동 완성 기능을 지원하기 때문에 긴가민가 할 때 사용하기 좋다
단점
- 러닝 커브(학습 곡선)가 존재한다
- QueryDSL은 의존성을 추가해서 도입하는 것이기 때문에 추가적인 설정이 필요하고 Q 클래스 생성을 위한 빌드 설정이 필요하다.
결과
결과
JPQL은 간단하고 표준적인 CRUD 작업에 적합하고 SQL과 유사한 문법으로 빠르게 쿼리를 작성한다. 그에 반해 QueryDSL은 복잡한 쿼리와 타입의 안정성을 중요하시 하기에 좋다. 또한 복잡한 쿼리는 오타를 유발할 수 있고 작성하다가 내가 헷갈릴수 있다는 것을 방지해서 컴파일 단계에서 체크를 해줘야 했으면 했다.
그래서 QueryDSL을 도입했다
쿼리 제작 ( 서브 쿼리 사용하기 )
JPQLQuery<Long> subQuery = JPAExpressions
.select(workCategoryGenre.work.id)
.from(workCategoryGenre)
.where(workCategoryGenre.category.eq(findCategory)
.and(workCategoryGenre.genre.eq(findGenre)));
List<Tuple> fetch = jpaQueryFactory.select(work.id, work.title)
.from(work)
.where(work.id.in(subQuery))
.fetch();
💦 해석
- 중간 테이블이 존재하기 때문에 그곳에 일치하는 작품을 검색하고 분류한다
- 그리고 직접 work 테이블에 가져 서브 쿼리에서 분류한 작품들을 다시 검색하여 결과를 list로 받아낸다
📖 테스트
역시 성능을 향상했다면 테스트가 빠질 수 없다
테스트 조건은 간단하다 특정 카테고리와 특정 장르가 겹치는 5천개의 작품이 있다
💢 포인트
사실 테스트 결과를 보고 굳이 JMeter로 실험까지도 불필요 했다....그만큼 참혹했다
수정 이전의 결과
보이는 것처럼 단 한번의 요청의 2.6초 응답이 필요했다. 물론 과하게 많은 작품을 기준으로 했지만 카테고리마다 장르는 많고 그것의 값이 만약 균일하다면 모든 요청에 적어도 사용자는 2.6초는 기다려야 한다는 것이다. 이래서 사실 JMeter의 테스트가 필요가 없었다.
수정 이후의 결과
결과는 엄청 났다. 무려 2.6초에서 25ms 말도 안되는 성능을 보이게 된것이다.
쿼리문도 이렇게 단 한개를 통해서 가져오는 것이다.
여기서 나는 쿼리의 튜닝이 얼마나 중요한지 알게 되었다.
💢 느낀점
사실 쿼리문은 내가 바보 같이 설계를 한게 맞다 굳이 전부를 가져오는게 아니고 그냥 원하는것만 가져오면 되는 것이였는데 오로지 JPA가 알아서 쿼리문을 작성하도록 두어서 더 쿼리문이 많이 발생한 것이다.
그리고 쿼리를 튜닝하는 과정에 팁을 몇 개 살펴보았었다. 거기에 서브쿼리를 최소화하라고 작성이 되어 있었지만 나는 한개만 사용하니까 괜찮지 않을까 싶었다...서브 쿼리가 많다는 건 그만큼 많은 테이블에 접촉을 해야하고 과연 그것이 좋은 정규화 상태일까? 라는 생각이 들긴했다
하지만 이번 쿼리 튜닝은 굉장히 좋은 기회였다고 생각한다. 앞으로 쿼리 튜닝을 통해 DB에서 데이터를 가져오는 속도를 향상시키는 것은 굉장히 좋다고 생각한다
'프로젝트 > 스위프 프로젝트(Lit Map)' 카테고리의 다른 글
🎋 조회수 관리(동시성 관리)를 위한 @Modifying 사용 (1) | 2024.07.23 |
---|---|
🎋 최신 업데이트 된 작품들 가져오기 (Native Query의 UnionALL) (0) | 2024.07.19 |