TODO: 트래픽/데이터량 기반 추산 및 그에 맞는 백엔드 아키텍처 리디자인
트래픽 및 데이터량 예상
쿠팡과 같은 대규모 사용자 기반을 가진 서비스들이 고객이라면 로그 수집량과 트래픽이 폭발적으로 증가하기 때문에, 기존 MongoDB 단일 노드 아키텍처로는 감당 불가능하다.
| 항목 | 수치 (가정) |
| 일일 사용자 수 | 약 1,000만명 |
| 사용자당 이벤트 수 | 평균 50개 (페이지 진입, 클릭, 스크롤 등) |
| 이벤트 1건당 크기 | 약 3KB |
| 하루 총 이벤트 수 | 1,000만 × 50 = 5억 건 |
| 하루 총 데이터량 | 5억 × 3KB ≒ 1.055TB/일 |
| 월간 데이터량 | 약 45TB/월 |
🔹 DB에서 중복되는 필드 제거하기

기존에는 DB에 referrs가 저장되고 properties 안에 referrs가 또 저장되는 등 중복되는 데이터가 일부 존재했다. 이미 존재하는 데이터를 중복으로 추가하지 않도록 백엔드 코드를 리팩토링한 결과, 동일 쿼리에 대해 생성된 json 파일의 크기가 2.11KB에서 1.69KB로 약 20% 감소했다. 이것을 하루 데이터량으로 비교한다면 1.055TB/일 → 845GB/일로 대략 210GB가 감소하는 효과를 가진다.
GPT는 엔터프라이즈급 트래픽/데이터량을 줄일 수 있는 방안에 대해 다음과 같이 제안했다.
🔹 Selective Logging (이벤트 필터링)
- 중요 이벤트만 기록 (예: page_view, purchase, exit)
- 너무 자주 발생하는 mousemove, scroll, heartbeat 이벤트는 샘플링 (예: scroll → 1초에 1번만 기록)
- 효과: 이벤트 수 최대 50%까지 감소 가능
🔹 Client-Side Sampling (SDK 단)
- 클라이언트에서 client_id 기준으로 샘플링
-
if (hash(client_id) % 100 >= 5) return; // 5%만 수집
- 또는 트래픽 밀집 시간대만 필터링
- 효과: 고객사별 트래픽 양을 제어, 과금 기준 분리 가능
🔹 Batch 전송
- JSON 한 개씩 보내는 대신 여러 개 묶어 전송:
-
{ events: [ {...}, {...}, {...} ] }
- navigator.sendBeacon() 또는 fetch로 주기 전송
- 효과: 요청 수 감소, TCP/HTTP 오버헤드 ↓, 대역폭 절약
🔹 전송 시 gzip 압축
- SDK → 서버, 또는 서버 → 저장 전 단계에서 gzip 적용
-
fetch("/collect", { method: "POST", headers: { "Content-Encoding": "gzip" }, body: gzip(json) });
-
효과: 일반 JSON 대비 평균 60~70% 용량 감소
🔹 Short Key Format (데이터 포맷 최소화)
- "page_path" → "pp", "timestamp" → "ts" 등
- 키 이름이 20~30% 차지하므로, 이거만 줄여도 크기 절감
- { "en": "page_view","ts": "...", "cid": "...", "pp": "/", ... }
- 효과: 10~20% 절감 가능, 특히 clickstream에서 효과 큼
🔹 Client-Side Pre-Aggregation
- SDK에서 사용자의 활동 요약한 후 전송:
- { "page_path": "/", "click_count": 17, "duration": 82 }
- 효과: 수백 건의 로그를 1개 요약본으로 전송 가능
🔹 TTL + Long-Term Archiving
- MongoDB TTL로 7~30일만 보관 → 이후 압축 or S3 백업
- Long-term 분석용은 Parquet + S3 저장 + Athena로 조회
- 효과: MongoDB 공간 유지 + 쿼리 성능 유지
백엔드 아키텍처 리디자인
🔹 메세지 큐 + insertMany 사용하기
기존에는 SDK에서 우리의 Express 서버에 api 요청을 fetch로 보내면 바로 insertOne으로 mongoDB에 저장하는 방식이었다. 여기서 3000건 정도의 트래픽을 서버로 보내면 점차 서버의 응답 속도가 느려지고, 시각화된 정보가 안정된 값을 가지는 것이 아니라 뒤죽박죽 섞이는 현상이 발생함을 확인했다.
- 작은 insert가 여러 번 발생하게 되면 병목 현상이 심해지고 리소스 낭비가 커진다.
- Node.js는 일반적으로 싱글 스레드 기반이기 때문에 동시에 여러 요청을 처리할 수 없다.
- 물론 async/await를 사용해 비동기적으로 처리할 수 있다. 하지만 mongoDB 입장에서는 연속적으로 수많은 insert 동작을 수행해야 하기 때문에 병목 문제가 해결되지 않은 채로 남아 있다.
이 문제에 대한 해결책으로는 크게 두 가지를 들 수 있다.
- Bulk Insert 적용 (쓰기 최소화)
- Mongo는 insertMany의 경우 쓰기 효율 5~10배 향상된다고 한다.
- 메시지 큐 기반 Write Buffering (비동기 로깅)
- API 서버는 단순히 큐에 넣는 역할만 → 응답속도 매우 빠름
- MongoDB는 Worker가 적절한 속도로 비동기 insert
Mongo에서 insertOne을 여러 번 하는 것 보다 insertMany를 한 번 하는 것이 실제로 더 효율이 좋은지 테스트 코드를 작성해 테스트를 진행했다.
// insertOne.js
const N = 10000;
console.time(`insertOne x ${N}`);
const tasks = Array.from({ length: N }, (_, i) => {
return logs.insertOne({
index: i,
timestamp: new Date(),
message: "load test",
});
});
await Promise.all(tasks); // 동시에(병렬적으로) 실행
console.timeEnd(`insertOne x ${N}`);
// insertMany.js
const N = 10000;
const docs = Array.from({ length: N }, (_, i) => ({
index: i,
timestamp: new Date(),
message: "load test",
}));
console.time(`insertMany x ${N}`);
await logs.insertMany(docs);
console.timeEnd(`insertMany x ${N}`);
| 건수 | insertOne (병렬) | insertMany (일괄) | 속도 차이 |
| 3,000건 | 1.415초 | 0.093초 (93.074ms) | ~15x |
| 5,000건 | 1.830초 | 0.143초 (142.985ms) | ~13x |
| 10,000건 | 3.003초 | 0.178초 (177.611ms) | ~17x |
실험 결과 상당히 유의미한 속도 차이가 났음을 확인했다! 여기서 이 테스트가 로컬 환경에서 진행되었음을 감안해야 한다. 실제 EC2 환경에서 테스트를 진행하면 더 긴 시간이 측정될 것이고, 속도 차이는 6~12배로 예상된다. 하지만 여전히 insertOne보다 insertMany의 효율이 훨씬 더 좋다.
여기에 메세지 큐까지 추가하면 어떻게 될까? 실험 결과는 다음과 같다.
insertMQ x 10000 (100): 1.560s
insertMQ x 10000 (500): 319.672ms
insertMQ x 10000 (1000): 167.45ms
메세지 큐의 배치 사이즈(batch size)가 커질수록 응답 속도가 빨라졌다. 위에서 진행했던 insertMany 테스트보다는 시간이 더 걸렸지만, 해당 테스트에서는 1만 건의 데이터를 한 번만에 삽입했다는 점을 감안하면(배치 사이즈가 10000인 메세지 큐를 쓴 것과 같다), MQ를 사용하는 것이 전략적으로 유리하다고 할 수 있다.
메시지 큐는 두 개 이상의 애플리케이션이나 시스템 컴포넌트 간 비동기 통신을 가능하게 하는 메커니즘이다.
▪ 생산자가 메시지를 큐에 넣으면, 소비자는 큐에서 메시지를 가져와 처리한다.
▪ 메시지 큐는 메시지를 임시 저장하여 시스템 간 결합도를 낮추고, 높은 확장성과 탄력성을 제공한다.
▪ 메시지 큐는 분산 시스템에서 애플리케이션 컴포넌트 간 비동기 통신을 가능하게 하여 결합도를 낮추고 시스템 확장성을 높이는 데 사용된다.
▪ 또한, 신뢰성, 유연성, 탄력성 등의 장점을 제공하여 데이터 손실을 방지하고 다양한 상황에 유연하게 대처할 수 있도록 한다.
메세지 큐에도 Kafka, Redis, RabbitMQ, SQS 등 다양한 종류가 있다. GPT는 그 중에서도 Redis를 추천해줬다.
- Redis MQ 추천 이유
| 이유 | 설명 |
| 1. 메시지 양이 엄청나진 않음 | 초당 수백~수천 이벤트면 Redis로 충분히 커버 가능 |
| 2. 복잡한 MQ 운영 원하지 않음 | Kafka는 클러스터, Zookeeper 등 셋업이 무거움 |
| 3. 딜레이 민감 + 즉시 처리 구조 | Redis는 메모리 기반이라 지연이 매우 낮음 |
| 4. Node.js에서 사용이 매우 간단 | ioredis, redis 모듈로 바로 rpush, lrange, ltrim 사용 가능 |
| 5. 도입/테스트 부담이 적음 | 로컬에서도 1분 만에 돌릴 수 있음 (Docker or 설치) |
- Redis MQ의 특징
| 항목 | 내용 |
| 저장 방식 | 메모리 (선택적으로 AOF 또는 snapshot) |
| 처리 방식 | Producer → rpush / Consumer → lrange+ltrim |
| 단일 서버 구조 | 단순하고 빠름, 수평 확장성은 낮음 (vs Kafka) |
| 내결함성 | AOF 설정 시 복구 가능 (단 기본은 유실 위험 있음) |
| 지연 시간 | 밀리초 단위 (매우 빠름) |
| 도입 난이도 | 매우 쉬움 (vs Kafka: 어려움) |
- 다른 MQ와의 비교
| MQ 종류 | 특징 |
| Redis (추천) | 빠르고 단순, 즉시 처리에 최적 |
| Kafka | 초대규모 스트리밍 처리에 최적, 하지만 무거움 |
| RabbitMQ | 복잡한 라우팅/신뢰성 보장 필요할 때 좋음 |
| AWS SQS | 서버리스 기반, 트래픽 폭주에도 견디지만 유료 + 딜레이 있음 |
| NATS / ZeroMQ | 경량 통신용, 하지만 저장성 약함 |
- 우리 프로젝트와의 적합도
| 조건 | Redis 적합도 |
| MongoDB + Express | ✅ 매우 잘 맞음 |
| 초당 10만 건 이상의 로그 수집 | ❌ Kafka 추천 |
| Redis 설치 및 관리 여건 있음 | ✅ |
| SDK는 1건씩 전송, 서버에서 batch | ✅ |
| 분산 로그 처리, 순서 보장 필요 없음 | ✅ |
하지만 10만/s 이상의 로그를 수집하기 위해서는 Kafka를 사용해야 한다고 한다. 그러나 팀 내부에 Kafka를 경험해 본 인원이 없다면 Kafka를 바로 도입하는 것은 어려워 보이고, MVP 단계에서는 Redis를 사용해서 구현하고 이후에 Kafka로 전환을 고려하는 것이 좋을 것이다. 처음부터 Redis → Kafka 마이그레이션을 염두하고 설계하면 나중에 바꿀 때 쉽다고 한다.
- 구조 요약
[SDK] → (fetch 1건 전송)
↓
[Express API 서버] ← 요청 수신
↓
[메모리/Redis 큐] ← 여기에 임시 저장
↓
[백그라운드 워커] ← 100건씩 모이면 insertMany()
↓
[MongoDB] ← DB에 효율적으로 저장
- Express API 서버: 수집 요청을 큐에 넣기만 함
// collect.js (라우터)
const express = require("express");
const router = express.Router();
const { pushToQueue } = require("../services/queue"); // 메모리 or Redis 큐로 추상화
router.post("/collect", async (req, res) => {
const data = req.body;
try {
await pushToQueue(data); // 큐에 넣기만 함 (DB 저장 아님)
res.status(200).json({ status: "queued" });
} catch (err) {
console.error("Queue error:", err);
res.status(500).json({ error: "Queue push failed" });
}
});
module.exports = router;
- 간단한 메모리 큐 (실전에서는 Redis 추천)
// services/queue.js
const queue = [];
function pushToQueue(event) {
queue.push(event);
return Promise.resolve(); // 비동기 흉내
}
function pullBatchFromQueue(batchSize = 100) {
return queue.splice(0, batchSize);
}
function getQueueLength() {
return queue.length;
}
module.exports = { pushToQueue, pullBatchFromQueue, getQueueLength };
- 워커 프로세스 (queue → insertMany)
// worker.js
const { pullBatchFromQueue } = require("./services/queue");
const { connectMongo } = require("./config/mongo");
async function startWorker() {
const db = await connectMongo();
const logs = db.collection("logs");
setInterval(async () => {
const batch = pullBatchFromQueue(100);
if (batch.length > 0) {
try {
await logs.insertMany(batch);
console.log(`insertMany: ${batch.length}건 저장됨`);
} catch (err) {
console.error("insertMany 실패:", err);
}
}
}, 500); // 0.5초 간격으로 체크
}
startWorker();
GPT는 대규모 (일 10GB+ 이상, 다수 고객사) 서비스의 아키텍처에 대해 다음과 같이 제안했다.
- Event Ingestion Layer
- Kafka Cluster or Amazon MSK
- Kafka Connect로 S3 + Data Warehouse 연동
- 로그 DB: ClickHouse / BigQuery / Snowflake
- 분석 파이프라인: Apache Airflow or dbt
- 대시보드: Metabase / Superset
→ SaaS 분석 플랫폼 수준의 아키텍처 : AppsFlyer, PostHog, Amplitude도 유사 구조 사용.
🔹 권장 리디자인 단계
| 성장 단계 | 추천 스택 예시 |
| 초기 (MVP) | FastAPI + Mongo + EC2 |
| 중기 | FastAPI + Kafka + S3 백업 + DocumentDB |
| 고도화 | Kafka → S3 + ClickHouse + Athena/Presto + 대시보드 툴 |
🔹 성능/비용 최적화를 위한 전략
- MongoDB TTL 인덱스로 오래된 로그 자동 삭제
- 중요 지표만 RDS로 이중 기록 전략 (예: page_view만 MySQL 기록)
- 비정기적 데이터 이관: Mongo → Parquet → S3 저장
- 사용자 단위 sharding: client_id 기준 분산 저장 설계
'Krafton Jungle > 7. 나만무' 카테고리의 다른 글
| [나만무 WEEK5] 나만무 프로젝트 후기 (0) | 2025.07.29 |
|---|---|
| [나만무 WEEK1] 1주차 회고 (0) | 2025.06.24 |