๋์ ์ปค์คํ ํ๋ ๊ฒ์์์ MySQL EAV ํจํด์ ํ๊ณ๋ฅผ Elasticsearch๋ก ํด๊ฒฐํ ์ฑ๋ฅ ์ต์ ํ ํ๋ก์ ํธ
๐ Live Demo: eav-elasticsearch-benchmark.vercel.app
50๋ง ๊ฑด ๋ฐ์ดํฐ ๊ธฐ์ค, ์ปค์คํ ํ๋ ๊ฒ์์์ ์ต๋ 218๋ฐฐ ์ฑ๋ฅ ๊ฐ์ ๋ฌ์ฑ
| ์๋๋ฆฌ์ค | MySQL (EAV) | Elasticsearch | ๊ฐ์ ์จ |
|---|---|---|---|
| ์ปค์คํ ํ๋ ํํฐ | 1,847ms | 21ms | 88x |
| ์ปค์คํ ํ๋ ์ ๋ ฌ | 3,521ms | 19ms | 185x |
| ํํฐ + ์ ๋ ฌ ์กฐํฉ | 5,234ms | 24ms | 218x |
- Transactional Outbox Pattern: MySQL-ES ๊ฐ ๋ฐ์ดํฐ ์ผ๊ด์ฑ ๋ณด์ฅ
- BullMQ ๊ธฐ๋ฐ ๋น๋๊ธฐ ๋๊ธฐํ: ํ๊ท ~10ms ๋ด ES ๋ฐ์
- Clean Architecture: Domain/Application/Infrastructure ๋ ์ด์ด ๋ถ๋ฆฌ
- EAV โ Document ๋น์ ๊ทํ: 5.64GB โ 312MB (18๋ฐฐ ์ ์ฅ ํจ์จ)
Salesforce์ ๊ฐ์ SaaS ํ๋ซํผ์ ์ฌ์ฉ์๊ฐ ๋์ ์ผ๋ก ์ปค์คํ ํ๋๋ฅผ ์ถ๊ฐํ ์ ์์ต๋๋ค. ์ด๋ฅผ RDBMS์์ ๊ตฌํํ๋ ์ ํต์ ์ธ ๋ฐฉ๋ฒ์ EAV ํจํด์ ๋๋ค:
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ contacts โ โ custom_field_values โ
โโโโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ id โโโโโโโโถโ contact_id (FK) โ
โ email โ โ field_definition_id (FK) โ
โ name โ โ value โ
โ created_at โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโ โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ custom_field_definitions โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ id โ
โ api_name (tier__c) โ
โ field_type โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๋ฌธ์ ์ :
| ์์ | EAV ์ฟผ๋ฆฌ ๋ณต์ก๋ | ์ฑ๋ฅ |
|---|---|---|
| ์ปค์คํ ํ๋ ๊ฒ์ | N๊ฐ์ ์๋ธ์ฟผ๋ฆฌ ํ์ | O(N ร M) |
| ์ปค์คํ ํ๋ ์ ๋ ฌ | PIVOT ์ฟผ๋ฆฌ + GROUP BY | ๋งค์ฐ ๋๋ฆผ |
| ์ง๊ณ (GROUP BY) | ๋ณต์กํ CASE WHEN | ๋งค์ฐ ๋๋ฆผ |
50๋ง ๊ฑด ๊ธฐ์ค ์ฑ๋ฅ ๋น๊ต:
| ์์ | MySQL (EAV) | Elasticsearch |
|---|---|---|
| ๋จ์ ๋ชฉ๋ก ์กฐํ | ~200ms | ~15ms |
| ์ปค์คํ ํ๋ ์ ๋ ฌ | ~3,000ms | ~20ms |
| ์ปค์คํ ํ๋ ํํฐ + ์ ๋ ฌ | ~5,000ms+ | ~25ms |
{
"id": "uuid-1234",
"email": "john@example.com",
"name": "John Doe",
"customFields": {
"tier__c": "GOLD",
"score__c": 95,
"notes__c": "VIP ๊ณ ๊ฐ"
},
"createdAt": "2024-01-15T09:00:00Z",
"updatedAt": "2024-01-15T09:00:00Z"
}์ฅ์ :
- ๋ชจ๋ ํ๋๊ฐ ํ๋์ ๋ฌธ์์ โ JOIN ๋ถํ์
- ์ญ์ธ๋ฑ์ค(Inverted Index)๋ก ๋น ๋ฅธ ๊ฒ์
- ์ปค์คํ ํ๋ ์ ๋ ฌ/์ง๊ณ๊ฐ ๊ธฐ๋ณธ ํ๋์ ๋์ผํ ์ฑ๋ฅ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Client (Next.js) โ
โ https://eav-elasticsearch-benchmark.vercel.app โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ NestJS API Server โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ContactController โ โ
โ โ โ โ
โ โ GET /search โโโฌโโ dataSource=es โโโโถ ElasticsearchService โ โ
โ โ โ โ โ
โ โ โโโ dataSource=mysql โโถ MySQL (EAV) ์ง์ ์ฟผ๋ฆฌ โ โ
โ โ โ โ
โ โ POST/PATCH/DELETE โโโถ ContactService โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ContactService โ โ
โ โ โ โ
โ โ 1. MySQL ํธ๋์ญ์
์์ โ โ
โ โ 2. Contact ์ ์ฅ โ โ
โ โ 3. Outbox ์ด๋ฒคํธ ์ ์ฅ (๊ฐ์ ํธ๋์ญ์
) โ โ
โ โ 4. ํธ๋์ญ์
์ปค๋ฐ โ โ
โ โ 5. BullMQ์ ์์
์ถ๊ฐ (best-effort) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ โ
โผ โผ โผ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ MySQL โ โ Redis โ โ Elasticsearchโ
โ โ โ โ โ โ
โ - contacts โ โ BullMQ Queue โ โ contacts โ
โ - field_ โ โ (es-sync) โ โ index โ
โ values โ โ โ โ โ
โ - outbox โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโถโ ๋น์ ๊ทํ๋ โ
โ โ Cron์ด โ โ Worker๊ฐ โ ๋ฌธ์ ์ ์ฅ โ
โ โ PENDING โ โ ES ๋๊ธฐํ โ โ
โ โ ์ด๋ฒคํธ ์ฒ๋ฆฌโ โ โ โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
๋ฐ์ดํฐ ์ผ๊ด์ฑ์ ๋ณด์ฅํ๋ฉด์ ๋น๋๊ธฐ๋ก ES๋ฅผ ๋๊ธฐํํฉ๋๋ค.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Write Path (์ฐ๊ธฐ) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ MySQL Transaction โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ 1. Contact INSERT/UPDATE/DELETE โ โ
โ โ 2. Outbox INSERT (PENDING ์ํ) โ โ
โ โ โ โ
โ โ โ ๊ฐ์ ํธ๋์ญ์
= ์์์ฑ ๋ณด์ฅ โ โ
โ โ โ Contact ์ ์ฅ ์คํจ ์ Outbox๋ ๋กค๋ฐฑ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Best-Effort Queue โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ํธ๋์ญ์
์ปค๋ฐ ํ BullMQ์ ์์
์ถ๊ฐ ์๋ โ โ
โ โ โ โ
โ โ ์ฑ๊ณต โ Worker๊ฐ ์ฆ์ ES ๋๊ธฐํ (~10ms) โ โ
โ โ ์คํจ โ Outbox Cron์ด ๋์ค์ ์ฒ๋ฆฌ (~10์ด ํ) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Background Processors โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ EsSyncProcessor (BullMQ Worker) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Queue์์ ์์
์์ โ โ
โ โ โ CONTACT_CREATED: ES ๋ฌธ์ ์์ฑ โ โ
โ โ โ CONTACT_UPDATED: ES ๋ฌธ์ ์
๋ฐ์ดํธ โ โ
โ โ โ CONTACT_DELETED: ES ๋ฌธ์ ์ญ์ โ โ
โ โ โ โ
โ โ ์คํจ ์ ์๋ ์ฌ์๋ (BullMQ ๋ด์ฅ) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OutboxProcessor (Cron - Fallback) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ @Cron(EVERY_10_SECONDS) โ โ
โ โ โ PENDING ์ํ ์ด๋ฒคํธ โ Queue์ ์ถ๊ฐ โ DONE ์ฒ๋ฆฌ โ โ
โ โ โ โ
โ โ @Cron(EVERY_MINUTE) โ โ
โ โ โ FAILED ์ด๋ฒคํธ ์ฌ์๋ (์ต๋ 5ํ) โ โ
โ โ โ โ
โ โ @Cron(EVERY_DAY_AT_MIDNIGHT) โ โ
โ โ โ 7์ผ ์ง๋ DONE ์ด๋ฒคํธ ์ ๋ฆฌ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
PENDING โโโฌโโโถ PROCESSING โโโฌโโโถ DONE (์ฑ๊ณต)
โ โ
โ โโโโถ FAILED โโโฌโโโถ PROCESSING (์ฌ์๋)
โ โ
โ โโโโถ FAILED (์ต๋ 5ํ ํ ํฌ๊ธฐ)
โ
โโโโถ (๋ค๋ฅธ ํ๋ก์ธ์ค๊ฐ ์ ์ )
src/
โโโ domain/ # ๋๋ฉ์ธ ๋ ์ด์ด (์์ ๋น์ฆ๋์ค ๋ก์ง)
โ โโโ contact/
โ โ โโโ contact.domain.ts
โ โโโ customField/
โ โโโ customFieldDefinition.domain.ts
โ โโโ customFieldValue.domain.ts
โ
โโโ application/ # ์ ํ๋ฆฌ์ผ์ด์
๋ ์ด์ด (์ ์ค์ผ์ด์ค)
โ โโโ contact/
โ โ โโโ contact.service.ts # Contact CRUD + Outbox
โ โ โโโ contact-search.service.ts # MySQL/ES ๊ฒ์
โ โโโ customField/
โ โโโ customField.service.ts
โ
โโโ infrastructure/ # ์ธํ๋ผ ๋ ์ด์ด
โ โโโ elasticsearch/
โ โ โโโ elasticsearch.service.ts # ES ์ธ๋ฑ์ฑ/๊ฒ์
โ โโโ queue/
โ โ โโโ es-sync.processor.ts # BullMQ Worker
โ โ โโโ es-sync.types.ts
โ โโโ outbox/
โ โ โโโ outbox-processor.service.ts # Cron Fallback
โ โโโ persistence/
โ โโโ typeorm/
โ โโโ entity/
โ โ โโโ contact.entity.ts
โ โ โโโ fieldDefinition.entity.ts
โ โ โโโ fieldValue.entity.ts
โ โ โโโ outbox.entity.ts
โ โโโ repository/
โ
โโโ interface/ # ์ธํฐํ์ด์ค ๋ ์ด์ด (API)
โ โโโ http/
โ โโโ contact/
โ โโโ contact.controller.ts
โ
dashboard/ # Next.js ํ๋ก ํธ์๋
โโโ src/
โ โโโ app/
โ โโโ components/
โ โ โโโ ContactsTable.tsx
โ โ โโโ DataSourceToggle.tsx # MySQL/ES ์ ํ
โ โ โโโ QueryTimeDisplay.tsx # ์ฟผ๋ฆฌ ์๊ฐ ํ์
โ โโโ hooks/
โ โโโ useContacts.ts
Backend: NestJS ยท TypeORM ยท TypeScript Database: MySQL 8.0 (EAV) ยท Elasticsearch 8.11 Queue: BullMQ ยท Redis Frontend: Next.js 14 ยท React Query ยท TailwindCSS Infra: Docker Compose
# Docker Compose๋ก MySQL, ES, Redis ์คํ
docker compose up -d
# ์ํ ํ์ธ
docker compose ps# ์์กด์ฑ ์ค์น
npm install
# ๊ฐ๋ฐ ์๋ฒ ์คํ
npm run start:dev# ๊ธฐ๋ณธ (50๋ง ๊ฑด)
npm run seed:large
# ES๋ง ์ฌ์์ฑ
npm run seed:es-only
# ์๋ ํ
์คํธ (1๋ง ๊ฑด)
npm run seed:smallcd dashboard
npm install
npm run dev- Dashboard: http://localhost:4000
- API: http://localhost:3000
- Kibana: http://localhost:5601
# Elasticsearch ๊ฒ์ (๋น ๋ฆ)
GET /api/v1/contacts/search?dataSource=es&page=1&pageSize=20
# MySQL ๊ฒ์ (EAV - ๋๋ฆผ)
GET /api/v1/contacts/search?dataSource=mysql&page=1&pageSize=20
# ํค์๋ ๊ฒ์
GET /api/v1/contacts/search?dataSource=es&search=john
# ์ปค์คํ
ํ๋ ํํฐ
GET /api/v1/contacts/search?dataSource=es&filter=[{"field":"tier__c","operator":"eq","value":"GOLD"}]
# ์ปค์คํ
ํ๋ ์ ๋ ฌ
GET /api/v1/contacts/search?dataSource=es&sort=[{"field":"score__c","direction":"desc"}]# ์์ฑ
POST /api/v1/contacts
{
"email": "new@example.com",
"name": "New User",
"customFields": {
"tier__c": "SILVER",
"score__c": 50
}
}
# ์กฐํ
GET /api/v1/contacts/:id
# ์์
PATCH /api/v1/contacts/:id
{
"name": "Updated Name",
"customFields": {
"tier__c": "GOLD"
}
}
# ์ญ์
DELETE /api/v1/contacts/:id| ์ ์ฅ์ | ์ฉ๋ | ์ค๋ช |
|---|---|---|
| MySQL (์ ์ฒด) | 5.64 GB | contacts + custom_field_values (EAV) |
| Elasticsearch | 312.8 MB | 50๋ง ๋ฌธ์ (๋น์ ๊ทํ) |
MySQL: โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 5.64 GB
ES: โโ 312.8 MB (18๋ฐฐ ์์)
EAV ํจํด์ ์ ์ฐํ์ง๋ง ์ ์ฅ ํจ์จ์ด ๋ฎ์ต๋๋ค. Contact 1๊ฐ๋น ์ปค์คํ ํ๋ 10๊ฐ = ํ 10๊ฐ (500๋ง ํ ํญ๋ฐ) ES์ ๋น์ ๊ทํ + Lucene ์์ถ์ด ์คํ๋ ค ๋ ํจ์จ์ ์ ๋๋ค.
- ํธ๋ ์ด๋์คํ ์ค๊ณ: RDBMS์ ACID vs ๊ฒ์ ์์ง์ ์ฑ๋ฅ, ์์ชฝ ์ฅ์ ์ ์ด๋ฆฌ๋ ์ํคํ ์ฒ
- ๋ถ์ฐ ์์คํ ์ ์ผ๊ด์ฑ: Transactional Outbox๋ก Eventually Consistent ๋ณด์ฅ
- ์ค์ธก ๊ธฐ๋ฐ ์์ฌ๊ฒฐ์ : ์ถ์ธก์ด ์๋ ๋ฒค์น๋งํฌ ์์น๋ก ๊ธฐ์ ์ ํ ๊ฒ์ฆ
MIT License