
시스템 디자인 스터디에서 "배달의민족 가게 노출 시스템"을 주제로 선정했습니다. 대규모 트래픽을 처리하는 검색 시스템 설계 경험을 쌓고, 실무에서 마주칠 수 있는 다양한 기술적 트레이드오프를 고민해보기 위함이었습니다.
배민의 가게 검색은 단순해 보이지만, 실제로는 많은 고민이 필요한 시스템입니다:
이 글에서는 어떤 고민을 했고, 어떤 기술적 선택을 했는지 공유하겠습니다.
핵심 기능:
규모:
성능:
확장성:
트래픽 분석 결과 다음과 같은 패턴을 발견했습니다:
전체 요청 분포:
├─ 인기 지역 검색: 20% (강남, 홍대, 잠실 등)
├─ 상세 필터링: 30% (여러 조건 조합)
├─ 개인화: 일부
└─ 실시간 상태 변경: 중요
핵심 인사이트:
[사용자 - Peak QPS 10만]
↓
[Load Balancer]
↓
[API Servers (15대, Auto Scaling)]
↓
┌────┴─────┬──────────┬──────────┐
↓ ↓ ↓ ↓
[로컬캐시] [Redis] [ES] [RDB]
70% 15% 15% Source of
1-2ms 5-10ms 30-50ms Truth
1. API Servers (멀티 서버 + LB)
2. 3-Tier 캐싱
3. ElasticSearch
4. PostgreSQL
5. Redis
6. CDC 파이프라인
읽기 경로 (검색):
Client 요청
↓
로컬 캐시 확인 (인기 지역이면)
↓ Hit (70%) → 1-2ms 응답
↓ Miss
Redis 확인
↓ Hit (15%) → 5-10ms 응답
↓ Miss
ElasticSearch 쿼리 (15%)
↓
store_id 리스트 반환
↓
Redis Materialized View에서 상세 정보 조회
↓
최종 응답 (30-100ms)
쓰기 경로 (가게 정보 변경):
사장님이 가게 정보 변경
↓
PostgreSQL 업데이트
↓
Binary Log (CDC)
↓
Kinesis Streams
↓
Lambda Worker
├─ ElasticSearch 재색인
├─ Redis View 갱신 (배치)
└─ Redis Pub/Sub 발행
↓
모든 API 서버
↓
로컬 캐시 무효화
문제 인식:
Peak QPS 10만을 ElasticSearch가 직접 받으면?
해결책:
캐싱으로 85% 트래픽 차단 → ES는 1.5만 QPS만 처리
10만 QPS 중:
├─ 로컬 캐시: 7만 (70%) ← 인기 지역
├─ Redis: 1.5만 (15%)
└─ ElasticSearch: 1.5만 (15%)
대상:
구조:
각 API 서버 메모리:
local_cache = {
"wydm6:치킨:rating:page:1": {...}, // 강남역
"wydjx:중식:distance:page:1": {...}, // 홍대
...
}
메모리 사용: 5-10MB (서버당)
TTL: 2-3분
성능:
동기화 문제:
서버가 여러 대인데, 어떻게 동기화?
→ Redis Pub/Sub 사용
가게 정보 변경 시:
↓
Lambda Worker
↓
Redis Pub/Sub 발행
"wydm6 지역 캐시 무효화"
↓
모든 API 서버 수신
↓
로컬 캐시 해당 키 삭제
↓
다음 요청 시 다시 캐싱
메시지 유실은?
Eviction 정책: LFU (Least Frequently Used)
메모리 초과 시:
- 사용 빈도 가장 낮은 캐시 삭제
- 단, QuadTree Top 20은 보호 (삭제 금지)
대상:
Key 설계:
stores:list:{geohash}:{category}:{filters_hash}:page:{page}
예시:
stores:list:wydm6:치킨:a3f2d8:page:1
filters_hash = MD5(카테고리+배달팁+별점+정렬)
성능:
Redis Cluster:
구성:
- 3 Master Shards
- 3 Replica Shards
- 총 6 nodes
메모리: 각 16GB (총 48GB)
역할:
응답 시간:
개념:
가게 상세 정보를 미리 조합해서 Redis에 저장
배경:
ElasticSearch에서 store_id만 받아옴
→ 상세 정보는 어디서?
→ Redis Materialized View!
구조:
Key: store:detail:{store_id}
Value (JSON, 10-50KB):
{
"store_id": 123,
"name": "맛있는 치킨",
"description": "...",
"menus": [...],
"reviews": [...],
"business_hours": {...},
"statistics": {...}
}
TTL: 10-30분
갱신:
장점:
질문:
어떤 지역을 로컬 캐시에 넣어야 하나?
옵션:
선택: 동적 선정 (QuadTree)
개념:
지도를 재귀적으로 4분할하여 트래픽 밀집 영역 자동 감지
[서울 전체 - 10만 요청]
↓ 4분할
┌────┴────┐
↓ ↓
[강남 7만] [강북 3만]
↓ 강남이 많네? 더 분할
┌────┴────┐
↓ ↓
[강남역 5만] [삼성 2만]
↓ 강남역이 핫스팟!
vs Geohash 정적 분할:
QuadTree: 트래픽에 따라 크기 변동
Geohash: 고정 크기 격자
QuadTree가 더 효율적!
실용적 접근:
10분마다:
1. 모든 요청의 Geohash 집계
예: wydm6(강남역) → 5만 건
wydjx(홍대) → 3만 건
2. Top 20 Geohash 추출
3. Redis Pub/Sub 발행
"Top 20 목록 업데이트"
4. 모든 API 서버 수신
→ 로컬 캐시 대상 갱신
시간대별 변화 반영:
점심 시간 (12시):
- 업무 지구: 강남, 여의도 🔥
- 주거 지역: 낮음
저녁 시간 (19시):
- 주거 지역: 노원, 송파 🔥
- 업무 지구: 낮음
→ 10분마다 자동 조정!
Geohash란?
위도/경도를 문자열로 인코딩
강남역: (37.498, 127.028) → "wydm6"
정밀도:
- 5자리 (wydm6): 약 2.4km × 2.4km
- 6자리 (wydm6g): 약 610m × 610m
캐시 키에 활용:
기존: stores:list:37.498:127.028:치킨
→ 좌표 조금만 달라도 다른 캐시 (비효율)
개선: stores:list:wydm6:치킨
→ 같은 영역 사용자는 캐시 공유! ⭐
→ 캐시 히트율 대폭 증가
경계 문제 해결:
사용자가 wydm6 경계 근처
- 인접 8개 Geohash도 검색
- wydm3, wydm4, wydm7, wydm9 등
핵심 원칙: 각 DB를 적재적소에 활용
PostgreSQL (RDB)
└─ Source of Truth
- 가게, 메뉴, 리뷰 등
- 트랜잭션
- 관계형 데이터
ElasticSearch
└─ 검색 전용
- 최소 데이터만 (500 bytes)
- 검색/필터링에 필요한 필드만
- 재색인 최소화
Redis
└─ 캐시 + View
- 검색 결과 캐싱
- Materialized View (상세 정보)
문제:
모든 데이터를 ES에 저장하면?
ES 문서에 포함:
- 가게 기본 정보
- 메뉴 전체 (10-50개)
- 영업시간
- 리뷰 (최근 10개)
→ 문서 크기: 10-20KB
메뉴 1개 추가 → 전체 문서 재색인
리뷰 1개 추가 → 전체 문서 재색인
→ 재색인 폭탄! 💸
해결: 최소 데이터만 저장
ES 문서 (500 bytes):
{
"store_id": 123,
"name": "맛있는 치킨",
"category": "치킨",
"location": {"lat": 37.498, "lon": 127.028},
"geohash_5": "wydm6",
"rating": 4.5,
"review_count": 1234,
"delivery_fee": 2000,
"min_order": 15000,
"is_open": true,
"thumbnail": "https://..."
}
재색인 트리거:
✅ 재색인 필요:
- 가게명 변경
- 카테고리 변경
- 위치 변경 (이사)
- 배달팁, 별점 변경
❌ 재색인 불필요:
- 메뉴 추가/수정
- 상세 설명 변경
- 영업시간 변경
- 리뷰 개별 추가
→ RDB만 업데이트, ES 영향 없음
별점 업데이트:
리뷰 100개 추가 (10분 동안)
→ 배치 작업 (10분마다)
→ 영향받은 가게만 ES 업데이트
→ 재색인 1번만!
[PostgreSQL]
↓ Binary Log
[Debezium / AWS DMS]
↓ Change Event
[Kinesis Streams]
↓ 실시간 스트리밍
[Lambda Worker] ←─── [배치 Server (Fallback)]
↓
┌───┴────┬──────────┬──────────┐
↓ ↓ ↓ ↓
[ES] [Redis [Redis [로컬캐시
재색인 View] Pub/Sub] 무효화]
Lambda Worker:
역할:
1. ES 재색인 (중요 필드만)
2. Redis Pub/Sub 발행 (즉시)
3. 변경 이벤트 큐잉 (배치용)
배치 Server (Fallback):
역할:
1. Redis View 재조합 (10분마다)
2. Lambda 놓친 것 보정
3. 데이터 일관성 검증
트리거:
- 주기적 (1시간마다)
- Lambda 장애 시 (5분으로 단축)
검색 결과 조회:
1. ES 검색 → store_id 리스트
[123, 456, 789, ...]
2. Redis MGET로 상세 정보 조회
MGET store:detail:123 store:detail:456 ...
3. 병합해서 응답
각 가게의 전체 정보 포함
Redis Miss 시:
Redis에 없으면?
→ PostgreSQL 직접 조회
→ 여러 테이블 JOIN
→ Redis에 저장 (다음 요청 대비)
→ 느리지만 (20-50ms) 드물게 발생
배민의 실제 검색 패턴:
구조화 검색 (50%):
"강남 치킨"
→ 지역(구조) + 카테고리(구조)
키워드 검색 (30%):
"바삭한 치킨"
"매운 떡볶이"
→ 형용사(비정형) + 명사(구조)
자연어 검색 (20%):
"야식으로 좋은 치킨집"
"혼자 먹기 좋은 중식"
→ 완전 비정형
검색 대상:
강력한 텍스트 분석:
검색어: "바삭한 치킨"
Analyzer:
1. 형태소 분석 (nori)
"바삭한" → "바삭", "하다"
"치킨" → "치킨", "닭"
2. 유사어 확장
"바삭" → "크리스피", "바사삭"
3. Fuzzy Search (오타 허용)
"바사삭" → "바삭"
결과:
1. "바삭바삭 후라이드" ⭐⭐⭐
2. "크리스피 치킨" ⭐⭐
3. "바사삭 닭강정" ⭐
Inverted Index:
일반 DB:
전체 문서 Scan → "바삭" 포함 찾기 (느림)
ElasticSearch:
Inverted Index:
"바삭" → [doc123, doc456, doc789]
→ 즉시 찾음! ⚡
복합 쿼리:
{
"query": {
"bool": {
"must": [
{"match": {"description": "바삭한"}}, // 비정형
{"term": {"category": "치킨"}} // 구조
],
"filter": [
{"geo_distance": {...}}, // 지역
{"range": {"delivery_fee": {"lte": 3000}}}
]
}
},
"sort": [
{"_score": "desc"}, // 검색 점수
{"rating": "desc"} // 별점
]
}
이 부분이 가장 중요한 의사결정 포인트였습니다!
DynamoDB 검토 배경:
치명적 한계: 비정형 검색 불가능
DynamoDB FilterExpression:
- "바삭한" 정확히 매칭만 가능
- 형태소 분석 ❌
- 유사어 검색 ❌
- 오타 허용 ❌
- Scan 필요 (전체 테이블) → 💸💸💸
비용 문제:
"바삭한" 검색:
→ 100만 개 가게 모두 Scan
→ FilterExpression으로 필터링
→ 100만 개에 대한 RCU 과금!
→ Peak 시 10만 QPS × 100만 개
→ 재앙... 💸💸💸
결론:
❌ DynamoDB로는 비정형 검색 불가능
✅ ElasticSearch 필수!
요구사항:
아키텍처:
[사용자 요청]
↓
API Server (Orchestration)
↓
┌──────────┬──────────┬──────────┬──────────┐
↓ ↓ ↓ ↓ ↓
[캐시/ES] [광고 [개인화 [실시간 [RDB]
일반가게 서비스] 서비스] 상태] 필요시
↓
병렬 조회 (Promise.all)
↓
[조합 & 정렬]
↓
최종 리스트 (20개)
별도 Microservice:
Input:
- 사용자 위치 (geohash)
- 카테고리
- 검색 조건
Output:
- 광고 가게 리스트
- 우선순위 순 (입찰가, CTR 기반)
API:
GET /ads/search?geohash=wydm6&category=치킨&limit=5
광고 데이터 저장소:
DynamoDB 또는 별도 RDB:
- campaign_id
- store_id
- target_region
- target_category
- bid_amount (입찰가)
- budget (예산)
- ctr (클릭률)
개인화 데이터 (DynamoDB):
사용자별:
- favorites: [store_123, store_456]
- recent_orders: [store_789]
- preference_category: "치킨"
- preference_price_range: [15000, 25000]
API:
GET /personalization/user/{user_id}
1단계: 병렬 조회 (50-100ms)
const [stores, ads, personalization, statuses] =
await Promise.all([
getStores({geohash, category}), // 캐시/ES
getAds({geohash, category}), // 광고 서비스
getPersonalization({userId}), // 개인화 서비스
getRealtimeStatuses() // Redis
]);
2단계: 점수 계산 (5-10ms)
stores.forEach(store => {
// 기본 점수 (ES에서)
let score = store._score;
// 개인화 점수 추가
if (personalization.favorites.includes(store.store_id)) {
score += 50; // 찜한 가게
}
if (personalization.recent_orders.includes(store.store_id)) {
score += 30; // 최근 주문
}
// 실시간 상태 병합
store.is_open = statuses[store.store_id]?.is_open ?? store.is_open;
store.final_score = score;
});
3단계: 정렬 & 광고 삽입 (1-2ms)
// 점수 순 정렬
stores.sort((a, b) => b.final_score - a.final_score);
// 광고 삽입 (1, 6, 11, 16번 위치)
const result = [];
const adPositions = [0, 5, 10, 15];
for (let i = 0; i < 20; i++) {
if (adPositions.includes(i) && ads[adIndex]) {
result.push({...ads[adIndex], is_sponsored: true});
adIndex++;
} else if (stores[storeIndex]) {
result.push(stores[storeIndex]);
storeIndex++;
}
}
총 응답 시간: 60-120ms ✅ (목표 200ms 이내)
일반 가게 리스트:
광고:
개인화:
우리 팀은 DynamoDB를 두 가지 용도로 검토했습니다.
고려 이유:
결론: ❌ 부적합
이유:
1. 복잡한 관계형 데이터
- 가게 ↔ 메뉴 (1:N)
- 가게 ↔ 리뷰 (1:N)
- JOIN 필수
2. Materialized View 생성 복잡
- 여러 테이블 조합 어려움
- RDB 쿼리 하나면 끝날 것을
여러 번 조회 + 애플리케이션 병합
3. 트랜잭션 제약
- 가게 등록 시: 가게+메뉴+영업시간 (원자성)
- DynamoDB 트랜잭션은 제한적
4. 데이터 무결성
- Foreign Key 없음
- 애플리케이션에서 보장 → 버그 위험
고려 이유:
결론: ❌❌❌ 절대 불가능
치명적 한계:
1. 비정형 검색 불가능 (핵심!)
- "바삭한 치킨" 검색 불가
- Full-text Search 없음
- 형태소 분석 불가
2. 복잡한 필터링 제약
GSI 구조:
PK: geohash#category
SK: rating
Query:
- PK + SK 범위 조건만 가능
- 다른 조건은 FilterExpression
→ 쿼리 후 필터링 (비용 폭탄)
예: "강남 치킨, 배달팁 3000원 이하"
- geohash#category로 쿼리 → 500개
- FilterExpression 적용 → 20개만 남음
- 500개에 대한 RCU 과금! 💸
3. GSI 제한
- 최대 20개
- 필터 조합 수백 가지 → 불가능
4. 정렬 제약
- SK 하나만 정렬 가능
- 복합 정렬 불가
(별점 DESC + 배달시간 ASC 동시 불가)
비용 비교:
ElasticSearch:
- 고정 비용: 월 $1,500
- 실제 조회: 1.5만 QPS (캐시 85%)
DynamoDB:
- FilterExpression 사용 시
- 평균 500개 읽고 20개 반환
- 1.5만 QPS × 500개 = 750만 RCU/초
- 💸💸💸 재앙적 비용
✅ 적합:
- 개인화 데이터 (찜, 최근 주문)
- 세션 관리
- 실시간 상태 (배달 가능 여부)
- Key-Value 조회만 필요한 경우
❌ 부적합:
- 검색 엔진 (ElasticSearch 필수)
- Source of Truth (RDB 필수)
- 복잡한 관계형 데이터
검토:
ElasticSearch Shard를 지역별로 분산?
Shard 1: 강남 지역 가게
Shard 2: 홍대 지역 가게
Shard 3: 잠실 지역 가게
...
장점:
- 같은 지역 검색 시 Single Shard Query
- 성능 향상 가능
미채택 이유:
1. QuadTree와 맞지 않음
- QuadTree: 동적 영역 (시간대별 변화)
- 지역 샤딩: 정적 영역
→ 불일치
2. 캐시 효율이 높음
- 85% 캐시 히트
- ES 조회 자체가 적음 (15%)
→ 샤딩 최적화 효과 미미
3. Hot Shard 우려
- 강남 Shard에 부하 집중
- 불균형 발생
4. Round-Robin이 더 적합
- 인기 지역도 여러 Shard에 분산
- 균등한 부하 분산
- 단순함
최종 선택: Round-Robin 샤딩
store_id 해싱으로 균등 분산
→ 6 Shards에 고르게 배치
→ 안정적인 성능
Redis Pub/Sub vs Redis Streams
Redis Pub/Sub:
✅ 간단, 빠름
✅ Redis 기본 기능
❌ 메시지 유실 가능 (구독자 오프라인 시)
❌ At-most-once
Redis Streams:
✅ 메시지 저장됨
✅ Consumer Group
✅ At-least-once
❌ 복잡도 증가
❌ Consumer 관리 필요
선택: Redis Pub/Sub
이유:
1. TTL 백업 (2-3분)
- 메시지 놓쳐도 TTL 만료 시 자동 삭제
2. 허용 가능한 지연
- 최악 3분 동안 오래된 데이터
- 가게 정보는 실시간 필수 아님
3. 단순함
- 운영 부담 최소화
응답 시간 분석:
Use Case별:
├─ 인기 지역 (70%): 1-2ms (로컬 캐시) ⭐
├─ Redis Hit (15%): 5-10ms
├─ ES 조회 (15%): 30-50ms
└─ 광고/개인화 추가: +20-50ms
평균 응답 시간: 50-100ms
Peak 응답 시간: 100-150ms
목표 200ms: ✅ 달성
QPS 처리 능력:
Peak 10만 QPS 분산:
├─ 로컬 캐시: 7만 (서버당 4,667)
├─ Redis: 1.5만 (Cluster 분산)
└─ ElasticSearch: 1.5만 (6 shards, 각 2,500)
ES 처리 여유:
- ES 노드당 처리량: 1,000-3,000 QPS
- 현재 부하: 833 QPS/node
→ ✅ 여유 있음
캐시 히트율:
목표: 85% 이상
├─ 로컬 캐시: 90-95% (인기 지역)
├─ Redis: 70-80%
└─ 전체: 85% ✅
인프라 비용:
ElasticSearch (18 nodes):
- 6 Data nodes (각 r5.xlarge) × 3 (Master+2 Replica)
- 월 $1,500
PostgreSQL RDS:
- db.r5.xlarge (Master)
- db.r5.large (Read Replica 2대)
- 월 $600
Redis Cluster:
- 6 nodes (각 cache.r5.large)
- 월 $400
Kinesis Streams:
- 3 Shards
- 월 $30
Lambda:
- 월 실행 시간 기준
- 월 $50
배치 서버 (ECS Fargate):
- 월 $100
DynamoDB (개인화):
- On-Demand 모드
- 월 $100
CloudWatch 모니터링:
- 월 $50
총 예상 비용: $2,830/월
비용 대비 효과:
캐싱 없이 ES만으로 10만 QPS 처리:
- ES 노드 50대 이상 필요
- 월 $5,000+
현재 설계 (캐싱 포함):
- 월 $2,830
→ 약 50% 절감 💰
예상 부하:
Peak QPS: 30만 (현재의 3배)
캐싱 비율 유지 시:
├─ 로컬 캐시: 21만 (70%)
├─ Redis: 4.5만 (15%)
└─ ES: 4.5만 (15%)
필요 리소스:
API Servers:
- 현재 15대 → 45대 (Auto Scaling)
ElasticSearch:
- 18 nodes → 54 nodes (3배)
- 월 $4,500
Redis Cluster:
- 6 nodes → 18 nodes
- 월 $1,200
PostgreSQL:
- Read Replica 추가 (2대 → 6대)
- 월 $1,200
총: $7,000/월 (약 2.5배 증가)
→ 트래픽은 3배인데 비용은 2.5배
→ 캐싱 효과로 비용 효율 증가 ⭐

