원문 캡처 · github.com
원문 캡처 · github.com

Kavach

Kavach는 힌디어로 ‘갑옷’을 뜻하며, 수백만 건의 요청이 한꺼번에 몰려도 충돌이나 멈춤, 좌석 중복 예약을 막도록 설계한 오픈소스 예약 엔진이다. 제작자는 IRCTC 같은 시스템의 핵심 기능을 작고 읽기 쉬운 서비스로 구현해 앱 앞단에 붙일 수 있다고 설명한다. 각 계층이 필요한 이유를 README에서 설명하는 것도 프로젝트의 목표다.

설계와 요청 처리

실제 타트칼(Tatkal) 예매가 시작되면 수천 명이 같은 순간에 예약을 시도한다. Kavach는 이런 상황에서 데이터베이스 과부하, 프로세스 메모리 고갈, 앱의 무한 대기라는 세 가지 실패를 막도록 설계됐다.

  • 요청이 데이터베이스에 직접 닿지 않도록 하고, 요청 처리 파이프라인 뒤에서 데이터베이스에 묶음으로 기록한다.
  • 처리 가능한 요청만 받아들인다. 용량이 제한된 대기열이 가득 차면 시스템이 무너지기 전에 요청을 거절하고, 몇 초 뒤 다시 시도하라는 신호를 보낸다. 거절은 복구할 수 있지만 충돌은 그렇지 않다는 설명이다.
  • 좌석마다 비교 후 교환(compare-and-swap, CAS)을 적용한다. 두 작업자가 같은 좌석을 두고 경쟁해도 둘 다 차지할 수 없도록 하는 것이 불변 조건이다.

요청은 다음 계층을 순서대로 거친다.

계층기능목적
Edge요청을 검증하고 멱등성 키를 요구한다재시도로 예약이 두 번 생성되지 않게 한다
Rate limiter사용자·키별 토큰 버킷을 적용한다봇 요청을 초기에 차단한다
Admission queue크기가 제한된 대기열로, 가득 차면 부하를 줄인다메모리 고갈 대신 백프레셔를 적용한다
Worker pool고정된 수의 작업자가 대기열에서 요청을 가져온다처리 용량을 추측이 아닌 설정값으로 정한다
Inventory cache좌석 현황을 메모리에 두고 원자적으로 갱신한다데이터베이스를 기다리지 않고 조회한다
Seat lock좌석마다 원자적 CAS를 적용한다같은 좌석을 두고 경쟁해도 한 명만 차지한다
Reservation lifecycleTTL이 있는 보류, 확정, 해제, 만료 작업을 처리한다결제가 이뤄지지 않으면 좌석을 돌려준다
Event log모든 예약을 JSONL 형식으로 추가 기록한다감사에 쓰는 영속 기록이자 기준 데이터로 삼는다
Metrics/metrics에서 카운터와 지연 시간 백분위수를 제공한다관측할 수 없는 문제는 고칠 수 없다는 취지다

빠른 시작

다음 명령은 좌석 5,000개와 작업자 8명으로 엔진을 빌드하고 실행한다. 이어 사용자 ID, 멱등성 키, 좌석 수를 보내 좌석을 예약하고 남은 좌석과 지표를 조회한다.

sh
# build and start the engine with 5,000 seats
cd engine
go build -o kavach .
./kavach -seats 5000 -workers 8

# book a seat (that's the whole API)
curl -X POST http://localhost:8080/book \\
 -H 'Content-Type: application/json' \\
 -d '{"user_id":"priya","idempotency_key":"priya-booking-1","seats":1}'
# {"status":"confirmed","seats":[2],"booking_id":"BK00000001"}

# see what's left, and how the engine is doing
curl http://localhost:8080/seats
curl http://localhost:8080/metrics

제작자가 내세우는 ‘두 줄’ 사용 방식은 앱이 /book에 요청을 보내고 사용자 ID와 멱등성 키를 전달하면 된다는 것이다. 나머지 동시 요청 처리는 Kavach가 맡는다고 설명한다.

네트워크 보안 모드(v1.5)

로컬 모드는 키 없이 기본 설정으로 쓸 수 있다. 네트워크에 노출할 때는 다음과 같이 API 키, HMAC 비밀키, IP별·사용자별 요청 제한을 지정한다.

sh
./kavach -seats 5000 -workers 8 \\
 -api-keys "sk_live_abc:tenant-a,sk_live_xyz:tenant-b" \\
 -hmac-secret "$KAVACH_HMAC_SECRET" \\
 -ip-rate 20 -user-rate 2

