메시지 큐 쓰려면 Redis와 Celery를 또 깔아야 했다. honker는 "이미 있는 SQLite 파일 안에 큐를 같이 두면 되지 않냐"는 단순한 발상으로 출발한 SQLite 확장 + 다국어 바인딩 프로젝트다.
SQLite, 큐, NOTIFY/LISTEN — 기본 단어부터.
honker를 이해하려면 세 가지 단어를 먼저 한 번씩 풀고 가야 합니다. SQLite, 메시지 큐, 그리고 NOTIFY/LISTEN. 셋 다 백엔드 개발에서 자주 등장하지만, 셋이 한 자리에 모이는 경우는 드물어서 처음 보면 어색합니다.
app.db 같은 파일 하나가 곧 데이터베이스 전체입니다. 휴대폰 앱, 브라우저, 운영체제 안에 이미 셀 수도 없이 깔려 있는, 세상에서 가장 많이 배포된 DB라고 해도 과언이 아닙니다.이 셋을 합치면 honker의 한 줄짜리 자기소개가 됩니다 — "SQLite 한 파일 안에 큐와 NOTIFY/LISTEN을 통째로 욱여넣은 확장". 어떤 언어든 SQLite만 쓸 수 있으면 같은 기능을 빌려 쓸 수 있게 설계됐습니다.
일반적인 구성: 우리 회사 서류는 캐비닛(SQLite)에 보관, 업무 지시는 별도 게시판(Redis)에 붙임. 캐비닛 정리하는 사람과 게시판 보는 사람이 다르고, 게시판이 망가지면 업무가 멈춥니다.
honker가 제안하는 구성: 캐비닛 안 맨 뒷칸에 "오늘 할 일" 폴더를 같이 넣어둡니다. 서류 보관과 업무 지시가 같은 잠금장치를 공유하니, 거래 기록을 넣는 그 순간 메일 발송 지시도 같이 들어가고, 만약 거래가 취소되면 메일 지시도 함께 사라집니다.
이 프로젝트가 던지는 핵심 메시지.
지금까지 SQLite 기반 앱이 큐가 필요하면 "Redis + Celery 깔자"가 정답이었습니다. 데이터스토어가 둘이 되고, 백업 정책도 둘이 되고, 비즈니스 테이블과 큐 사이에 이중 기록(dual-write) 문제가 생기고, 브로커 서버를 운영해야 합니다.
honker는 정반대 접근입니다 — 큐 자체를 SQLite 안의 한 테이블로 두면, INSERT INTO orders와 queue.enqueue(...)가 같은 트랜잭션에서 커밋됩니다. 롤백하면 둘 다 사라집니다. 외부 의존성 없이 "한 파일 + WAL 사이드카" 구조로 모든 게 끝납니다.
기존 방식의 통증을 정확히 짚는다.
"SQLite는 임베디드 DB일 뿐"이라는 오래된 인식이 깨지고 있습니다. litestream(SQLite를 S3로 실시간 복제하는 도구), turso(엣지 SQLite 호스팅), cloudflare D1 같은 흐름이 동시에 등장하면서, "SQLite로도 진짜 서비스 만들 수 있다"는 사례가 늘었습니다. 그러나 진짜 서비스에는 항상 두 가지가 더 필요합니다 — 발행/구독(pub/sub)과 태스크 큐.
주문을 DB에 저장 → 그다음 Redis 큐에 "메일 보내기" 작업을 넣는다. 그런데 DB 저장은 성공하고 Redis 호출이 실패하면? 주문은 들어왔는데 환영 메일은 영영 안 갑니다. 반대로 DB 저장 후 앱이 죽으면 큐에는 작업이 안 들어갑니다.
DB 백업은 매일 새벽 3시, Redis는 메모리에 있고 별도 스냅샷 정책. 장애 복구 시 두 시점이 일치하지 않으면 "주문은 있는데 큐는 비어 있는" 어색한 상태가 됩니다.
Redis든 RabbitMQ든, 데몬(daemon, 백그라운드 상주 프로세스)이 하나 더 늘어납니다. 모니터링·로그·메모리 한도·페일오버 — 운영 부담이 두 배. 1인 개발자나 작은 팀에겐 부담스러운 비용입니다.
비즈니스 INSERT와 큐 INSERT를 같은 SQLite 트랜잭션 안에 묶으면 위 셋이 한꺼번에 풀립니다. 둘 다 커밋되거나 둘 다 롤백. 백업도 파일 하나만 챙기면 됩니다. 별도 데몬도 필요 없습니다 — 워커는 SQLite의 WAL 파일이 변하는 걸 직접 감시합니다.
이 패턴은 사실 새로운 게 아닙니다. Postgres 진영에는 이미 pg-boss(Node), Oban(Elixir) 같은 "DB 안에 큐를 넣는" 라이브러리가 황금 표준으로 자리잡았습니다. honker는 그 패턴을 SQLite 세계로 가져오는 것이 목표입니다.
Postgres 세계의 pg_notify는 SQL로 "이 채널에 신호 보내라"를 호출하면 듣고 있던 클라이언트가 깨어나는 기능입니다. 이걸 큐와 결합한 게 pg-boss, Oban이고요. honker는 이 구조 전체를 SQLite의 WAL(쓰기 선행 로그)과 stat() 시스템 콜로 재현한 작품입니다.
Rust 코어 하나에 11개 언어 바인딩이 매달려 있다.
honker는 단일 라이브러리가 아니라 "Rust로 짠 코어를 모든 언어가 빌려 쓰는" 구조입니다. 같은 디스크 레이아웃을 한 번 Rust로 정의해두고, 각 언어 바인딩은 같은 테이블을 읽고 씁니다. 그래서 Python 워커가 Node 앱이 넣은 작업을 처리할 수 있습니다.
전체 시스템의 심장. Rust rlib(다른 Rust 크레이트가 의존하는 정적 라이브러리)로, SQLite 테이블 스키마 정의·WAL 감시 스레드·큐 클레임 로직 등이 모두 여기 있습니다. PyO3, napi-rs 같은 다양한 바인딩이 이 한 크레이트를 공유합니다.
SQLite loadable extension(외부에서 불러올 수 있는 동적 라이브러리, 확장자는 OS별로 .so/.dylib/.dll)으로 컴파일되는 cdylib. SQLite CLI에서 .load ./libhonker_ext 한 줄로 불러올 수 있고, 그러면 honker_claim_batch(), honker_stream_publish() 같은 SQL 함수가 등록됩니다. 언어 바인딩 없이 순수 SQL만으로 큐를 다룰 수 있게 해주는 계층.
모든 바인딩은 packages/ 디렉토리 아래 in-tree 디렉토리로 들어 있으며, 같은 레포 안에서 관리됩니다. site/ 디렉토리(honker.dev 문서 사이트)만 별도 저장소(russellromney/honker-site)를 가리키는 git 서브모듈로 연결되어 있습니다. 서브모듈은 "다른 저장소를 내 저장소 안에 폴더처럼 연결해두는" git 기능입니다.
honker)가장 풍부한 바인딩. PyO3(Rust로 Python C 확장을 만드는 프레임워크)로 작성되어 있어 속도는 거의 네이티브 수준입니다. Huey나 Celery 같은 @task 데코레이터 스타일까지 제공해서, 함수 위에 줄 하나만 붙이면 그 함수 호출이 자동으로 큐로 들어갑니다.
@russellthehippo/honker-node)napi-rs(Rust로 Node 네이티브 모듈을 만드는 도구)로 빌드. 사전 빌드된 바이너리가 npm에 올라와 있어서 설치 후 별도 컴파일이 필요 없습니다.
같은 honker-core 또는 loadable extension 위에 각 언어 관용 API를 입혀둔 얇은 래퍼. 총 11개 바인딩(Python·Node 포함)이 같은 디스크 포맷을 공유하므로, 언어가 늘어나도 호환성 깨질 위험이 낮습니다.
저장소 루트는 Rust 워크스페이스(Cargo.toml)와 Python 워크스페이스(pyproject.toml)가 공존합니다. uv(Rust로 만든 빠른 Python 패키지 매니저)로 락 파일(uv.lock)을 관리하고, 빌드는 make build 한 방에 PyO3 모듈과 loadable extension을 모두 컴파일합니다. CI는 GitHub Actions, 테스트는 make test가 Rust + Python + Node를 약 10초에 통과시킵니다.
honker의 진짜 마술은 "푸시 신호 없는 SQLite에 푸시를 만든 방법"이다.
Postgres에는 와이어 프로토콜(wire protocol, 서버가 클라이언트에 메시지를 보내는 네트워크 규약)이 있어 서버가 클라이언트를 깨울 수 있습니다. 하지만 SQLite는 서버가 없습니다. 그냥 파일입니다. 그러면 어떻게 "큐에 새 작업이 들어왔다"를 다른 프로세스에 알릴까요?
가장 쉬운 답은 "워커가 1초마다 SELECT * FROM queue WHERE state='pending' 던지고 결과 있으면 처리". 단점이 셋입니다 — (1) 대기시간이 평균 500ms 늘어남, (2) 1만 개 워커 × 1초 = 초당 1만 쿼리, (3) 아무 일도 없을 때조차 CPU/디스크가 계속 일합니다.
honker의 기본(stable) 백엔드는 SQLite의 PRAGMA data_version 값을 1ms마다 읽어 카운터가 증가했는지 확인합니다. SQLite는 커밋이 일어날 때마다 이 카운터를 올리므로, honker는 카운터 변화를 감지하는 그 순간 모든 구독자를 깨웁니다. 파일시스템 메타데이터(mtime)를 건드리지 않고 DB 레벨에서 직접 확인하는 방식입니다.
선택적(source 빌드) 백엔드도 두 가지 존재합니다 — kernel-watcher(inotify/kqueue/FSEvents를 feature flag로 활성화)와 shm-fast-path(WAL 공유 메모리를 직접 읽는 방식). 이 둘은 Cargo.toml의 optional feature로 분리되어 있으며 기본 배포에는 포함되지 않습니다.
폴링 방식은 1분마다 우체통을 직접 열어보는 것. 편지가 매번 없어도 매번 가야 하니 지칩니다.
honker의 data_version 방식은 우체통 옆에 작은 카운터가 달려서, 우체통 문이 한 번이라도 열리면 숫자가 1 올라갑니다. 1ms마다 카운터만 흘끔 보면 됩니다 — 카운터가 그대로면 편지도 없는 것이고, 변했으면 그제서야 문을 엽니다. "확인"은 마이크로초, "실제 읽기"는 변할 때만 일어납니다.
honker-core 안에는 SharedUpdateWatcher라는 구조체가 있습니다. 내부적으로 poll_data_version() 함수가 1ms 간격으로 data_version 카운터를 확인하며, 데이터베이스 하나당 폴링 스레드 단 한 개가 돌면서, 구독자(워커, 리스너)가 100명이든 1000명이든 신호 하나만 받으면 바운디드 채널(bounded channel, 정해진 크기의 메시지 큐)로 전부에게 한 번에 부채살처럼 펼쳐서(fan out) 알려줍니다.
┌──────────────────────────────────────────────────────┐
│ 하나의 poll_data_version() 스레드 (1ms마다 data_version 확인) │
└────────────────────┬─────────────────────────────────┘
│ data_version 카운터 증가 감지
▼
┌─────────────┼─────────────┐
▼ ▼ ▼
[구독자 1] [구독자 2] [구독자 N]
bounded ch bounded ch bounded ch
│ │ │
▼ ▼ ▼
SELECT … WHERE id > last_seen
(부분 인덱스 활용)
│
▼
yield 작업, ack 후 다시 대기
큐는 두 개의 테이블입니다 — _honker_live(처리 중 또는 대기 중)와 _honker_dead(재시도 다 소진된 실패 작업). 핵심은 부분 인덱스(partial index, 조건을 만족하는 행만 포함하는 인덱스):
(queue, priority DESC, run_at, id) WHERE state IN ('pending','processing')
완료/사망 행은 인덱스에 들어가지 않으므로, 10만 개의 데드 레터가 쌓여 있어도 클레임 속도는 큐가 비어 있을 때와 똑같습니다. 클레임은 UPDATE … RETURNING 한 번, ack는 DELETE 한 번 — 모든 핫패스가 O(1)에 가깝습니다.
현대 마이크로서비스 패턴 중 가장 유명한 게 트랜잭셔널 아웃박스(transactional outbox)입니다. "비즈니스 테이블 옆에 outbox 테이블을 두고, 이벤트도 같은 트랜잭션으로 INSERT, 별도 디스패처가 outbox 읽어서 메시지 큐로 보낸다"는 패턴이죠. honker에서는:
with db.transaction() as tx:
tx.execute("INSERT INTO orders (user_id) VALUES (?)", [42])
emails.enqueue({"to": "alice@example.com"}, tx=tx)
stream.publish({"event": "order_created"}, tx=tx)
주문 저장, 이메일 큐 등록, 이벤트 스트림 발행이 한 트랜잭션 안에서 원자적으로 처리됩니다. 별도 outbox 테이블도, 별도 디스패처 프로세스도 필요 없습니다. "부수효과 행 자체가 곧 커밋된 행"이고, WAL을 보고 있던 누구나 1ms 안에 알아챕니다.
PRAGMA journal_mode=WAL로 켜며, 그러면 메인 .db 옆에 .db-wal과 .db-shm 파일이 생깁니다. 읽기와 쓰기가 서로 막지 않는다는 게 가장 큰 장점.honker는 WAL 모드를 강제합니다. WAL 없이는 DELETE 저널 모드(기본값)에서 모든 쓰기가 EXCLUSIVE 락을 잡는데, 그러면 워커 한 명이 클레임하는 동안 모든 enqueue가 줄을 서야 합니다. 또 .db-wal 파일은 커밋마다 증가하고 체크포인트에서만 줄어들기 때문에 단조 증가하는 신호로 쓰기 좋습니다.
리눅스의 inotify, 맥의 FSEvents, BSD의 kqueue — 모두 커널이 파일 변화를 직접 푸시해주는 더 우아한 방법입니다. 그런데 honker의 stable backend는 이들 대신 PRAGMA data_version 폴링을 씁니다. 이유는 커널 파일 이벤트가 같은 프로세스 안의 쓰기를 누락할 수 있기 때문입니다. 한 Python 프로세스 안에 enqueuer와 listener가 같이 있으면 서로를 못 봅니다. data_version은 OS 무관하게 SQLite 레벨에서 ~1ms 정확도로 안정 작동하고, 비용은 약 0.5ms 추가 지연뿐입니다. kernel-watcher(inotify/kqueue)와 shm-fast-path는 optional source-build feature로 선택적으로 활성화할 수 있습니다.
레포 안에 뭐가 어디에 있는지.
honker 레포의 폴더 구조는 "코어는 in-tree, 바인딩은 submodule"이라는 원칙으로 깔끔하게 갈라져 있습니다.
honker/
├── honker-core/ ← [핵심] Rust rlib. 모든 바인딩의 공통 토대
├── honker-extension/ ← [핵심] SQLite loadable extension (cdylib)
├── packages/
│ ├── honker/ ← Python (PyO3)
│ ├── honker-node/ ← Node.js (napi-rs)
│ ├── honker-rs/ ← Rust 친화 래퍼
│ ├── honker-go/ ← Go
│ ├── honker-ruby/ ← Ruby
│ ├── honker-bun/ ← Bun
│ ├── honker-ex/ ← Elixir
│ ├── honker-cpp/ ← C++
│ ├── honker-dotnet/ ← .NET/C#
│ ├── honker-jvm/ ← JVM
│ └── honker-kotlin/ ← Kotlin
├── tests/ ← 크로스 언어 통합 테스트
├── bench/ ← wake_latency, real_bench 등 성능 측정
├── site/ ← honker.dev 문서 사이트 (Astro) [git submodule]
├── Cargo.toml ← Rust 워크스페이스 정의
├── pyproject.toml ← Python 워크스페이스
├── Makefile ← make test / make build 진입점
├── ROADMAP.md ← Phase 단위로 정리된 향후 작업
└── CHANGELOG.md ← 출시된 변경사항
주목할 디테일 셋:
honker-core와 honker-extension은 메인 레포에 직접 들어 있습니다. 모든 바인딩이 의존하는 토대이기 때문에 일관성이 절대 필요합니다. 언어별 바인딩도 packages/ 아래 in-tree 디렉토리로 같은 레포에서 관리됩니다. 각자의 패키지 저장소(PyPI, npm, crates.io 등)로의 발행은 별도로 이루어지지만, 소스 코드는 단일 레포 안에 있습니다. site/만 예외적으로 별도 저장소를 가리키는 git 서브모듈입니다.
같은 .db 파일을 Python으로 쓰고 Node로 읽거나, Ruby로 쓰고 Python으로 읽는 시나리오를 검증합니다. test_extension_interop.py는 스키마 호환성을 핀으로 박아두는 일종의 계약 테스트입니다.
"빠르다"고 주장하기 전에 측정합니다. wake_latency_bench.py는 커밋 → 워커 깨어남 지연을 재고, real_bench.py는 워커 N명·인큐어 M명의 처리량을 잽니다. M1/M2 맥북에서 중앙값 1~2ms 정도 나옵니다.
honker는 그 자체로도 좋지만, 코드를 읽으면 더 좋은 학습 재료다.
honker 코드는 분량이 적당하고(Python ~27% + Rust ~24%, 나머지는 C#·Go·JS·Elixir·C++·Ruby 등 다수 언어, 약 290커밋 이상) 의도가 명확해서 다음 주제들을 실전 코드로 익히기에 적합합니다.
WAL 모드의 진짜 의미, PRAGMA(SQLite 설정 명령) 활용, partial index의 위력, loadable extension 작성법 — honker는 이 모든 걸 사용합니다. 특히 wal_autocheckpoint를 10000 페이지로 설정해서 fsync(디스크 동기화) 호출을 줄이고, 그게 성능의 대부분을 차지한다는 분석은 따로 글을 써도 될 만한 통찰입니다.
한 번 Rust로 짠 코드를 PyO3로 Python에, napi-rs로 Node에, 그대로 cdylib으로 빌드해 SQLite에 — 같은 코어를 여러 ABI로 노출하는 모범 사례를 통째로 볼 수 있습니다. ABI(Application Binary Interface, 바이너리 인터페이스)는 "컴파일된 코드끼리 어떻게 함수를 호출하는가"에 대한 약속입니다.
이 패턴은 분산 시스템에서 끝없이 반복됩니다. 카프카, NATS, Postgres NOTIFY, Linux inotify — 모두 같은 문제를 다른 방법으로 풉니다. honker는 아무 동기화 원시(primitive)도 없는 SQLite에서 푸시 비슷한 걸 어떻게 만들 수 있는가에 대한 한 가지 답입니다. 이 사고방식은 다른 도구를 다룰 때도 응용됩니다.
Cargo 워크스페이스 + uv + Makefile의 조합으로 다언어 모노레포(monorepo, 단일 저장소에 여러 프로젝트)를 깔끔하게 관리하는 법. 서브모듈을 적극 활용하면서도 핵심은 in-tree로 두는 결정이 흥미롭습니다.
ROADMAP.md는 단순한 TODO가 아니라 Phase <Name> 형식으로 정리되어 있습니다. 번호 대신 이름을 쓰는 이유는 "중간에 새 작업을 끼워넣어도 번호를 다시 매기지 않아도 되기 때문"입니다. 큰 오픈소스 프로젝트의 계획 관리 방식을 엿보기 좋습니다.
honker가 안 맞는 상황을 솔직히 짚는다.
좋은 도구라도 모든 상황에 맞지는 않습니다. honker의 설계는 다음을 전제합니다.
SQLite의 락은 한 호스트 안에서만 동작합니다. 두 서버가 NFS(네트워크 파일 시스템)로 같은 .db를 동시에 쓰면 파일이 깨집니다. honker는 한 머신 위에서만 의미가 있습니다. 여러 머신이 필요하면 파일 단위로 샤딩하거나 Postgres로 옮겨야 합니다.
journal_mode = WAL이 켜져 있어야만 동작합니다. WAL 사이드카 파일이 생기면 안 되는 환경(공유 파일시스템 백업 대상 등)에선 쓸 수 없습니다.
WAL이 변하면 모든 구독자가 깨어납니다. 본인 채널의 일이 아니어도 일단 SELECT가 한 번 일어납니다. 이걸 over-triggering이라 부르고, honker는 일부러 이걸 받아들입니다 — "한 명이 놓치는 것보다 열 명이 헛깨는 게 낫다"가 명시적 철학입니다.
소규모 SaaS, 개인 프로젝트, 엣지 배포, 자체 호스팅 도구, 노트북에서 도는 백그라운드 작업러너 등. 별도 인프라 없이 "파일 하나 = 시스템 전체"를 유지하고 싶을 때 압도적으로 편합니다.
난이도별로 손에 잡히는 시작점을 제시한다.
Python으로 pip install honker 후, "이메일 보내는 함수"를 큐로 처리해봅니다. print(payload)만 하는 가짜 발송 함수면 충분합니다. 한 터미널에서 enqueue를 N번 호출하고, 다른 터미널에서 워커를 띄워 작업이 차곡차곡 처리되는 걸 관찰합니다.
"주문 생성 → 환영 이메일 큐 등록 → 분석 이벤트 스트림 발행"을 같은 트랜잭션에 묶어봅니다. 그다음 raise Exception()으로 일부러 롤백을 발생시키고, 세 개가 모두 사라지는지 확인합니다.
같은 app.db 파일을 Python에서 열어 db.queue("emails").enqueue(...)로 작업을 넣고, Node.js에서 @honker/node로 같은 큐를 claim 합니다. 디스크 레이아웃이 정말 공유되는지 직접 확인.
python bench/wake_latency_bench.py --samples 500를 본인 머신에서 실행하고, 1ms·5ms·99percentile 지연이 어느 정도 나오는지 측정. PRAGMA wal_autocheckpoint 값을 1000 / 10000 / 100000으로 바꿔보며 처리량이 어떻게 달라지는지 비교.
Python 바인딩 없이 cargo build -p honker-extension --release로 libhonker_ext만 빌드하고, sqlite3 CLI에서 .load로 불러서 순수 SQL로 enqueue/claim을 실행해봅니다. SELECT honker_claim_batch('emails', 'me', 10, 300); 같은 SQL이 어떤 JSON을 반환하는지 직접 관찰.
honker를 이해하면서 함께 익혀두면 좋은 주제들.
"SQLite The Definitive Guide", 공식 문서의 WAL 페이지, PRAGMA 목록을 한 바퀴. 핵심은 (1) WAL이 왜 동시성에 좋은가, (2) checkpoint 메커니즘, (3) busy_timeout과 락 대기. litestream 문서도 함께 보면 SQLite를 프로덕션에서 쓰는 감각이 생깁니다.
at-least-once vs at-most-once vs exactly-once 의미를 정확히 구별하기. visibility timeout, dead letter queue, exponential backoff, idempotency 키 — 모두 honker가 다루는 개념입니다. AWS SQS 문서가 이 용어들의 표준 정의를 잘 풀어놓고 있습니다.
honker가 베껴오려는 원본을 직접 만져봅니다. LISTEN channel; -- 또 다른 세션에서 -- NOTIFY channel, 'hello';를 psql로 실행해보고, pg-boss 또는 Oban의 README를 정독. 같은 문제를 Postgres 진영은 어떻게 풀었는지 비교하면 honker의 디자인 결정이 또렷해집니다.
"오늘의 환율" 같은 작은 함수를 Rust로 짜서 PyO3와 napi-rs로 동시에 배포해봅니다. honker가 어떻게 한 코어를 여러 ABI로 노출하는지 손으로 따라할 수 있습니다.
이 글에 처음 나온 용어를 한 곳에 모아둔다.
WHERE 조건을 만족하는 행만 포함하는 인덱스. honker는 state IN ('pending','processing')인 행만 색인해서, 데드 레터가 쌓여도 클레임 속도가 느려지지 않게 합니다..load 명령으로 동적으로 불러올 수 있는 동적 라이브러리. honker-extension이 이 형식으로 빌드되어, 언어 바인딩 없이 순수 SQL로도 큐를 다룰 수 있게 합니다.pip install honker, README의 "At a glance" 예제 7줄을 그대로 실행. 두 터미널에 enqueuer와 worker를 띄우는 것만으로 메시지 큐의 기본 흐름이 손에 잡힌다..db 파일을 열어 큐가 진짜 "그냥 행 N개"라는 걸 눈으로 확인. payload, state, attempts 같은 컬럼이 어떻게 변하는지 관찰하면 추상이 사라진다.SharedWalWatcher 또는 claim_batch 구현을 골라 한 함수만 깊이 읽어보면, "이론으로 들은 설계가 코드에서는 이렇게 생겼구나"가 명확해진다.