ACOR V3: 버전이 있는 사전, 스냅샷, 그리고 100만 키워드
들어가며
ACOR의 V2 스키마는 키워드 몇천 개에서 몇만 개 규모의 필터에는 잘 맞지만 사전이 100만 키워드로 커지면 V2 구조의 부담이 커진다. V2는 트라이 전체를 해시 하나에 직렬화해 키워드 하나만 추가해도 전체를 다시 계산하고 써야 하며 사전 전체를 교체하는 동안 검색이 어느 버전을 사용하는지도 보장하기 어렵다.
그래서 v1.6.0에서 이 문제를 다루는 새 저장 형식 V3 versioned dictionaries를 opt-in으로 넣었고, v1.7.0에서 한 번 더 다듬었다. 이 글에서는 V3의 API와 저장 구조, 실험했다가 되돌린 delta 검색, 그리고 100만 키워드에서 측정한 숫자를 정리한다.
V3의 계약
V3는 같은 Go 모듈 안의 별도 형식으로, OpenVersioned로 열 때 새 컬렉션 이름을 써야 하며, 기존 Create와 V1/V2 키, 그 에러 계약은 그대로 유지된다.
V3는 사전의 모든 상태에 버전을 붙여, 쓰기 요청이 “내가 본 버전"을 함께 보내도록 한다. 그사이에 다른 쓰기가 있었다면 요청을 실패시켜 서로 덮어쓰지 못하게 하는 방식이다. 아래는 v1.7.0 원본 문서의 예제를 옮긴 코드로, 원본에는 <!-- doccheck --> 표시가 있어 ACOR의 CI가 컴파일하지만, 블로그에 옮긴 코드까지 그 검사에 포함되는 것은 아니다.
| |
V3를 쓸 때 알아 둬야 할 규칙은 다음과 같다.
- 버전은 불투명한 동등 비교 토큰이다. 숫자나 문자열로 크기를 비교해서는 안 된다.
- 같은 버전을 들고 있는 두 쓰기는 서로를 덮어쓰지 못한다. 경합에서 진 쪽은
ErrConcurrencyConflict를 받는다. - 배치 변경은 원자적이다.
Replace에는 빈 사전도 넘길 수 있다. - 같은 사전을 다시 적용하면 버전이 바뀌지 않고 무효화도 publish하지 않는다.
커밋 이후 검색에 반영되는 시점
V3에서는 커밋이 끝나는 시점과 검색에 반영되는 시점을 구분해야 한다. Replace가 성공했더라도 Redis 커밋만 끝난 것이어서 호출한 인스턴스를 포함한 모든 인스턴스가 아직 이전 엔진으로 검색하고 있을 수 있고, 각 인스턴스가 새 버전을 발견해 엔진을 다시 만들고 교체한 뒤에야 그 버전으로 검색하게 된다.
쓰기 직후 읽기가 필요할 때는 WaitForVersion을 쓰면 되는데, 호출한 VersionedCollection의 로컬 엔진이 해당 커밋이나 그 이후 버전을 검색에 반영할 때까지 기다리며 context 취소도 처리한다. 다만 이 호출이 성공했다고 다른 인스턴스까지 모두 갱신된 것은 아니다.
새 버전을 발견하고 재빌드하는 과정은 다음과 같이 동작한다.
| 동작 | |
|---|---|
| 발견 | 기본 30초 폴링, Pub/Sub로 가속 |
| 병합 | 고정 20ms 디바운스 창이 연속 이벤트를 하나로 합침. 새 이벤트가 창을 늘리지 않음 |
| 진행 중인 빌드 | 끝까지 마친 뒤, 그사이 관찰한 최신 버전을 다시 로드 |
| 실패 | 이전 서빙 엔진을 유지 |
로컬 캐시 무효화 글에서 겪은 문제를 반영해, Pub/Sub는 새 버전을 빨리 발견하는 데 쓰고 정확성은 폴링으로 확보한다. 재빌드 중에 새 커밋이 들어와도 진행 중인 빌드를 취소하지 않는데, 계속 취소하면 쓰기가 이어지는 동안 재빌드를 영원히 마치지 못하기 때문이다.
Status()는 Redis를 읽지 않고 로컬 상태만 돌려주며 ActiveVersion에는 Redis에서 마지막으로 관찰한 커밋 버전, ServingVersion에는 현재 검색에 쓰는 버전이 담긴다. 두 값이 다르면 이 인스턴스가 관찰한 커밋을 아직 검색에 반영하지 못한 상태라는 뜻이다. 다만 버전은 불투명 토큰이라 숫자 차이로 지연을 계산할 수 없고 이 값만으로 다른 인스턴스까지 포함한 전체 상태를 판단할 수도 없다.
버킷, 청크, 매니페스트의 저장 구조
V3에서는 키워드를 SHA-256 앞 12비트에 따라 4,096개 고정 버킷에 나누고 버킷 안의 키워드를 정렬해 중복을 제거한다. 이를 이스케이프까지 포함해 1MiB 이하인 JSON 문자열 배열 청크로 나누는데, 각 청크는 내용으로 주소를 매기는 불변 객체다.
active pointer ──▶ generation manifest (version N)
├─ bucket 0000 ──▶ chunk sha256:ab12…
├─ bucket 0001 ──▶ chunk sha256:cd34…
└─ …4,096 buckets
이 구조 덕분에 변경 비용은 바뀐 버킷 수에 비례하게 된다.
- 쓰기: 키워드를 추가하거나 지울 때는 영향받는 버킷만 내려받아 준비한다. 전체 교체는 모든 버킷을 비교하고 바뀌지 않은 버킷을 재사용한다.
- 로컬 재빌드: 바뀌지 않은 버킷의 키워드 슬라이스를 재사용하고, 바뀐 버킷만 내려받는다.
- 커밋: 마지막 Lua 커밋은 기대 포인터, 준비 리스(lease), 유지보수 잠금을 확인하고 영수증을 남긴 뒤 포인터를 바꿀 뿐 매니페스트를 파싱하거나 키워드를 순회하지 않으며, 준비된 데이터는 커밋 전까지 어디서도 참조되지 않는다.
모든 검색은 로컬에 만든 엔진에서 일어나므로 검색 경로에는 Redis가 없는 대신, 서빙하는 모든 복제본이 검색 가능한 사전 전체를 메모리에 들고 있어야 한다. 재빌드 중에는 이전 엔진과 새 엔진이 함께 존재하므로, 전체 교체 때는 잠시 엔진 두 벌만큼 메모리가 필요하다.
커밋 결과를 모를 때
네트워크가 끊겨 커밋 결과를 알 수 없을 때 V3는 ErrCommitUnknown을 돌려준다. 이 경우 WriteResult.OperationID를 보관했다가 ResolveOperation(ctx, id)로 영수증을 확인하고 영수증이 있다면 커밋이 성공했다고 확정할 수 있다. 다만 영수증이 없다고 해서 진행 중인 요청이 나중에 커밋되지 않는다는 증거는 아니다라는 점에 주의해야 하며, 결과가 애매한 쓰기를 무작정 다시 적용해서는 안 된다.
리스와 정리
스냅샷, 빌드, 준비 중인 쓰기는 서버 시간 기준으로 기본 5분의 리스를 잡고 1분마다 갱신한다. Prune(ctx)는 활성 세대, 24시간 안에 준비되거나 커밋된 데이터, 유효한 리스가 보호하는 데이터를 남기고 나머지 청크와 매니페스트를 지운다. 정리 중에는 단조 증가하는 유지보수 펜스를 잡아서, 만료된 정리 프로세스가 후임자가 생긴 뒤에도 계속 지우는 일을 막으며, 정리하는 동안에도 검색은 계속된다.
샤딩 (v1.7.0)
v1.7.0에서는 ShardCount에 2에서 256 사이의 2의 거듭제곱을 지정해 사용할 수 있는 opt-in 샤딩을 추가했다. 기존 4,096개 버킷을 bucketID % shardCount로 샤드에 배정하고 샤드마다 별도 해시 태그를 써, Redis Cluster의 여러 슬롯에 나눠 놓을 수 있게 했다.
로컬 재빌드에서는 바뀌지 않은 샤드 엔진을 재사용하고 바뀐 샤드만 다시 만든 뒤, 필요한 샤드가 모두 검증됐을 때 완전한 세대를 원자적으로 설치한다. 후보 검증에 실패하면 이를 버리고 이전의 완전한 세대로 검색을 계속한다.
기존 컬렉션을 열 때 ShardCount를 다르게 줘도 레이아웃은 바뀌지 않으며 변경하려면 일반 쓰기처럼 기대 버전을 검사하는 Reshard(ctx, expected, count)를 명시적으로 호출해야 한다. 다중 노드 Cluster에서의 장애 조치와 리샤딩은 이 릴리즈에서 아직 검증하지 못했으므로 문서에도 그 범위를 적어 두었다.
delta 검색을 제거한 과정
V3 초기에는 작은 변경을 빠르게 반영하려고 delta 검색을 실험했다. 기본 automaton 하나에 추가분 automaton과 삭제 집합을 덧붙여, 작은 변경마다 전체 엔진을 다시 만들지 않는 방식이다. 키워드 하나를 추가하려고 100만 개짜리 엔진을 다시 만드는 것은 아무래도 낭비처럼 보였기 때문이다.
그런데 측정한 검색 p95는 전체 재빌드 방식의 2.62배, 한국어 사전의 최대 RSS는 1.55배까지 올라 릴리즈 기준을 통과하지 못했다. 검색마다 두 automaton을 확인하고 삭제 집합을 거르는 비용이 재빌드를 아끼는 이득보다 컸던 것이다.
그래서 v1.7.0부터 V3는 모든 버전에 대해 불변 엔진 하나로 검색을 처리한다. DeltaSearch 옵션은 소스 호환성을 위해 타입에 남겨 뒀지만 아무 효과가 없으며 상태에서도 항상 false를 보고한다. 같은 아이디어가 다시 나왔을 때 판단 근거를 참고할 수 있도록 실험의 측정값은 문서의 보관용 페이지에 남겨 두었다.
100만 키워드 측정
v1.7.0에 포함된 단일 엔진 변경을 커밋 212179f에서 2026-09-15에 측정했다. 환경은 Apple M4, Go 1.26.7, Redis 8.10.1 로컬호스트이고, MemoryEfficient 프리셋, 측정용 50ms 폴링을 썼다. 크기와 분포별로 새 Go 프로세스 세 개를 띄워 중앙값을 표에 옮겼으며, 앞의 ShardCount: 4 예제를 측정한 결과는 아니다.
측정 범위와 원시 결과는 원본 보고서에서 볼 수 있고, Redis를 준비해 측정 커밋 212179f를 체크아웃한 뒤 다음 명령으로 재현할 수 있다.
| |
| 분포 | 초기 로드 (s) | 키워드 1개 추가 반영 (s) | 전체 교체 반영 (s) | 최대 RSS (GiB) | 추가 중 검색 p95 (µs) |
|---|---|---|---|---|---|
| 공통 접두사 | 1.198 | 0.520 | 1.643 | 1.111 | 3.834 |
| 다양한 접두사 | 1.708 | 0.879 | 2.099 | 2.395 | 4.166 |
| 한국어 | 2.112 | 1.019 | 2.740 | 2.961 | 4.708 |
표의 “반영"은 Replace를 호출한 뒤 로컬 엔진이 해당 버전으로 검색을 처리할 때까지 걸리는 시간이다. 커밋 자체는 수 ms면 끝나지만 새 버전을 발견하고 엔진을 다시 만드는 시간이 더해지며 키워드 하나만 추가해도 바뀐 세대의 엔진 전체를 다시 만들어 0.5~1초가 걸린다. delta 검색을 제거하면서 이 비용을 받아들인 대신, 업데이트 중에도 검색 지연은 수 µs로 유지했다.
분포에 따라서도 비용 차이가 커서 한국어 키워드는 공통 접두사 키워드보다 메모리를 약 2.7배 쓴다. 문서가 “공통 접두사, 다양한 접두사, 한국어 키워드는 엔진 비용이 다르다. 배포할 호스트에서 직접 측정하라"고 권하는 이유다.
Valkey는 이 측정에 포함하지 않았다. 이전 기준선(R1)에서는 Redis와 Valkey 9.1.2가 비슷한 범위로 측정됐지만 Valkey 9.1.2 소스 빌드가 기본 prefetch 설정에서 SIGSEGV로 종료되는 일이 있었다. 최종 결과도 prefetch-batch-max-size 0으로 얻은 것이라, 문서에서는 이것이 해당 빌드의 기본 설정을 지원한다는 의미는 아니라고 설명한다. 이런 차이가 있기 때문에 측정 조건은 반드시 숫자와 함께 기록해야 한다.
이 숫자는 개발자 워크스테이션에서 잰 값이라 운영 환경의 지연이나 메모리를 보장하지 않으며, 로컬호스트에서 RDB와 AOF를 끈 채 측정했으므로 내구성 비용과 네트워크 비용도 빠져 있다.
V2에서 옮기기
V3로 옮길 때는 V1→V2 마이그레이션과 달리 제자리 변환을 제공하지 않으므로 다음 절차를 따른다.
- 다른 이름을 쓴다.
CopyV2(ctx, sourceName, expected, nil)이 V2의 버전과 키워드를HMGET한 번으로 읽어 V3 대상에 넣고, 원본 버전, 정규화된 개수, 정렬된 키워드 배열의 SHA-256 체크섬을 보고한다. - 리허설한다. 복사하고, 개수와 체크섬, 대표 검색 결과(대소문자 구분, 한국어, 겹치는 매칭)를 비교한다.
- 전환한다. 마지막 복사 동안 V2 쓰기를 멈추고 검증한 뒤 애플리케이션을 새 V3 이름으로 돌린다. 자동 이중 쓰기는 지원하지 않는다.
- 되돌릴 때의 데이터 손실을 확인한다. V3 쓰기가 시작된 뒤 V2로 돌아가면 그 쓰기를 잃는다.
마치며
V3를 적용하기 전에는 사전 크기와 분포에 맞춰 시작 시간과 메모리를 확인하고 쓰기 직후 검색할 인스턴스에서는 WaitForVersion으로 로컬 엔진에 반영될 때까지 기다려야 한다. 백만 키워드 표는 212179f의 단일 엔진 측정값이므로 v1.7.0의 샤딩 설정을 선택할 때는 그 설정으로 다시 측정하는 편이 좋다.
전체 소스 코드는 github.com/skyoo2003/acor에서 확인할 수 있다.