이 모드에서 모든 쓰기 요청에는 Authorization: Bearer <key>, 유닉스 초 단위 시각을 담은 X-Kavach-Timestamp(현재 시각에서 ±5분 이내), 요청마다 고유한 X-Kavach-Request-Id, 그리고 다음 형식의 X-Kavach-Signature가 필요하다.

hex(HMAC-SHA256(secret, timestamp + "\n" + request_id + "\n" + raw_body))

재전송된 요청은 거부한다. 같은 멱등성 키를 다른 페이로드와 함께 쓰면 idempotency_conflict가 반환된다. 요청 시간이 초과돼 결과가 모호한 경우 GET /status?idempotency_key=...로 확인할 수 있다. 예약 확정 응답은 로컬 디스크에 fsync를 마친 뒤 반환한다(응답 전 영속화). 전체 감사 자료와 14개 테스트로 구성된 승인 테스트는 docs/AUDIT.md와 sim/security_test.py에 있다.

좌석 보류·확정·해제(v1.6)

직접 예약하는 /book 경로는 속도를 위해 유지한다. 실제 상거래에서는 먼저 좌석을 장바구니에 보류하고 나중에 결제를 진행할 수 있도록 예약 생명주기를 제공한다. 다음 요청은 좌석 두 개를 기본 5분 동안 보류한다.

sh
# 1. hold seats (they leave the free pool immediately, for a TTL)
curl -X POST http://localhost:8080/reserve \\
 -H 'Content-Type: application/json' \\
 -d '{"user_id":"priya","idempotency_key":"priya-cart-1","seats":2}'
# {"status":"held","reservation_id":"RSV00000001","seats":[2,3],"hold_expires_at":...}

# 2. after your payment provider confirms, turn the hold into a booking
curl -X POST http://localhost:8080/confirm \\
 -H 'Content-Type: application/json' \\
 -d '{"reservation_id":"RSV00000001","user_id":"priya"}'
# {"status":"confirmed","booking_id":"BK00000001","seats":[2,3]}

# 3. or give the seats back before the TTL (default 5 minutes, -hold-ttl)
curl -X POST http://localhost:8080/release \\
 -H 'Content-Type: application/json' -d '{"reservation_id":"RSV00000001"}'

# resolve any ambiguity: what happened to that reservation?
curl 'http://localhost:8080/reservation?id=RSV00000001'

만료 작업자는 버려진 보류 좌석을 매초 사용 가능한 좌석으로 되돌린다. 상태 변경(held → confirmed, held → expired, held → released)은 한 작업만 성공하는 CAS로 처리한다. 따라서 확정과 만료가 경합해도 한쪽만 성공한다. 좌석이 만료와 판매 상태를 동시에 가질 수 없고, 확정된 좌석은 다시 풀리지 않으며, 재확정 요청에는 같은 예약이 반환된다는 설명이다. 예약 생명주기의 모든 이벤트도 응답 전에 fsync한다. 승인 테스트 19개는 sim/lifecycle_test.py에 있다.

대기 명단(v1.8)

대기 명단은 좌석이 매진됐을 때만 이용한다. 남은 좌석이 있으면 엔진은 409 {"status": "seats_available"}를 반환하고 직접 예약을 안내한다. 좌석이 모두 팔린 경우 사용자는 다음 요청으로 선착순 대기 명단에 들어간다.

sh
curl -X POST http://localhost:8080/waitlist \\
 -H 'Content-Type: application/json' \\
 -d '{"user_id":"priya","idempotency_key":"priya-line-1","seats":1}'
# {"status":"queued","waitlist_id":"WL00000001","position":1}

# your place in line
curl 'http://localhost:8080/waitlist?user_id=priya'

# leave the line
curl -X POST http://localhost:8080/waitlist/cancel \\
 -H 'Content-Type: application/json' -d '{"waitlist_id":"WL00000001"}'

공정성 규칙에 따라 만료되거나 해제된 좌석은 대기열 맨 앞의 사용자에게 먼저 배정한다. 대기열 앞 사용자가 남은 좌석보다 더 많은 좌석을 요청했다면 뒤의 사용자가 앞지를 수 없도록 그 뒤의 항목도 막힌다. 순번에 따라 좌석을 얻은 사용자는 자동으로 실제 예약을 받는다. -webhook-url http://...을 지정하면 waitlist_fulfilled 이벤트를 해당 주소로 POST한다. 전송은 요청 경로를 막지 않는 비동기 방식이다. 승인 테스트 13개는 sim/waitlist_test.py에 있다.

