업데이트로 돌아가기
New releaseAug 5, 2026

slater v0.24.4

로컬 복제 그래프 사용 사례를 위해 설계된 저메모리 그래프 DB로, Bolt+TLS 지원, 저장 데이터 암호화 및 벡터를 제공합니다.

공유

Slater

CI Release

현재 버전: v0.24.4모든 릴리스.

한 줄 요약: Slater는 메모리에 맞지 않는 그래프 — 수억 개의 노드와 수십억 개의 엣지를 수백 MB RAM에 담아 — 표준 Bolt 프로토콜로 서빙하므로 어떤 neo4j 드라이버든 그대로 동작하며, 디스크 기반 벡터 검색이 그래프 바로 옆에 있고, 이를 포기하지 않으면서 실시간 영속 쓰기를 수용합니다. 상주 메모리는 그래프 크기가 아니라 사용자가 선택한 캐시 예산에 의해 결정됩니다.


바로가기

Slater가 존재하는 이유

그래프 데이터베이스는 데이터를 사물(노드)과 사물 간의 관계(엣지)로 저장하며, 관계를 일급 시민으로 취급합니다. 질문이 행이 아니라 연결에 관한 것일 때 필요한 것이 바로 이것입니다 — "이 계정에서 3홉 이내에 누가 있나?", "이 빌드 뒤의 전체 의존성 체인은 무엇인가?", "어떤 계정이 기기, 주소, 카드를 공유하나?" — SQL에서는 재귀 조인의 늪이 되지만 그래프에서는 자연스럽게 드러나는 질문들입니다.

그래프 데이터베이스에 대한 가장 흔한 불만은 RAM에 담을 수 있는 크기 이상으로 확장되지 않는다는 것입니다. 많은 제품(예: neo4j, Memgraph, FalkorDB 등)은 전체 그래프를 메모리에 상주시킵니다: 40 GB 그래프는 40 GB의 메모리를 요구합니다 — 인스턴스당. 지역별, 테넌트별, 또는 파드별로 복제본을 원하십니까? 비용이 배가됩니다. 그리고 일정 크기를 넘어서면 아예 로드되지 않습니다: 예를 들어 9천만 노드 / 15억 엣지 규모의 Wikidata 그래프는 약 64–128 GiB 상주 메모리가 필요하므로, 인메모리 엔진은 이를 열 수조차 없습니다.

Slater는 그에 대한 반박입니다. 그래프를 메모리에 로드하는 대신, 오프라인에서 한 번 컴파일합니다: slater-build는 데이터를 콘텐츠 주소 지정 방식의 불변 온디스크 이미지로 변환하고, 그 후 원하는 수의 Slater 서버가 해당 이미지를 Bolt 프로토콜로 서빙합니다(따라서 기존 neo4j 드라이버가 그대로 동작합니다). 블록을 필요에 따라 페이징하고 고정된 캐시 예산만 상주시킵니다. 이것이 동일한 9천만 노드 그래프를 수백 MB의 RAM으로 서빙할 수 있는 방식입니다 — 그래프 크기와 메모리 비용이 분리되어 있습니다. 4 GB 그래프와 400 GB 그래프를 서빙하는 데 필요한 RAM은 동일하므로, 값싸고 상태 없는 읽기 복제본을 확장하고 힙이 아니라 스토리지가 그래프를 보유하게 할 수 있습니다.

이는 RAG 뒤의 지식 그래프, 추천 및 신원 그래프, 의존성 그래프 — 크고 연결되어 있으며 저렴하고 자주 질의하고 싶은 모든 것에 자연스러운 선택이 됩니다. 디스크 기반 벡터 검색이 그래프 바로 옆에 있으므로, 동일한 엔진이 임베딩의 검색 계층이기도 합니다.

하지만 한 번 컴파일했다고 해서 얼어붙은 것은 아닙니다. 그 이미지는 **기본(base)**이지 최종 상태가 아닙니다: 옵트인 쓰기 레이어가 그 위에 있으므로, 라이브 그래프를 아무것도 다시 빌드하지 않고 수정하고 확장할 수 있습니다.

읽기와 쓰기

코어는 불변이지만 그래프는 그렇지 않습니다. 쓰기 가능 레이어(delta.enabled)를 켜면 Bolt를 통해 쓸 수 있습니다 — 속성 하나를 수정하고, 노드를 추가하고, 엣지를 철회하면 — 이미지를 다시 빌드하지 않고도 변경이 영구적으로 반영됩니다. 읽기 측면에서 비용을 낮게 유지하는 것은 쓰기가 어디에 저장되는가입니다.

쓰기는 불변 코어 위의 로그 구조 병합(LSM) 레이어에 누적됩니다: 쓰기 전 로그(write-ahead log)와 인메모리 테이블이 있으며, 불변 델타 세그먼트로 넘쳐 흐르고(스필), 주기적인 **통합(consolidation)**을 통해 새 코어로 병합됩니다. 이를 통해 얻는 이점은 다음과 같습니다:

  • 쓰지 않은 그래프에 대한 읽기는 이전과 정확히 같은 비용입니다. 빈 델타는 병합이 아니라 하나의 예측 가능한 분기입니다 — 쓰기 가능 레이어가 켜져 있든 꺼져 있든 읽기 경로는 바이트 단위로 동일합니다.
  • 쓰기의 읽기 비용은 그래프 크기가 아니라 델타 크기에 비례합니다. 전체 그래프에 대한 응답 — count(*), 레이블 및 관계 유형 주변값 — 은 쓰기가 남아 있어도 메타데이터 읽기로 유지됩니다: 델타는 자체 카운터를 보관하므로, 9,160만 노드 코어에 50만 개의 대기 중인 쓰기가 있어도 count(*)는 단일 블록을 건드리지 않고 수십 밀리초 안에 응답합니다.
  • 승인은 곧 영속성을 의미합니다. 단일 작성자가 큐를 비우고 쓰기를 포함하는 fsync 이후에만 SUCCESS를 반환합니다. 쓰기를 그룹화하면 저렴합니다 — write-UNWIND는 행마다가 아니라 배치마다 하나의 fsync를 커밋합니다.
  • 두 방언 모두에서 비즈니스 키 쓰기. 노드의 식별 속성을 키로 하는 MERGE / MATCH … SET / DELETE(및 CREATE / REMOVE, 분리 삭제, 관계 쓰기) — 또는 동일한 경로로 내려가는 동등한 ISO GQL 데이터 수정 문(INSERT / SET / REMOVE / DELETE). 데이터가 이미 구성된 방식 그대로 노드와 엣지에 대해 수정, 삽입, 업서트 및 철회를 수행합니다.

레이어가 꺼진 상태(기본값)에서는 Slater가 순수 불변 코어를 서빙하고 쓰기를 거부합니다. 전체 모델은 쓰기 가능 레이어를 참조하세요.

이름에 대하여. Slater는 Archer (훌륭한 쇼)에 나오는 CIA 요원의 이름을 따왔습니다. 그는 "그냥… Slater"라고 단일 이름으로 불리기를 고집하며, 제가 가장 좋아하는 캐릭터 중 하나입니다. 캐릭터 위키 페이지를 참조하세요.

제공되는 기능

  • RAM은 그래프 크기가 아니라 캐시 예산으로 결정됩니다 — 원하는 만큼 읽기 복제본을 확장하세요; 그래프는 결코 메모리에 맞출 필요가 없습니다.
  • 그래프용 드롭인(drop-in) — Bolt를 사용하므로 모든 표준 neo4j 드라이버(JS, Python, Go…)가 변경 없이 동작합니다. Cypher(일부 ISO GQL 읽기/쓰기 포함)이며 새로 배울 것이 없습니다.
  • 실시간 영속 쓰기 — 불변 코어 위의 옵트인 LSM 레이어: 노드와 엣지에 대한 비즈니스 키 MERGE / SET / DELETE, 그룹 커밋 및 fsync 영속성, 통합을 통해 새 코어로 병합됩니다. 읽기는 그 비용을 부담하지 않습니다.
  • 파일 교체로 배포 — 오프라인에서 새로운 콘텐츠 해시 *세대(generation)*를 빌드하고, current 포인터를 원자적으로 전환하면 서버가 이를 감지합니다. 모든 블록이 체크섬되므로, 절반만 복사된 이미지는 서빙되지 않고 거부됩니다.
  • 벡터 검색 내장 — 디스크 기반 근사 최근접 이웃(cosine, L2 또는 dot KNN)이 그래프 바로 옆에 있어 RAG 파이프라인 뒤의 검색 계층 역할을 하며, 임베딩을 제자리에서 쓸 수 있습니다 — 벡터를 추가하거나 변경하기 위해 오프라인 재빌드가 필요 없습니다.
  • 설계상 잠금 — 읽기 및 쓰기 권한이 독립적이며, 선택적 저장 데이터 암호화, TLS Bolt, argon2id 해시 ACL, 읽기 복제본용 읽기 전용 컨테이너 rootfs를 제공합니다. 마스터 키를 구성하면 온디스크 이미지가 암호화될 뿐만 아니라 인증됩니다 — 매니페스트에 키 MAC이 포함되므로, 데이터 디렉터리에 쓰기 권한이 있지만 키가 없는 공격자는 서버가 수락할 매니페스트를 위조할 수 없습니다. 키가 없어도 콘텐츠 해시를 얻을 수 있으며, 이를 통해 절반만 복사되었거나 손상된 이미지를 감지할 수 있습니다 — 그러나 의도적인 변조는 감지하지 못합니다. 어떤 구성이 무엇을 제공하는지.

