Optimize Transaction Throughput: 3 Patterns for Scaling with Convex and ACID Databases
Quick Summary
이 글은 직렬화 가능한 트랜잭션의 충돌을 줄여 처리량을 높이는 방법으로 큐, hot/cold 테이블 분리, predicate locking 세 가지 패턴을 설명한다.
🖼️ 인포그래픽
🖼️ 4컷 인포그래픽
💡 한 줄 요약
이 글은 직렬화 가능한 트랜잭션의 충돌을 줄여 처리량을 높이는 방법으로 큐, hot/cold 테이블 분리, predicate locking 세 가지 패턴을 설명한다.
📌 핵심 요약
- 직렬화 가능한 트랜잭션은 한 트랜잭션이 다른 트랜잭션이 쓰는 데이터를 읽거나 쓰면 충돌하며, 이 경우 병렬 실행이 어려워져 처리량이 떨어진다.
- 첫 번째 패턴인 큐는 배치 처리용 테이블 전체를 읽는 대신 생성 시각이나 인덱스 범위를 기준으로 오래된 일부 항목만 읽어 enqueue 작업과 처리 작업의 충돌을 줄인다.
- 두 번째 패턴인 hot/cold 테이블 분리는 자주 변경되는 필드와 자주 읽히지만 드물게 변경되는 필드를 별도 테이블로 나누어 불필요한 읽기-쓰기 충돌을 없앤다.
- 세 번째 패턴인 predicate locking은 전체 현재값을 읽는 대신 비정상 상태에 해당하는 조건만 인덱스로 조회해, 정상 상태의 빈번한 변경과 검사 작업이 서로 충돌하지 않게 한다.
- 핵심은 트랜잭션이 실제로 필요한 문서만 읽도록 쿼리를 조정하는 것이며, 이를 통해 동시성을 높이고 mutation 처리량을 개선할 수 있다는 점이다.
🧩 주요 포인트
- 직렬화 가능한 트랜잭션은 한 트랜잭션이 다른 트랜잭션이 쓰는 데이터를 읽거나 쓰면 충돌하며, 이 경우 병렬 실행이 어려워져 처리량이 떨어진다.
- 첫 번째 패턴인 큐는 배치 처리용 테이블 전체를 읽는 대신 생성 시각이나 인덱스 범위를 기준으로 오래된 일부 항목만 읽어 enqueue 작업과 처리 작업의 충돌을 줄인다.
- 두 번째 패턴인 hot/cold 테이블 분리는 자주 변경되는 필드와 자주 읽히지만 드물게 변경되는 필드를 별도 테이블로 나누어 불필요한 읽기-쓰기 충돌을 없앤다.
- 세 번째 패턴인 predicate locking은 전체 현재값을 읽는 대신 비정상 상태에 해당하는 조건만 인덱스로 조회해, 정상 상태의 빈번한 변경과 검사 작업이 서로 충돌하지 않게 한다.
- 핵심은 트랜잭션이 실제로 필요한 문서만 읽도록 쿼리를 조정하는 것이며, 이를 통해 동시성을 높이고 mutation 처리량을 개선할 수 있다는 점이다.
🧠 상세 정리
1. 트랜잭션 충돌이 처리량을 제한하는 이유
글은 더 많은 트랜잭션을 초당 처리하기 위한 방법을 소개하면서, 예시는 Convex를 사용하지만 원리는 ACID 데이터베이스, 특히 낙관적 동시성 제어를 사용하는 시스템에 일반화될 수 있다고 설명한다. 직렬화 가능한 두 트랜잭션은 한쪽이 쓰는 데이터를 다른 쪽이 읽거나 쓰면 충돌한다. 충돌이 발생하면 두 트랜잭션은 서로 병렬로 실행될 수 없고, 그 결과 전체 처리량이 낮아진다. 글의 문제의식은 흔한 워크로드에서 가장 직관적인 구현이 실제로는 너무 많은 문서를 읽어 불필요한 충돌을 만든다는 데 있다.
2. 큐 패턴의 출발점: 전체 테이블을 읽는 배치 처리의 문제
첫 번째 예시는 이메일을 테이블에 쌓아 두었다가 배치로 보내는 구조다. 겉으로는 여러 이메일을 한 번에 보내므로 효율적으로 보이지만, processBatchOfEmails mutation이 emails 테이블 전체를 읽는다는 점이 병목이 된다. 이 전체 읽기는 새 이메일을 삽입하는 모든 enqueueEmail mutation과 충돌한다. 이메일 요청이 계속 들어오면 배치 처리 mutation은 연속적인 enqueue에 막혀 성공하지 못할 수 있고, 실패할수록 이메일 목록이 길어져 다음 실행은 더 오래 걸리고 다시 막힐 가능성도 커진다.
3. 큐 패턴의 개선: 삽입 위치와 처리 위치를 분리하기
글은 enqueueEmail이 새 문서를 높은 _creationTime으로 기록한다는 점을 이용해, 배치 처리가 새로 들어오는 문서와 같은 영역을 읽지 않게 만들라고 제안한다. 예를 들어 processBatchOfEmails가 전체 테이블을 수집하지 않고 처음 10개만 가져오거나, 생성된 지 30초가 지난 이메일만 인덱스로 조회할 수 있다. 이렇게 하면 새 이메일을 넣는 작업과 오래된 이메일을 처리하는 작업이 동시에 진행될 수 있다. 반복 호출은 여전히 필요하지만, 이는 기존 방식에서도 마찬가지이며, 추가로 무한정 커지는 쿼리와 쿼리 한도 문제도 피할 수 있다.
4. 큐 패턴의 본질: FIFO처럼 인덱스 범위를 사용하기
저자는 이 방식을 큐 패턴이라고 부른다. 테이블이 사실상 FIFO 큐처럼 동작하고, 삽입은 인덱스 범위의 한쪽 끝에서 일어나며 처리는 다른 쪽 끝에서 일어난다. 예시에서는 by_creation_time 인덱스를 사용하지만, 꼭 생성 시각 인덱스일 필요는 없고 같은 원리를 만족하는 다른 인덱스도 가능하다. 들어오는 데이터가 많아 충돌이 처리량을 막을 정도라면, 큐의 양 끝은 충분히 떨어져 있어 삽입과 처리의 충돌을 줄일 수 있다. 글은 이 패턴이 Convex Workpool 컴포넌트에서 광범위하게 사용된다고 덧붙인다.
5. Hot/Cold 테이블 분리: 자주 바뀌는 필드와 드물게 바뀌는 필드의 충돌
두 번째 패턴은 큰 테이블 안에 자주 바뀌는 필드와 거의 바뀌지 않는 필드가 함께 있을 때 유용하다. 예시는 학생 명단으로, sendEmailToAllStudents는 students 테이블을 읽어 모든 학생에게 이메일을 보내고, updateStudentGrade는 특정 학생의 성적 필드를 갱신한다. 문제는 성적 업데이트가 학생 문서를 수정하므로, 학생 문서를 읽는 전체 이메일 발송 mutation과 충돌한다는 점이다. 이메일 주소는 드물게 바뀌고 여러 작업에서 읽히는 cold 필드인 반면, 성적은 자주 갱신되고 일부 작업에서만 필요한 hot 필드로 볼 수 있다.
6. Hot/Cold 테이블 분리의 효과: 같은 동작, 적은 충돌
해결책은 hot 필드와 cold 필드를 별도 테이블로 나누는 것이다. 예시에서는 자주 쓰이는 성적 정보를 studentGrades 테이블로 옮기고, students 테이블에는 이메일처럼 드물게 변경되고 자주 읽히는 정보만 남긴다. 그러면 전체 학생에게 이메일을 보내는 mutation은 여전히 students만 읽고, 성적 갱신 mutation은 studentGrades에서 해당 학생의 성적 문서를 찾아 수정한다. 사용자에게 보이는 동작은 기존과 같지만, 이메일 발송이 읽는 문서와 성적 갱신이 쓰는 문서가 분리되어 불필요한 트랜잭션 충돌이 제거된다.
7. Predicate locking의 문제 상황: 정상 여부 확인이 잦은 변경과 충돌할 때
세 번째 패턴은 자주 바뀌는 값이 있고, 다른 mutation이 그 값이 비정상 상태인지 검사하는 경우를 다룬다. 예시는 은행 계좌 잔액으로, withdraw는 인출 전에 계좌가 초과 인출 상태인지 확인하고 잔액을 줄이며, issueLoan도 대출 발급 전에 같은 검사를 수행한다. 이 코드는 초과 인출 계좌에는 인출이나 대출을 허용하지 않는다는 제약을 직접 확인하므로 겉으로는 이상적으로 보인다. 그러나 두 mutation 모두 balances 문서를 읽고, withdraw는 같은 문서를 쓰기 때문에 서로 충돌한다. 높은 처리량이 필요하다면 같은 외부 동작을 유지하면서 더 좁게 읽는 방법이 필요하다.
8. Predicate locking의 해법: 전체 값 대신 비정상 상태만 조회하기
글은 balances 테이블의 인덱스를 accountId뿐 아니라 balance까지 포함하는 복합 인덱스로 바꾸는 방법을 제시한다. 계좌마다 잔액 문서가 하나뿐이라 처음에는 쓸모없어 보이지만, 이 인덱스를 사용하면 특정 계좌에서 balance < 0인 문서만 조회할 수 있다. 그러면 초과 인출 여부 검사는 잔액 문서 전체를 읽는 대신, 실제로 초과 인출 상태인 문서만 읽는다. 결과적으로 인출 후 잔액이 여전히 양수라면 대출 발급 mutation과 병렬로 실행되어도 충돌하지 않는다. 자주 바뀌는 문서에서 대부분의 상태가 정상이고 일부 비정상 상태만 중요할 때, 이런 조건부 읽기는 안정 상태의 처리량을 높인다.
9. 종합: 필요한 문서만 읽도록 쿼리를 조각하기
마무리에서 저자는 mutation 처리량 문제가 트랜잭션이 불필요한 문서를 읽기 때문에 발생할 수 있다고 정리한다. 해결의 공통 원리는 ctx.db.query가 꼭 필요한 문서만 보도록 쿼리 범위를 조정하는 것이다. 큐 패턴은 삽입과 처리를 인덱스의 서로 다른 끝으로 분리하고, hot/cold 테이블 분리는 필드의 변경 빈도에 따라 문서를 나누며, predicate locking은 전체 현재값 대신 특정 조건의 비정상 상태만 읽게 한다. 저자는 Workpool 컴포넌트를 만들 때 이 세 가지와 그 밖의 패턴을 사용했다고 밝히며, 트랜잭션들이 서로 충돌하지 않도록 만들면 애플리케이션의 확장성이 크게 좋아진다고 주장한다.
🧾 핵심 주장 / 시사점
- 처리량 최적화의 핵심은 트랜잭션을 더 빠르게 실행하는 것보다, 서로 충돌하지 않도록 읽기 범위를 줄이는 데 있다.
- 전체 테이블 조회나 전체 문서 조회는 코드상 단순해 보여도 고동시성 환경에서는 enqueue, update, 검사 mutation을 막는 병목이 될 수 있다.
- 인덱스는 단순한 조회 성능 도구가 아니라, 트랜잭션이 어떤 데이터 범위를 읽었다고 간주되는지를 좁혀 충돌 가능성을 줄이는 설계 도구로 쓰일 수 있다.
✅ 액션 아이템
- 큐 패턴에서는 배치 처리 쿼리를 전체 스캔에서 생성 시각·인덱스 범위 기반의 과거 일부 항목 조회로 바꿔 enqueue와 처리 충돌을 줄인다.
- hot/cold 테이블 분리는 변경 빈도가 높은 필드와 읽기 빈도가 높은 필드를 분리해 불필요한 읽기-쓰기 충돌을 제거한다.
- predicate locking은 전체 현재값 조회를 피하고 비정상 상태 조건만 인덱스로 조회해 검사 로직과 갱신 로직 간 충돌을 줄이는 방향으로 쿼리를 정렬한다.
❓ 열린 질문
- 트랜잭션에서 실제 필요한 문서만 읽도록 쿼리를 줄일 때, 어떤 조건을 필터로 두어 충돌 완화 효과를 얻을 것인가?
- 큐 패턴 적용 시 생성 시각·인덱스 범위를 얼마까지 확장하면 처리량 향상과 처리 지연 완화의 균형을 맞출 수 있는가?
- hot/cold 분리 후 predicate locking의 비정상 상태 조건이 정상 상태의 빈번 변경과 검사 요구사항을 모두 포착하는지 어떤 기준으로 판단할 것인가?