재시작 복구와 장애 주입(v1.9)

추가 전용 로그가 기준 데이터다. 엔진은 시작할 때 bookings.jsonl과 holds.jsonl을 시간순으로 재생해 판매·보류 좌석, 최종 상태와 원래 만료 시각을 포함한 예약, 대기열의 정확한 선착순 순서, 멱등성 키, 그리고 충돌하지 않는 ID 카운터를 복구한다. 확인 응답을 보낸 데이터는 실행 중 SIGKILL이 발생해도 잃지 않는다고 설명한다. 로그 예시는 다음과 같다.

text
kavach recovery: 45 bookings, 46 reservations, 1 waitlist entries restored

v1.9 이전에 작성된 로그에는 만료 시각이 없으므로, 해당 보류 건은 복구할 때 새 TTL을 받는다.

실제 장애는 작업 도중 발생한다는 점을 보여주기 위해 엔진은 장애 주입 옵션도 제공한다.

sh
./kavach -seats 2000 -chaos-prob 0.05 -chaos-stall-ms 700 -chaos-panic 0.04

-chaos-prob와 -chaos-stall-ms는 무작위 요청 일부를 지연시켜 느린 하위 의존성을 흉내 낸다. 지연 시간은 나빠지지만 정확성은 유지된다고 설명한다. -chaos-panic은 작업자가 무작위로 패닉을 일으키게 한다. 작업마다 한 번 적용하는 recover()가 패닉을 처리해 해당 요청에는 정상적인 500 오류를 보내고, 작업자 한 명의 패닉 때문에 엔진 전체가 종료되지 않도록 한다.

sim/chaos_test.py는 두 장애를 주입한 상태로 요청을 몰아 정확한 좌석만 판매되고 중복 판매가 없으며 프로세스가 살아 있는지 확인한다. chaos_stalls와 chaos_panics 카운터는 /metrics에서 볼 수 있다.

여러 노드로 확장(v2.0)

프로세스 하나가 사용할 수 있는 CPU에는 한계가 있다. 단일 노드가 포화되면 -peers에 클러스터의 모든 노드 주소를 지정해 여러 노드를 실행한다. 목록에 현재 노드 주소도 포함해야 한다.

sh
# terminal 1, 2, 3: three nodes, each owning a disjoint slice of the inventory
./kavach -seats 100 -workers 4 -peers 127.0.0.1:8081,127.0.0.1:8082,127.0.0.1:8083 -addr :8081
./kavach -seats 100 -workers 4 -peers 127.0.0.1:8081,127.0.0.1:8082,127.0.0.1:8083 -addr :8082
./kavach -seats 100 -workers 4 -peers 127.0.0.1:8081,127.0.0.1:8082,127.0.0.1:8083 -addr :8083

노드마다 겹치지 않는 좌석 구간을 맡는다. 예를 들어 노드 0은 0~99번, 노드 1은 100~199번 좌석을 관리한다. -seats는 노드별 좌석 수이며 클러스터 전체 좌석 수는 노드 수에 노드별 좌석 수를 곱한 값이다. 모든 쓰기는 해당 노드가 소유한 구간에서 CAS로 처리하므로 공유 잠금이 필요 없다. 단일 노드와 같은 원자적 CAS를 구간별로 적용해 중복 판매를 막으며 Redis, 합의 시스템, 추가 의존성은 필요하지 않다고 설명한다.

요청을 받은 노드의 좌석이 매진이면 링 구조에 따라 다음 피어로 요청을 전달한다. 원래 요청 본문과 인증 헤더를 보내고 X-Kavach-Forwarded-By 표시를 붙인다. 좌석을 확정하는 노드를 만날 때까지 다음 피어로 전달하므로 어느 노드로든 요청을 보낼 수 있다.

노드 k는 k에 10,000,000을 곱한 값부터 ID를 발급한다. 따라서 노드별 BK, RSV, WL ID가 겹치지 않고 로그를 합칠 수 있다. 전달된 요청의 재시도는 좌석 소유 노드의 멱등성 저장소가 기존 결과를 반환한다. 응답이 유실돼도 두 번째 예약이 생기지 않는다. /reservation, /status, /confirm, /release 조회나 작업은 해당 노드에서 객체를 찾지 못하면 다른 노드로 전달되므로 어느 노드에서든 객체를 확인할 수 있다.

