Articlestack.convex.dev·2026년 7월 9일·0

Convex Cookbook: Dynamic Query Builders

Quick Summary

이 글은 Convex에서 런타임 조건에 따라 인덱스, 정렬, 필터가 달라지는 동적 쿼리를 TypeScript 타입 검사를 유지한 채 구성하는 단계별 패턴을 설명한다.

Convex Cookbook: Dynamic Query Builders 관련 대표 이미지

🖼️ 인포그래픽

Convex Cookbook: Dynamic Query Builders 내용을 설명하는 본문 이미지

🖼️ 4컷 인포그래픽

Convex Cookbook: Dynamic Query Builders 내용을 설명하는 본문 이미지

💡 한 줄 요약

이 글은 Convex에서 런타임 조건에 따라 인덱스, 정렬, 필터가 달라지는 동적 쿼리를 TypeScript 타입 검사를 유지한 채 구성하는 단계별 패턴을 설명한다.

📌 핵심 요약

  • Convex 쿼리는 보통 고정된 구조로 작성하지만, 경우에 따라 작성자 필터, 대화 필터, 본문 검색, 정렬 방향, 숨김 제외 여부처럼 런타임 인자에 따라 쿼리 구조가 달라질 수 있다.
  • JavaScript에서는 하나의 query 변수에 withIndex, withSearchIndex, order, filter를 조건부로 이어 붙이는 방식이 동작해 보이지만, TypeScript에서는 쿼리 빌더의 타입이 단계마다 달라져 같은 변수로 처리할 수 없다.
  • Convex는 한 쿼리에 하나의 인덱스만 사용할 수 있고, 정렬도 하나만 적용할 수 있으며, 텍스트 검색 인덱스는 인덱스이면서 동시에 검색 관련 정렬을 포함하므로 일반 인덱스나 별도 정렬과 함께 쓸 수 없다.
  • 해결책은 테이블 선택, 인덱스 선택, 정렬 적용을 각각 다른 변수와 타입으로 나누어 구성하고, 타입을 바꾸지 않는 후처리 필터와 결과 조회는 마지막에 적용하는 것이다.
  • 이 단계적 구조는 여러 필터를 동시에 넣었을 때 실제로 무엇이 적용되는지 드러내며, 두 조건을 모두 적용하려면 하나는 후처리 필터로 처리하거나 다중 필드 인덱스를 정의해야 한다.

🧩 주요 포인트

  1. Convex 쿼리는 보통 고정된 구조로 작성하지만, 경우에 따라 작성자 필터, 대화 필터, 본문 검색, 정렬 방향, 숨김 제외 여부처럼 런타임 인자에 따라 쿼리 구조가 달라질 수 있다.
  2. JavaScript에서는 하나의 query 변수에 withIndex, withSearchIndex, order, filter를 조건부로 이어 붙이는 방식이 동작해 보이지만, TypeScript에서는 쿼리 빌더의 타입이 단계마다 달라져 같은 변수로 처리할 수 없다.
  3. Convex는 한 쿼리에 하나의 인덱스만 사용할 수 있고, 정렬도 하나만 적용할 수 있으며, 텍스트 검색 인덱스는 인덱스이면서 동시에 검색 관련 정렬을 포함하므로 일반 인덱스나 별도 정렬과 함께 쓸 수 없다.
  4. 해결책은 테이블 선택, 인덱스 선택, 정렬 적용을 각각 다른 변수와 타입으로 나누어 구성하고, 타입을 바꾸지 않는 후처리 필터와 결과 조회는 마지막에 적용하는 것이다.
  5. 이 단계적 구조는 여러 필터를 동시에 넣었을 때 실제로 무엇이 적용되는지 드러내며, 두 조건을 모두 적용하려면 하나는 후처리 필터로 처리하거나 다중 필드 인덱스를 정의해야 한다.

🧠 상세 정리

1. 동적 쿼리가 필요한 상황

글은 Convex의 messages 테이블 예시에서 시작한다. 이 테이블에는 author, conversation, body, hidden 필드가 있고, 작성자별 인덱스, 대화별 인덱스, 본문 검색 인덱스가 정의되어 있다. 일반적인 경우에는 특정 작성자의 최근 메시지 10개를 가져오는 식으로 원하는 쿼리 구조를 미리 알고 작성할 수 있다. 그러나 실제 애플리케이션에서는 작성자로 찾을 수도 있고, 대화로 찾을 수도 있으며, 아무 필터 없이 가져올 수도 있다. 여기에 최신순 또는 오래된순 정렬, 본문 검색, 숨김 메시지 제외 같은 조건이 런타임 인자로 달라지면 쿼리를 동적으로 조립해야 한다.

2. 하나의 변수로 쿼리를 누적하는 방식의 한계

직관적인 접근은 let query = ctx.db.query(...)로 시작한 뒤, 조건문마다 query = query.withIndex(...), query = query.order(...), query = query.filter(...)처럼 같은 변수에 계속 대입하는 것이다. JavaScript에서는 타입 검사가 없기 때문에 이 방식이 실행 가능한 코드처럼 보인다. 하지만 TypeScript에서는 Convex 쿼리 빌더가 현재 어떤 작업을 이미 적용했는지를 타입에 담고 있기 때문에 문제가 생긴다. 처음 테이블 쿼리 상태와 인덱스를 적용한 상태, 정렬을 적용한 상태는 모두 같은 타입이 아니다. 따라서 변수 하나가 여러 단계의 타입을 오가도록 만드는 코드는 TypeScript의 타입 시스템과 맞지 않는다.

