쿼리 빌더의 호출 순서, LIMIT 뒤의 필터는 어디에 적용될까
체인 형태가 비슷해도 쿼리 빌더가 SQL 절을 조립하는지, 데이터 변환 순서를 표현하는지에 따라 결과가 달라진다. 상위 N개 조회 예제로 의미의 차이를 확인하고, 도입·리팩터링 때 생성 SQL과 결과 집합을 검증할 기준을 정리한다.
결제된 주문 가운데 금액이 큰 세 건을 찾는 것과, 금액이 큰 세 건을 고른 뒤 결제된 주문만 남기는 것은 서로 다른 요구다. 그런데 쿼리 빌더에서 where, order, limit을 연결한 코드만 보면 이 차이를 놓치기 쉽다. 호출 순서를 정리하는 작은 리팩터링도 결과 집합의 의미를 바꿀 수 있어, 체인을 읽는 기준이 필요하다.
이 질문의 계기는 FunSQL 공식 문서의 쿼리 빌더 비교 글이다. GeekNews에는 2026년 10월 1일 오전 5시 4분, 한국 시각으로 등록됐다. 원문 페이지에는 발표일이 표시돼 있지 않다. 최근 피드에서 소개된 설계 설명으로 읽고, 특정 버전의 신규 기능 발표로 해석하지 않았다.
같은 체인이 서로 다른 것을 조립한다
원문은 쿼리 빌더를 두 방식으로 구분한다. 데이터 지향 방식은 각 단계가 이전 단계의 결과를 어떻게 바꿀지 표현한다. 구문 지향 방식은 WHERE, ORDER BY, LIMIT 같은 SQL 절의 내용을 채워 나간다. 구문 트리란 쿼리의 구성 요소와 관계를 구조로 표현한 것이다. 둘 다 SQL을 생성하지만 체인이 나타내는 대상이 다르다. 원문의 분류
저자가 제시한 예제에서 FunSQL·EF/LINQ·dbplyr는 정렬과 개수 제한을 필터 앞으로 옮기면 결과가 달라진다. Active Record·Laravel은 해당 예제의 호출 순서를 바꿔도 같은 조건과 제한을 가진 SQL을 만든다. 원문의 비교 다만 이것을 어느 라이브러리든 모든 메서드의 순서가 무관하다는 규칙으로 확대해서는 안 된다. Rails의 공식 가이드도 order를 여러 번 호출하면 정렬 조건이 뒤에 추가된다고 설명한다. 같은 종류의 절을 반복해서 쓰는 경우에는 순서가 의미를 갖는다.
내가 주목한 부분은 메서드 체인과 데이터 처리 파이프라인을 구분해야 한다는 점이다. 메서드를 점으로 연결했다고 앞의 호출이 만든 행을 다음 호출이 직접 받는 것은 아니다. 리뷰에서는 코드가 위에서 아래로 읽히는지에 더해, 중간 결과를 어디에서 확정하는지 확인해야 한다.
상위 세 건을 언제 확정하는가
차이는 작은 데이터로 확인할 수 있다. orders 테이블에 다음 다섯 행이 있다고 가정한다. paid는 결제 완료, unpaid는 미결제다.
| id | status | amount |
|---|---|---|
| 1 | paid | 1000 |
| 2 | unpaid | 900 |
| 3 | unpaid | 800 |
| 4 | paid | 700 |
| 5 | paid | 600 |
아래 첫 쿼리는 결제된 주문 중 상위 세 건을, 둘째 쿼리는 전체 주문의 상위 세 건 중 결제된 주문을 구한다. 이 SQL 두 개는 Python 3.13.15와 SQLite 3.50.4의 메모리 DB에서 위 데이터로 실행해 확인했다. 쿼리 빌더 라이브러리는 실행하지 않았다. 재현하려면 같은 열과 데이터를 가진 테이블이 필요하다.
SELECT id FROM orders
WHERE status = 'paid'
ORDER BY amount DESC, id
LIMIT 3;
SELECT id FROM (
SELECT id, status, amount FROM orders
ORDER BY amount DESC, id
LIMIT 3
) AS top_orders
WHERE status = 'paid'
ORDER BY amount DESC, id;
첫 결과는 1, 4, 5, 둘째 결과는 1이다. 두 번째 요구에서 LIMIT 3을 바깥 쿼리로 옮기면 뜻이 달라진다. 원문의 데이터 지향 빌더 설명도 이런 처리 순서를 보존하기 위해 쿼리를 중첩하는 구조를 보여 준다. 생성 SQL 예제
여기서 순서는 결과의 의미를 설명하는 논리적 순서다. 데이터베이스가 물리적으로 어느 연산을 먼저 수행하는지까지 지정하는 말은 아니다. 또한 금액이 같은 주문의 선택을 재현할 수 있도록 유일한 id를 정렬 조건에 넣고, 바깥 결과에도 정렬을 지정했다. 내부 쿼리의 정렬만으로 최종 출력 순서까지 기대하지 않는 편이 안전하다.
표현할 수 있는 연산과 실행 위치를 함께 본다
데이터 변환 순서를 코드에 담는 방식은 보고서처럼 여러 처리 단계를 조합할 때 유용하다고 본다. 필터, 집계, 순위 계산의 위치가 업무 요구와 맞는지 읽기 쉽기 때문이다. 반면 일반적인 목록 조회에서는 SQL 절에 대응하는 기존 빌더가 팀의 이해와 유지보수 비용 측면에서 충분할 수 있다.
하지만 표현이 자연스럽다고 모든 연산이 데이터베이스에서 실행되는 것은 아니다. Microsoft의 EF Core 문서는 결과에 남길 열을 정하는 마지막 Select에서 일부 클라이언트 평가를 허용하고, 그 밖의 위치에서 서버로 번역할 수 없는 표현은 예외로 처리한다고 설명한다. AsEnumerable이나 ToList로 명시적으로 경계를 바꾸면 이후 처리를 애플리케이션에서 수행할 수도 있다.
따라서 도입할 때는 행을 유지하며 순위·누적값을 계산하는 윈도 함수나 계층을 따라가는 재귀 조회를 표현할 수 있는지 확인해야 한다. 데이터베이스 연결과 SQL 변환을 맡는 모듈인 프로바이더의 지원 범위도 함께 본다. 번역 실패를 피하려고 먼저 데이터를 메모리에 가져오면 전송량과 메모리 비용이 새로 생긴다. 원문 저자가 자신의 라이브러리에 부여한 표현력 평가도, 우리 쿼리와 데이터베이스 조합에서 확인할 가설로 받아들이는 편이 맞다.
리팩터링의 기준은 SQL과 결과 집합이다
나는 쿼리 빌더를 바꾸기 전에 핵심 조회 하나의 요구를 평서문으로 적겠다. “결제된 주문 중 상위 세 건”인지 “전체 상위 세 건 중 결제된 주문”인지 먼저 합의하면, 어떤 구현을 검증해야 하는지 분명해진다.
다음에는 두 해석의 결과가 달라지는 데이터와 동점 데이터를 넣고, 반환된 식별자와 순서를 확인한다. 생성 SQL도 함께 읽어 필터가 내부와 외부 중 어디에 붙는지 살핀다. 정확성을 확인한 뒤 실제 데이터 규모에서 실행 계획과 전송량을 비교한다. 중첩 쿼리가 있다는 이유만으로 느리다고 판단하거나, 체인이 짧다는 이유로 효율적이라고 판단하지 않는다.
복잡한 조회 하나를 위해 빌더 전체를 교체할 필요는 없다. 기존 빌더로 서브쿼리 경계를 명시하거나, 해당 조회를 매개변수화한 SQL·CTE로 분리하는 대안도 있다. CTE는 쿼리 안에서 이름을 붙여 참조하는 부분 쿼리다. 어느 방식이든 값 전달 방식과 팀이 SQL을 검토할 경로를 함께 유지해야 한다.
결국 확인할 질문은 “어느 빌더가 더 현대적인가”보다 구체적이다. 이 함수는 SQL에 조건을 추가하는가, 앞 단계의 결과를 다시 처리하는가. 그 답이 코드, 생성 SQL, 검증 데이터에서 일치한다면 호출 순서를 바꾸거나 도구를 교체할 근거가 생긴다.
참고 자료
이 이야기가 도움이 되었나요?