각 노드는 자체 추가 전용 로그를 보관하고 재시작 때 이를 재생한다. 요청이 몰리는 도중 노드를 종료해도 다시 시작하면 그 노드의 좌석 구간을 복구한다. /seats는 전체 좌석·판매·보류·잔여 현황을 노드별로 합산해 보여주며, 노드별 상세는 /seats?local=1로 조회한다.

제작자가 명시한 제한도 있다. 대기 명단과 사용자별 요청 제한은 노드별로 적용되며, 클러스터 전체 대기 명단에는 공유 상태 기능이 필요하다. /metrics도 노드별 데이터만 제공한다. 전달받은 요청은 진입 노드에서 이미 IP 제한을 거쳤다는 이유로 피어의 IP 제한을 건너뛰지만 인증 검증은 모두 수행한다. 따라서 -peers는 사설 네트워크에서만 사용해야 한다.

PostgreSQL 쓰기 지연(v2.1)

로컬 추가 전용 로그는 계속 기준 데이터로 사용한다. 예약은 이전과 마찬가지로 로컬 로그의 영속화를 마친 뒤 응답한다. PostgreSQL 연결을 설정하면 백그라운드 작업자가 확정된 로그 줄을 PostgreSQL로 전송해 SQL 조회, 대시보드, 보존 정책에 활용할 수 있다. PostgreSQL은 요청 처리 경로에 포함되지 않는다. 데이터베이스가 느리거나 중단돼도 엔진은 요청 처리를 계속하고, 데이터베이스가 복구되면 밀린 로그를 전송한다.

sh
cd engine && go build -tags postgres -o kavach_pg . && \\
./kavach_pg -pg-dsn "postgres://user@host:5432/db?sslmode=disable" -node-id n1

기본 빌드인 go build -o kavach .에는 의존성이 없다. postgres 태그를 붙일 때만 순수 Go이며 cgo를 사용하지 않는 pgx v5를 가져온다. 스키마는 자동으로 생성된다.

  • kavach_events(node_id, source, line_off, logged_at, data jsonb): 확정된 로그 줄마다 한 행을 저장하며 data에는 JSON 이벤트가 들어간다.
  • kavach_ship_state(node_id, source, byte_offset): 재개 위치를 저장한다. 삽입 뒤 커서 갱신 전에 충돌이 나면 같은 로그 줄을 다시 삽입하지만, 기본 키가 중복을 무시하므로 최소 한 번 전송하고 결과는 한 번만 반영한다.

재시작하면 저장된 커서부터 전송을 재개한다. 새 데이터베이스에 처음 연결할 때는 전체 이력을 전송한다. -node-id는 해당 노드의 이름을 지정하며 여러 노드를 쓸 때 중요하다. psql을 설치하지 않고도 다음 명령으로 데이터를 조회할 수 있다.

sh
go run ./pgq "SELECT data->>'user_id', data->>'seats' FROM kavach_events WHERE source='bookings.jsonl' LIMIT 5"

승인 테스트는 python3 sim/pg_test.py로 실행한다. 실행 중인 PostgreSQL과 ./kavach_pg가 필요하다.

클라이언트 SDK

Python SDK는 표준 라이브러리만 사용하며 추가 의존성이 없다. JavaScript SDK는 Node 18 이상이나 최신 브라우저에서 사용할 수 있다.

python
# sdk/python/kavach.py - standard library only, no dependencies
from kavach import Kavach
k = Kavach("http://localhost:8080")
print(k.book("priya", "priya-booking-1", seats=1))
javascript
// sdk/javascript/kavach.mjs - Node 18+ or any modern browser
import { Kavach } from "./kavach.mjs";
const k = new Kavach("http://localhost:8080");
console.log(await k.book("priya", "priya-booking-1", 1));

api_key와 hmac_secret을 전달하면 두 SDK 모두 별도의 인증 코드를 작성하지 않아도 베어러 키, 시각, 요청 ID, HMAC 서명을 포함한 운영용 인증 절차를 처리한다. 각각 자체 승인 테스트를 거치며 테스트 항목은 Python 11개, JavaScript 9개다. 테스트 파일은 sdk/test_sdks.py, sdk/test_sdk.mjs다. 브라우저에서 앞서 설명한 기능을 따라 해보는 안내서는 TEST_GUIDE.md다.

부하 시뮬레이션과 측정 결과

