프로젝트 회고

주특기 프로젝트 중 QueryDSL 도입 회고

iksadnorth 2023. 9. 24. 17:45

👣 개요

항해 99에서 최종 프로젝트 전에 수행하는 주특기 프로젝트 중
QueryDSL을 도입한 과정을 회고하고자 이 게시글을 적게 되었다.

해당 프로젝트는 '블라인드' 사이트와 같이 회사에 대해 평가하고
정보를 나눌 수 있는 커뮤니티라고 볼 수 있다.

구현해야 하는 API 중 특정 회사명을 입력하면 
해당 회사명이 포함된 회사들을 출력하는 API가 있었다.

 

👣 Query Method를 통한 검색 구현

우선 주특기 프로젝트를 수행하기 앞서 이번 프로젝트에서의 나의 각오는
프론트엔드 팀의 요구사항을 재빠르게 수용하고 결과를 내놓는 백엔드 개발자가 되는 것이었다.
이것을 위해선 유지보수가 매우 용이한 구조의 코드를 짜야 했었고
확장성을 염두해서 코드를 작성해야 했다.

검색 기능을 구현하는 것은 JPA에서 제공하는 기능을 그대로 사용할 수는 없기에
직접 JPQL 쿼리를 작성하거나 Spring Data JPA의 Query Method 기능을 사용해야 한다.

최악의 가독성, 최악의 유지 보수 난이도

나의 경우 가독성도 최악이고 유지보수성도 최악이라고 여기는 Spring Data JPA의 Query Method 기능은
아예 고려 대상에서 제외하고 기술을 고민했다.

만약 Entity의 필드명이 변경된다해도 Query Method를 사용한다면 해당 쿼리가 잘못되었다는 것을
컴파일 단계가 아닌 런타임에서 알 수 있기 때문에 견고한 코드를 작성할 수 없다는 것은 물론이거니와
개인적으로 띄어쓰기도 할 수 없는 메서드를 작성해야 하기에 가독성도 최악이라고 여겼다.

그리고 앞에서 언급했다 싶이 프론트엔드의 요구사항을 가리지 않고 빠르게 수용하기 위해서는
확장성을 염두해서 코드를 짜야 한다.
하지만 Query Method로는 Group By, SubQuery를 작성하는 것이 매우 여렵기 때문에 
고려사항에서 제외했다.

 

👣 JPQL를 통한 검색 구현

그래서 사용하고자 했던 기술이 JPQL이었다.
JPQL을 이용하면 Query Method에 비하면 가독성이 매우 좋다.
물론 컴파일 단계에서 쿼리 오류를 발견하기 어렵다는 것도 매한가지였지만
어차피 구현한 쿼리에 대해 테스트 코드를 작성할 예정이었기에 그다지 큰 단점은 아니었다.

검색 기능을 구현하기 위해 Like 연산자를 사용하기로 했다.

위 이미지와 같이 사용하면 검색 기능은 쉽게 구현이 된다.

하지만 여기서 다소 찜찜한 생각이 들기 시작했다.
왜냐면 keyword 값이 Null값으로 들어온다면?

SELECT c FROM Company AS c WHERE c.companyName LIKE '%%';
SELECT c FROM Company AS c;

위의 2개의 쿼리는 같은 결과를 내놓는다.
하지만 2개의 쿼리는 성능상으로 동일할까??

 

👣 쿼리 성능 비교 분석

MySQL의 옵티마이저는 실행 계획을 수립할 때,
인덱스의 유무가 큰 영향을 준다.

인덱스는 실제 테이블보다 메모리도 적게 차지하고 
조회 속도도 비교할 수 없을 만큼 빠르다.

그렇다면 아래의 2개의 쿼리도 인덱스를 이용한 성능 향상을 기대해볼 수 있을까?

SELECT c FROM Company AS c;
SELECT c FROM Company AS c WHERE c.companyName LIKE '%%';

결론은 '그렇지 못하다'이다.
왜냐 하면 두 쿼리 모두 결과를 내놓기 위해선 어차피 Full Table Scan을 할 수 밖에 없다.
Full Table Scan을 하면서 [Where company_name LIKE '%%'] 검사를 하던지 안하던지
그다지 큰 성능 차이를 바랄 수는 없다.

실제로 Explain 명령어로 2개의 쿼리의 실행 계획을 살펴보면 아래와 같다.

어차피 2개의 쿼리 모두 type = ALL 인 것을 보아 Full Table Scan을 하고 있다는 것을 알 수 있다.
때문에 저런 부분에서의 걱정은 기우에 불과하다는 것을 알 수 있다.

 

👣 프론트엔드 팀에서의 페이징 처리 요구

하지만 프론트엔드 팀에서 페이징을 요구하게 된다면 이야기는 달라진다.

