백엔드 데이터 인프라 65편. Redis Secondary Indexing — 주 키가 아닌 속성으로 효율적 조회. Sorted Set 으로 범위·정렬, Set 으로 필터·집합 연산, Hash 로 역인덱스, 지리·전문 검색까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 130편 중 65편이에요. 64편 까지 Redis 패턴을 깊이 풀었다면, 이번 65편은 Secondary Indexing(주 키가 아닌 속성으로 빠르게 조회하는 패턴)을 다뤄요. RDB(관계형 DB)의 세컨더리 인덱스 를 Redis 자료구조로 모방하는 방법이고, Part 4-4 패턴의 마지막 글입니다.
Secondary Indexing이 어렵게 느껴지는 이유
Redis 는 키-값 모델 이라 키가 아닌 속성으로 조회하는 게 네이티브(엔진 자체)로 지원되지 않아요. RDB 에서는 CREATE INDEX 한 줄로 끝나는 작업인데, Redis 에서는 인덱스를 직접 자료구조로 만들어 둬야 합니다.
첫째, 어떤 자료구조를 어떤 쿼리에 매핑할지가 처음에는 안 잡혀요. 범위 쿼리에는 Sorted Set, 필터에는 Set, 정확 매칭에는 Hash가 맞는데, 이 매핑이 한눈에 들어오지 않아서 헤매기 쉽습니다.
둘째, 인덱스 유지가 수동이에요. RDB 는 INSERT 시점에 인덱스가 자동으로 갱신되지만, Redis 는 애플리케이션이 원본 데이터와 인덱스 자료구조를 둘 다 직접 업데이트해야 합니다. 그래서 일관성을 맞추는 일이 까다로워요.
셋째, 복합 조건이 단일 명령으로 안 풀려요. price < 50000 AND category = "electronics" 같은 조건은 Set 교집합이나 Sorted Set 필터링을 엮는 조합 패턴 으로 풀어야 합니다.
이 글에서 5가지 인덱스 패턴(범위·필터·역·Lex·지리) 과 복합 조건·일관성 유지 전략·실무 함정까지 한 번에 정리해요.
인덱스 패턴 1: Sorted Set 으로 범위 인덱스
가장 흔한 패턴이에요. 가격·나이·시간처럼 연속된 값으로 정렬하고 범위 쿼리를 거는 자리에 씁니다.
예제 — 가격대별 상품
# 상품 저장 (Hash)
> HSET product:1 name "iPhone" price 1200000 category electronics
> HSET product:2 name "MacBook" price 2500000 category electronics
> HSET product:3 name "Cup" price 8000 category kitchen
# 가격 인덱스 (Sorted Set, score = 가격, member = 상품 ID)
> ZADD products:by_price 1200000 product:1
> ZADD products:by_price 2500000 product:2
> ZADD products:by_price 8000 product:3
# 10만원 미만 상품
> ZRANGEBYSCORE products:by_price 0 100000
1) "product:3"
# 100만원~300만원 상품
> ZRANGEBYSCORE products:by_price 1000000 3000000
1) "product:1"
2) "product:2"
# 가격순 페이징 (Top 10 저렴한 것)
> ZRANGE products:by_price 0 9 WITHSCORES
핵심은 score 가 정렬 기준이 되고 member 가 ID 자리에 들어간다는 점이에요. 범위 쿼리와 페이징이 한 줄로 끝납니다.
Hash 와 동기화
상품 가격을 갱신할 때는 Hash 와 Sorted Set 을 둘 다 업데이트해야 해요.
def update_price(pid, new_price):
pipe = r.pipeline()
pipe.hset(f"product:{pid}", "price", new_price)
pipe.zadd("products:by_price", {f"product:{pid}": new_price})
pipe.execute()
파이프라이닝(여러 명령을 한 왕복으로 묶기) 으로 네트워크 왕복 한 번이면 끝나요. 진짜 어토믹(중간 끼어들기 불가능한 단일 실행) 이 필요하면 Lua(Redis 임베디드 스크립트 언어) 나 MULTI/EXEC(트랜잭션 묶음) 을 씁니다.
인덱스 패턴 2: Set 으로 필터 인덱스
카테고리·태그·국가처럼 이산 값 으로 필터를 걸 때 쓰는 패턴이에요.
# 각 카테고리에 속한 상품 ID
> SADD products:category:electronics product:1 product:2
> SADD products:category:kitchen product:3
# electronics 카테고리 상품
> SMEMBERS products:category:electronics
복합 조건 — 집합 연산
여기가 정말 중요한 자리에요. 카테고리 = electronics AND 색상 = blue 같은 복합 조건은 집합 연산으로 풀립니다.
> SADD products:category:electronics product:1 product:2 product:5
> SADD products:color:blue product:2 product:7
# AND
> SINTER products:category:electronics products:color:blue
1) "product:2"
# OR
> SUNION products:category:electronics products:color:blue
# AND NOT (electronics 인데 blue 가 아닌)
> SDIFF products:category:electronics products:color:blue
1) "product:1"
2) "product:5"
RDB 의 WHERE category = ? AND color = ? 가 Redis 에서는 Set 교집합 한 줄로 옮겨와요. 인덱스가 잘 박힌 RDB 와 비슷한 속도가 나오고, 인덱스 구조가 단순하면서 분산 환경으로 가져가기 좋다는 점이 장점이에요.
인덱스 패턴 3: 범위 + 필터 복합 조건
여기서 시험 함정이 하나 있어요. "가격 100만원~300만원 AND electronics" 처럼 범위 + 필터 가 섞이면 단일 명령으로 안 풀려서 두 단계로 처리해야 합니다.
옵션 A: 두 Set 교집합 후 가격 필터
# 1. electronics 인 상품 ID
electronics = r.smembers("products:category:electronics")
# 2. 가격 범위 안 상품 ID (Sorted Set + ZRANGEBYSCORE)
in_range = r.zrangebyscore("products:by_price", 1000000, 3000000)
# 3. 교집합 (Python 메모리에서)
result = set(electronics) & set(in_range)
옵션 B: ZINTERSTORE 로 Redis 안에서
# electronics 를 Sorted Set 으로 (score = 0 모두)
> ZADD products:category:electronics:zset 0 product:1 0 product:2 0 product:5
# 두 Sorted Set 교집합 (가격 score 살리기)
> ZINTERSTORE result 2 products:by_price products:category:electronics:zset WEIGHTS 1 0
# 가격 범위로 필터
> ZRANGEBYSCORE result 1000000 3000000
옵션 B 는 모든 처리가 Redis 안에서 끝나서 네트워크 왕복이 적은 대신, electronics 를 Sorted Set 으로 따로 유지하는 비용이 붙어요.
옵션 C: Lua 스크립트
local category_members = redis.call('SMEMBERS', KEYS[1])
local in_range = redis.call('ZRANGEBYSCORE', KEYS[2], ARGV[1], ARGV[2])
local set = {}
for _, m in ipairs(in_range) do set[m] = true end
local result = {}
for _, m in ipairs(category_members) do
if set[m] then table.insert(result, m) end
end
return result
복잡한 쿼리는 Lua 로 가는 게 깔끔하고 어토믹까지 챙겨요.
인덱스 패턴 4: Lexicographic 인덱스
Sorted Set 에서 score 가 같은 멤버 는 사전순으로 정렬되는데, 이 성질을 활용하면 문자열 기반 정렬·검색 을 할 수 있어요. 렉시코그래픽(Lexicographic, 사전식) 인덱스라고 부르는 이유가 여기 있습니다.
# 모든 멤버 score 0 으로 추가 → 사전순 정렬
> ZADD users:by_name 0 "Alice" 0 "Bob" 0 "Charlie" 0 "David"
# 사전순 범위 (A~B 시작)
> ZRANGEBYLEX users:by_name "[A" "[C"
1) "Alice"
2) "Bob"
# Prefix 검색 (B 로 시작)
> ZRANGEBYLEX users:by_name "[B" "(C"
1) "Bob"
[ = 포함, ( = 제외, - = 최소, + = 최대.
활용:
- 자동 완성 (
autocomplete:words) — prefix 검색 - 알파벳 정렬 사용자 목록
- 태그 사전 검색
인덱스 패턴 5: 지리 인덱스 (Geospatial)
48편 데이터 타입 매핑에서 본 지오스페이셜(Geospatial, 위경도 좌표 기반) 인덱스에요. 위치 기반 검색을 엔진 자체에서 지원해 줍니다.
> GEOADD stores 127.0276 37.4979 "store:42"
> GEOADD stores 127.0290 37.4985 "store:99"
# 반경 1km 안
> GEOSEARCH stores FROMLONLAT 127.03 37.50 BYRADIUS 1 km
1) "store:42"
2) "store:99"
# 거리 같이
> GEOSEARCH stores FROMLONLAT 127.03 37.50 BYRADIUS 1 km WITHCOORD WITHDIST
내부 구현을 들여다보면 Sorted Set 위에 지오해시(geohash, 좌표를 문자열로 인코딩)를 얹은 구조예요. score 자리에 geohash 값을 박아 공간적 근접성 을 수치적 근접성 으로 바꿔 둡니다.
인덱스 유지 — 일관성 전략
가장 까다로운 부분이에요. 원본과 인덱스를 둘 다 갱신해야 한다는 뜻이고, 어떻게 묶느냐가 전략의 갈림길입니다.
전략 1: Pipelining (가장 흔함)
def update_product(pid, **changes):
pipe = r.pipeline()
if 'price' in changes:
pipe.hset(f"product:{pid}", "price", changes['price'])
pipe.zadd("products:by_price", {f"product:{pid}": changes['price']})
if 'category' in changes:
# 기존 카테고리에서 제거 + 새 카테고리에 추가
old_cat = r.hget(f"product:{pid}", "category")
pipe.srem(f"products:category:{old_cat}", f"product:{pid}")
pipe.sadd(f"products:category:{changes['category']}", f"product:{pid}")
pipe.hset(f"product:{pid}", "category", changes['category'])
pipe.execute()
장점은 단순하고 어토믹 격리가 어느 정도 보장된다는 점이에요. 단점은 old_cat 을 먼저 읽는 조건부 로직이 별도 호출로 빠지면서 레이스 컨디션(race, 두 요청이 같은 키를 동시에 건드리는 상황) 위험이 생긴다는 점입니다.
전략 2: Lua Script — 진짜 atomic
-- KEYS[1] = product:42
-- KEYS[2] = products:by_price
-- KEYS[3] = products:category:OLD
-- KEYS[4] = products:category:NEW
-- ARGV[1] = new price
-- ARGV[2] = new category
local pid = string.match(KEYS[1], "product:(.+)")
redis.call('HSET', KEYS[1], 'price', ARGV[1], 'category', ARGV[2])
redis.call('ZADD', KEYS[2], ARGV[1], 'product:' .. pid)
redis.call('SREM', KEYS[3], 'product:' .. pid)
redis.call('SADD', KEYS[4], 'product:' .. pid)
return 1
장점은 완전히 어토믹하고 조건부 로직도 안에서 끝낼 수 있다는 점이에요. 단점은 Cluster 환경에서 모든 키가 같은 슬롯(slot, 키가 배정되는 16384개 분할 단위) 에 들어가도록 해시 태그(hash tag, {} 로 같은 슬롯 강제) 를 묶어 줘야 한다는 점입니다.
전략 3: 비동기 인덱스 (eventual consistency)
def update_product(pid, **changes):
r.hset(f"product:{pid}", mapping=changes)
queue.publish("index:rebuild", {"pid": pid})
# 별도 워커가 비동기로 인덱스 갱신
장점은 쓰기 응답이 매우 빠르다는 점이에요. 단점은 잠시 인덱스가 원본과 어긋날 수 있어서 결과적 일관성(eventual consistency, 시간이 지나면 같아지는 모델) 을 허용해야 한다는 점입니다.
한계 — 진짜 검색이 필요하면 RediSearch
여기까지 따라오셨다면 한 가지 의문이 들어요. "풀텍스트 검색·복잡한 쿼리는?" 이라는 질문인데, Redis 자료구조 조합만으로는 한계가 있어요.
- 풀텍스트 검색 (영어 contains·한국어 형태소) — 직접 구현 매우 어려움
- N개 이상 조건 복합 쿼리 — Lua 도 코드 복잡
- 순위 + 필터 결합 — 비효율
이런 영역은 RediSearch (75편) 또는 외부 Elasticsearch / OpenSearch 로 옮겨가는 게 맞아요.
RediSearch 가 제공:
- 풀텍스트 검색 (TF-IDF·BM25)
- Geo·Numeric·Tag 인덱스 native
- Aggregation
- JSON 통합 (RedisJSON)
시험 직전 한 번 더 — Secondary Indexing 함정 압축 노트
- Redis = 키-값 모델 → 주 키 아닌 조회 = 자료구조로 인덱스 직접 만들기
- 인덱스 5가지 = Sorted Set 범위·Set 필터·Set 집합 연산·Lex 인덱스·Geospatial
- Sorted Set 인덱스 = score=값, member=ID → 범위 쿼리·페이징
- 가격대 조회 =
ZRANGEBYSCORE products:by_price min max - Set 인덱스 = SADD 카테고리:category 상품ID
- 복합 조건 = SINTER·SUNION·SDIFF (RDB WHERE AND/OR/NOT)
- 범위 + 필터 복합 = 두 단계 (Set + Sorted Set) 또는 ZINTERSTORE 또는 Lua
- Lexicographic 인덱스 = ZADD score=0 + ZRANGEBYLEX (사전순)
- 자주 쓰는 자리 = 자동 완성·사전 검색
- Geospatial = GEOADD + GEOSEARCH (내부는 Sorted Set + geohash)
- 인덱스 유지 = 원본 + 인덱스 둘 다 갱신 (애플리케이션 책임)
- 유지 전략 — Pipelining (단순) · Lua (atomic 진짜) · 비동기 (eventual)
- Cluster 환경 = 인덱스와 원본이 같은 slot — hash tag 필수
- 한 슬롯 =
product:{42}·products:by_price:{42}같이 묶기 - 어려운 영역 — 풀텍스트 검색·N개 조건 복합·순위+필터 = RediSearch (75편) 또는 ES
- 자주 쓰는 함정 — 인덱스 갱신 누락 → 원본과 인덱스 불일치
- 자주 쓰는 함정 — Set 멤버 너무 많아짐 → 큰 키 부담
- 자주 쓰는 함정 — Cluster 환경 같은 slot 안 잡힘 → CROSSSLOT 에러
- 자주 쓰는 함정 — 비결정적 정렬 키 (timestamp 충돌) → score 에 미세 변동 추가
공식 문서: Redis Secondary Indexing 에서 자세한 사양과 변형 패턴을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 60편 — Redis Lua Scripting (EVAL · EVALSHA)
- 61편 — Redis Functions (Library · FCALL · 영속 스크립트)
- 62편 — Redis 패턴 9가지 한눈에 매핑
- 63편 — Distributed Lock + Redlock 알고리즘
- 64편 — Twitter Clone 패턴 + Fanout 전략
다음 글: