Merging Streams of Convex data
Quick Summary
이 글은 Convex에서 convex helpers의 스트림 기능을 사용해 여러 인덱스 범위의 문서를 병합하고, 조인형 확장과 변환, 필터링, 페이지네이션을 더 효율적으로 구현하는 방법을 설명한다.
🖼️ 인포그래픽
🖼️ 4컷 인포그래픽
💡 한 줄 요약
이 글은 Convex에서 convex-helpers의 스트림 기능을 사용해 여러 인덱스 범위의 문서를 병합하고, 조인형 확장과 변환, 필터링, 페이지네이션을 더 효율적으로 구현하는 방법을 설명한다.
📌 핵심 요약
- 글의 핵심은 Convex의 기본 쿼리가 인덱스 범위 읽기에 집중되어 있어 SQL에서 익숙한 UNION ALL, JOIN, DISTINCT, GROUP BY, WHERE 유사 패턴을 그대로 표현하기 어렵게 느껴질 수 있지만, convex-helpers의 스트림 도구로 이를 코드 수준에서 다룰 수 있다는 점이다.
- 양방향 대화 메시지를 가져오는 예시에서는 두 방향의 메시지를 각각 쿼리해 모두 collect한 뒤 배열을 합치고 정렬하는 방식이 먼저 제시된다. 하지만 이 방식은 일부 메시지만 필요하거나 페이지네이션을 해야 할 때도 양쪽 결과 전체를 읽어야 하는 한계를 가진다.
- stream(ctx.db, schema).query(...)로 만든 스트림은 인덱스 필드 순서에 맞춰 정렬된 비동기 이터러블처럼 동작한다. 기존 Convex 쿼리와 비슷한 문법을 쓰면서도 first, unique, take, collect, paginate 같은 소비 방식을 사용할 수 있다.
- mergedStream은 여러 스트림의 다음 후보 문서를 비교해 지정된 정렬 기준에 따라 하나의 스트림으로 합친다. 이를 통해 두 방향의 메시지 스트림을 하나의 대화 스트림으로 병합하고, 필요한 개수나 페이지 단위만 읽을 수 있다.
- flatMap과 map은 스트림 기반 조인 패턴을 구성하는 데 쓰인다. 친구 목록 스트림의 각 친구를 해당 친구가 보낸 메시지 스트림으로 확장하고, map을 통해 친구 문서의 정보까지 결과 메시지에 덧붙이는 식으로 더 복합적인 결과를 페이지네이션할 수 있다.
🧩 주요 포인트
- 글의 핵심은 Convex의 기본 쿼리가 인덱스 범위 읽기에 집중되어 있어 SQL에서 익숙한 UNION ALL, JOIN, DISTINCT, GROUP BY, WHERE 유사 패턴을 그대로 표현하기 어렵게 느껴질 수 있지만, convex-helpers의 스트림 도구로 이를 코드 수준에서 다룰 수 있다는 점이다.
- 양방향 대화 메시지를 가져오는 예시에서는 두 방향의 메시지를 각각 쿼리해 모두 collect한 뒤 배열을 합치고 정렬하는 방식이 먼저 제시된다. 하지만 이 방식은 일부 메시지만 필요하거나 페이지네이션을 해야 할 때도 양쪽 결과 전체를 읽어야 하는 한계를 가진다.
- stream(ctx.db, schema).query(...)로 만든 스트림은 인덱스 필드 순서에 맞춰 정렬된 비동기 이터러블처럼 동작한다. 기존 Convex 쿼리와 비슷한 문법을 쓰면서도 first, unique, take, collect, paginate 같은 소비 방식을 사용할 수 있다.
- mergedStream은 여러 스트림의 다음 후보 문서를 비교해 지정된 정렬 기준에 따라 하나의 스트림으로 합친다. 이를 통해 두 방향의 메시지 스트림을 하나의 대화 스트림으로 병합하고, 필요한 개수나 페이지 단위만 읽을 수 있다.
- flatMap과 map은 스트림 기반 조인 패턴을 구성하는 데 쓰인다. 친구 목록 스트림의 각 친구를 해당 친구가 보낸 메시지 스트림으로 확장하고, map을 통해 친구 문서의 정보까지 결과 메시지에 덧붙이는 식으로 더 복합적인 결과를 페이지네이션할 수 있다.
🧠 상세 정리
1. Convex 쿼리와 SQL식 데이터 처리 욕구
글은 Convex가 데이터베이스 쿼리를 일반 TypeScript 코드로 작성할 수 있게 해주며, 그 결과 쿼리가 자동으로 반응형이고 캐시되며 레이스 컨디션 없이 동작한다고 설명한다. 다만 데이터베이스 레코드를 가져올 때 기본 인터페이스는 인덱스 범위 읽기에 맞춰져 있어, SQL에 익숙한 개발자에게는 union, join, aggregate, group, filter 같은 작업이 부족하게 느껴질 수 있다. 저자는 이 문제를 TypeScript로 직접 모두 구현할 수도 있지만, 매번 바퀴를 새로 발명할 필요는 없다고 말한다. 그래서 Convex 팀이 제공하는 convex-helpers의 여러 도구를 통해 익숙한 데이터 처리 패턴을 더 쉽게 구현할 수 있다고 글의 문제의식을 설정한다.
2. 단순 UNION 구현의 한계
양방향 대화 메시지 예시는 글의 중심 사례다. messages 테이블에는 from, to, body 필드가 있고 from_to 인덱스가 있으며, Convex에서는 모든 인덱스가 암묵적으로 _creationTime으로 끝난다는 점이 언급된다. 가장 직접적인 방식은 u1에서 u2로 보낸 메시지와 u2에서 u1로 보낸 메시지를 각각 collect한 다음 배열을 합치고 _creationTime 기준으로 정렬하는 것이다. 이 방식은 기능적으로 union처럼 보이지만, 정렬하고 일부만 반환하려면 두 범위의 모든 메시지를 먼저 읽어야 한다. 따라서 처음 5개 메시지만 필요하거나 대화 내용을 페이지 단위로 가져오려는 경우에는 불필요한 데이터 읽기가 발생한다.
3. 스트림이라는 새 추상화
convex-helpers는 Convex 쿼리에 스트림이라는 개념을 도입한다. 스트림은 인덱스 필드에 의해 정렬된 문서들의 비동기 이터러블로 설명되며, 생성만 해서는 실제로 아무 일도 하지 않고 소비를 시작할 때 문서를 하나씩 읽는다. 문법은 기존 ctx.db.query와 매우 유사하지만, ctx.db를 stream(ctx.db, schema)로 감싸서 사용한다는 차이가 있다. 스트림으로 만든 쿼리에도 withIndex와 order 같은 기존 방식의 조건을 적용할 수 있고, 이후 first, unique, take, collect, paginate 같은 메서드로 결과를 가져올 수 있다. 이 점 때문에 기존 Convex 쿼리 감각을 유지하면서도 지연 소비와 페이지네이션에 유리한 구조를 만들 수 있다.
4. mergedStream으로 여러 인덱스 범위 병합하기
여러 인덱스 범위에서 데이터가 스트림 형태로 흘러오면, mergedStream을 사용해 이들을 하나의 새 스트림으로 병합할 수 있다. 예시에서는 u1에서 u2로 가는 메시지 스트림과 u2에서 u1로 가는 메시지 스트림을 각각 만들고, mergedStream([messages1, messages2], ["_creationTime"]) 형태로 합친다. 병합된 스트림은 다시 일반 스트림처럼 취급할 수 있으므로 take(10)처럼 필요한 만큼만 가져오거나 paginate를 적용할 수 있다. 내부적으로는 각 스트림의 다음 후보 문서를 살펴보고 지정된 순서에서 더 앞서는 문서를 하나씩 내보낸다. 이 방식은 전체 결과를 collect한 뒤 정렬하는 방식과 달리, 필요한 결과를 얻는 과정에서 각 스트림을 순서대로 소비한다는 점이 핵심이다.
5. 병합 순서와 인덱스 필드 prefix의 의미
mergedStream의 두 번째 인자는 병합에 사용할 정렬 기준을 정한다. 글에서는 예시로 _creationTime을 사용하지만, 반드시 그것만 가능한 것은 아니라고 설명한다. 예를 들어 한 스트림은 Egon이 Peter에게 보낸 메시지이고 다른 스트림은 Peter가 Egon에게 보낸 메시지라면, 각 스트림 안에서는 from과 to 값이 고정되어 있다. 그러므로 각 스트림은 _creationTime뿐 아니라 from, to, _creationTime 순서로도 정렬되어 있다고 볼 수 있다. 이 경우 from, to, _creationTime 순서로 병합하면 한 사용자의 메시지가 다른 사용자의 메시지보다 앞에 오게 된다. 저자는 이것을 인덱스 필드의 일부 prefix를 고정해 withIndex에서 eq 조건을 거는 방식과 유사한 유연성으로 설명한다.
6. flatMap으로 페이지네이션 가능한 조인형 흐름 만들기
글은 조인 자체는 Relationship helpers나 Ents로도 지원되지만, 이 방식들이 복잡한 페이지네이션에는 충분하지 않을 수 있다고 말한다. 특히 한 페이지의 상위 결과를 가져온 뒤 각 결과마다 조인 객체를 가져오는 것은 가능하지만, 각 항목에서 임의 개수의 하위 객체가 나오는 구조를 자연스럽게 페이지네이션하기는 어렵다. 이를 위해 스트림은 flatMap 인터페이스를 제공한다. 예시에서는 먼저 특정 사용자의 friends 테이블을 스트림으로 읽고, 각 friend를 그 친구가 해당 사용자에게 보낸 messages 스트림으로 확장한다. 결과적으로 친구 스트림은 메시지 스트림으로 바뀌며, 정렬은 friendId와 메시지 생성 시간의 흐름을 따르게 된다.
7. flatMap의 인덱스 지정과 map을 통한 결과 변환
flatMap 예시에서 두 번째 인자는 내부 스트림의 인덱스 필드를 나타낸다. 친구 한 명을 메시지 스트림으로 확장할 때 messages 테이블의 from_to 인덱스를 사용하므로, 글에서는 ["from", "to"]를 두 번째 인자로 넘긴다. 하지만 flatMap만 사용하면 원래 friend 문서에 있던 정보가 결과 메시지에서 사라질 수 있다. 이때 map 메서드를 사용해 스트림의 각 문서를 원하는 값으로 변환할 수 있다. 단순히 message.body만 반환할 수도 있고, 더 복합적으로는 friend.isBest나 friend._creationTime 같은 친구 관계 정보를 메시지 객체에 추가할 수도 있다. 글은 이렇게 map을 결합하면 두 테이블의 필드를 함께 담은 JOIN에 가까운 결과를 만들 수 있다고 보여준다.
8. 필터링과 페이지네이션의 주의점
마지막 부분에서는 필터링을 다룬다. TypeScript predicate를 사용한 필터링은 Array.filter나 filter helper로 이미 가능하지만, 글은 이런 필터가 페이지네이션에서 원치 않는 동작을 만들 수 있다고 지적한다. 필터가 많은 문서를 제외하면 한 페이지에 들어오는 결과가 지나치게 적거나 아예 비어 있을 수 있기 때문이다. 초반 TL;DR과 첫 코드 예시에서는 messages.filterWith(async (m) => !m.archived)처럼 아카이브된 메시지를 제외한 뒤 paginate를 호출하는 흐름이 제시된다. 다만 제공된 원문은 “The alternativ”에서 끊겨 있어, 필터링 대안의 구체적인 설명은 이 발췌문만으로는 확인할 수 없다.
🧾 핵심 주장 / 시사점
- 핵심 설계 포인트는 SQL 기능을 그대로 복제하는 것이 아니라, Convex의 인덱스 기반 읽기 모델 위에서 스트림을 조합해 같은 사용 경험에 가까운 패턴을 만드는 데 있다.
- 전체 collect 후 정렬하는 방식과 스트림 병합 방식의 차이는 데이터가 많아질수록 중요해진다. 특히 limit, take, paginate처럼 일부 결과만 필요한 상황에서는 지연 소비가 쿼리 구조의 실질적인 개선으로 이어진다.
- flatMap과 map의 조합은 단순 병합보다 더 넓은 의미를 가진다. 여러 테이블의 관계를 따라가면서도 결과를 페이지 단위로 다루려는 경우, 스트림을 데이터 처리 파이프라인처럼 구성할 수 있음을 보여준다.
✅ 액션 아이템
- convex-helpers의 stream(ctx.db, schema).query를 사용해 인덱스 정렬 순서에 맞춘 스트림을 설계해 기존 인덱스 범위 쿼리 대비 병합 비용을 검증한다.
- 양방향 메시지 조회에서 collect+정렬 방식 대신 mergedStream과 take/paginate를 적용해 필요한 메시지 수만 읽도록 처리 경로를 전환한다.
- flatMap과 map을 결합해 친구 스트림을 메시지 스트림으로 확장하고 친구 문서 정보까지 결과에 병합해 복합 조인형 페이지네이션을 구현한다.
❓ 열린 질문
- mergedStream은 다음 후보 비교 규칙을 어떤 정렬키 조합으로 두어 대화 순서를 동률 포함 일관되게 유지할 것인가?
- take/unique/collect/paginate 중 어떤 소비 방식 조합이 부분 조회와 전체 페이지네이션 모두에서 읽기 과다를 줄일 수 있는가?
- 친구 목록 기반 확장 시 map이 붙이는 친구 메타 정보와 메시지 본문 중 어떤 조합이 중복 없이 필요한 맥락을 보존할 수 있는가?