기능

기능의미
제한되고 예측 가능한 메모리상주 메모리는 사용자가 설정한 세 가지 캐시 예산을 추적하며, 항목당 및 할당자 오버헤드의 제한된 범위 내에서 유지됩니다 — 그래프 크기에 따라 증가하지 않습니다; 전체 그래프를 프로비저닝하는 대신 성능/RAM 트레이드오프를 조정할 수 있습니다. 백그라운드 정리를 수행하는 jemalloc 할당자는 대규모 질의 버스트 후 해제된 메모리를 OS에 반환하므로, 상주 크기는 버스트 후 최고 수위에 고정되지 않고 유휴 수준으로 다시 내려갑니다.
즉시 사용 가능한 멀티 테넌시하나의 서버가 사용자별 읽기 권한으로 여러 그래프를 호스팅합니다 — 대부분의 그래프 DB가 유료/엔터프라이즈 등급에서만 제공하는 멀티 데이터베이스 격리입니다.
저장 및 전송 중 암호화블록별 XChaCha20-Poly1305 실링(키는 절대 디스크에 기록되지 않음)과 선택적 TLS(bolt+s://). 구조상 GDPR 친화적입니다. 암호화는 또한 인증된 무결성을 제공합니다: 빌더가 키 MAC으로 매니페스트를 실링하고, 키를 보유한 서버는 이를 검증하여 매니페스트가 위조, 변경 또는 MAC이 제거된 세대를 서빙하지 않습니다. 키가 없는(평문) 이미지는 키 없는 콘텐츠 해시로만 보호됩니다 — 완전성과 손상은 감지하지만 변조는 감지하지 못합니다. 각 구성에서 무결성의 의미.
작은 설치 용량디스트롤리스(distroless) glibc 베이스(셸/apt 없음)의 작은 스트립 바이너리 — 멀티 아키텍처(amd64/arm64) 이미지는 약 22 MB, 서버 전용 slater:latest-lite 태그는 약 12 MB; 순수 Rust TLS, OpenSSL 없음. 가져와서 실행하면 됩니다.
주기적 게시에 최적화오프라인에서 그래프를 빌드하고, 불변 상태로 서빙한 다음, 제로 다운타임으로 새 버전을 원자적으로 교체합니다 — 데이터 웨어하우스 / 예약 새로고침 워크로드에 이상적입니다.
부하에서도 견고함서버와 오프라인 빌더 모두 #![forbid(unsafe_code)]로 컴파일됩니다 — 엔진의 유일한 unsafe는 감사된 jemalloc 할당자 크레이트에 있습니다. 코어는 불변이므로 읽기는 락을 잡지 않으며 작성자를 기다리지 않습니다; 단일 작성자는 쓰기 경로 뒤에서만 변이를 직렬화합니다. GC 일시 중지도, 데이터 경합도 없습니다. 잘못된 질의 하나로 서버가 다운되지 않습니다.
기존 neo4j 도구와 호환Bolt 5.4 / 4.4 / 4.1을 사용합니다 — 표준 neo4j 드라이버(JS, Python, Go, Java…), cypher-shell, 또는 그래프 브라우저를 변경 없이 사용하세요.
풍부한 Cypher 질의 표면광범위한 읽기 표면: MATCH/WHERE/WITH/UNION, CALL {…} 하위 질의, 70개 이상의 함수 및 집계, 시간적 및 지리공간 값, 정규식.
실시간 영속 쓰기불변 코어 위의 옵트인 단일 작성자 LSM 레이어(delta.enabled): 노드와 관계에 대한 비즈니스 키 MERGE / SET / DELETE / CREATE / REMOVE, 일괄 write-UNWIND(배치당 fsync 1회), CALL slater.consolidate() — 그룹 커밋, fsync 영속성, 통합을 통해 새 코어로 병합됩니다. 델타가 비어 있으면 읽기 경로는 바이트 단위로 동일합니다.
ISO GQL, 읽기 및 쓰기동일한 Bolt 연결을 통해 ISO GQL(ISO/IEC 39075)의 하위 집합을 사용합니다 — 정량 경로, 경로 제한자, 최단 경로 선택자, 레이블/유형 부울 표현식, FOR, CAST, 선택적 GQL/CYPHER 방언 접두사 — 그리고 쓰기 가능 레이어가 켜져 있으면 GQL의 데이터 수정 문(INSERT / SET / REMOVE / [DETACH] DELETE)이 동일한 영속 쓰기 경로로 내려갑니다. 하나의 엔진에서 Cypher와 GQL, 읽기와 쓰기를 모두 제공합니다.
하나의 엔진에 벡터 + 그래프임베딩/RAG를 위한 디스크 기반 ANN 벡터 검색(Vamana + PQ; cosine / L2 / dot)과 그래프 알고리즘(PageRank, BFS, betweenness, WCC…) — 수백만 개의 벡터가 있어도 메모리가 제한적입니다. 임베딩은 쓰기 가능(FreshDiskANN 스타일 쓰기 래더)합니다: 벡터 삽입 / 업데이트 / 삭제, 즉시 KNN에 표시, 재빌드 없이 베이스로 병합됩니다.
네트워크 스토리지에서 안전모든 파일은 BLAKE3 콘텐츠 해시로 열 때 검증됩니다; 찢어지거나 절반만 복사된 이미지는 서빙되지 않고 거부됩니다. NFS/원격 볼륨용으로 설계되었습니다(mmap 문제 없음).
플러그형 스토리지 백엔드로컬 파일시스템, S3(S3 호환) 버킷, 또는 Google Cloud Storage 버킷에서 동일한 세대 형식을 서빙합니다 — 한 번 게시하고 상태 없는 복제본으로 확장 — 객체 스토어 앞에 선택적 로컬 SSD 캐시 계층을 둘 수 있습니다. 스토리지 백엔드를 참조하세요.

두 개의 바이너리로 워크스페이스가 구성됩니다:

바이너리역할
slater온라인 Bolt 서버(컨테이너 ENTRYPOINT): 읽기를 서빙하고, delta.enabled가 켜져 있으면 단일 작성자 영속 쓰기 경로를 제공합니다.
slater-build오프라인 컴파일러: 원시 Cypher 덤프를 불변의 콘텐츠 해시 세대 디렉터리로 변환합니다.

Slater는 대량 빌드서빙을 분리합니다: slater-build는 오프라인에서 데이터를 수집하고 불변 세대로 컴파일하는 무거운 작업을 수행하므로, 콜드 그래프가 서빙 핫 경로에서 조립되는 일이 없습니다. 서버 내에서 읽기 표면은 광범위한 Cypher 하위 집합을 처리합니다 — 패턴 매칭, WITH/UNION/CALL {…} 하위 질의, 70개 이상의 스칼라 및 집계 함수, 시간적 및 지리공간 값, 그래프 알고리즘(algo.*), 디스크 기반 벡터 KNN(db.idx.vector.queryNodes) — 반면 쓰기 가능 레이어의 델타 오버레이는 그 표면 아래에 있으며 비어 있을 때 비용이 0이므로, 읽기는 결코 쓰기 측 메커니즘을 부담하지 않습니다. 그래프를 업데이트하는 방법은 두 가지입니다: Bolt를 통해 라이브로 쓰거나(쓰기 가능 레이어 참조), 오프라인에서 새 세대를 빌드하고 current 포인터를 원자적으로 교체하는 것입니다. 실행 중인 서버는 세대 가드를 통해 이를 감지합니다(세대 가드 참조).

문서

전체 사용자 매뉴얼은 **docs/manual/**에 있습니다 — 모든 기능에 대해 그것이 무엇인지, 왜 존재하는지, 어떻게 사용하는지를 설명하는 기능별 가이드이며, 번들로 제공되는 샘플 그래프로 실행할 수 있는 작업 예제가 포함되어 있습니다. 이 개요를 넘어서는 모든 내용은 여기에서 시작하세요.

Docker로 실행하기

Slater는 Docker 배포로 실행되도록 설계되었습니다 — 이것이 예상되는 사용 방식입니다. 사전 빌드된 멀티 아키텍처 이미지(linux/amd64 + linux/arm64)는 Docker Hubhikarisystems/slater에 게시되며, 릴리스마다 :latest:vX.Y.Z 태그가 지정됩니다:```sh docker pull hikarisystems/slater:latest

Docker 명령어 전용 사용, 구성, 운영 가이드는
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md)에 있습니다(Docker Hub 개요 페이지에도 미러링됨) —
**배포하는 경우 여기서 시작하세요.** 요약하면:```sh
# Build a graph generation with the offline writer:
docker run --rm -v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
  --entrypoint /app/slater-build hikarisystems/slater:latest \
  --input /dumps/people.cypher --graph people --data-dir /data

# Serve it over Bolt on 7687 (read-only unless `delta.enabled`):
docker run -d --name slater -p 7687:7687 \
  -v slater-data:/data:ro -v "$PWD/acl.json:/config/acl.json:ro" \
  hikarisystems/slater:latest

대신 로컬에서 이미지를 빌드하려면 (예: 개발용):```sh

Build the image (both binaries).

docker compose build

Serve (expects generations under the slater-data volume / your /data mount).

docker compose up slater

Build a generation with the offline writer (profile build):

docker compose run --rm builder
--input /dumps/people.cypher --graph people --data-dir /data

빌더 단계는 rustls `aws-lc-rs` 백엔드를 위해 `cmake`, `clang` 및 `libclang-dev`를 설치합니다.
`git`(기본 이미지에 이미 포함됨)은 `.cargo/config.toml`이 git CLI를 통해 가져오는
`hs-utils` git+tag 의존성에 필요합니다.

아래 섹션에서는 디스크 상의 형식, 구성, ACL 및
로컬(비 Docker) 작업 예제를 다룹니다.

## 작동 방식```
            slater-build                         slater (Bolt server)
   dump.cypher ──────────▶ /data/<graph>/<uuid>/ ──────────▶ neo4j driver
   (offline, atomic)        MANIFEST.json, *.blk,            (bolt / bolt+s)
                            range/*.isam, vector/*.{vamana,pq},
                            current → <uuid>
  • 하나의 세대는 변경 불가능한 디렉터리입니다: MANIFEST.json (심볼 테이블, 인덱스 디스크립터, 선택적 암호화 헤더), 컬럼 기반 블록 파일 (node_props.blk, node_labels.blk, edge_props.blk, topology.csr.blk, vectors.f32.blk), 범위 인덱스 (range/<name>.isam), 임계값 초과 ANN 인덱스 (vector/<label>.<prop>.{vamana,pq}), 그리고 current 텍스트 포인터.
  • 모든 블록은 zstd로 압축되고 BLAKE3로 체크섬됩니다; --encrypt를 사용하면 각 블록은 XChaCha20-Poly1305로 추가로 봉인됩니다 (저장 시 AEAD).
  • 서버는 매니페스트를 기준으로 모든 파일을 다시 해싱하여 세대를 엽니다, 따라서 반쯤 복사되거나 잘린 이미지는 — 데이터 디렉터리로의 불완전한 복사본(원격/네트워크 스토리지일 수 있는) — 제공되지 않고 거부됩니다.
  • 읽기 작업은 세 개의 제한된 캐시 풀을 통과합니다 — 압축 해제된 블록 LRU, 벡터 인덱스 풀(상주 PQ 코드 + Vamana 블록 LRU), 그리고 결과 LRU — 각각 고유한 바이트 예산을 가집니다. 각 풀은 보유한 내용을 계량하고, 예산 내에 머물도록 축출합니다. 따라서 RSS는 예산을 따르므로 제한된 항목별 및 할당자 오버헤드 범위 내에서 유지되며, 그래프와 함께 커지지 않습니다.

쓰기 가능 계층

delta.enabled가 설정되면, 불변 세대는 작은 로그 구조 병합 트리의 **완전히 압축된 최하위 레벨("코어")**가 되고, 실시간 쓰기는 그 위에서 수행됩니다:``` write (Bolt) read (Bolt) │ │ ▼ ▼ ┌──────────────┐ flush ┌──────────────┐ ┌──────────────────────┐ │ WAL + active │ ───────▶ │ L0 delta │ │ a query pins one │ │ memtable │ │ segments │ │ (core, delta) view │ └──────────────┘ └──────┬───────┘ │ and reads the merge │ (fsync = ack) │ └──────────────────────┘ consolidation │ (folds core + delta → fresh core) ▼ ┌─────────────┐ │ new core │ (atomic current swap) └─────────────┘

* **내구성 최저선 — WAL.** 각 변이는 그래프당 단일 writer 뒤에서 직렬화되어
  그래프별 write-ahead log에 추가되고 Bolt `SUCCESS`가 반환되기 전에 `fsync`된다
  — 따라서 *승인 ⇒ 내구성*이며, 찢어진 꼬리(torn tail)는 재생 시 버려진다.
  배치 쓰기-`UNWIND`는 행을 추가하고 배치 전체에 대해 **한 번의** `fsync`로
  커밋한다. WAL은 **로컬 디스크 전용**이다(스토리지 백엔드를 거치지 않는다).
  그래서 *writer* 노드는 상태를 가진다: `delta.walDir`에 내구성 있는 로컬
  볼륨이 필요하다. 읽기 복제본은 무상태(stateless)로 유지된다.
* **Memtable → L0 → 통합.** 쓰기는 인메모리 memtable(`delta.memtableBytes`로
  크기 제한)에 누적되며, 가득 차면 불변 L0 델타 세그먼트로 플러시된다.
  **통합(consolidation)**은 병합된 뷰를 `slater-build`로 다시 직렬화하고
  `current`를 원자적으로 교체하여 `{core + delta}`를 새 코어로 병합한다 —
  게시된 모든 세대와 동일한 콘텐츠 해시 가드가 적용된다. 수동으로는
  `CALL slater.consolidate()`로, 자동으로는 코어 크기의
  `delta.deltaCorePercent`에서 트리거하며(선택적으로 비수기
  `delta.consolidateWindow`로 제한 가능), 또는 `delta.deltaHardBytes`
  스로틀이 폭주 성장을 백스톱으로 막게 할 수 있다.
* **오버레이는 읽기 표면 아래에 있다.** 실행기는 델타가 항상 비어 있는 순수
  코어이거나 병합된 `(core, delta)` 뷰인 `ReadView`를 통해 읽는다. 엔진은 그
  위에서 단형화(monomorphised)되므로 빈 델타는 단일하고 예측 가능한 분기로
  컴파일되고, 읽기 전용 경로는 바이트 단위로 동일하다. 전체 그래프 카운터
  (`count(*)`, 레이블/관계유형 주변값)는 델타 자체의 라이브 카운터에서
  제공되므로 쓰기가 대기 중이어도 메타데이터 읽기로 유지된다.
* **쿼리는 안정적인 스냅샷을 본다.** 쿼리는 수명 전체에 걸쳐 하나의
  `(core, delta)` 튜플을 고정한다. 다중 문장 트랜잭션도 롤백도 없다 — 쓰기는
  내구성 있고 비즈니스 키로 주소 지정되는 보정이지 OLTP 트랜잭션이 아니다.

정확한 쓰기 문법과 조절 항목은 아래 [구성](#environment--configuration)
표(`delta.*`)와 [작동 예제](#worked-example)에 있다.

### 범위 인덱스 (ISAM)

범위 인덱스(`range/<name>.isam`, 인덱스된 `(label, property)`마다 하나)는
`MATCH (n:Label {prop: v})` 또는 `WHERE n.prop <op> v`가 **레이블을 스캔하지
않고** 일치하는 노드 ID를 찾을 수 있게 한다. 이것은
**[ISAM](https://en.wikipedia.org/wiki/ISAM)**(Indexed Sequential Access Method)
구조다 — 고전적인 *정적, 정렬, 블록 구조* 인덱스로, 불변 세대에 정확히 맞는
형태다. 재균형을 위한 삽입이 없으므로, ISAM의 단순함으로 B-트리의 변경
메커니즘이 오히려 복잡하게 만들 뿐인 이점을 얻는다.

* `(value, entity_id)` 항목은 값순으로 정렬되어 다른 모든 것과 동일한
  zstd 압축 256 KiB 블록에 담긴다.
* 작은 **상주 최상위 레벨**이 각 블록의 첫 번째 키(희소 인덱스)를 보관한다.
  조회는 인메모리 최상위 레벨을 이진 탐색하여 키가 있을 수 있는 *단 하나의*
  블록을 찾고, 그 블록을 읽고 + 압축 해제한 뒤 스캔한다 — 따라서 동등
  조회는 **블록 한 번 읽기**이고, 범위 스캔은 자신이 걸쳐 있는 연속 블록
  구간을 순회한다. (그래서 `meshUi` 인덱스 기반 조회는 한 자릿수 밀리초인
  반면, 인덱스되지 않은 속성에 대한 동일한 매치는 전체 레이블을 스캔한다.)
* 플래너는 `NodeScan::RangeEq` / `RangeRange`를 통해 이를 선택한다.
  인덱스되지 않은 조건은 레이블 스윕 또는 전체 스캔으로 폴백하며, 어느
  쪽이든 실행기가 모든 조건을 다시 확인한다.

### 벡터 검색 (Vamana + PQ) — cosine, L2 및 dot, 읽기 *및* 쓰기

벡터 KNN(`db.idx.vector.queryNodes`)은 **cosine, L2 또는 dot-product (MIPS)**
인덱스에서 동작한다. 기본 인덱스는 오프라인에서 두 가지 실행 경로로
빌드되며, 인덱스마다 `--ann-threshold`(기본값 50,000 벡터)가 경로를 선택한다:

* **임계값 미만 — brute force.** 전체 `f32` 벡터는 `vectors.f32.blk`에 있다.
  쿼리는 인덱스의 그룹을 스캔하고 인덱스의 메트릭으로 정확한 거리를
  계산한다. 단순하고 정확하며, 벡터 세트가 작을 때 적합하다.
* **임계값 이상 — Vamana + PQ**, 벡터 수와 관계없이 상주 메모리를 제한된
  상태로 유지하는 디스크 네이티브 ANN 경로다:
  * **[Vamana](https://arxiv.org/pdf/2401.11324)**는 DiskANN 연구 계열의
    그래프 인덱스다. 단일 근접 그래프로, 엣지는 가지치기되고
    (`--vamana-r` out-degree와 `--vamana-alpha` 긴 엣지 계수) *탐욕적 빔
    탐색* — medoid에서 시작해 쿼리 방향으로 반복 점프하며
    `vectorQuery.beamWidth` 너비의 후보 목록을 유지하는 방식 — 이 몇 홉 만에
    노드의 실제 이웃에 도달한다. 즉 **쿼리당 무작위 블록 읽기가 적다**.
    그래프 블록(`vector/<label>.<prop>.vamana`)은 벡터 캐시를 통해 페이지
    인되며, 통째로 보관되지 않는다.
  * **[곱 양자화 (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)**
    는 각 벡터를 짧은 코드(`--pq-subspaces` × `--pq-bits`)로 압축한다.
    차원은 부분공간으로 분할되고 각각 독립적으로 k-means 클러스터링되며,
    벡터는 가장 가까운 중심점 ID들의 튜플로 저장된다. 이 코드들
    (`vector/<label>.<prop>.pq`)은 **상주**시키기에 충분히 작아서, 빔 탐색은
    RAM에서 후보를 점수화하고 선택된 소수의 전체 벡터만 디스크에서 읽는다.
    그 상주 PQ 세트가 바로 `cache.vectorCacheBytes` 풀이 고정하는 대상이다.

**쓰기 가능한 임베딩 — 벡터 쓰기 사다리([FreshDiskANN](https://arxiv.org/abs/2105.09613) 방식).**
인덱스된 임베딩은 일급 쓰기 가능 값이다. `SET n.embedding = vecf32([…])`
(및 `REMOVE`)는 쓰기 델타에 들어가 **정확한 순위로 즉시 KNN에 보이며**, 이후
세그먼트 플러시, 병합, 통합을 거쳐도 유지된다. 쿼리는 최대 세 레벨 — 봉인된
기본 인덱스, 봉인된 세그먼트별 인덱스, 인메모리 **RW-index**(쓰기 델타 위의
라이브 가변 Vamana) — 을 병합하므로, 쓰기가 누적되어도 지연 시간은 대기 중인
쓰기 수에 따라 늘어나지 않고 일정하게 유지된다. 삭제는 *구멍(hole)*을 남긴다.
노드는 더 이상 반환되지 않지만, 백그라운드 **delete-consolidation**(삭제
통합)이 그래프에서 잘라낼 때까지 탐색 경유지로 남는다. 따라서 삭제는 더 이상
쿼리 IO 비용을 발생시키지 않는다. 또한 디스크상의 그래프는 노드 ID가 아닌
레이아웃 위치로 이웃을 주소 지정하므로, `CALL slater.consolidate()`는 Vamana를
**참조로**(하드 링크, 바이트 단위 동일) 옮기고 작은 ID 열만 다시 쓴다. 그
결과 O(N·R·L) 그래프 재빌드 **없이** 벡터 쓰기가 기본 인덱스에 병합된다.
측정 수치와 주의사항은 [성능 보고서](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md)에 있다.

## 스토리지 백엔드 (filesystem / S3 / GCS)

모든 세대 파일은 `std::fs`를 직접 사용하는 대신 **`ObjectStore`** 추상화를
통해 열린다. 따라서 *동일한* 온디스크 바이트 형식 — 블록, 인덱스, 매니페스트,
`current` 포인터 — 이 어떤 백엔드에서든 변경 없이 제공된다. 다른 것은
*바이트가 어디서 오는지*뿐이며, 리더, 쿼리 엔진, 무결성 검사는 결코 다르지
않다. 핫 경로는 위치 기반 읽기(`read_exact_at`)로, 로컬 파일의 `pread`와
객체 스토어의 HTTP 바이트 범위 요청에 대응한다 — Slater는 절대 mmap하지
않으므로, 명시적이고 경계가 있는 읽기 모델은 모든 곳에서 동일하다.

**세 가지 일급 백엔드**는 `dataBackend.kind`로 선택된다. 파일시스템은 단순한
기본값이다. **Amazon S3와 Google Cloud Storage는 동등하고 완전히 지원되는
객체 스토어 백엔드다** — 배포 이미지에는 둘 다 컴파일되어 포함되므로
각각은 설정만으로 사용할 수 있으며, 한 번 빌드된 세대는 재빌드 없이 이 중
어느 백엔드에서든 서빙될 수 있다(`fs` → S3 → GCS로 마이그레이션된 경우까지).

| `dataBackend.kind` | 위치 기반 읽기 | 열 때 무결성 | 자격 증명 |
| --- | --- | --- | --- |
| `fs` *(기본값)* | `pread` | 각 파일의 전체 BLAKE3 재해시 | — |
| `s3` | HTTP `Range` GET | `HEAD`를 통한 서버 **SHA-256** (없으면 BLAKE3 바디 재해시) | config 키, AWS 체인 또는 IAM 역할 |
| `gcs` | HTTP 범위 읽기 | `get_object`를 통한 서버 **CRC32C** (없으면 BLAKE3 바디 재해시) | ADC / Workload Identity 또는 서비스 계정 JSON |

두 객체 스토어 모두 **스토어가 이미 계산해 보관하는 체크섬**으로 무결성을
검증하며, 이를 객체 메타데이터로 가져온다. `slater-build`는 업로드 시
체크섬을 보내고(스토어는 이를 기준으로 바이트를 검증하고 저장한다), 서버는
열 때 이를 다시 읽어 매니페스트와 비교한다 — 파일당 메타데이터 요청 한 번으로,
바디 다운로드는 없다. 이는 콘텐츠 수준의 검증이며 S3(SHA-256)와
GCS(CRC32C)에서 그 취지가 동일하다. 객체에 서버 저장 체크섬이 **없는**
경우(대역 외로 복사되었거나 다른 기본값으로 업로드된 경우), 서버는 바이트
길이를 신뢰하는 대신 **객체 바디를 매니페스트 BLAKE3로 재해시**한다 — 요청된
무결성 검사가 크기 비교로 조용히 격하되는 일은 없다. Slater가 게시한 세대는
항상 체크섬을 포함하므로 저비용 메타데이터 경로를 유지한다.

이 열이 모든 백엔드에서 검사하는 것은 파일이 **매니페스트와 일치**하는지다.
매니페스트 자체를 신뢰할 수 있는지는 별개의 문제이며, 이에 답하는 것은 마스터
키다. 키가 구성되어 있으면 매니페스트는 키 기반 MAC을 지니며, 서버는 (이
해시들을 포함한) 어떤 필드도 신뢰하기 전에 이를 검증한다. 따라서 변조된
파일을 설명하도록 다시 쓰인 매니페스트는 거부된다. 키가 없으면 비교는
전반적으로 키가 없는 상태이며, 데이터 디렉터리에 쓸 수 있는 사람은 파일과
매니페스트를 함께 다시 쓸 수 있다. 자세한 내용은 [각 구성에서 무결성이 의미하는 바](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration)를
참조하라. 검사 자체는 `dataBackend.verifyIntegrity: false`로 끌 수 있으며,
이는 더 빠른 열기와 검사를 맞바꾸는 것이다.

### 파일시스템 (`fs`)

기본값이며 루트는 `dataBackend.fs.dir`이다. 대부분의 배포에 적합한 선택이다:
로컬 SSD(또는 NFS/EBS 마운트)의 세대를 읽기 전용으로 서빙한다. 무결성은 열 때
모든 파일의 전체 BLAKE3 재해시다.

### Amazon S3 (`s3`)

S3 또는 S3 호환 버킷(AWS, MinIO, localstack)이다. 자격 증명은 **우선적으로**
config(`dataBackend.s3.awsAccessKey` / `awsSecretKey`, 임시 STS 자격 증명에는
`awsSessionToken`)에서 가져오며, 비어 있으면 표준 AWS 체인(`AWS_ACCESS_KEY_ID`
/ `AWS_SECRET_ACCESS_KEY` 환경 변수, 공유 프로파일 또는 인스턴스/IRSA
역할)으로 폴백한다.```sh
# serve from S3 (env-var form; see the config table for every key)
dataBackend__kind=s3
dataBackend__s3__bucket=slater
dataBackend__s3__region=eu-west-2
dataBackend__s3__awsAccessKey=…        # omit to use the AWS chain / instance role
dataBackend__s3__awsSecretKey=…
# S3-compatible (e.g. MinIO): also set
dataBackend__s3__endpoint=http://minio:9000
dataBackend__s3__pathStyle=true        # required by most S3-compatible servers
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
  --publish-s3-bucket slater --publish-s3-region eu-west-2 --publish-s3-prefix prod
#   MinIO: add  --publish-s3-endpoint http://localhost:9000 --publish-s3-path-style

Google Cloud Storage (gcs)

JSON API를 통해 액세스하는 GCS 버킷입니다. 인증은 GCP 네이티브 방식입니다. 기본적으로 Application Default Credentials — GKE Workload Identity, GCE 메타데이터 서버 또는 gcloud / GOOGLE_APPLICATION_CREDENTIALS 키를 사용합니다. 명시적 키를 위해 dataBackend.gcs.credentialsPath(서비스 계정 JSON 키 파일) 또는 인라인 credentialsJson을 설정하세요. dataBackend.gcs.endpointfake-gcs-server 에뮬레이터를 가리키며, dataBackend.gcs.anonymous=true해당 에뮬레이터에서만 인증 없는 액세스를 활성화합니다 — 실제 GCS에는 절대 사용하지 마세요.```sh

serve from GCS (env-var form; see the config table for every key)

dataBackend__kind=gcs dataBackend__gcs__bucket=slater dataBackend__gcs__prefix=prod dataBackend__gcs__credentialsPath=/secrets/sa.json # omit for ADC / Workload Identity

```sh
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
  --publish-gcs-bucket slater --publish-gcs-prefix prod
#   explicit key: add  --publish-gcs-credentials /secrets/sa.json

모든 경우에 slater-build는 완성된 세대(generation)를 먼저 --data-dir에 기록하고(로컬 스테이징 영역) 추가로 버킷에 업로드합니다. 원격 current 포인터는 마지막에 기록되므로 서빙 노드는 절반만 게시된 세대를 볼 수 없습니다.

객체 스토어(S3 또는 GCS)를 사용해야 하는 경우

fs 대신 s3gcs를 사용하는 것은 세대를 노드 디스크가 아니라 내구성 있는 중앙 객체 스토리지에 두고 싶을 때입니다. 일반적으로: 한 번 게시하고 동일한 버킷을 읽는 많은 무상태(stateless), 디스크 없는 서버 복제본으로 팬아웃하거나, 빌드 호스트와 서브 호스트를 분리하거나, 볼륨을 관리하는 대신 스토어의 내구성/버전 관리/수명 주기 기능을 활용하려는 경우입니다. 트레이드오프는 지연 시간입니다: 콜드 블록은 로컬 읽기(~0.1 ms) 대신 네트워크 왕복(~10–50 ms)이 걸립니다. Slater는 인메모리 블록 캐시, 동시 읽기 미리 읽기(read-ahead), 그리고 아래의 선택적 디스크 캐시로 대부분을 숨깁니다. 세대가 이미 빠른 로컬 스토리지에 있고 중앙 버킷 모델이 필요 없다면 fs가 더 간단하고 빠릅니다.

로컬 디스크 블록 캐시(객체 스토어 2차 계층)

인메모리 BlockCache는 의도적으로 작습니다(RSS 상한이 핵심 보장입니다). 따라서 RAM보다 큰 작업 세트에서는 동일한 블록이 스필(spill)될 때마다 객체 스토어에서 다시 가져와야 합니다. 선택적 로컬 SSD 2차 캐시 계층이 이를 해결합니다: RAM에서 축출된 블록은 새로운 객체 GET 대신 로컬 디스크(~0.1 ms)에서 제공되며, 인메모리 축출 후에도 살아남아 객체 스토어 요청 수/비용을 줄입니다. 이를 통해 객체 스토어 기반 노드가 워밍된 후 로컬 파일시스템 성능에 가까워집니다. s3gcs 모두 옵트인(opt-in) 이며, dataBackend.<s3|gcs>.diskCacheBytes > 0과 쓰기 가능한 diskCacheDir을 설정하여 활성화합니다.

  • 가져온 그대로의 봉인된(sealed) 바이트를 캐시합니다 — 이미 압축되어 있고(--encrypt 세대의 경우) 여전히 AEAD로 봉인된 상태로 — 복호화/압축 해제 아래에서 캐시합니다. 캐시 계층은 암호화 키를 보유하지 않으며 다시 암호화하지 않으므로 저장 시 상태가 그대로 보존됩니다: 암호화된 세대는 여전히 봉인된 채로 디스크에 저장됩니다.
  • 쓰기는 지연 쓰기(write-behind) 입니다: 미스(miss) 시 가져온 바이트를 쿼리에 즉시 반환한 다음, 백그라운드 스레드가 디스크 쓰기와 LRU 정리를 수행하므로 쿼리 경로는 디스크 I/O에서 블로킹되지 않습니다. 축출은 캐시를 바이트 예산 내로 유지합니다. 모든 읽기에서 검증되는 파일별 체크섬은 손상된 캐시 파일을 미스로 자가 치유합니다(→ 객체 스토어에서 다시 가져옴).
  • diskCacheDir 은 실제 쓰기 가능한 볼륨을 가리켜야 하며, 절대 tmpfs가 아니어야 합니다 (tmpfs는 RAM이므로 바운드된 RSS 보장을 무효화합니다). 이를 추적하는 인메모리 인덱스는 약간의 RAM(캐시된 블록당 수십 바이트)을 소비하며, 이는 RSS 상한에 포함됩니다 — 디렉토리를 인메모리 블록 캐시보다 훨씬 크게 잡으십시오.
  • 이 계층의 다른 RAM 비용은 지연 쓰기 큐로, 디스크로 가는 블록을 스테이징합니다. blockCacheBytes / 8(diskCacheBytes로 하한 적용)로 제한되며 — 기본값에서 8 MiB — 커지기보다는 버려지므로(shed) 콜드 스캔이 이를 부풀릴 수 없습니다. 버려진 블록은 다음 미스에서 단순히 다시 가져옵니다. 별도 구성이 필요 없습니다: blockCacheBytes에 따라 확장되므로 디스크 계층은 인덱스 외에 RSS 예산에 새로운 숫자를 추가하지 않습니다.

마운트

읽기 복제본(read replica)읽기 전용 루트 파일시스템과 비루트 사용자(appuser:1000)로 실행됩니다 — 필요한 모든 것이 읽기 전용으로 마운트됩니다. 작성자(writer)(delta.enabled)는 WAL용으로 내구성 있는 쓰기 가능 볼륨이 하나 더 필요합니다.

경로용도참고
/data그래프 세대(<graph>/<uuid>/… + current).복제본에게 읽기 전용; slater-build가 생성합니다. 원격/네트워크 스토리지(예: NFS)에 있을 수 있으므로 읽기가 빠른 로컬 SSD 지연 시간이라고 가정하지 않습니다.
/sandbox환경별 구성 오버레이 + 시크릿./sandbox/config.json은 내장된 config.json 위에 딥 병합(deep-merge)됩니다; acl.json, TLS PEM 자료, 저장 시(at-rest) 키 파일도 보유합니다.
/tmp, /run스크래치(tmpfs).읽기 복제본은 기본적으로 디스크에 쓰지 않습니다.
(작성자) delta.walDirdelta.enabled일 때의 쓰기 전 로그 + L0 델타 세그먼트.쓰기 가능하며, 내구성 있는 실제 볼륨 — 절대 tmpfs 아님(내구성의 최소 기준입니다). 상대 경로는 데이터 디렉터리 아래로 해석됩니다; 작성자에게 여기에 자체 영구 볼륨을 제공하세요.
(선택) 디스크 캐시dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0일 때의 로컬 디스크 블록 캐시.쓰기 가능하며, 실제 볼륨 — tmpfs 아님. s3gcs 백엔드에서 사용됩니다; 스토리지 백엔드를 참조하세요.

환경 / 구성

구성은 하우스 표준 계층형 로더로 로드됩니다: 내장된 config.json, 그 위에 /sandbox/config.json이 딥 병합되고, 그 다음 KEY__sub 환경 변수 오버라이드(중첩은 이중 밑줄; 키는 camelCase 구성과 일치)가 적용됩니다.

모든 구성 노브 — camelCase 키, KEY__sub 환경 변수 오버라이드, 기본값, 그리고 동작 — 는 구성 참조 에 표로 정리되어 있습니다. 가장 많이 튜닝되는 노브는 캐시 예산(cache.*), 쿼리 가드(query.*), 연결 상한(server.*), 스토리지 백엔드(dataBackend.*), 쓰기 가능 계층(delta.*)입니다.

상주 메모리(RSS)blockCacheBytes + vectorCacheBytes + resultCacheBytes바운드된 항목별 및 할당자 오버헤드 이내로 추적합니다 — 각 풀은 자체 내용물(문자열과 컨테이너는 할당된 용량 기준)의 무게를 재고 예산을 유지하기 위해 축출하지만, 항목별 부기와 할당자의 크기 클래스 반올림은 설정한 숫자 위에 추가됩니다 — 여기에 작은 고정 오버헤드(그리고 차수 합 count(endpoint) 고속 경로가 실행된 후 lazy 차수 열에 대한 degreeColumnBytes까지)가 더해집니다. 이는 그래프 크기와 무관합니다 — 이것이 핵심 보장이며, rss_stays_bounded_under_sustained_knn_load 통합 테스트로 검증됩니다. 이 테스트는 피크 대 워밍 RSS 증가를 합산 예산 내에 유지합니다. 연결별 버퍼는 캐시 예산 밖에 있으므로, 적대적 부하에서도 보장이 유지되는 이유는 server.maxConnections가 동시에 존재할 수 있는 연결 수를 제한하기 때문입니다.

네트워크 태세

Slater는 읽기 복제본 핸들입니다. 일차 연결 보안 제어는 바이너리가 아니라 네트워크입니다. 개인 인터페이스에 바인딩하고, 네트워크 계층(보안 그룹 / NetworkPolicy)에서 소스 범위를 제한하며, 신뢰할 수 없는 클라이언트를 마주하는 경우 연결 제한 L4 프록시(HAProxy maxconn + 소스별 stick-table, 또는 nftables connlimit + hashlimit)를 앞에 두십시오. 이는 파일 디스크립터가 프로세스에 전달되기 전에 적용되므로 가장 견고한 제한입니다.

위의 인바이너리 제한(maxConnections, maxPreAuthConnections, maxConnectionsPerIp, 차등 바이트 상한, loginTimeoutMs)은 심층 방어(defence-in-depth) 입니다: 기본적으로 켜져 있고 관대하여 정상 클라이언트 집단에는 보이지 않지만, 프록시를 잊어버렸을 때에도 바운드된 RSS 보장이 유지되도록 합니다. 전체 방어 태세는 docs/HARDENING.md 를, 표준 상세 내용은 THREAT_MODEL.md / SECURITY_WORKLIST.md를 참조하세요.

세대 가드

Slater는 각 그래프의 current 포인터를 generationPollMs마다 폴링합니다(폴링이지 inotify가 아님 — 데이터 디렉터리는 NFS와 같은 원격/네트워크 스토리지일 수 있고, 파일시스템 변경 이벤트는 신뢰할 수 없기 때문입니다). 변경되면:

  • reloadStrategy=exit(기본값): 서버는 치명적 오류를 로그로 남기고 0이 아닌 코드로 종료하여 오케스트레이터가 새 세대에 맞춰 깨끗하게 재시작합니다.
  • reloadStrategy=swap: 서버는 새 세대를 열고 검증한 다음(부팅과 동일한 콘텐츠 해시 가드), 원자적으로 교체하고 진행 중인 쿼리는 이전 세대에서 완료되도록 둡니다. 손상되었거나 불완전한 새 이미지는 거부되고 이전 세대가 계속 서빙합니다.

ACL

acl.json은 사용자를 argon2id 비밀번호 해시 및 그래프별 read / write 권한에 매핑합니다. 해시를 생성하려면(평문을 저장하지 마세요) 다음을 사용하십시오:```sh slater hash-password 's3cret' # prints a $argon2id$… string for acl.json

스타터 `acl.json`은(는) 저장소 루트에 포함되어 있으며, 그 형태는 다음과 같습니다:```json
{
  "users": {
    "reporting": {
      "passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
      "grants": {
        "people": ["read"],
        "products": ["read", "write"]
      }
    }
  }
}
  • users — 로그인마다 하나의 항목이며, 사용자 이름으로 키가 지정됩니다.

  • passwordArgon2idslater hash-password$argon2id$… 문자열 (절대 평문이 아닙니다. 파일 자체는 일반 JSON이며 공유 스토리지에 있습니다).

  • grants — 그래프별 권한 목록입니다. 두 가지 권한이 의미 있습니다:

    • read — 그래프를 조회합니다. 사용자의 권한에 없는 그래프는 사용자에게 보이지 않습니다.
    • write — 쓰기 가능 레이어(delta.enabled)를 통해 그래프를 변경합니다: MERGE / SET / DELETE 문과 CALL slater.consolidate().

    이들은 독립적입니다: read 권한은 쓰기 접근을 부여하지 않습니다. 따라서 쓰기 가능 레이어를 켜도 기존 읽기 사용자가 쓰기 사용자로 승격되지 않습니다. 쓰기 사용자는 둘 다 — ["read", "write"] — 필요합니다. 쓰기 위해 비즈니스 키를 해석하는 것은 읽기이기 때문입니다. 인식할 수 없는 권한 문자열은 무시됩니다 (아무것도 부여하지 않습니다).

aclPath가 지정하는 경로(기본값 /config/acl.json)에 읽기 전용으로 마운트하세요. 서버는 세대 핫스왑 때마다 이 파일을 다시 로드하며, 저장된 ACL 스탬프는 모든 재로드 시 다시 확인됩니다 (requireAclStamp 참조).

상태 확인

slater 바이너리는 자체 활성 프로브(liveness probe) 역할도 합니다: slater healthcheck [host] [port]는 서버에 대해 Bolt 핸드셰이크(HTTP 요청이 아님)를 수행하고 프로토콜 버전을 협상하면 0으로 종료하며, 그렇지 않으면 1로 종료합니다 — 기본값은 localhost와 구성된 Bolt 포트입니다. 이것이 컨테이너 HEALTHCHECK가 실행하는 내용이므로, 오케스트레이터는 단순히 열린 소켓이 아니라 실제로 Bolt를 사용할 준비가 된 서버를 확인합니다:```sh slater healthcheck localhost 7687 # exit 0 = healthy docker exec slater /app/slater healthcheck # inside the container

## 일회성 쿼리

스크립팅, CI 검사, 빠른 조회를 위해 `slater query`는 그래프의 현재 세대를 마운트하고, 프로세스 내에서 단일 읽기 전용 Cypher 쿼리를 실행한 다음, 결과를 JSON 객체로 출력하고 종료합니다 — 서버도, Bolt 연결도 없습니다. 서버와 동일한 구성(스토리지 백엔드, 암호화 키, 쿼리 예산)을 따릅니다:```sh
# GRAPH defaults to `defaultGraph`. Without -q, normal datestamped logging
# (config, "opened generation", …) is written to stdout alongside the result.
slater query mygraph 'MATCH (n) RETURN count(n) AS c'

# -q/--quiet ⇒ logging suppressed, so stdout is *only* the compact result JSON
slater query mygraph -q 'MATCH (c:Company) RETURN c.ticker AS t LIMIT 3' | jq
# {"columns":["t"],"rows":[["AUPH"],["KYMR"],["MREO"]]}

노드와 관계는 해당 레이블/유형 및 속성으로 확장됩니다. 머신이 파싱 가능한 출력을 원할 때는 -q를 사용하세요(결과 JSON이 stdout에 유일하게 출력됩니다). 로그가 포함된 운영자용 실행에서는 이를 생략하세요. -q를 사용하지 않으면 각 실행 후 메트릭 전용 요약이 로그로 기록됩니다 — 예:```text INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10

쿼리 `cost`(청구된 요소), `resultCount`, `execMs`, 그리고
`limitRowCount`(쿼리가 `LIMIT`를 지정한 경우에만) — 쿼리 텍스트나
어떤 결과 값도 절대 포함하지 않습니다. 종료 상태는 성공 시 `0`, 파싱/열기/실행
오류 시 `1`입니다(메시지는 stderr로).

## Export a graph (`slater dump`)

`slater dump`는 **실행 중인** 서버에서 그래프를 비즈니스 키 `MERGE`
Cypher로 내보냅니다 — `slater-build`가 읽어들이는 것과 동일한 방언 — 그래프가
(덤프 → `slater-build` → 새 생성)으로 왕복하여 마이그레이션 또는 텍스트 백업에 사용할 수 있습니다.
`slater query`와 달리 **Bolt**를 통해 연결하고, 인증하며, 그래프별
ACL을 준수하므로 서버에 대한 디스크 접근이 필요 없습니다. 비밀번호는
`SLATER_DUMP_PASSWORD` 또는 stdin에서 읽습니다(절대 플래그로 전달하지 않으므로 `ps`/history에 노출되지 않습니다).```sh
# List the graphs the authenticated user may read.
SLATER_DUMP_PASSWORD=pw slater dump --list -u reporting

# Dump a graph to a file (identity keys inferred from range indexes).
SLATER_DUMP_PASSWORD=pw slater dump people -u reporting -o people.cypher

# Rebuild it into a fresh generation.
slater-build --input people.cypher --graph people --data-dir ./data

각 라벨의 identity 키는 해당 range 인덱스가 보유한 속성이며, --key Label=prop(반복 가능) 또는 전역 --pk <field>로 재정의할 수 있습니다. CREATE INDEX DDL이 먼저 내보내지므로 재빌드 시 인덱스가 다시 생성됩니다. 다중 라벨 노드는 모든 라벨을 유지하며 MERGE (n:Ident:Other {key: v})로 내보내집니다. identity 라벨(비즈니스 키를 제공하는 라벨)이 먼저 오고 나머지는 정렬됩니다. 머지(MERGE)는 identity 라벨만을 기준으로 수행되므로 뒤따르는 라벨들은 추가 노드를 만들지 않고 해당 노드에 기록됩니다. 라벨, 관계 유형 및 특수 문자가 포함된 속성 키는 내보낼 때 백틱으로 인용되므로 특이한 이름도 원래대로 유지되며 재빌드에 Cypher를 주입할 수 없습니다. 벡터(및 Cypher 리터럴 표현이 없는 값)는 MERGE 덤프에 포함될 수 없으며 stderr에 경고와 함께 버려집니다. 종료 상태는 성공 시 0, 오류 시 1입니다.

실습 예제

완전하고 실행 가능한 단계별 예제 — 그래프를 구축하고, 이를 서비스하고, neo4j JavaScriptPython 드라이버로 연결하며, 데이터를 쓰는 과정 — 은 매뉴얼의 빠른 시작데이터 쓰기 페이지에서, docs/manual/examples/에 포함된 샘플 그래프를 사용하여 확인할 수 있습니다.

개발```sh

export PATH="$HOME/.cargo/bin:$PATH" cargo build cargo test # unit + the bounded-RSS headline integration test cargo clippy --all-targets -- -D warnings cargo fmt --all -- --check

### 객체 스토리지 백엔드는 선택적 cargo 기능입니다

일반 `cargo build`는 **파일시스템 전용** 바이너리를 생성합니다 — `s3` 및 `gcs`
백엔드는 cargo 기능 뒤에 게이트되어 기본 빌드가 작게 유지됩니다 (AWS나
Google SDK, 비동기 런타임 없음). 필요한 것을 **모두** `slater`
(serve) 및 `slater-build` (publish)에서 활성화하세요:```sh
# S3 only / GCS only / both
cargo build -p slater -p slater-build --features s3
cargo build -p slater -p slater-build --features gcs
cargo build -p slater -p slater-build --features s3,gcs

각 크레이트는 s3 / gcs 기능을 노출하며, 이는 graph-format/{s3,gcs}로 전달됩니다. 런타임에 백엔드를 요청하면(dataBackend.kind=s3|gcs, 또는 slater-build --publish-{s3,gcs}-*) 해당 기능이 컴파일되어 있지 않은 경우 "… 기능 없이 빌드됨" 오류와 함께 빠르게 실패합니다. 게시된 Docker 이미지는 둘 다 활성화합니다 (Dockerfile CARGO_FEATURES), 따라서 사전 빌드된 이미지에는 추가 플래그가 필요하지 않습니다 — 이는 소스에서 빌드할 때만 문제됩니다. 통합 테스트도 마찬가지로 게이트 처리됩니다: --features s3 --test s3_minio, --features gcs --test gcs_emulator (fake-gcs-server), --features gcs --test gcs_real (ADC를 통한 실제 GCS); 각각은 해당 SLATER_* 환경 변수가 설정되지 않으면 건너뜁니다.

설계, 마일스톤 원장, 결정 로그는 docs/PLAN.md, docs/PROGRESS.md, docs/DECISIONS.md를 참조하세요.

Performance

최대 6개 엔진, 하나의 단일 클라이언트 스위트, 62k 노드 토이부터 Wikidata 91.6M 노드 / 1.5B 엣지까지의 그래프. 각 엔진은 격리된 상태로 측정됩니다(다른 모든 컨테이너 중지 — RSS와 지연 시간은 해당 엔진 자체의 점유율). 아래 지연 시간 테이블Slater 0.21.0(쓰기 가능한 빌드)에서 재측정되었습니다: 소형/중형 그래프(MeSH, EU-AI-Act)는 새로, 91.6M 그래프는 새로운 동일 박스, 공유 앵커 slater-vs-Neo4j 패스로 측정했습니다(해당 테이블 참조). 상주 메모리 수치는 이전 패스의 값을 이어받습니다(컨테이너 cgroup으로 측정; 읽기 경로는 쓰기 가능한 레이어가 유휴 상태일 때와 바이트 단위로 동일). 다른 엔진의 수치는 확립된 크로스 엔진 실행 결과입니다(해당 버전/성능은 변경되지 않음). 모든 수치는 중앙값(ms) 또는 최대 상주 메모리(MiB)입니다. 모든 곳에서 낮을수록 좋음; 굵게 = 행에서 최고. slater는 로컬 파일시스템(fs) 백엔드에서 실행되었습니다; S3 및 GCS 백엔드는 로컬 읽기 지연 시간을 객체 스토어 왕복과 맞바꿉니다(인메모리 캐시와 선택적 로컬 디스크 캐시 계층으로 완화), 따라서 이 수치는 네트워크 스토리지 배포가 아닌 엔진 자체를 특성화합니다.

engineclassmemory bound
slaterdisk-backed, pagedquery.maxIntermediate caps the working set automatically
Neo4j 5disk-backed, JVM~2 GiB heap + off-heap, committed regardless of query
Memgraph · FalkorDBin-memorywhole graph resident in RAM
ArcadeDBin-memory, JVMwhole graph resident; heaviest
LadybugDBembedded, columnarmanual buffer pool that must exceed the query

디스크에서 페이징하는 세 엔진(slater, Neo4j 5, LadybugDB)은 다섯 개 그래프를 모두 로드합니다. 인메모리 트리오(Memgraph · FalkorDB · ArcadeDB)는 1.5B 엣지 그래프를 전혀 담을 수 없으며 (약 64–128 GiB 상주 필요), ArcadeDB의 임포터도 이를 완료하지 못합니다.

Resident memory (MiB) — bounded as the graph grows ~1,500×

각 수치는 커밋된 작업 메모리입니다 — OS가 회수할 수 없는 메모리입니다. slater를 제외한 모든 엔진은 그래프를 커밋된 익명 메모리(자체 힙, Neo4j의 off-heap 페이지 캐시 또는 버퍼 풀)에 보유하므로 최대 RSS가 곧 커밋된 점유율입니다. 오직 slater만 디스크 기반 스토어의 회수 가능한 OS 페이지 캐시에서 서비스하므로, 해당 수치는 익명 작업 세트입니다. 스토어의 페이지 캐시(압박 시 축출 가능 — slater는 계속 서비스)는 제외되며, 91.6M 그래프의 경우 괄호 안에 전체로 표시됩니다. 굵게 = 최저.

graph (nodes / edges)slaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
pole — 62k / 106k117461141401,556198
MeSH — 341k / 469k631,0833584551,631121
EU-AI-Act — 21k / 45k (+55 MiB vec)997292293121,948286
Wikidata — 91.6M / 1.5B584 (4,595 total)~2,900cannot-loadcannot-loadcannot-load~652 †

slater는 모든 규모에서 가장 낮으며 그래프가 약 1,500× 성장하는 동안 약 50×만 성장합니다 — 그 점유율은 그래프가 아닌 쿼리 작업 세트를 따릅니다(전 과정에서 유휴 상태 약 16–71 MiB). 인메모리 트리오는 거의 선형으로 성장하며 1.5B 그래프를 로드할 수 없습니다; Neo4j는 쿼리와 무관하게 약 2 GiB 힙을 커밋합니다. († LadybugDB는 제한된 형태에 대해서만 — 1.5B 엣지에서 허브 / 가변 길이 / 최단 경로 탐색은 읽기 풀을 ≥2 GiB로 올려야 하는 반면, slater는 자동 maxIntermediate 상한을 사용합니다.) 빌드 타임 값→카운트 히스토그램은 무시할 만한 상주 메모리만 추가합니다 — 낮은 카디널리티 인덱스 컬럼의 경우 몇 KB, Wikidata와 같은 고유 키 그래프의 경우 0 (wikidata_id가 히스토그램 카디널리티 상한을 초과하므로 저장되지 않음) — 따라서 이 수치는 해당 기능과 무관하게 동일합니다.

Latency (median ms) — graph fits in RAM (MeSH, 341k / 469k)

shapeslaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
count(*) all nodes0.4115.023.816.482.02.2
label count0.424.220.71.14.44.3
indexed point lookup0.433.90.480.480.658.8
idx-eq count0.424.95.02.03812.5
1-hop (indexed anchor)1.285.81.214.13904.9
2-hop (unanchored)1.405.68.516.74446.4
group-by / count(DISTINCT)0.4547–5163–6431–394115.3
full-scan CONTAINS0.435.424.11.716.34.1

slater는 메타데이터 / 인덱스 / 스캔 형태(count, 라벨, idx-eq, 스캔 — 약 0.4 ms, 서비스 엔진 대비 10–200×), 인덱스 포인트 조회(0.43 ms, 이제 인메모리 쌍의 0.48 ms에 근접), 비앵커 멀티홉(관계 유형 스캔을 통한 2-hop 1.40 ms, 해당 분야에서 가장 빠름), 그리고 빌드 타임 값→카운트 히스토그램을 통한 전체 라벨 group-by / count(DISTINCT)(0.45 ms, LadybugDB의 컬럼형 5.3 ms보다 빠름)를 장악합니다. 인메모리 서버는 원시 1-hop만 유지합니다(Memgraph 1.21 ms vs slater 1.28 ms). (pole 62k/106k도 동일: slater가 count/스캔 약 0.4 ms, 홉 약 1.3–2.6 ms에서 유일하게 가장 빠름.)

Latency (median ms) — vectors (EU-AI-Act kNN, 15k × 1024-dim)

shapeslaterNeo4j 5MemgraphFalkorDBLadybugDB
kNN top-10 Concept2.98.61.91.22.8
kNN top-10 Chunk2.45.71.91.53.2

slater는 다른 엔진이 근사 상주 HNSW를 사용하는 반면 정확한 브루트포스 스캔으로 kNN에 응답합니다(이 세트는 50k 벡터 ANN 임계값 미만) — 따라서 slater의 결과는 정확합니다(recall 1.0). SIMD 거리 커널 + 상주하는 사전 정규화 벡터 행렬 덕분에 Concept은 ~23 → ~2.9 ms, Chunk는 ~10 → ~2.4 ms로 개선되어, slater는 이제 Neo4j와 LadybugDB를 이기고 Memgraph의 ~1.4배 이내에 있으며 FalkorDB에만 뒤집니다 — 정확성을 유지하면서.

Vector write ladder — insert / update / delete without a rebuild

위 테이블은 크로스 엔진 읽기 비교입니다. 벡터 쓰기 경로(정적 Vamana 기반 위의 FreshDiskANN 스타일 쓰기 사다리)는 크로스 엔진 대응물이 없습니다 — 여기서 다른 어떤 엔진도 디스크 네이티브 쓰기 가능한 ANN을 제공하지 않습니다 — 따라서 아래 수치는 crates/slater/benches/에 커밋되고 docs/PERF-REPORT.md에 방법론과 모든 주의사항과 함께 완전히 기술된, 합성의 임베딩 유사 픽스처(저랭크 매니폴드, 차원 768, 비균등 노름)에 대한 단일 엔진 컴포넌트 벤치마크입니다. Recall은 항상 라이브 세트에 대한 정확한 브루트포스 기준으로 측정되며, 인덱스 간 비교가 아닙니다. 여기서 규모는 대표적이며 메트릭이 크기 선형인 경우에만 외삽됩니다.

propertymeasuredwhy it matters
KNN latency vs pending writesRW-index ~1.5–2 ms, flat to 50k pending; the pre-index brute-forced overlay 1.9 → 115 ms (linear in delta) — 61× at 50kquery latency does not degrade as writes pile up between consolidations
Embedding insert~1.5–2 ms per vector into the live indexa write is KNN-visible at once; the delta-rebuild budget is ≈ 2 ms × the delta cap
Delete IO at iso-recall2.9× fewer node-fetches per query at 67 % deleted, 5.2× at 80 % (recall ≥ 0.90)a consolidated graph pays no read tax for deleted vectors
Consolidation, pure permutationO(1) — the .vamana is hard-linked byte-identical, only the id column is rewrittenfolding vector writes into the base skips the O(N·R·L) rebuild
Recall across the ladderconsolidated ≥ base for cosine, L2 and dotthe write ladder preserves recall at every rung

전용 성능 상자가 필요한 유일한 수치는 슬로우 패스 통합 재작성 처리량입니다 — 통합이 순수 순열이 아닌 삭제나 새 벡터를 수반할 때, 단일 스레드 zstd와 로컬 디스크에 의해 제한되는 순차 재압축이므로 절대 MiB/s는 환경에 따라 다릅니다(보고서는 그 형태를 보여주고 환경적 변동 범위를 설명합니다).

Latency (median ms) — graph ≫ RAM (Wikidata 91.6M / 1.5B)

인메모리 엔진(Memgraph / FalkorDB / ArcadeDB)은 이 그래프를 전혀 로드할 수 없습니다 (약 64–128 GiB 상주). 오직 slater와 Neo4j 5만 가능합니다. 이는 공유된 고정 앵커 세트에 대한 새로운 동일 박스, 동일 날짜 패스입니다 — 모든 쿼리가 두 엔진 모두에서 동일한 노드를 대상으로 하므로, 직접 비교는 동등한 조건의 비교입니다(중간 차수 앵커의 공통 wikidata_id 풀; 그 중요성은 아래 메모 참조). slater는 두 팬아웃으로 표시됩니다(query.maxFanout 1 = 처리량 기본값, 8 = 콜드 블록 읽기를 겹치는 지연 시간 다이얼). 굵게 = 행에서 최고.

shapeslater (fan 1)slater (fan 8)Neo4j 5
count(*) all nodes0.410.413606
point lookup (indexed)0.720.496.3
degree (1-hop count)0.430.446.0
1-hop neighbours9.84.510.1
2-hop372334.5
3-hop322574
var-length *1..2 distinct985105647

솔직한 그림: slater는 메타데이터 / 인덱스 형태를 지배합니다 — count(*)는 메타데이터로 처리되며(0.41 ms vs Neo4j의 3.6 s 디스크 스캔, 약 8800×), 포인트 조회 / 차수 / 3-hop은 약 2–10× 더 빠릅니다 — 1–2-hop에서는 Neo4j와 대등하지만(팬아웃 8은 콜드 읽기에서 앞섬), var-length *1..2 distinct에서는 결정적으로 패배합니다(약 1 s vs Neo4j의 47 ms): slater의 가변 길이 distinct 확장은 여기서 실질적으로 더 느리며, 별도 조사가 필요한 실제 약점입니다. 이 모든 것이 Neo4j의 커밋된 약 2 GiB 힙 대비 수백 MB의 RSS로 이루어집니다.

앵커에 관하여. 이 탐색 수치는 어느 노드에서 시작하는지에 크게 의존합니다 — Wikidata 메가 허브("human", "country")에서 한 링크 떨어진 노드는 수백만 개 규모의 2-hop 이웃을 가지므로, 가변 길이/홉 비용은 앵커 선택에 따라 수십 배로 변동합니다. 이 테이블의 이전 버전은 각 엔진의 자체 "스캔 기준 상위 N"을 샘플링했는데, 이는 안정적이지도 비교 가능하지도 않습니다; 이번 패스는 두 엔진 모두에 대해 단일 공유, 차수 제한 앵커 세트를 고정합니다. (shortestPath는 이 패스에서 제외되었습니다 — 두 임의 앵커 사이에서는 경로 존재 여부에 의존하고 분산이 너무 커서 중앙값이 의미 없습니다.)

Multi-hop count(*) — memory decoupled from result size

상한 없는 멀티홉 RETURN count(*)는 일치하는 행을 구체화하는 대신 확장 중에 카운트합니다. 91.6M 그래프에서 동일한 허브 앵커를 사용, maxIntermediate=20M:

3-hop count(*) @ 91.6Mfanout=1fanout=8
latency / peak working set554 ms / 0.66 GiB298 ms / 1.9 GiB

카운트는 O(1) 행을 보유합니다. 차징은 변경되지 않으므로, 메가 허브 카운트는 여전히 계산(인접성 읽기)에서 maxIntermediate를 트리거하며, 이전과 동일하게 제한됩니다.

Per-query parallelism (maxFanout)

query.maxFanout을 높이면 쿼리의 콜드, I/O 바인딩 블록 읽기를 코어에 걸쳐 중첩합니다 — 대형 콜드 작업 세트의 디스크 바인딩 형태에 도움이 되며 웜 형태에서는 변화가 없습니다. 1.5B 그래프에서: shortestPath ≤6 918 → 608 ms (1.5×, 최대 검색 6,269 → 2,350 ms, 2.7×); 3-hop 카운트 547 → 298 ms. maxFanout=1이 기본값(처리량 중심)입니다; 8은 더 많은 일시적 워커 메모리를 사용하는 지연 시간 다이얼입니다.

Where slater wins / trails

dimensionslaterbest of the fieldverdict
resident memory, any scale11–584 MiB (62k → 91.6M)in-memory 1.5–2.7 GiB; can't load 1.5Bslater
count / metadata / scan~0.4 msservice engines 5–80 msslater (10–200×)
indexed point lookup0.43 ms (MeSH)Memgraph · FalkorDB 0.48 msslater (edges the in-memory pair)
unanchored multi-hop (rows)1.40 ms (MeSH 2-hop)Neo4j 5.6 msslater (relationship-type scan)
aggregation (group-by / DISTINCT)0.45 msLadybugDB 5 ms (columnar)slater (build-time histogram)
kNN2.4–2.9 ms (exact)FalkorDB 1.2 ms (HNSW)beats Neo4j/Ladybug; ~1.4× off Memgraph; exact
91.6M metadata / point / degree / 3-hop0.4–32 msNeo4j 6–3,600 msslater (2–8800×)
91.6M 1–2-hop4.5–23 ms (fan 8)Neo4j 10–35 ms~even
91.6M var-length *1..2 distinct~1 sNeo4j 47 msNeo4j (a real slater weak spot)
multi-hop count(*) at scale0.3–0.6 GiBin-memory engines materialise the row setslater, bounded

전체 엔진별 테이블(pole, MeSH, EU-AI-Act + blockCacheBytes RAM↔지연 시간 다이얼, Wikidata 1M & 91.6M)은 perf/cross-engine-hs/README.md에 있습니다; 새로운 slater 전용 패스(두 팬아웃, 모든 데이터셋)는 perf/PERF_CURRENT_STATUS.md에 있습니다.

Concurrency & brown-out (load testing)

위 벤치마크는 단일 클라이언트입니다. 보완 축 — 다수의 동시 클라이언트 하에서의 동작 — 은 자체 하네스 perf/loadtest/를 가집니다: Bolt 위의 Locust 드라이버와 부하를 증가시키고 CALL slater.diagnostics()를 읽고 용량 한계 지점을 찾아 제한자를 명명하는 코디네이터(전체 방법은 docs/LOAD-TESTING.md에 있음). Wikidata-1M 그래프(16코어 박스 1대)에서 256 MiB 캐시로 실행한 주요 결과:

resultmeasurement
Holds to 1000 concurrent clients, zero failuresthroughput peaks ~2.5k rps; the latency knee sets in around 750 clients (p99 51 → 750 ms) — queueing under core contention, not a hard cap (single-run, WSL2)
Block cache bounded and effective100% hit rate, 0 evictions, 50 MB resident for a cache-fitting working set
RSS held under sustained loadthe jemalloc allocator holds RSS to ~0.6 GB across a 100→500-client wiki_cache_churn ramp — cache-bound and stable, with no MALLOC_* tuning (the former MALLOC_ARENA_MAX=2 + trim threshold is retired); its background purge also returns the post-burst high-water instead of leaving it pinned
Aggregate memory boundedserver-wide query.maxIntermediateGlobal + adjacency-charged expansion hold the wiki_budget 2-hop flood at 1000 clients without OOM (RSS ~0.6 GB; the guard sheds ~60% of hub queries as retryable budget errors)

로드 테스트가 드러낸 두 메모리 문제는 모두 해결되었습니다. 모두 로드 테스트 문서에 추적되어 있습니다.

License

Apache License, Version 2.0에 따라 라이선스가 부여됩니다. 전문은 LICENSE를, 귀속 정보는 NOTICE를 참조하세요. 명시적으로 달리 밝히지 않는 한, Apache 2.0 라이선스에 정의된 대로 이 저작물에 포함하기 위해 의도적으로 제출된 모든 기여는 추가 약관이나 조건 없이 위와 같이 라이선스가 부여됩니다.

SPDX-License-Identifier: Apache-2.0

카테고리