동적 쿼리와의 전쟁... Querydsl을 도입할 수 없었던 이유
0. 글을 시작하며
2024년 회사에서 새로운 프로젝트를 시작한 것도 어느덧 2달이 넘어가고 있습니다. 이미 오랜 기간 개발이 되어오던 프로젝트라 복잡한 DB 구조 및 비즈니스 로직, 새로운 요구사항을 반영할수록 높아지는 클래스간 결합도 등 저를 괴롭히는 문제들이 많지만, 그 중에서 단연 가장 큰 문제는 바로 "동적쿼리" 처리 방식이었습니다.
해당 프로젝트는 현재 Spring Boot에 JPA를 주요 기술스택으로 사용하고 있습니다. JPA에서 동적쿼리 문제를 가장 이상적으로 해결할 수 있는 기술이 querydsl 이라는 것은 많은 분들이 이미 알고 계실겁니다. 하지만 이번 글에서는 제가 자신있게 querydsl을 도입해서 현재 프로젝트의 동적쿼리 문제를 더 깔끔하게 풀어낼 수 없었는 지, native query와의 불편한 동거를 할 수 밖에 없었던 이유가 무엇인지 가볍게 다뤄보고자 합니다.
결과적으로 저는 기존 방식과 타협하면서 현재의 동적쿼리 구현방식이 가지는 문제를 해결하는 방향으로 접근하였습니다. 그렇기 때문에 다른 분들이 보시기에 훨씬 더 효과적이고 좋은 방법이 있을 수 있습니다. 만약 그렇다면 댓글을 통해서 함께 의견 주신다면 이 글이 더 좋은 지식들을 다루는 글이 될 수 있을 것이라고 생각합니다.
1. 동적 쿼리 문제를 처음 발견했을 때
해당 프로젝트를 처음 인수인계 받을 때 백엔드의 주요 기술스택은 Spring Boot와 JPA 였고 사용자에게 지원하는 기능 중 다양한 검색 조건을 적용하여 데이터를 보여주는 기능이 있음을 알았기 때문에, 동적쿼리의 존재는 이미 예상하고 있던 상황이었습니다.
Table 구조가 꽤 복잡하던데, Querydsl 코드로도 상당히 길겠다.
그래서 Querydsl에서 부족한 지식들을 찾아보고 공부하며 실제 코드를 전달받기 전까지 시간을 보냈습니다. 하지만 소스코드를 전달받고 Repository 레벨 코드를 열어본 저는 매우 당황스러운 상황을 만나게 됩니다.
네... Repository를 열어보는 순간 Querydsl 코드가 아닌 Native Query로 작성된 코드를 보게 됩니다. 문제는 이게 동적쿼리이고, 통계쿼리의 성격을 가지다 보니 그 길이가 상당히 길었습니다. 예를 들어서 Repository 파일 내의 메서드는 2개 밖에 되지 않지만 해당 소스의 길이가 거의 1000라인을 넘어거는 어질어질한 상황을 보게 됩니다. 그리고 이러한 성격의 Repository 파일이 10개가 넘어가는 상황이었습니다.
실무 상황의 코드를 직접 넣을 수는 없지만, 대충 어떤 형식의 구조였는 지를 대충 묘사해보면 다음과 같은 구조로 작성되어 있었습니다.
@Repository
public class DomainARepository {
public List<DomainADTO> searchMethodA(Integer offset, Integer limit, SearchObject search) {
StringBuilder query = new StringBuilder();
query.append("SELECT fieldA, fieldB, ... fieldN");
query.append(" FROM sourceA JOIN sourceB JOIN (");
query.append(" SELECT fieldX, fieldY");
/* 이 사이가 수백라인의 서브쿼리들을 구성하는 코드가 위치하는 구조 */
query.append(" WHERE 1 = 1 ");
/* 동적 쿼리 처리하는 부분 */
if (search.getCaseA()) {
query.append(" AND case_a = '???'");
}
/* 이런 방식으로 조건들을 체크하여 검색 조건 쿼리들을 이어붙여나감 */
DomainADTO domainA = entityManager.createNativeQuery(query.toString(), "domainAMapping");
return domainA;
}
}
많은 분들이 공감하시겠지만, String을 지속적으로 Concat 하는 비용을 줄이기 위해 StringBuilder를 사용하였더라도 그것이 중요한 것이 아니라 이 방식은 많은 문제점을 가지고 있었습니다. 제가 느낀 대표적인 문제점들을 다음과 같이 정리해볼 수 있었습니다.
1. Query Syntax 검사가 불가하여 쿼리 수정 시 이를 테스트하는 것이 매우 번거로움
2. StringBuilder에 쿼리를 한라인 한라인 append 했기 때문에 새롭게 쿼리 작성 or 수정 시 매우 번거로움
3. 가독성이 너무 나쁘다
즉, 유지보수 및 신규 개발 측면에서 이러한 구성의 동적쿼리 처리방식은 문제가 매우 많았습니다. 그래서 저는 초반에는 이것들을 모두 Querydsl로 리펙터링하여 문제를 해결하려고 하였으나, 이를 가로막는 요소들이 꽤 많음을 깨닫는 데 얼마 걸리지 않았습니다. 대표적으로 다음과 같은 이슈들로 인해 Querydsl의 도입을 통한 리펙터링을 적극적으로 수행할 수 없게 됩니다.
1. FROM절에 위치한 수많은 서브 쿼리
2. 촉박한 개발일정
3. DBA의 반대
FROM절에 위치한 수많은 서브 쿼리, 촉박한 개발일정
우선 JPA의 Querydsl의 경우, FROM절에 서브쿼리를 사용하는 것에 대해서는 제약사항이 있음을 많은 분들이 알고 계실 겁니다. 즉, 기존에 사용된 Native Query가 FROM절에 여러 개의 서브쿼리를 포함하고 있었으므로, 기존의 native query를 단순히 querydsl로 옮기는 성격의 작업은 불가능하다는 의미였습니다. FROM절에 서브쿼리가 없는 형태로 쿼리 튜닝을 진행해야하고, 이걸 다시 querydsl로 옮겨야했기에 촉박한 개발일정에서 querydsl을 도입하기에는 리스크가 너무 큰 상황이었습니다.
DBA의 반대
그래서 이와 관련해서 DBA 분과 생각을 나누었고, DBA분은 현재 시스템이 사용하는 DB를 변경할 가능성은 거의 없고, 세세한 튜닝이나 DBMS 고유기능을 유연하게 사용하기 위해서는 native query를 사용하는 것이 더 좋을 것 같다는 의견을 주셨습니다. 현재 프로젝트에서 사용하는 모든 쿼리가 DBA를 거치는 것은 아니지만, 슬로우쿼리(조회성능 이슈)와 같은 중요한 문제가 발생하는 경우에는 DBA분이 직접 쿼리를 보시기 때문에 Qeurydsl을 도입하면 DBA분과의 커뮤니케이션이 어려워지는 리스크도 존재했습니다.
2. 그래서 시도해본 다른 방법들
하지만 그렇다고 해서 현재의 코드를 그대로 방치할 수는 없었기에 저는 현재 문제를 해결하기 위한 몇 가지 방법을 생각해보았습니다.
1. 쿼리를 SQL 파일로 따로 분리해서 읽어온 뒤, 조건을 이어붙이는 방식으로 동적쿼리 구성하기
2. SQL Mapper (MyBatis)를 사용하여 모든 동적쿼리들을 처리하기
쿼리를 SQL 파일로 따로 분리해서 읽어온 뒤, 조건을 이어붙이는 방식으로 동적쿼리 구성하기
현재 1차적으로 적용하여 사용하고 있는 솔루션 입니다. 바로 기존에 Sring을 한라인 한라인 append 하는 방식이 아니라 기본 쿼리를 *.sql 파일로 따로 분리해두고, 다음과 같이 SQL 파일을 읽어오는 메서드를 작성하여 쿼리를 불러오는 방식을 사용하는 것입니다.
private String getBaseQueryString(String fileName) {
String baseQueryString = "";
try {
ClassPathResource resource = new ClassPathResource("sql/" + fileName);
BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream()));
baseQueryString = reader.lines().collect(Collectors.joining(System.lineSeparator()));
} catch (IOException e) {
log.error("failed to read sql file {}", fileName);
}
return baseQueryString;
}
물론 동적으로 추가되는 where 절 부분은 이전과 동일하게 StringBuilder에 append 하는 성격으로 작업해야하고, 아무래도 추가적인 I/O 비용이 발생할 수 있지만, SQL 파일을 따로 분리함으로써 Repository 레벨 코드의 로직을 더욱 명확하게 표현할 수 있고, IDE의 기능을 통해서 syntax 검사도 가능했습니다. 현실적으로 가장 빠르게 적용해볼 수 있는 방법이어서 현재는 이 방법을 사용하는 중 입니다. 하지만 여전히 DB에 어떤 쿼리가 날아가는 지 한눈에 확인하기 어렵고, 여전히 일부 쿼리 구분들을 String으로 다루어야한다는 단점이 존재했습니다.
SQL Mapper (MyBatis)를 사용하여 모든 동적쿼리들을 처리하기
또 다른 방법은 JPA 외에 MyBatis를 추가로 연동하여 동적쿼리들 자체는 MyBatis로 처리하고, 그 외의 다른 작업들은 JPA를 사용하는 방식입니다. MyBatis와 같은 SQL Mapper를 사용하면 동적쿼리를 처리하기 위한 솔루션을 제공하기 때문에 하나의 파일에서 실제로 어떤 쿼리가 DB로 날아가는 지 한눈에 들어오고, 쿼리의 관리도 비교적 쉬워진다는 강력한 장점이 존재했습니다.
하지만 JPA와 MyBatis를 하나의 시스템에 연동하는 경우에도 장점만 존재하는 것은 아닌데 그것은 바로 JPA와 MyBatis를 하나의 트렌젝션으로 묶어주는 과정이 필요하고, 서로 다른 스타일의 코드가 하나의 프로젝트 내에 포함되면서 오히려 프로젝트에 코드에 대한 가독성을 떨어뜨리며, 유지보수를 오히려 어렵게 만들 수 도 있다는 점이었습니다.
이러한 이유 때문에 MyBatis 함께 연동하는 솔루션은 적용해보지 않았으며, 신속한 개발을 위해 임시로 첫번째 방식을 채택하여 사용하고, 이후 급한 개발일정을 한 번 지나가면 이후에 새로운 방식을 시도해볼 예정입니다.
3. 글을 마무리 하며
해당 이슈를 두고 다른 동료들의 의견을 구해보니 다양한 의견들이 있었습니다.
1. 그럴리 없다! 모든 것을 Qeurydsl로 대체할 수 있다.
2. native 쿼리를 사용하는 게 잘못된 방식 같지는 않은데 굳이? MyBatis까지 연동해야하는 지 모르겠다.
3. 괜찮은 방법같다. 한번 시도해보는 것도 꽤 좋은 접근이 될 것 같다.
현재의 문제가 단순히 기술적인 이슈만이 아닌 개발일정이라는 요소가 있어 명확한 정답이 있는 문제는 아니라고 생각합니다. 프로젝트를 시작하면서 지금까지 여전히 저를 괴롭혀오고 있는 이슈이고, 현재 어떤 방식으로 이 문제를 해결해가려고 노력하고 있는 지를 기록으로 남겨보고 다른 분들은 이러한 상황이라면 어떻게 접근하실 지에 대한 내용들을 나눠보고 싶어 이번 글을 작성하게 되었습니다. 혹시 더 좋은 방법이 있음을 알고 계시는 분들이시라면 댓글로 나눠주셔도 좋을 것 같고, 저와 비슷한 상황으로 고통받고 계신 분이시라면 조금이나마 이 글이 위로가 되었으면 좋겠습니다.
어떤 형태이든지 피드백이나 개인적인 의견은 환영합니다! 긴 글 읽어주셔서 감사합니다.
'ETC > 회고' 카테고리의 다른 글
단위 테스트가 프로젝트에 가져온 긍정적 효과 (0) | 2024.03.31 |
---|---|
프로젝트 인수인계 시 꼭 기억할 5가지 (0) | 2024.02.04 |
자기개발 미루지 않는 개발자 되기 (1) | 2024.01.21 |
댓글
이 글 공유하기
다른 글
-
단위 테스트가 프로젝트에 가져온 긍정적 효과
단위 테스트가 프로젝트에 가져온 긍정적 효과
2024.03.31 -
프로젝트 인수인계 시 꼭 기억할 5가지
프로젝트 인수인계 시 꼭 기억할 5가지
2024.02.04 -
자기개발 미루지 않는 개발자 되기
자기개발 미루지 않는 개발자 되기
2024.01.21