3. Convex 쿼리 타입이 강하게 제한되는 이유

Convex 쿼리는 유효한 쿼리만 만들도록 TypeScript 타입으로 제약된다. 한 번의 쿼리에 두 개의 인덱스를 사용할 수 없으므로 withIndex(...).withIndex(...)는 잘못된 구조다. 정렬도 하나만 가질 수 있기 때문에 order('desc').order('asc') 같은 조합은 허용되지 않는다. 또한 텍스트 검색 인덱스는 단순한 인덱스가 아니라 검색 관련성 내림차순 정렬까지 포함하므로 일반 인덱스나 별도의 order와 함께 쓸 수 없다. 이런 제약은 쿼리 오류를 일찍 드러내기 위한 것이지만, 그 결과 쿼리를 동적으로 구성할 때는 각 단계의 타입 변화를 명시적으로 다뤄야 한다.

4. 단계별 변수로 쿼리를 구성하는 해결책

글이 제시하는 해결책은 쿼리 구성을 여러 단계로 나누는 것이다. 첫 단계에서는 tableQuery로 어떤 테이블을 조회할지 정한다. 두 번째 단계에서는 indexedQuery를 두고 작성자 인덱스나 대화 인덱스처럼 사용할 인덱스를 선택한다. 세 번째 단계에서는 orderedQuery로 정렬을 적용한다. 본문 검색 인덱스는 인덱스와 정렬을 동시에 포함하므로 별도로 tableQuery.withSearchIndex(...)에서 바로 orderedQuery로 이어진다. 이렇게 각 단계마다 알맞은 타입의 변수를 두면 TypeScript가 쿼리의 유효성을 확인할 수 있고, 동적 조건도 안전하게 반영할 수 있다.

5. 후처리 필터와 결과 조회의 위치

단계적으로 테이블, 인덱스, 정렬을 정한 뒤에는 숨김 메시지 제외 같은 후처리 필터를 적용할 수 있다. 글에서는 excludeHidden 조건이 참이면 filter를 사용해 hidden 필드가 false인 문서만 남기는 예시를 든다. 중요한 점은 이런 필터는 쿼리 빌더의 타입을 바꾸지 않는다는 것이다. 그래서 인덱스 선택이나 정렬 선택처럼 별도의 타입 단계로 분리할 필요가 없다. 마지막에는 완성된 orderedQuery에 대해 first, unique, collect, take, paginate 같은 메서드로 결과를 가져오면 된다. 예시에서는 최종적으로 take(10)을 호출해 10개의 결과를 조회한다.

6. 여러 필터를 동시에 적용할 때 드러나는 구조

글은 작성자 필터와 대화 필터가 동시에 전달되는 경우를 통해 단계별 구조의 장점을 보여준다. 타입 없는 코드에서는 두 조건이 모두 적용되는 것처럼 보이지만, 실제로는 한 쿼리에 두 인덱스를 사용할 수 없기 때문에 런타임 오류가 발생한다. 단계별 TypeScript 코드에서는 indexedQuery가 각각 tableQuery.withIndex(...)의 결과로 덮어써지는 모습이 드러나므로, 뒤의 대화 필터가 적용되면 앞의 작성자 필터가 사라진다는 점을 더 명확히 볼 수 있다. 만약 두 조건을 모두 적용해야 한다면 하나를 filter나 헬퍼 함수로 후처리하거나, conversationauthor를 함께 포함하는 다중 필드 인덱스를 정의해야 한다.

🧾 핵심 주장 / 시사점

  • 동적 쿼리에서 TypeScript 오류는 단순한 불편이 아니라 Convex가 허용하지 않는 인덱스·정렬 조합을 설계 단계에서 드러내는 신호로 볼 수 있다.
  • 쿼리 빌더를 단계별 변수로 나누면 타입 검사를 통과하는 것뿐 아니라, 어떤 조건이 실제로 적용되고 어떤 조건이 덮어써지는지 코드 구조상 더 분명해진다.
  • 여러 필터를 동시에 지원하려면 조건문을 많이 붙이는 방식보다 인덱스 설계, 후처리 필터, 검색 인덱스의 정렬 특성을 함께 고려해야 한다.

✅ 액션 아이템

  • 런타임 인자별 분기에서 테이블 선택, 인덱스 선택, 정렬 적용을 단계별 변수로 분리해 TypeScript 타입 안정성을 유지한다.
  • 한 쿼리에 인덱스와 정렬을 각 하나씩만 허용한다는 제약을 반영해 다중 조건 충돌 시 처리 위치를 사전 정의한다.
  • 필터가 겹칠 때는 우선 적용 가능한 조합을 먼저 구성하고, 나머지는 후처리 필터 또는 다중 필드 인덱스로 분리한다.

❓ 열린 질문

  • 작성자·대화·본문 검색·숨김 제외 조건이 동시에 오면 어떤 조건을 후처리로 남기고 어떤 조건을 인덱스로 분리할 것인가?
  • withIndex, withSearchIndex, order, filter를 단계별로 나누는 구조에서 실제로 타입 안정성이 깨지지 않으려면 변수 설계를 어떻게 고정할 것인가?
  • 텍스트 검색 인덱스의 정렬 성격이 필요한 경로에서 별도 정렬 요구를 어느 수준까지 허용하고, 언제 다중 필드 인덱스로 확장할 것인가?

관련 문서

공통 태그와 주제 흐름을 기준으로 같이 보면 좋은 문서를 이어서 제안합니다.