시간 계열 쿼리를 느리게 만든 세 가지 버그와 그 발견 방법
요약
MoteDB 개발팀은 임베디드 AI용 데이터베이스에서 시간 기반 쿼리 성능을 저하시키는 세 가지 버그를 발견하고 수정했습니다. 이 버그들은 일반적인 테스트 스위트에서는 감지하기 어려웠으며, 특히 '빠른 경로'가 충돌 없이 조용히 작동하는 것이 위험성을 높였습니다. 이를 통해 1.2초 걸리던 쿼리를 1.76ms로 대폭 개선했습니다.
핵심 포인트
- 임베디드 AI 데이터베이스에서 시간 기반 쿼리 성능 최적화에 대한 깊은 통찰을 제공합니다.
- 버그들은 일반적인 테스트 스위트로는 발견하기 어려우며, '빠른 경로'의 위험성을 강조합니다.
- 영역 맵(zone map)과 쓰기 버퍼 처리 로직 등 세부 메타데이터 처리가 성능 병목 지점임을 보여줍니다.
100만 행 테이블에 대한 SELECT ... ORDER BY ts DESC LIMIT 10 쿼리가 1.2초가 걸렸습니다. 이는 한 자리 밀리초(single-digit milliseconds)로 나와야 했습니다. 최악의 부분은 무엇일까요? 우리의 EXPLAIN 결과는 우리가 빠른 경로(fast path)를 사용하고 있다고 보여주었습니다.
우리는 틀렸습니다. 이 글은 그 숫자의 배후에 있던 세 가지 버그, 그것을 발견한 방법, 그리고 MoteDB의 v0.12.1 버전이 어떻게 이를 수정했는지 — 1,196ms에서 1.76ms로 낮추어 동일 데이터에 대한 DuckDB 전체 스캔보다 빠르게 만든 과정을 이야기합니다.
만약 '빠른 경로'를 가진 소프트웨어를 배포한다면 이 글을 읽어야 합니다. 왜냐하면 이 버그들 각각이 일반적인 테스트 스위트에서는 살아남기 때문입니다.
설정 (The setup)
MoteDB는 임베디드 AI(embodied AI)용 데이터베이스로, 로봇, AR 안경, 산업용 팔 등에 사용됩니다. 로봇에서 가장 빈번하게 발생하는 쿼리는 다음과 같은 변형 형태입니다:
SELECT * FROM sensor WHERE device = 'arm_7' ORDER BY ts DESC LIMIT 10
결과: 빠른 경로에서 0개 행. 오류 없음. 로그 없음. 빈 후보 목록이 생성되었고, 실행기(executor)는 이를 "일반 스캔으로 폴백(fall back)" 처리합니다.
이것이 버그 번호 1이며, 가장 위험한 종류입니다: 빠른 경로가 충돌하지 않고 조용히 아무것도 생성했습니다. 폴백이 100만 개의 디코드 작업을 수행했습니다. 아무도 알아차리지 못했는데, 정확성(correctness)에는 문제가 없었기 때문입니다 — 느린 경로는 정확합니다. 단지 느렸을 뿐입니다.
버그 #2: 모든 것을 가지치기한 영역 맵 (zone map)
모든 컬럼 세그먼트는 타임스탬프(timestamp) 열에 대한 최소/최대 메타데이터를 가지고 있으며, 스캔은 이 정보를 사용하여 범위 내에 행을 포함할 수 없는 세그먼트를 건너뜁니다. 그렇다면 행이 여전히 쓰기 버퍼(write buffer)에 남아 있을 때는 어떻게 될까요?
버퍼의 시간 추적 코드는 Value::Timestamp를 볼 때 통계(stats)를 업데이트했습니다. 우리의 INT 테이블 행은 Value::Integer입니다. 따라서 해당 세그먼트에 대한 추적 범위는 초기화 값인 (0, 0)에 머물렀습니다.
그러자 영역 맵 게이트가 모든 세그먼트에 대해 물었습니다: "이 세그먼트가 최근 타임스탬프를 포함할 수 있을까?" 최소값이 0이고 최대값이 0인 세그먼트가 가장 최근의 데이터를 포함하고 있음에도 불구하고 — 가지치기되었습니다. 전부 다요. 게이트는 자신이 설계된 대로 정확하게 작동했지만, 조용히 채워지지 않은 메타데이터에 대해서였습니다.
수정은 의미론적(semantic)이며 일반화됩니다: **(0, 0)은 "이 세그먼트에 최근 데이터가 없다"는 뜻이 아니라, "알 수 없음(unknown)"**입니다 — 그리고 "알 수 없음"은 가지치기되어서는 안 됩니다. 게이트는 이제 (0,0) 세그먼트를 폐기하는 대신 통과시킵니다.
버그 #3: 가시성 거짓말 (visibility lie)
로봇의 쓰기는 먼저 쓰기 버퍼에 도착한 다음 체크포인트에서 세그먼트로 접혀 들어갑니다. snapshot_rows는 버퍼링된(커밋되지 않은) 행이 쿼리의 시간 창 내에 포함되는지 결정하기 위해 범위 내 확인(in-range check)을 수행합니다. Integer 기반 버퍼의 경우, 이 확인은 하드코딩되어 있었습니다:
false // Integer buffers: never in range
의미: 마지막 몇 초 동안 기록된 쓰기 데이터—정확히 "ORDER BY ts DESC LIMIT 10"이 반환하도록 존재하는 행들—는 체크포인트가 실행될 때까지 top-k에 보이지 않았습니다. 30초마다 체크포인트를 수행하는 로봇의 경우, "최신 읽기 값"이 절반 시간만큼 오래되었을 수 있으며, 아무도 이를 알려주지 못했습니다.
버그 #4 (가장 겸손하게 만드는 것): fast path가 전혀 실행되지 않음
이 모든 것을 측정하는 과정에서 우리는 더 심각한 것을 발견했습니다. Python 바인딩의 쿼리 진입점이 시간 계열 top-k를 fast kernel로 라우팅하지 않았던 것입니다. EXPLAIN은 fast path를 선택된 계획으로 즐겁게 출력했지만, 그것은 종이 위의 계획일 뿐이었습니다. 스트리밍 진입점에는 라우팅 분기가 누락되어 있어, Python에서 오는 모든 쿼리가 처음부터 일반적인 실행기(generic executor)를 사용했습니다.
따라서 우리는 다음과 같은 상황에 놓였습니다:
- INT 컬럼에서 빈 결과를 생성하는 fast path (버그 1),
- 가장 최신 데이터를 숨기는 세그먼트 가지치기(segment pruning) (버그 2),
- 커밋되지 않은 행을 숨기는 가시성 로직(visibility logic) (버그 3),
- 그리고 대부분의 프로덕션 트래픽이 그 어떤 것도 도달하지 못하게 만드는 라우팅 계층(routing layer) (버그 4),
…반면 우리의 EXPLAIN 출력은 모든 것이 괜찮다고 말해주었습니다.
수정 사항
v0.12의 수정 사항들을 간략히 설명하면 다음과 같습니다:
- 그룹 디코드(Grouped decode) — 이전 루프는 세그먼트당 생존하는 행 수에 대해 쿼드라틱(quadratic) 비용을 가졌기 때문에, 이제 모든 생존 행에 대해 세그먼트의 필요한 컬럼을 재디코딩하는 대신 2개의 디코드를 per-segment로 전달합니다.
- 영역 게이트 의미론(Zone gate semantics) —
(0,0)메타데이터는 더 이상 가지치기를 수행하지 않으며, 알 수 없는 값으로 처리됩니다. - 가시성(Visibility) —
in_range가 Integer 버퍼를 처리하며; 커밋되지 않은 행은 top-k에 즉시 가시적입니다. - 라우팅 + 프로젝션 백필(Routing + projection backfill) — 스트리밍 진입점은
ORDER BY ts LIMIT k를 top-k kernel로 라우팅하고, 프로젝션이 컬럼을 생략했을 때 pass-2b가ts값을 백필합니다.
1M 행에 대한 결과 (Apple Silicon, 릴리스 빌드, 리포의 벤치마크 스위트로 재현 가능):
| Query | Before | After |
|---|---|---|
ORDER BY ts DESC LIMIT 10 | 1,196 ms | 1.76 ms |
| DuckDB 전체 스캔 (참조) | — | 2.1 ms |
680배의 개선을 이루었으며, 이제는 빠른 경로(fast path)가 실제로 모든 것을 스캔하는 것보다 빠릅니다. 이는 바로 빠른 경로의 핵심 목적입니다.
교훈 (공유할 가치가 있는 부분)
1. 차분 테스트(differential test) 없이는 빠른 경로는 소문일 뿐이다. 네 가지 버그 모두 기존 테스트를 통과했기 때문입니다. 느린 경로가 올바르다는 것이 증명되었기 때문이죠. 정확성 측면에서 문제를 발견하게 해준 것은 SQLite-as-oracle 차분 하네스(differential harness)였으며, 성능 측면에서 최종적으로 문제를 드러낸 것은 빠른 경로의 출력값과 타이밍을 일반 경로와 비교하는 적대적 벤치마크(adversarial benchmark)였습니다. 만약 동일한 쿼리에 대해 두 개의 코드 경로를 유지한다면, 모든 커밋마다 서로 차분화되어야 합니다. 즉, 같은 입력에 대해 같은 출력을 내야 하며, 빠른 경로가 실제로 빨라야만 합니다.
2. EXPLAIN은 측정치가 아니라 주장이다. 버그 #4는 우리의 EXPLAIN 출력이 실행되지 않은 계획을 설명했음을 의미합니다. 그 이후로 모든 지연 시간 조사(latency investigation)는 단계별 실시간 타이밍(wall-clock timing per stage)으로 시작되며, EXPLAIN은 신뢰하기보다 현실과 비교하여 확인받습니다.
3. 타입 일반성(Type-generality)이 정확성의 표면이다. 여기서 근본 원인 사슬은 하나의 추상화 누수입니다.
Arrow/pandas 상호 운용성(interop) — query_arrow()는 VECTOR(n) 열을 Arrow의 표준 형식인 fixed_size_list<float32>로 매핑한 pyarrow.Table을 반환하며, insert_arrays를 통해 내구성(durability)을 유지하면서 초당 약 20만 개의 행으로 numpy 배열을 대량 적재합니다.
결과를 누락하지 않는 필터링된 벡터 검색(Filtered vector search that doesn't drop results) — 이전에 WHERE zone = 'bay_3' ORDER BY emb <-> ? LIMIT 10 구문을 top-k 이후에 필터링하는 방식으로 사용되어, 선택도가 높은 조건절이 요청한 행보다 적거나 아예 결과를 반환하지 않을 수 있었습니다. 이제 후보 깊이(Candidate depth)가 k개의 생존자가 나올 때까지 반복적으로 심화됩니다.
스캔 UPDATE/DELETE 조건절 푸시다운(Scan UPDATE/DELETE predicate pushdown) — 초당 112.6K 행을 처리하며, 동일한 내구성 수준에서 SQLite의 110K를 능가했습니다.
ANN 꼬리 지연 시간(ANN tail latency) — p99가 30ms에서 1.5ms로 개선되었습니다. 이
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기