다음 명령으로 비동기 HTTP 클라이언트인 aiohttp를 설치하고, 5,000개 좌석에 20,000명이 동시에 요청하는 타트칼 부하와 봇 10%를 포함한 100만 건의 지속 부하를 시뮬레이션할 수 있다. 마지막 명령은 예약 로그에서 좌석 중복 판매 여부를 검증한다.

sh
pip install aiohttp
# Tatkal burst: 20,000 users fire at once for 5,000 seats
python sim/simulate.py --users 20000 --concurrency 10000

# sustained hammer: 1,000,000 requests with 10% bots
python sim/simulate.py --total 1000000 --concurrency 3000 --users 100000 --bots 0.1

# prove no seat was ever double-sold
python sim/verify.py engine/bookings.jsonl 5000

제작자가 공개한 측정은 CPU 코어 2개와 메모리 4GB를 갖춘 작은 컨테이너에서 이뤄졌다. 모든 실행에서 Python/asyncio로 작성한 테스트 클라이언트가 병목이었으며, 엔진의 지연 시간 백분위수는 서버 측에서 측정했다고 설명한다.

시험부하결과
타트칼 집중 부하20,000명이 동시에 요청, 좌석 5,000개정확히 5,000석 판매, 중복 예약 0건, 오류 0건, p50 0.005ms, p99 5.6ms
지속 부하요청 1,000,000건, 사용자 100,000명, 봇 10%, 좌석 2,000개정확히 2,000석 판매, 매진 응답 998,000건, 오류 0건, 충돌 0건, p50 0.004ms, p99 1.1ms
봇 폭주고정 ID 50개와 봇 절반을 사용해 64초 동안 요청 135,033건128,575건(95%)을 초기에 제한, 정확히 2,000석 판매, 오류 0건, p99 0.28ms
장애 주입요청 폭주 중 엔진 프로세스를 멈춘 뒤 재개정확성을 유지하며 복구 후 처리 완료, 데이터 손실 없음
k6 장바구니 흐름, 쓰기마다 fsync(v1.6)사용자 200명, 반복마다 예약·확정, k6 v0.54초당 229.7건, p95 1.2초, 실패 0건, 좌석 중복 0건
k6 장바구니 흐름, 그룹 커밋(v1.7)같은 장비와 작업 부하에서 그룹 커밋만 변경초당 2,276.6건(9.9배), p95 139ms, 실패 0건, 좌석 중복 0건
k6 장바구니 흐름(v2.1, 단일 노드)같은 장비와 작업 부하, 실행 중 컨테이너에서 PostgreSQL도 실행초당 1,966.6건, p95 157ms, 실패 0건, 좌석 중복 0건

명시된 한계와 로드맵

현재 여러 노드 모드는 노드마다 겹치지 않는 좌석 구간을 맡기는 샤딩 방식이다. 전역 요청 제한과 클러스터 전체 대기 명단을 위한 Redis 기반 공유 저장소는 다음 단계로 제시돼 있으며 설계 문서가 있다고 한다. 이벤트 로그는 의도적으로 로컬 파일에 저장한다. v2.1의 PostgreSQL 쓰기 지연 기능은 SQL 조회와 분석을 위해 파일 로그를 복제할 뿐, 기준 데이터는 여전히 파일이다.

v1의 장애 주입은 프로세스 전체를 멈췄으며, v1.9에서 작업자 패닉을 개별 처리하고 무작위 지연을 주입하는 기능이 추가됐다. 재시작할 때 추가 전용 이벤트 로그를 재생해 판매 좌석, 원래 만료 시각이 있는 보류 좌석, 대기열 순서, 멱등성 키를 복구한다. 다만 예약 객체는 재시작 사이에는 메모리에 있다. 데이터베이스 쓰기 지연 계층은 v2.1에 추가됐다.

README의 로드맵에는 Redis 기반 분산 좌석 잠금과 여러 노드 기능, PostgreSQL로 이벤트 로그를 재생하는 쓰기 지연 계층, 대기 명단, Python·JavaScript SDK, 장애 주입 v2가 열거돼 있다. 같은 문서는 이 가운데 PostgreSQL 쓰기 지연(v2.1)은 v2.1에, 대기 명단과 SDK는 v1.8에, 장애 주입 v2는 v1.9에, 좌석 구간 샤딩·링 전달·분리된 ID 범위를 사용하는 여러 노드 기능은 v2.0에 출시됐다고 표시한다.

라이선스는 MIT다.