단순히 "캐싱을 한다"가 아니라
"어떻게 효율적으로 캐싱할 것인가"가 핵심
3-Tier 캐싱으로:
- 85% 트래픽 차단
- ES 부하 대폭 감소
- 비용 50% 절감
"만능 기술"은 없다!
ElasticSearch:
- 검색/필터링에 최적
- 하지만 Source of Truth로는 부적합
PostgreSQL:
- 관계형 데이터 관리 최적
- 하지만 대규모 검색은 부적합
DynamoDB:
- Key-Value 조회 최적
- 하지만 복잡한 쿼리는 부적합
→ 각각의 강점을 활용하는 조합이 최선!
정적 설정:
- 강남, 홍대 등 고정
동적 감지 (QuadTree):
- 시간대별 변화 반영
- 효율성 증가
비용은 조금 증가하지만
효과는 훨씬 크다!
모든 선택에는 장단점이 있다:
DynamoDB 검토:
- 운영 편의 vs 기능 제약
→ 기능 제약이 너무 커서 포기
로컬 캐시:
- 복잡도 증가 vs 성능 향상
→ 성능 효과가 커서 채택
지역 샤딩:
- 최적화 vs 복잡도
→ 캐시로 충분해서 미채택
이 설계는 이론이 아닙니다. 실제 대규모 서비스에서 사용되는 패턴들입니다:
우버, 에어비엔비:
- PostgreSQL + ElasticSearch 조합
- 3-Tier 캐싱
넷플릭스:
- DynamoDB (개인화)
- ElasticSearch (검색)
아마존:
- DynamoDB (장바구니, 세션)
- 검색은 별도 엔진
1. 더 깊은 논의 부족
2. 비용 검증 부족
2시간동안 진행된 시스템 디자인 스터디였습니다. 처음에는 단순해 보였던 "가게 검색"이 얼마나 복잡하고 흥미로운 문제인지 깨달았습니다.
특히 DynamoDB를 검토하면서 "비정형 검색의 중요성"을 체감했고, ElasticSearch가 왜 필수인지 명확히 이해하게 되었습니다.
캐싱 전략 설계는 가장 재미있었던 부분입니다. 단순히 Redis를 쓰는 것이 아니라, QuadTree로 동적으로 인기 지역을 감지하고, 로컬 캐시까지 활용하는 3-Tier 전략은 실무에서도 충분히 활용 가능한 패턴입니다.
이 경험을 바탕으로 다음에는 더 깊이 있는 주제로 도전해보고 싶습니다:
긴 글 읽어주셔서 감사합니다! 🙏
시스템 디자인 참고:
기술 스택 공식 문서:
스터디: 시스템 디자인 스터디, 테크다이브 2기 2회차
스터디 진행일: 2026년 2월 19일
태그: 시스템디자인 ElasticSearch 캐싱전략 대규모트래픽 아키텍처