포스트

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.

구조

굵은 선은 사용자 요청이 실제로 타는 경로, 점선은 지표가 흐르는 경로다. 두 경로는 같은 머신 위에 있지만 서로 다른 포트로 분리되어 있다.

k6 nginx 1.27-alpine · :8080 → :80 app1 app2 app3 MySQL 8.4 · :3306 Redis 7 · :6379 Prometheus · :9090 Grafana 11.1 · :3000 mysqld-exporter redis-exporter MacBook Air M1 · 부하 생성기 ramp · mixed · single least_conn · keepalive 256 · read_timeout 5s proxy_next_upstream error / timeout / 502 / 503 Spring Boot 3.4Xmx512m · G1GC2 vCPU · 768M Spring Boot 3.4Xmx512m · G1GC2 vCPU · 768M Spring Boot 3.4Xmx512m · G1GC2 vCPU · 768M innodb_buffer_pool 2G max_connections 500 song 100만 · artist 1만 slow_query_log > 100ms maxmemory 512mb allkeys-lru chart:top100 · TTL 60s cache-aside · 가설 2 :9104 :9121 scrape_interval 5s 익명 접속 · 대시보드 프로비저닝 LAN 경계 · 부하 생성기는 다른 머신 관측 · Prometheus가 5초마다 끌어간다 HTTP :8080 JDBC · HikariCP 20 GET/SET chart:top100 scrape 5s /actuator/prometheus query
요청 경로 (nginx → 앱 → 데이터) 지표 수집 (exporter·actuator → Prometheus)
앱 3대는 모두 같은 MySQL 한 대와 Redis 한 대를 본다. 수평으로 늘어나는 건 앱뿐이라, 부하를 올리면 병목은 아래쪽 두 박스로 내려간다 — 실험 10개 중 7개가 그 지점을 노린다.

요청이 지나는 길

API는 다섯 개고, k6/mixed.js가 이 비율대로 섞어 쏜다. 곡 ID는 균등이 아니라 파레토다 — 50%가 상위 100곡, 30%가 상위 1만곡에 몰린다. 핫 키와 핫 로우를 만들어내는 건 이 분포다.

API믹스지나는 곳노리는 병목
GET /api/songs30%MySQL — fetch join 또는 지연 로딩가설 4 · N+1
GET /api/songs/{id}25%MySQL 단건 조회가설 3 · 커넥션 풀
GET /api/chart/top10030%Redis 히트, 미스면 MySQL top100가설 2 · 캐시 스탬피드
POST /api/plays10%play_log INSERT (동기)가설 6 · 쓰기 처리량
POST /api/songs/{id}/like5%song.like_count 직접 UPDATE가설 5 · 핫 로우 락

지금 떠 있는 컨테이너

2026-09-19 06:20 UTC 기준. healthcheck 수정 후 재기동한 상태다. 호스트로 열린 포트만 표기했고, exporter 두 대는 compose 네트워크 안에서만 Prometheus에 노출된다.

서비스이미지포트상태
app1로컬 빌드 · temurin 21-jre내부 8080Up
app2로컬 빌드 · temurin 21-jre내부 8080Up
app3로컬 빌드 · temurin 21-jre내부 8080Up
nginxnginx:1.27-alpine8080 → 80Up
mysqlmysql:8.43306Up (healthy)
redisredis:7-alpine6379Up
prometheusprom/prometheus:v2.53.09090Up
grafanagrafana/grafana:11.1.03000Up
mysql-exporterprom/mysqld-exporter:v0.15.1내부 9104Up
redis-exporteroliver006/redis_exporter:v1.62.0내부 9121Up

실험 스위치

코드를 고치지 않고 환경변수로 조건을 바꾼다. docker compose up -d 전에 export 하거나 .env에 둔다.

변수기본값바꾸면
DB_POOL_SIZE2010으로 낮춰 커넥션 풀 고갈 재현 (가설 3)
CHART_TTL_SECONDS60TTL 만료 순간 DB로 몰리는 폭을 조절 (가설 2)
SERVER_SHUTDOWNimmediategraceful과 비교해 배포 중 에러율 측정 (가설 9)
TOMCAT_MAX_THREADS200스레드 수와 p99의 관계
JAVA_OPTS-Xmx512m G1GC힙·수집기를 바꿔 GC와 p99 관찰 (가설 7)

가설 10개

위 구조를 정해진 순서로 밀어붙이는 실험 목록. 1~9는 이 단일 머신에서, 10은 클라우드로 올라간 뒤에 한다.

  1. 01

    베이스라인 — 수치가 흔들리기 시작하는 지점 찾기ramp.js · 500 → 16,000 RPS

  2. 02

    캐시 스탬피드 — TTL 만료 순간 전원이 DB로single.js API=chart

  3. 03

    커넥션 풀 고갈mixed.js + DB_POOL_SIZE=10

  4. 04

    N+1 — 지연 로딩 경로 대 fetch joinsingle.js API=list N_PLUS_ONE=true

  5. 05

    핫 로우 락 경합 — 한 곡에 좋아요가 몰릴 때single.js API=like HOT_RATIO=0.5

  6. 06

    쓰기 병목 — 동기 INSERT의 한계single.js API=play TARGET_RPS=1600

  7. 07

    GC와 p99mixed.js + JAVA_OPTS

  8. 08

    인스턴스 장애 — 3대 중 1대가 죽을 때mixed.js + docker compose kill app2

  9. 09

    무중단 배포 — immediate 대 gracefulmixed.js + SERVER_SHUTDOWN

  10. 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
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.