오브젝트 스토리지에 저장된 단 하나의 JSON 파일과 몇 개의 무상태 프로세스만으로, FIFO 실행·최소 1회(at-least-once) 전달 보장·높은 가용성을 갖춘 분산 작업 큐를 구축하는 과정을 단계별로 설명합니다.
쿼리 요금을 최대 94%까지 인하했습니다 — 쿼리 요금을 최대 94%까지 인하했습니다
2026년 2월 12일•Dan Harrison (Engineer)
최근 우리는 내부 인덱싱 작업 큐를 교체했습니다. 이 큐는 데이터가 WAL에 기록된 뒤 인덱싱 노드에 알림을 보내, 검색 인덱스를 생성하고 업데이트하도록 합니다. 이 큐는 쓰기 경로의 일부가 아니라, 비동기 인덱싱 작업을 스케줄링하기 위한 순수한 알림 시스템입니다. 이전 버전은 인덱싱 노드들에 큐를 샤딩해 두었기 때문에, 느린 노드가 자신에게 할당된 모든 작업을 막아버리면 다른 노드들이 놀고 있어도 해당 작업들은 진행되지 못했습니다. 새 버전은 오브젝트 스토리지에 단일 큐 파일을 두고, FIFO 실행과 최소 1회(at-least-once) 보장, 그리고 기존 구현 대비 꼬리 지연(tail latency) 10배 감소를 위해 무상태 브로커를 사용합니다. 그 결과 인덱싱 작업이 큐에서 대기하는 시간이 줄었습니다.
차트 데이터 로딩 중...
우리가 오브젝트 스토리지 위에 구축하는 데 이렇게 집착하는 이유는 뭘까요? 단순하고 예측 가능하며, 온콜로 관리하기 쉽고, 확장성이 극도로 좋기 때문입니다. 우리는 오브젝트 스토리지가 어떻게 동작하는지 알고 있고, 그 경계 내에서 설계하는 한 원하는 성능이 나온다는 것도 압니다.
새 큐의 최종 설계를 위에서 아래로 설명하기보다는, 가장 단순하게 동작하는 것부터 바닥에서 위로 쌓아 올리며 필요할 때마다 복잡도를 추가해 보겠습니다.
turbopuffer 작업 큐에 들어가는 데이터의 전체 크기는 작습니다. 1 GiB보다 훨씬 작습니다. 이는 메모리에 쉽게 올릴 수 있으므로, 가장 단순하게 동작하는 설계는 단일 파일(예: queue.json)을 큐의 전체 내용으로 반복해서 덮어쓰는 것입니다.
큐 푸셔(pusher) 는 큐의 내용을 읽고, 새 작업을 끝에 추가한 다음 compare-and-set (CAS)로 씁니다.
큐 워커(worker) 도 마찬가지로 CAS를 이용해 아직 클레임되지 않은 첫 번째 작업을 진행 중으로 표시합니다(○ → ◐). 그리고 일을 시작합니다.
푸셔와 워커를 클라이언트 라고 부르고, 푸시와 클레임 작업을 요청(request) 이라고 부르겠습니다.
compare-and-set(CAS) 프리미티브가 이를 원자적으로 만들어 줍니다. 쓰기는 queue.json이 읽힌 이후로 바뀌지 않았을 때만 성공합니다. 만약 바뀌었다면, 클라이언트는 새 내용을 다시 읽고 재시도합니다. 이는 복잡한 락 없이도 강한 일관성 보장을 제공합니다.
queue.json
┌──────────────────────────────────────┐
│ {"jobs": ["◐", "○", "○", "○", "○",]} │
└──────────────────────────────────────┘
▲ ▲
│ │
CAS write │ CAS write │
│ │
┌─────┴────┐ ┌──────┴───┐
│ worker │ │ pusher │
└──────────┘ └──────────┘
queue.json
┌─────────────────────────────────┐
│ {"jobs":["◐","○","○","○","○",]} │
└─────────────────────────────────┘
▲ ▲
│ │
│ │
CAS │ CAS │
write │ write │
│ │
│ │
┌─────┴──┐ ┌─────┴──┐
│ worker │ │ pusher │
└────────┘ └────────┘
이 가장 단순한 큐는 놀라울 정도로 잘 동작합니다! 초당 최대 1 요청(GCS가 부과하는 제한)까지는, 오브젝트 스토리지가 해주는 모든 덕분에 이미 프로덕션급입니다.
하지만 대부분의 큐(우리 큐 포함)는 초당 1건보다 더 많은 요청을 받습니다. 더 높은 처리량이 필요합니다.
오브젝트 스토리지는 장점이 많지만, 낮은 쓰기 지연은 그중 하나가 아닙니다. 파일을 교체하는 데 최대 200ms까지 걸릴 수 있으므로, 작업을 하나씩 쓰는 대신 배치로 처리해야 합니다. 쓰기가 진행 중인 동안에는 들어오는 요청을 메모리에 버퍼링합니다. 쓰기가 끝나자마자, 버퍼를 다음 CAS 쓰기로 플러시합니다.
이 기법은 흔히 그룹 커밋(group commit) 이라고 부르며, turbopuffer가 WAL에 쓰기를 배치 처리할 때도 같은 패턴을 사용합니다. 전통적인 데이터베이스들도 디스크로의 커밋 처리량을 극대화하기 위해 fsync(2) 호출을 합치는(coalesce) 데 이 기법을 씁니다.
queue.json
┌───────────────────────────────────────────────────────────────┐
│ {"jobs": ["◐", "◐", "◐", "◐", "○", "○", "○", "○", "○", "○",]} │
└───────────────────────────────────────────────────────────────┘
▲ ▲
│ │
│ │
group commit │ group commit │
│ │
┌── buffer ──┴──────┐ ┌── buffer ───────┴─┐
│ ┌───┬───┬───┬───┐ │ │ ┌───┬───┬───┬───┐ │
│ │ ◐ │ ◐ │ ◐ │ ◐ │ │ │ │ ○ │ ○ │ ○ │ ○ │ │
│ └───┴───┴───┴───┘ │ │ └───┴───┴───┴───┘ │
└─────────▲─────────┘ └─────────▲─────────┘
│ │
│ │
┌─────┴────┐ ┌─────┴────┐
│ worker │ │ pusher │
└──────────┘ └──────────┘
queue.json
┌─────────────────────────────────┐
│ {"jobs":["◐","◐","◐","○","○",]} │
└─────────────────────────────────┘
▲ ▲
group │ group │
commit │ commit │
│ │
┌─buffer────┴─┐ ┌─buffer──┴───┐
│┌───┬───┬───┐│ │┌───┬───┬───┐│
││ ◐ │ ◐ │ ◐ ││ ││ ○ │ ○ │ ○ ││
│└───┴───┴───┘│ │└───┴───┴───┘│
└──────▲──────┘ └──────▲──────┘
│ │
┌────┴───┐ ┌────┴───┐
│ worker │ │ pusher │
└────────┘ └────────┘
그룹 커밋은 쓰기율을 요청율과 분리함으로써 처리량 문제를 해결합니다. 확장의 병목은 쓰기 지연(~200ms/쓰기)에서 네트워크 대역폭(~10 GB/s)으로 이동합니다. 이는 turbopuffer가 인덱싱 작업을 추적하는 데 필요한 것보다 훨씬 큽니다.
하지만 여전히 문제가 있습니다. 어떤 turbopuffer 리전이든, 많은 네임스페이스에 새 데이터가 기록되면서 수십~수백의 클라이언트가 단일 큐 오브젝트를 두고 경쟁하게 됩니다.
CAS는 각 쓰기가 시간상 겹치지 않도록 강제하여 강한 일관성을 보장하므로, 1 / ~200ms = 초당 ~5회 쓰기만 넣을 수 있습니다(그리고 GCS의 1 RPS 제한도 여전히 존재합니다).
이제 문제는 처리량이 아닙니다. 작성자(writer)를 줄여야 합니다.
참고: 이 설계를 로컬 큐로 샤딩하는 방식과 결합한 것이, 이번 업데이트 이전에 우리가 프로덕션에서 사용하던 방식과 대략 비슷합니다. 다음 섹션들은 turbopuffer의 현재 프로덕션 인덱싱 큐를 설명합니다.
큐 오브젝트에 대한 경합을 없애기 위해, 오브젝트 스토리지와의 모든 상호작용을 담당하는 무상태 브로커(broker) 를 도입합니다. 이제 모든 클라이언트는 오브젝트 스토리지에 직접 쓰는 대신 브로커를 통해야 합니다.
브로커는 모든 클라이언트를 대신해 단일 그룹 커밋 루프를 실행하므로, 누구도 오브젝트를 두고 경쟁하지 않습니다. 결정적으로, 브로커는 그룹 커밋이 오브젝트 스토리지에 실제로 반영되기 전에는 쓰기를 승인(ack)하지 않습니다. 클라이언트는 자신의 데이터가 내구적으로 커밋될 때까지 다음 단계로 진행하지 않습니다.
이제 브로커가 병목이지만, 쓰기가 매우 작기 때문에 단일 브로커 프로세스가 수백~수천의 클라이언트를 가볍게 처리할 수 있습니다. I/O를 기다리는 동안 연결을 유지하고 요청을 메모리에 버퍼링할 뿐입니다. 무거운 일은 오브젝트 스토리지가 다 합니다.
queue.json
┌───────────────────────────────────────────────────────────────────────────────────┐
│ {"jobs": ["◐", "◐", "◐", "◐", "○", "○", "○", "○", "○", "○", "○", "○", "○", "○",]} │
└───────────────────────────────────────────────────────────────────────────────────┘
▲
│
│ brokered group commit
│
╔═ broker ═════════════════════════════════╧════════════════════════════════════════╗
║ ║
║ ┌─ buffer ────────────────────────────────────────────────────────────────────┐ ║
║ │ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ │ ║
║ │ │ ◐ │ ◐ │ ◐ │ ◐ │ ◐ │ ◐ │ ◐ │ ◐ │ ◐ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ │ ║
║ │ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘ │ ║
║ └─────────────────────────────────────────────────────────────────────────────┘ ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════════════╝
▲ ▲ ▲ ▲ ▲ ▲
│ │ │ │ │ │
┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴───┐
│ worker │ │ worker │ │ worker │ │ pusher │ │ pusher │ │ pusher │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘
queue.json
┌─────────────────────────────────┐
│ {"jobs":["◐","◐","◐","○","○",]} │
└─────────────────────────────────┘
▲
│ brokered
│ group commit
│
╔══ broker ═════╧═════════════════╗
║ ┌─ buffer ───────────────────┐ ║
║ │ ┌───┬───┬───┬───┬───┬───┐ │ ║
║ │ │ ◐ │ ◐ │ ◐ │ ○ │ ○ │ ○ │ │ ║
║ │ └───┴───┴───┴───┴───┴───┘ │ ║
║ └────────────────────────────┘ ║
╚════════╤═══════════════╤════════╝
│ │
┌────┴────┐ ┌────┴────┐
│ workers │ │ pushers │
└─────────┘ └─────────┘
스케일링은 여기서 끝입니다. 이제 시스템은 turbopuffer의 인덱싱 트래픽을 처리할 수 있습니다. 하지만 고가용성이 필요합니다.
브로커가 올라간 머신은 언제든 죽을 수 있습니다. 마찬가지로 어떤 워커는 작업을 클레임한 뒤 영영 끝내지 않을 수도 있습니다. 각 문제에 대한 해결책은 형태가 같습니다 — 사라졌음을 감지하고 책임을 넘긴다 — 하지만 디테일은 다릅니다.
클라이언트가 브로커에 보낸 어떤 요청이든 너무 오래 걸리면, 새 브로커를 시작합니다. 클라이언트는 새 브로커를 찾는 방법이 필요하므로, 브로커의 주소를 queue.json에 기록합니다.
브로커는 무상태이므로 옮기기 쉽고 비용도 적게 듭니다. 그리고 동시에 브로커가 둘 이상 떠버리면 어떨까요? 괜찮습니다. 브로커가 두 개여도 CAS가 정합성을 보장합니다. 이전 브로커는 queue.json에 CAS 실패가 발생하면 자신이 더 이상 브로커가 아님을 결국 알아차립니다. 단점은 이 짧은 기간 동안 약간의 경합, 즉 느려짐이 생긴다는 점뿐입니다.
작업 클레임에는 하트비트를 추가합니다. 워커는 주기적으로 여전히 작업을 진행 중임을 타임스탬프로 브로커에 확인시키고, 브로커는 이를 해당 작업에 대해 queue.json에 기록합니다(클레임된 작업당 하트비트 1개). 큐에 있는 어떤 작업의 마지막 하트비트가 타임아웃을 넘기면, 원래 워커가 사라졌다고 보고 다음 워커가 중단된 지점부터 인계받습니다.
queue.json
┌──────────────────────────────────────────────────────────────────────────────────┐
│ { │ read
│ "broker": "10.0.0.42:3000", │◀──┐
│ "jobs": ["◐(♥)", "◐(♥)", "◐(♥)", "◐(♥)", "◐(♥)", "○", "○", "○", "○", "○",] │ │
│ } │ │
└──────────────────────────────────────────────────────────────────────────────────┘ │
▲ │
│ │
│ brokered group commit │
│ │
╔═ broker ═════════════════════════════════╧════════════════════════════════════════╗ │
║ ║ │
║ ┌─ buffer ────────────────────────────────────────────────────────────────────┐ ║ │
║ │ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ │ ║ │
║ │ │ ◐ │ ◐ │ ◐ │ ◐ │ ◐ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ ○ │ │ ║ │
║ │ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘ │ ║ │
║ └─────────────────────────────────────────────────────────────────────────────┘ ║ │
║ ║ │
╚═══════════════════════════════════════════════════════════════════════════════════╝ │
▲ ▲ ▲ ▲ ▲ ▲ │
│ │ │ │ │ │ │
┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴───┐ │
│ worker │ │ worker │ │ worker │ │ pusher │ │ pusher │ │ pusher │─────┘
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘
queue.json
┌─────────────────────────────────┐
│ { │
│ "broker":"10.0.0.42:3000", │
│ "jobs":["◐(♥)","◐(♥)","○",] │
│ } │
└─────────────────────────────────┘
▲ ▲
brokered │ read │
group commit │ │
│ │
╔══ broker ═════╧═════════════════╗
║ ┌─ buffer ───────────────────┐ ║
║ │ ┌───┬───┬───┬───┬───┬───┐ │ ║
║ │ │ ◐ │ ◐ │ ○ │ ○ │ ○ │ ○ │ │ ║
║ │ └───┴───┴───┴───┴───┴───┘ │ ║
║ └────────────────────────────┘ ║
╚════════╤═══════════════╤════════╝
│ │ │
┌────┴────┐ ┌────┴────┐ │
│ workers │ │ pushers │─┘
└─────────┘ └─────────┘
우리는 오브젝트 스토리지 위의 단 하나의 파일과, 몇 개의 무상태 프로세스만으로 신뢰할 수 있는 분산 작업 큐를 만들었습니다. 이는 우리의 처리량 요구를 쉽게 만족하고, 최소 1회(at-least-once) 전달을 보장하며, 필요 시 어떤 노드로든 페일오버합니다. turbopuffer의 핵심 아키텍처에 익숙한 분이라면 이 유사성을 알아볼 것입니다. 오브젝트 스토리지는 프리미티브가 많지는 않지만 강력합니다. 이들이 어떻게 동작하는지 익히기만 하면, 이미 있는 것들만으로도 탄력적이고 성능 좋으며 고도로 확장 가능한 분산 시스템을 만들 수 있습니다.
turbopuffer는 2.5조(2.5T)+ 문서를 호스팅하고, 초당 1천만(10M)+ 쓰기를 처리하며, 초당 1만(10k)+ 쿼리를 제공합니다. 우리는 더 큰 규모도 충분히 준비되어 있습니다. 여러분의 쿼리를 맡겨 주시길 바랍니다.
지원
팔로우
© 2026 turbopuffer Inc.