무한 스크롤을 위한 Slice를 요구한다면 전체 요소의 갯수를 요구하는 것이 아니니
별반 다를 것이 없을 수도 있지만

Page를 요구하는 것이라면 전체 요소 갯수 조회를 위한 쿼리를 
추가로 요구하기 때문에 분명한 성능 차이를 보여줄 수 있다.

아래의 2개의 쿼리는 인덱스를 이용한 성능 향상을 기대해볼 수 있을까?

SELECT COUNT(c) FROM Company AS c;
SELECT COUNT(c) FROM Company AS c WHERE c.companyName LIKE '%%';

결론은 '그렇다'이다.
왜냐 하면 전체 레코드 수를 계산하기 위해 굳이 Full Table Scan을 해야할 이유는 없기 때문이다.
따라서 [Select Count(c) From Company As c;]라는 쿼리는 Full Index Scan을 하기 때문에
훨씬 성능을 향상 시킬 수 있다.
하지만, [Select Count(c) From Company As c Where c.company_name Like '%%';]라는 쿼리는
Where절을 계산하기 위해 Full Table Scan을 무조건 수행해야 하기 때문에
성능은 [Select c From Company As c Where c.company_name Like '%%';]와 다를 바가 없게 된다.

실제로 Explain 명령어로 2개의 쿼리의 실행 계획을 살펴보면 아래와 같다.

각각의 쿼리는 type = index, type = ALL 인 것을 보아
한 쪽은 Full Index Scan을 하고 있지만 한쪽은 Full Table Scan을 하고 있다는 것을 알 수 있다.
때문에 페이징을 위한 전체 쿼리 갯수 조회에서는 성능 차이가 존재할 수 있음을 알 수 있다.

결론적으로 JPQL을 통해 Keyword가 null인 경우와 아닌 경우에 대한 쿼리를 분리해서 운용해야 함을 알 수 있다.

 

👣 확장성이 떨어지는 JPQL

결국 성능상의 손해를 보지 않기 위해 아래와 같은 코드를 작성할 수 있다.

위 코드에 의하면 keyword가 만약 제대로 된 형태가 아닌 경우,

[Select c From Company As c;]
& [Select Count(c) From Company As c;]
라는 쿼리를 수행하도록 코드를 작성했고

keyword가 제대로 주어진다면,
[Select c From Company As c Where c.company_name Like '%%';]
& [Select Count(c) From Company As c Where c.company_name Like '%%';]
라는 쿼리를 수행하도록 했다.

여기까지 한다면 가독성도 챙기고 성능도 챙길 수 있는 코드를 작성하게 된다.
하지만 2가지 결점이 존재한다.

1. 컴파일 단계가 아닌 런타임 단계에서 철자 오류를 확인할 수 있기 때문에
테스트 코드 작성이 강제되거나 운영 중의 버그가 존재할 수 있다.
2. 추가적인 필터링 요구에 대처하기 어려운 코드다.

2번의 경우, 예를 들어 프론트엔드 팀에서 추가로 일정 매출액 이상의 회사를 걸러서 검색하는 
기능을 요구를 한다면 또 성능을 위해 다음과 같이 경우를 분기해야 한다.

상당히 억지스러운 예시일지 몰라도 성능까지 가져가고 싶다면 이런 극단적인 코드도
구성할 수 있다. 물론 이런 경우는 그냥 성능을 포기하고 하나의 메서드로 코드를 구성하게 될 것이다.

하지만 나는 기어코 성능까지 취하고 싶었고 더 나아가 유지 보수성도 가지고 싶었다.
이러한 이유로 QueryDSL을 사용하고자 마음먹었다.

 

👣 동적 쿼리를 위한 QueryDSL

QueryDSL은 직접 쿼리문을 작성하는 것이 아닌 대신해서 쿼리문을 작성해주기 때문에

SELECT COUNT(c) FROM Company AS c WHERE c.companyName LIKE '%%';

위 쿼리와 같은 쿼리를 호출하지 않을 수 있다.

아래는 JPQL로 작성한 코드를 리펙토링한 결과다.

CompanyController
CompanyService
CompanyRepository
SearchCompanyRepository
QuerydslConfig
CompanyRepositoryImpl
CompanyQueryUtil

위와 같이 Where 절 내부의 조건문을 마음대로 넣었다 뺐다를 쉽게 할 수 있을 뿐만 아니라
QueryDSL의 Where 메서드 내부의 값이 null로 들어간다면
알아서 where 절을 삭제해주므로 하나의 메서드로 동적인 쿼리를 만들어 낼 수 있다.

참고로 위와 같은 코드 스타일은 '우아콘 2020 중 QueryDSL 사용하기' 영상의 코드 스타일을 참고했다.