Translate SQL into Convex Queries
Quick Summary
이 글은 SQL에 익숙한 개발자가 UNION, WHERE 필터, JOIN, DISTINCT, GROUP BY 같은 패턴을 Convex 쿼리와 QueryStreams 방식으로 옮기는 방법을 Slack형 채팅 앱 예시로 설명한다.
🖼️ 인포그래픽
🖼️ 4컷 인포그래픽
💡 한 줄 요약
이 글은 SQL에 익숙한 개발자가 UNION, WHERE 필터, JOIN, DISTINCT, GROUP BY 같은 패턴을 Convex 쿼리와 QueryStreams 방식으로 옮기는 방법을 Slack형 채팅 앱 예시로 설명한다.
📌 핵심 요약
- 글은 사용자, 메시지, 채널이 있는 Slack형 채팅 앱을 가정하고, SQL에서 자주 쓰는 조회 패턴을 Convex의 인덱스 기반 쿼리로 변환하는 치트시트 형식으로 구성되어 있다.
- Convex 예시는 SQL과 동등한 인덱스를 사용해 비슷한 성능을 내도록 작성되며, 단순 예시에서는 collect()로 전체 결과를 모으지만 실제로는 take, paginate, first, unique 같은 제한된 읽기 방식을 권장한다.
- UNION은 여러 인덱스 쿼리를 각각 실행한 뒤 배열을 합치고 정렬하거나, QueryStreams의 mergedStream으로 여러 스트림을 병합해 페이지네이션하는 방식으로 옮긴다.
- 임의 필터와 JOIN 예시는 Convex가 TypeScript 코드와 비동기 로직을 필터나 매핑에 사용할 수 있음을 보여주지만, 인덱스를 쓰지 않는 필터는 전체 스캔이 되므로 좁은 인덱스 범위 뒤에 적용하는 것이 좋다고 설명한다.
- DISTINCT와 GROUP BY는 특정 필드의 고유값을 인덱스 순회로 찾거나 그룹별 행을 다시 읽는 방식으로 표현되며, COUNT처럼 모든 행을 읽어야 하는 작업은 SQL과 Convex 모두 비용이 들 수 있고 Convex에서는 Aggregate나 Sharded Counter 같은 대안을 고려할 수 있다고 말한다.
🧩 주요 포인트
- 글은 사용자, 메시지, 채널이 있는 Slack형 채팅 앱을 가정하고, SQL에서 자주 쓰는 조회 패턴을 Convex의 인덱스 기반 쿼리로 변환하는 치트시트 형식으로 구성되어 있다.
- Convex 예시는 SQL과 동등한 인덱스를 사용해 비슷한 성능을 내도록 작성되며, 단순 예시에서는 collect()로 전체 결과를 모으지만 실제로는 take, paginate, first, unique 같은 제한된 읽기 방식을 권장한다.
- UNION은 여러 인덱스 쿼리를 각각 실행한 뒤 배열을 합치고 정렬하거나, QueryStreams의 mergedStream으로 여러 스트림을 병합해 페이지네이션하는 방식으로 옮긴다.
- 임의 필터와 JOIN 예시는 Convex가 TypeScript 코드와 비동기 로직을 필터나 매핑에 사용할 수 있음을 보여주지만, 인덱스를 쓰지 않는 필터는 전체 스캔이 되므로 좁은 인덱스 범위 뒤에 적용하는 것이 좋다고 설명한다.
- DISTINCT와 GROUP BY는 특정 필드의 고유값을 인덱스 순회로 찾거나 그룹별 행을 다시 읽는 방식으로 표현되며, COUNT처럼 모든 행을 읽어야 하는 작업은 SQL과 Convex 모두 비용이 들 수 있고 Convex에서는 Aggregate나 Sharded Counter 같은 대안을 고려할 수 있다고 말한다.
🧠 상세 정리
1. 글의 목적과 기본 전제
이 글은 SQL에 익숙한 개발자와 LLM이 익숙한 SQL 패턴을 Convex 쿼리로 번역할 수 있도록 돕는 치트시트다. 다루는 주제는 UNION, JOIN, DISTINCT, GROUP BY, WHERE 절, SELECT 필드처럼 관계형 데이터베이스에서 자주 쓰는 조회 방식이다. 예시는 사용자들이 채널 안에서 메시지를 주고받는 Slack형 채팅 앱을 기준으로 설명된다. 다만 저자는 이 변환 방식이 일반적인 BTree 기반 인덱스를 쓰는 관계형 데이터베이스 애플리케이션에도 적용될 수 있다고 전제한다.
2. SQL 스니펫과 Convex 스니펫의 대응 방식
각 SQL 스니펫은 어떤 데이터를 가져오려는지를 보여주는 기준 역할을 한다. 예시 쿼리는 전체 데이터셋을 반환하도록 작성되어 있지만, 실제 사용에서는 LIMIT, OFFSET, 추가 WHERE 조건을 붙여 점진적으로 읽을 수 있다고 설명한다. Convex 쪽 스니펫은 SQL과 동등한 데이터를 가져오며, 동등한 인덱스를 사용해 SQL 쿼리와 비슷한 성능 특성을 갖도록 구성된다. 여러 SQL 문장, 서브쿼리, UNION ALL처럼 조합하던 패턴은 Convex에서는 일반 TypeScript 코드를 조합하듯 결합할 수 있다는 점도 강조된다.
3. collect()의 한계와 QueryStreams의 역할
본문의 기본 Convex 예시는 결과를 모두 collect()로 모으는 형태를 많이 보여준다. 하지만 저자는 실제 애플리케이션에서는 collect()를 피하고 take, paginate, first, unique처럼 읽는 양에 경계를 두는 방식을 선호해야 한다고 말한다. 결과를 스트림처럼 점진적으로 다루기 위해 convex-helpers의 QueryStream helper를 사용할 수 있으며, 이를 위해 stream과 mergedStream을 가져와 사용한다. QueryStreams는 paginate 호출 예시를 중심으로 제시되지만, 필요에 따라 take, first, unique, collect 같은 방식도 선택할 수 있다고 설명한다.
4. Convex로 번역할 때 얻는 장점
저자는 Convex 쿼리가 SQL보다 길거나 복잡해 보일 수 있지만 그만큼 얻는 이점이 있다고 설명한다. 첫째, Convex 쿼리는 지정한 인덱스를 정확히 사용하므로, SQL 실행 계획이 임의로 인덱스를 포기하고 전체 테이블 스캔을 선택하는 상황과 다르다고 말한다. 둘째, 쿼리를 문자열이 아니라 코드로 작성하기 때문에 추상화와 조합 능력이 커진다. 셋째, 데이터베이스 스키마와 런타임 값에 맞춘 타입 안정성을 얻을 수 있고, Convex 쿼리의 변경 구독 기반 반응성도 활용할 수 있다.
5. UNION 패턴의 변환
UNION 예시는 messages 테이블에서 작성자가 Alice 또는 Bob인 메시지를 _creationTime 기준 내림차순으로 가져오는 쿼리다. SQL에서는 IN, OR, UNION ALL 세 가지 형태가 같은 의도를 표현할 수 있다고 제시된다. Convex에서는 author 인덱스를 만들고, 각 author 값에 대해 별도의 인덱스 쿼리를 실행한 뒤 Promise.all로 결과 배열을 모은다. 이후 배열을 flat으로 합치고 _creationTime 기준으로 다시 정렬한다. QueryStreams 방식에서는 각 작성자별 스트림을 만든 뒤 mergedStream으로 병합하고, _creationTime 필드를 기준으로 페이지네이션 가능한 결과를 구성한다.
6. 임의 필터와 WHERE 조건의 차이
Arbitrary Filter 섹션은 Convex의 필터가 TypeScript 체크를 실행할 수 있고, 비동기 코드나 npm 라이브러리에서 가져온 코드도 사용할 수 있음을 설명한다. 예시로는 SQL의 CHAR_LENGTH(body) <= 280 조건을 Convex에서 message.body.length <= 280으로 필터링하는 방식이 나온다. 다만 이런 임의 필터는 인덱스를 사용하지 않기 때문에 전체 테이블을 스캔하게 되며, 페이지네이션을 쓰면 점진적으로 스캔할 수 있을 뿐이다. 그래서 저자는 필터를 다른 패턴과 결합할 수 있고, 특히 먼저 인덱스로 작은 범위로 좁힌 뒤 적용할 때 유용하다고 강조한다.
7. 일대일 JOIN의 변환
일대일 JOIN 예시는 사용자가 접근 가능한 채널을 channelMemberships 테이블을 통해 찾는 상황을 다룬다. 각 membership에는 userId와 channelId가 있고, channelId로 channels 테이블의 단일 채널 문서를 조회할 수 있다. SQL에서는 channelMemberships와 channels를 channelId와 _id 기준으로 JOIN하고 userId가 Bob인 행만 선택한다. Convex에서는 userId와 channelId를 포함한 user 인덱스로 Bob의 membership 목록을 먼저 collect한 다음, 각 membership의 channelId로 ctx.db.get을 호출해 채널을 가져오고 두 객체를 합친다. QueryStreams 방식에서는 같은 흐름을 stream 쿼리 뒤 map 단계에서 비동기 조회로 수행한다.
8. 일대다 JOIN의 변환
일대다 JOIN 예시는 사용자가 접근 가능한 각 채널의 메시지들을 함께 가져오는 경우다. SQL에서는 channelMemberships와 messages를 channelId 기준으로 JOIN하고, Bob이 가진 membership에 해당하는 메시지를 channelId와 _creationTime 순서로 정렬한다. Convex에서는 먼저 channelMemberships의 user 인덱스로 Bob의 membership을 찾고, 각 membership마다 messages 테이블의 channel 인덱스를 사용해 해당 channelId의 메시지들을 조회한다. 그런 다음 메시지마다 membership 정보와 message 정보를 합쳐 반환하고, 여러 채널에서 나온 배열을 flat으로 합친다. QueryStreams 방식에서는 memberships 스트림에 flatMap을 적용해 각 membership에서 messages 스트림을 만들고, channelId 기준으로 연결된 결과를 페이지네이션한다.
9. DISTINCT와 GROUP BY의 표현
DISTINCT 예시는 메시지는 많지만 채널 수는 상대적으로 적은 상황에서, 각 채널의 가장 최근 메시지를 찾는 방법을 보여준다. SQL에서는 DISTINCT ON(channelId)와 ORDER BY channelId DESC, _creationTime DESC를 쓰거나, channelId별 MAX(_creationTime)을 GROUP BY로 구할 수 있다. Convex에서는 channel 인덱스를 내림차순으로 순회하며 첫 메시지를 가져오고, 이후 현재 channelId보다 작은 channelId 범위로 다시 첫 메시지를 찾는 과정을 반복한다. QueryStreams에서는 같은 의도를 withIndex와 order 뒤 distinct(["channelId"])로 표현한다. GROUP BY 섹션에서는 그룹화가 DISTINCT와 JOIN의 조합처럼 생각될 수 있으며, 채널별 COUNT는 SQL과 Convex 모두 모든 행을 읽어야 하는 작업이라고 설명한다.
10. 성능 관점에서의 읽기 비용과 대안
본문은 단순히 SQL 문법을 Convex 문법으로 바꾸는 데 그치지 않고, 어떤 작업이 인덱스를 쓰는지와 어떤 작업이 많은 행을 읽어야 하는지를 계속 구분한다. UNION과 JOIN 예시는 명시된 인덱스를 통해 범위를 좁히는 방식으로 작성되지만, 임의 필터는 인덱스를 사용하지 않으므로 전체 스캔 비용을 가질 수 있다. GROUP BY의 COUNT 예시도 각 그룹의 행 수를 계산하려면 결국 모든 관련 행을 읽어야 한다고 설명한다. 이 때문에 Convex에서는 빠른 카운트를 위해 Aggregate나 Sharded Counter 컴포넌트를 선택할 수 있다고 덧붙인다. 제공된 원문은 Group By의 QueryStreams 코드 예시가 중간에서 끊겨 있어, 그 이후의 구체 구현은 확인할 수 없다.
🧾 핵심 주장 / 시사점
- Convex에서 SQL 패턴을 옮길 때 핵심은 SQL 문법을 그대로 흉내 내는 것이 아니라, 같은 데이터 접근 의도를 명시적 인덱스 쿼리와 TypeScript 조합으로 재구성하는 것이다.
- QueryStreams는 collect() 중심 예시의 한계를 보완해 큰 결과를 한 번에 모으지 않고 병합, 매핑, 필터링, 페이지네이션하는 방향을 제시한다.
- 임의 필터와 GROUP BY COUNT처럼 읽기 비용이 큰 패턴은 Convex에서도 자동으로 저렴해지지 않으므로, 인덱스로 먼저 좁히거나 별도 집계 컴포넌트를 고려하는 설계 판단이 중요하다.
✅ 액션 아이템
- Slack형 채팅 앱의 SQL 조회 패턴을 Convex 인덱스 쿼리로 대응해 UNION·WHERE·JOIN·DISTINCT·GROUP BY별 변환 규칙을 정리한다.
- collect() 대신 take·paginate·first·unique 등 제한형 읽기로 쿼리 범위를 관리해 페이지네이션 경로의 비용과 성능을 검토한다.
- 임의 필터는 인덱스 범위를 좁힌 뒤 적용하고, 인덱스를 쓰지 않는 필터·JOIN·COUNT의 전체 스캔 리스크를 중심으로 점검한다.
❓ 열린 질문
- UNION 구현 시 배열 정렬 병합과 QueryStreams의 mergedStream 병합 중 어떤 방식이 페이지네이션 정합성에 더 적합한가?
- 비인덱스 필터를 동반한 WHERE·JOIN 조합에서 먼저 인덱스 범위를 제한해야 하는 기준은 무엇인가?
- COUNT처럼 전행 스캔이 필요한 쿼리는 언제 Aggregate나 Sharded Counter로 전환하는 것이 합리적인가?