MAU 1500만 부하 실험 랩
단일 머신에서 1,500만 MAU를 가정한 부하 테스트 구조와 실험 계획
load-test-mau-15m · 1단계 단일 머신
make up 한 번에 컨테이너 10개가 뜬다. 요청은 k6에서 출발해 nginx·앱 3대를 지나 MySQL과 Redis에서 멈추고, 그 동안의 수치는 별도 경로로 Prometheus에 모인다. 아래는 그 두 경로의 구조다.
- 설계 목표
- 16,000RPS
- 피크 DB
- 9,600QPS
- 피크 Redis
- 38,000QPS
- M1 Pro 예상 상한
- 5~8kRPS
첫 기동에서 앱 3대가 죽었다 — healthcheck를 TCP로 고쳤다
MySQL이 TCP를 받기 시작한 건 06:07:16인데 app1~3은 06:07:06에 붙으러 갔다가 Connection refused로 종료됐다. 시드(곡 100만 행)를 돌리는 동안 MySQL은 임시 서버를 --skip-networking으로 띄우는데, healthcheck가 유닉스 소켓으로 묻는 mysqladmin ping -h localhost라 그 임시 서버에서 먼저 통과해버렸다. depends_on: service_healthy가 10초 일찍 풀린 것이다.
지금은 -h 127.0.0.1 --protocol=tcp로 묻고 start_period: 180s를 둔다. 실제로 연결을 받을 수 있는 시점까지 기다린다. 재기동 후 컨테이너 10개 전부 Up, API 5개 모두 2xx, Prometheus 타깃 5개 모두 up.
구조
굵은 선은 사용자 요청이 실제로 타는 경로, 점선은 지표가 흐르는 경로다. 두 경로는 같은 머신 위에 있지만 서로 다른 포트로 분리되어 있다.
요청이 지나는 길
API는 다섯 개고, k6/mixed.js가 이 비율대로 섞어 쏜다. 곡 ID는 균등이 아니라 파레토다 — 50%가 상위 100곡, 30%가 상위 1만곡에 몰린다. 핫 키와 핫 로우를 만들어내는 건 이 분포다.
| API | 믹스 | 지나는 곳 | 노리는 병목 |
|---|---|---|---|
| GET /api/songs | 30% | MySQL — fetch join 또는 지연 로딩 | 가설 4 · N+1 |
| GET /api/songs/{id} | 25% | MySQL 단건 조회 | 가설 3 · 커넥션 풀 |
| GET /api/chart/top100 | 30% | Redis 히트, 미스면 MySQL top100 | 가설 2 · 캐시 스탬피드 |
| POST /api/plays | 10% | play_log INSERT (동기) | 가설 6 · 쓰기 처리량 |
| POST /api/songs/{id}/like | 5% | song.like_count 직접 UPDATE | 가설 5 · 핫 로우 락 |
지금 떠 있는 컨테이너
2026-09-19 06:20 UTC 기준. healthcheck 수정 후 재기동한 상태다. 호스트로 열린 포트만 표기했고, exporter 두 대는 compose 네트워크 안에서만 Prometheus에 노출된다.
| 서비스 | 이미지 | 포트 | 상태 |
|---|---|---|---|
| app1 | 로컬 빌드 · temurin 21-jre | 내부 8080 | Up |
| app2 | 로컬 빌드 · temurin 21-jre | 내부 8080 | Up |
| app3 | 로컬 빌드 · temurin 21-jre | 내부 8080 | Up |
| nginx | nginx:1.27-alpine | 8080 → 80 | Up |
| mysql | mysql:8.4 | 3306 | Up (healthy) |
| redis | redis:7-alpine | 6379 | Up |
| prometheus | prom/prometheus:v2.53.0 | 9090 | Up |
| grafana | grafana/grafana:11.1.0 | 3000 | Up |
| mysql-exporter | prom/mysqld-exporter:v0.15.1 | 내부 9104 | Up |
| redis-exporter | oliver006/redis_exporter:v1.62.0 | 내부 9121 | Up |
실험 스위치
코드를 고치지 않고 환경변수로 조건을 바꾼다. docker compose up -d 전에 export 하거나 .env에 둔다.
| 변수 | 기본값 | 바꾸면 |
|---|---|---|
| DB_POOL_SIZE | 20 | 10으로 낮춰 커넥션 풀 고갈 재현 (가설 3) |
| CHART_TTL_SECONDS | 60 | TTL 만료 순간 DB로 몰리는 폭을 조절 (가설 2) |
| SERVER_SHUTDOWN | immediate | graceful과 비교해 배포 중 에러율 측정 (가설 9) |
| TOMCAT_MAX_THREADS | 200 | 스레드 수와 p99의 관계 |
| JAVA_OPTS | -Xmx512m G1GC | 힙·수집기를 바꿔 GC와 p99 관찰 (가설 7) |
가설 10개
위 구조를 정해진 순서로 밀어붙이는 실험 목록. 1~9는 이 단일 머신에서, 10은 클라우드로 올라간 뒤에 한다.
- 01
베이스라인 — 수치가 흔들리기 시작하는 지점 찾기ramp.js · 500 → 16,000 RPS
- 02
캐시 스탬피드 — TTL 만료 순간 전원이 DB로single.js API=chart
- 03
커넥션 풀 고갈mixed.js + DB_POOL_SIZE=10
- 04
N+1 — 지연 로딩 경로 대 fetch joinsingle.js API=list N_PLUS_ONE=true
- 05
핫 로우 락 경합 — 한 곡에 좋아요가 몰릴 때single.js API=like HOT_RATIO=0.5
- 06
쓰기 병목 — 동기 INSERT의 한계single.js API=play TARGET_RPS=1600
- 07
GC와 p99mixed.js + JAVA_OPTS
- 08
인스턴스 장애 — 3대 중 1대가 죽을 때mixed.js + docker compose kill app2
- 09
무중단 배포 — immediate 대 gracefulmixed.js + SERVER_SHUTDOWN
- 10
수평 확장 — 앱 2 → 4 → 8대클라우드 단계
돌리는 법
make up # bootJar → docker compose up -d --build make seed-check # 시드 확인 (첫 기동 후 1~2분, 곡 100만 행) make k6-ramp # 가설 1 베이스라인 make k6-mixed RPS=2000 DURATION=3m # 부하는 다른 머신에서 쏜다 k6 run -e BASE_URL=http://<M1 Pro IP>:8080 k6/mixed.js