상태: 1·2·3·5·6장 실측 (2026-09-12~14). 4장(백오프)은 4-0 만 재고 나머지는 스켈레톤. 규칙: 값을 먼저 정하고 이유를 붙이지 않는다. 실험해서 값을 정하고, 그 실험을 여기 기록한다.
| 항목 | 값 |
|---|---|
| 인스턴스 | AWS EC2 t3.small (2 vCPU burst, 2GB RAM) + swap 2GB · eu-north-1 |
| DB | MySQL 8.0.46, innodb_buffer_pool_size = 160M (프라이머리) |
| PHP | 8.2.33-fpm (CLI 워커는 같은 이미지) |
| 측정 일시 | 2026-09-12 |
리전이 eu-north-1 이다. 한국에서 접속하면 왕복 지연이 250ms 안팎 붙는다. 외부 API 호출(GA4) 수치에는 스톡홀름 → 구글 구간이 들어 있으므로, 서울 리전 수치와 비교하면 안 된다.
버스트 크레딧 주의: t3 는 CPU 크레딧이 소진되면 성능이 급락한다. 아래 측정은 크레딧이 남은 상태에서 했고, 워커 4개 10초 부하로는 소진되지 않았다.
SELECT id FROM dispatch_outbox
WHERE status = 'pending' AND next_retry_at <= NOW(3)
ORDER BY id LIMIT 100 -- 실제로는 FOR UPDATE SKIP LOCKED기대와 다른 결과가 나왔고, 그게 이 실험의 수확이다.
| 분포 | 옵티마이저 선택 | rows |
Extra |
|---|---|---|---|
| pending 2000 / sent 0 (큐가 밀린 상태) | PRIMARY | 100 | Using where |
| pending 20 / sent 2000 (정상 운영) | ix_poll |
20 | Using where; Using index; Using filesort |
- 큐가 밀렸을 때는 거의 모든 행이 조건을 만족한다.
ORDER BY id LIMIT 100이면 PK 를 순서대로 걸어가다 100건에서 멈추는 것이 가장 싸다. 보조 인덱스를 타면 1924건을 훑고 filesort 까지 해야 한다 - 정상 운영 상태에서는 pending 이 소수라
ix_poll이 20건만 보고 끝난다.Using index는 커버링 인덱스라 테이블을 안 읽었다는 뜻이다
그래서 "인덱스를 걸었더니 빨라졌다" 는 문장은 쓸 수 없다.
ix_poll이 값을 하는 것은 pending 이 전체의 소수일 때, 즉 워커가 큐를 따라잡고 있을 때다. 밀려 있을 때는 옵티마이저가 인덱스를 버리는데, 그건 옵티마이저가 맞는 판단을 한 것이다.인덱스 실습에서 흔히 하는 실수가 여기 있다 — 빈 테이블이나 전부 조건을 만족하는 테이블로 재면 인덱스가 안 먹는 것처럼 보인다. 분포를 만들어야 측정이 성립한다.
ix_poll 을 타도 filesort 가 붙는다. 컬럼 순서가 (status, next_retry_at, id) 인데
status등호 → 인덱스 앞부분 고정next_retry_at범위 → 여기서 정렬 순서가 깨진다- 그 뒤
id는 정렬에 쓸 수 없다
범위 조건 뒤의 컬럼은 인덱스 순서로 정렬에 쓰지 못한다. id 를 마지막에 둔 것은 정렬 때문이 아니라 커버링을 위해서다 — 실제로 Using index 가 나왔고 테이블 접근이 없다.
정상 운영에서 filesort 대상은 20건이라 무시할 만하다. 밀렸을 때는 옵티마이저가 PK 경로로 가버리므로 어차피 filesort 가 없다.
좀비 회수용으로 ix_zombie (status, claimed_at) 를 추가한 직후, 폴링 쿼리의 계획이 바뀌었다.
type: ref key: ix_zombie rows: 2002 Extra: Using index condition; Using where; Using filesort
둘 다 status 로 시작해서 옵티마이저가 더 좁은(2컬럼) 쪽을 골랐는데, 그쪽은 next_retry_at 을 걸러 주지 못해 2002건을 훑는다. 분포를 정상 운영으로 바꾸자 다시 ix_poll 로 돌아왔다.
인덱스를 추가하는 일은 기존 쿼리의 계획을 바꾸는 일이다. 추가할 때 그 인덱스를 쓸 쿼리만 보면 안 되고, 같은 선두 컬럼을 가진 기존 인덱스가 있는지 봐야 한다.
2000건을 적재하고 워커 4개(--scale worker=4)로 비웠다. 네트워크를 변수에서 빼려고 NoopChannel 로 측정했다.
| 항목 | 값 |
|---|---|
| 아웃박스 적재 | 2000건 |
| 워커 | 4개 (WORKER_BATCH_SIZE=100) |
| 소요 | 10.6초 (약 189건/초) |
dispatch_log 행 |
2000 |
DISTINCT outbox_id |
2000 |
(outbox_id, attempt) 중복 쌍 |
0 |
SELECT COUNT(*) FROM (
SELECT outbox_id, attempt FROM dispatch_log
GROUP BY outbox_id, attempt HAVING COUNT(*) > 1
) t;
-- 0워커 넷이 같은 행을 집지 않았다. 선점과 상태 변경을 한 트랜잭션에 넣고 잠근 결과다 → ADR-004
SKIP LOCKED때문이라고 적었다가 고쳤다. 나중에 끈 쪽을 돌려 보니 끄고도 중복은 0이었고 처리량도 안 떨어졌다. 중복을 막는 것은 잠금과 "한 트랜잭션" 규칙이고,SKIP LOCKED는 기다릴지 건너뛸지를 정할 뿐이다 → failure-scenarios.md D-1
적재가 2000건에 12.4초로 전송(10.6초)보다 느렸다. 전환 하나마다 트랜잭션을 열기 때문이다. 실제 서비스에서는 전환이 사용자 행동에 맞춰 흩어지므로 문제가 아니지만, 시드 자체는 배치 삽입으로 바꿀 여지가 있다.
Noop 채널 500건. 네트워크를 빼면 무엇이 남는지 보려는 측정이다.
| 구간 | 평균 | 최소 | 최대 |
|---|---|---|---|
total_ms |
4.9 | 3 | 10 |
db_ms (선점 배분 + 결과 반영) |
4.8 | ||
parse_ms |
0.0 | ||
send_ms |
0.0 | 네트워크 없음 |
db_ms 가 total_ms 의 거의 전부다. 전송을 빼면 남는 비용은 DB 뿐이라는 뜻이고, 이건 예상대로다. 의미는 GA4 를 붙였을 때 send_ms 가 190ms 라면 전체의 97%가 네트워크라는 것 — 최적화할 곳은 우리 쪽이 아니라 배치 크기와 동시성이다.
처음에는
db_ms평균이 31.6,total_ms평균이 7.8 이었다. 구간의 합이 전체보다 큰 값이다.배치 전체의 선점 시간을 모든 행에 그대로 더하고 있었다. 100건을 한 번에 집었으면 그 시간이 100번 더해진다.
구간을 나눠 적는 이유가 "합이 맞아야 어디가 느린지 안다" 인데, 합이 안 맞으면 아무 말도 못 한다. 건수로 나눠 배분하도록 고치고 다시 쟀다.
계측 코드도 검증 대상이다. 숫자가 나왔다고 맞는 숫자가 아니다.
앞선 표본은 3건이었다. 운영 엔드포인트로 500건을 실제로 보냈다.
전환은 시드로 만들었지만 전송은 진짜다 — www.google-analytics.com 이
받았고, 속성에 이벤트가 쌓였다.
검증 트래픽이라 모든 이벤트에 traffic_type=internal 을 붙였다
(→ GA4_TRAFFIC_TYPE). GA4 내부 트래픽 필터로 걸러낼 수 있게 하려는 것이고,
지울 방법 없이 운영 속성에 쏟아붓지 않는다는 규칙을 코드로 둔 자리다.
| 채널 | 표본 | send_ms 평균 |
비고 |
|---|---|---|---|
| Noop | 500 | 0.0 | 네트워크 없음 |
GA4 운영 (/mp/collect) |
500 | 44.9 | 전부 204 · 본문 없음 |
GA4 검증 (/debug/mp/collect) |
9 | 184 | validationMessages 를 돌려준다 |
① 성공률 500 / 500. 실패가 하나도 없었다.
전체 GA4 전송 500
2xx 500
비2xx·무응답 0
429(레이트리밋)도 없었다. 12초 안에 250건을 몰아넣어도 GA4 는 받아 줬다.
다만 이 숫자로 "성공률 100%" 를 말하면 안 된다 — 운영 엔드포인트는
페이로드가 틀려도 204 를 주기 때문이다(→ 7장 ①). 이 500건이 증명하는 것은
"전송이 도달했다" 지 "집계에 반영됐다" 가 아니다.
나중에 되읽어 보니 그 경계가 실제로 있었다 — 다만 유실이 아니라 시간이었다. 보낸 직후에는 매체 보고서에 1% 만 있었고, 40시간 뒤 100% 가 됐다 → 5-1
② 동시성을 올리면 처리량은 1.7배, 꼬리 지연은 2배
| 표본 | 소요 | 초당 | p50 | p95 | p99 | 최대 | |
|---|---|---|---|---|---|---|---|
| 워커 1개 | 250 | 11.85초 | 21.1 | 37 | 43 | 54 | 74 |
| 워커 4개 | 250 | 6.83초 | 36.6 | 48 | 83 | 111 | 141 |
워커를 넷으로 늘려 처리량은 1.7배가 됐는데(4배가 아니다), p95 는 43 → 83, p99 는 54 → 111 로 두 배가 됐다. vCPU 2개 위에서 워커 넷이 각자 TLS 악수를 하고 JSON 을 만드는 비용이 꼬리에 나타난 것이다.
평균만 보면 이 현상이 안 보인다. 평균은 44.9 하나뿐이고, 나뉘는 것은 분포의 꼬리다. 매체 전송에 타임아웃을 걸 때 기준이 되는 값은 평균이 아니라 p99 다 — 지금
DISPATCH_TIMEOUT_MS=3000은 p99(111ms)의 27배라 넉넉하다.
③ 실제 채널을 붙이자 비용의 주인이 바뀌었다
| 구간 | Noop 500건 | GA4 500건 |
|---|---|---|
total_ms |
4.9 | 51.3 |
db_ms |
4.8 (98%) | 6.4 (12%) |
send_ms |
0.0 | 44.9 (87%) |
3장에서 "GA4 를 붙이면 send_ms 가 전체의 대부분이 될 것" 이라고 적었는데,
87%로 맞았다. db_ms 는 6.4ms 로 거의 그대로다 — 워커가 늘어도 DB 쪽은
흔들리지 않았다.
그래서 최적화할 자리는 우리 코드가 아니라 배치 크기와 동시성이라는 3장의 결론이 유지된다. 다만 ②를 알고 나면 "동시성을 올린다" 가 공짜가 아니다 — 처리량과 꼬리 지연을 맞바꾸는 것이다.
④ 통화는 지연에 영향이 없다
| 통화 | 표본 | send_ms 평균 |
|---|---|---|
| KRW | 100 | 46.4 |
| USD | 100 | 44.8 |
| JPY | 100 | 43.6 |
| EUR | 100 | 45.1 |
| BHD | 100 | 44.8 |
같은 value_minor = 9900 을 다섯 통화로 보냈다. ISO 4217 소수 자릿수가
0·2·3 으로 갈리므로 GA4 에 찍히는 매출이 달라야 한다.
| 통화 | 자릿수 | 9900 minor → | 100건 합계 |
|---|---|---|---|
| KRW · JPY | 0 | 9900 |
990,000 |
| USD · EUR | 2 | 99.00 |
9,900 |
| BHD | 3 | 9.900 |
990 |
이건 GA4 매출 보고서에서 눈으로 확인할 수 있다. 자릿수를 무시하고 그냥 9900 을 보냈다면 USD 매출이 100배로 찍힌다 — 광고 매체에 넘기는 값이라 입찰과 정산이 그 숫자를 따라간다.
/metrics 의 구간별 소요 시간은 Metrics_model::dispatchByChannel() 이 뽑는다.
MySQL 8 에 PERCENTILE_CONT 가 없어서 순위 함수로 고른다.
MAX(CASE WHEN PERCENT_RANK() OVER (PARTITION BY channel ORDER BY total_ms) <= 0.95 THEN total_ms END)PERCENT_RANK 는 (순위 − 1) / (N − 1) 이다. 그래서 이 식이 고르는 값은
floor(0.95 × (N − 1)) + 1 번째 다 — 교과서의 nearest-rank(ceil(0.95 × N) 번째)와 같거나 한 순위 낮다.
1…N 을 넣고 운영과 같은 MySQL 8 에서 직접 계산했다(2026-09-18, 테이블을 건드리지 않는 읽기 전용 쿼리).
| N | p50 | nearest-rank p50 | p95 | nearest-rank p95 | 최댓값 |
|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 1 |
| 2 | 1 | 1 | 1 | 2 | 2 |
| 3 | 2 | 2 | 2 | 3 | 3 |
| 5 | 3 | 3 | 4 | 5 | 5 |
| 10 | 5 | 5 | 9 | 10 | 10 |
| 12 | 6 | 6 | 11 | 12 | 12 |
| 19 | 10 | 10 | 18 | 19 | 19 |
| 20 | 10 | 10 | 19 | 19 | 20 |
| 50 | 25 | 25 | 47 | 48 | 50 |
| 99 | 50 | 50 | 94 | 95 | 99 |
| 100 | 50 | 50 | 95 | 95 | 100 |
| 500 | 250 | 250 | 475 | 475 | 500 |
- p50 은 모든 N 에서 nearest-rank 와 같다
- p95 는 표본이 얇을수록 가장 느린 값을 뺀다. N 이 2~20 이면 p95 는 두 번째로 큰 값이다(N=2 에서는 작은 쪽)
- 표본이 커져도 차이는 없어지는 것이 아니라 많아야 한 순위로 줄어든다. 위 표에서 같은 것은 N = 1 · 20 · 21 · 100 · 500 이고, 예컨대 N = 250 이면 237번째 대 238번째다
- 이 절은 2장 · 3-1 표의 p95 가 어떤 방법으로 계산됐는지는 확인하지 않았다. 2장 표에는 이 SQL 에 없는 p99 가 있어 다른 경로로 낸 값일 수 있다
정정. 화면과 모델 주석은 이 계산을 "nearest-rank, 표본이 얇으면 p95 가 최댓값과 같아진다" 고 설명했다. 반대다 — 얇은 표본에서 p95 는 최댓값을 빼고 고른다. 화면을 옮기면서 설명을 docs 로 보내려다 문장을 식과 대조했고, 틀린 것을 찾았다. 그래서 화면은 p95 옆에 최대와 표본 수를 같이 낸다 — 꼬리의 가장 나쁜 한 건은 최대 칸에서 보인다.
식은 그대로 두었다. 차이는 많아야 한 순위이고 최대 칸이 가장 나쁜 값을 따로 보여 준다. 바꾸면 화면의 p95 가 조용히 달라지므로, 바꾼다면 이 절의 표와 함께 바꾼다.
이 정정을 쓰면서 한 번 더 틀렸다 — 처음에 "표본이 수백 건이면 차이가 없다" 고 적었는데 N = 250 에서 237 대 238 이었다.
타임아웃을 1ms 로 낮춰 두 건을 동시에 실패시켰다. 둘 다 attempt=1 이고 기본 대기는 30초다.
id 9 → next_retry_at 12:09:33.894
id 10 → next_retry_at 12:09:35.908 같은 배치, 2.0초 차이
매체가 잠시 죽었다 살아나면 실패한 전송이 전부 같은 시각에 재시도된다. 막 회복한 매체를 다시 넘어뜨리는 것이 그 지점이다. 지터 ±20% 가 흩뿌리는 것을 두 건으로 확인했다 — 표본이 작으니 건수를 늘려 분포로 다시 잰다.
초기 후보
| attempt | 대기 | 누적 |
|---|---|---|
| 1 | 즉시 | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2.5m |
| 4 | 10m | 12.5m |
| 5 | 1h | ~1.2h |
| 6+ | dead |
검증할 것
| 질문 | 측정 방법 | 결과 |
|---|---|---|
| 몇 번째 시도에서 대부분 성공하는가 | dispatch_log의 attempt 분포 |
☐ |
| 30초가 적절한가, 너무 짧은가 | 매체 장애 주입 후 회복 시각 관측 | ☐ |
dead 컷을 6회로 두면 유실률은 |
최종 실패 건수 ÷ 전체 | ☐ |
| 지터(jitter)가 필요한가 | 동시 재시도 몰림 관측 | ☐ |
"왜 이 값인지"를 실험으로 정한다는 것이 이 문서의 목적이다. 공공데이터 API에서 1,000번째 요청의 예외를 200ms 지연으로 해결했던 것과 같은 방식이다.
| 지표 | ISO 25010 | 목표 | 실측 | |
|---|---|---|---|---|
| 워커 중복 전송 | 기능 적합성 | 0 | 0 (9303 시도) | ✅ |
| 중복 전환 차단 | 기능 적합성 | 유실 0 | 동시 8개 → 1행 (3회) | ✅ |
| 매체 도달률 | 신뢰성 | ≥ 99% | GA4 543 / 543 = 100% (즉시) | ✅ |
| 매체 반영률 | 신뢰성 | ≥ 99% | GA4 543 / 543 = 100% (+40h) | ✅ 단, 시점이 붙는다 |
| 어트리뷰션 보존율 | 기능 적합성 | ≥ 95% | 20 / 20 | ◐ 표본 20 |
| 재시도 후 최종 성공률 | 신뢰성 | ≥ 99.9% | 9303 / 9303 | ◐ 대부분 Noop |
지표 하나를 둘로 쪼갰다. 그리고 반영률에는 시각이 붙어야 한다.
원래 "매체 전송 성공률" 하나였는데, 되읽어 보니 두 개의 다른 숫자였다.
| 무엇을 세는가 | 값 | |
|---|---|---|
| 도달률 | 매체가 2xx 를 돌려줬다 |
100% — 보내는 순간 확정 |
| 반영률 | 매체 보고서에 실제로 있다 | 1.2% → 100% — 40시간에 걸쳐 |
둘은 결국 같아졌다. 그런데 같아지기까지 40시간이 걸렸다. "반영률 몇 %" 는 언제 물었는지 없이는 뜻이 없는 숫자다.
GA4 Data API 되읽기로 transaction_id 를 한 건씩 맞댔다.
같은 데이터를 다섯 시점에 물었다.
| 전송 후 경과 | 누락 | 반영률 | 직전 구간 유입 | 그때 내린 판단 |
|---|---|---|---|---|
| +0h | 502 ¹ | 1.2% ¹ | — | |
| +7h | 35 | 93.6% | — | |
| +13h | 29 | 94.7% | 1.10건/h | |
| +15h | 27 | 95.0% | 0.75건/h | |
| +25h | 26 | 95.2% | 0.10건/h | |
| +40h | 0 | 100.0% | — | 전부 들어왔다 |
¹ 첫 줄만 분모가 다르다. +0h 에 잰 것은
6 / 508이다 — 그때까지 보낸 것이 508건이었다 (원본 기록: worklog 2026-09-13 "되읽기를 붙였더니 반영률이 1.0% 였다"). 전에는 이 줄도 분모를 최종 543 으로 맞춰누락 537 · 1.0%로 적었는데, 그러면 그 시점에 아직 보내지도 않은 35건까지 누락으로 세는 것이고 비율도 6/543 = 1.1% 라 적힌 값과 맞지 않았다. 지표 화면의 반영률 표도 같은 이유로 첫 줄에만 분모 508 을 쓴다 (2026-09-18 에 바로잡음).
① 첫 번째 오판 — 보내자마자 물었다. +0h 에 1.2%(6 / 508) 가 나왔다. 처리 지연이었고 7시간 만에 93.6%가 됐다.
② 두 번째 오판 — 감속을 수렴으로 읽었다. ← 이 장에서 가장 중요한 줄 유입 속도가 1.10 → 0.75 → 0.10건/h 으로 떨어지는 것을 보고 +25h 에서 "0에 수렴했다, 잔여 26건(4.8%)은 영구 유실" 이라고 문서에 적었다. GA4 가 적는 처리 창이 24~48시간인데 25시간에 판정한 것이다.
사용자가 GA4 보고서를 내려받아 보여 줬다 — Purchasers 544. 우리 543 + 수동 시험 1.
다시 대조하니 +40h 에 543 / 543, 누락 0 이었다.
감속은 수렴이 아니다. 0.10건/h 는 "거의 멈췄다" 가 아니라 "아직 오고 있다" 였다. 끝났는지 판정하려면 값이 멈춘 것을 봐야지 느려진 것을 보면 안 되고, 그 전에 상대가 적어 둔 처리 창을 다 기다려야 했다.
①과 ②는 같은 실수다. ①은 너무 일찍, ②는 조금 덜 일찍 물었을 뿐이다.
③ 속도별 비교는 "누락률" 이 아니라 "도착 지연" 을 잰 것이었다.
+25h 시점에 무리별로 이렇게 나눴었다.
| 무리 | 전송 속도 | +25h 에 아직 안 온 것 | +40h |
|---|---|---|---|
| 워커 1개 | ≈21건/초 | 16 / 250 | 0 |
| 워커 4개 | ≈36건/초 | 8 / 250 | 0 |
| A군 | 0.8건/초 | 1 / 20 | 0 |
| B군 | ≈17건/초 | 1 / 20 | 0 |
그때는 "속도와 누락률에 상관이 없다 — 스로틀링이 아니다" 로 읽었다. 결론(스로틀링 아님)은 맞았지만 근거가 틀렸다. 누락은 애초에 없었고, 우리가 비교한 것은 어느 무리가 조금 더 늦게 처리됐는가였다.
④ 그래서 남은 교훈은 "5%가 사라진다" 가 아니다.
204는 "받았다" 지 "집계했다" 가 아니다 — 여전히 참이다. +0h 에 둘은 1% 대 100% 였다- 그 차이는 유실이 아니라 시간이었다 — GA4 기준 최대 40시간
- 되읽기 대조는 처리 창을 다 기다린 뒤 한 번 돌리는 배치여야 한다. 중간값으로 지표를 채우면 그 지표는 측정 시각의 함수가 된다
우리 쪽 오탐 — 검증 엔드포인트. 처음 누락 40건 중 5건은 영원히 안 들어올 것이었다.
/debug/mp/collect 로 나간 것이라 검사만 하고 적재하지 않는다. 응답이 성공이라
아웃박스는 sent 로 적는다.
http_status 204 → 543건 운영 엔드포인트 (적재됨)
http_status 200 → 5건 검증 엔드포인트 (적재 안 됨)
상태 코드로 갈라 대조에서 제외했다. 이건 +40h 에도 그대로 맞다 — 진짜 영구 누락은 이 5건뿐이었고, 그건 매체 잘못이 아니라 우리가 적재를 요청하지 않은 것이다.
TEST-0001 — 초과 1건의 정체. 대조에 계속 "안 보냈는데 매체에 있다" 로 잡혔다.
내가 보낸 것이었다. 09-12 에 GA4 연결을 처음 확인하면서 아웃박스를 거치지 않고
curl 로 직접 쏜 시험 이벤트라 conversions 에 행이 없다. 출처를 사용자에게 확인해
달라고 두 번 요청했는데, 내 작업 기록을 먼저 뒤졌어야 했다.
✅ 두 개는 말할 수 있다.
워커 중복은 D-1 에서 네 조건(배치 100·5 × SKIP LOCKED 켬·끔)
모두 0이었다. 중복 전환은 같은 dedup_key 로 동시 8개를 세 번 때려
매번 conversions 1행 · outbox 1행이었다(D-3).
◐ 두 개는 아직 표본이 모자라다.
어트리뷰션 보존율은 정의가 틀려 있었다.
처음 정의는 "어트리뷰션이 남은 방문 ÷ 전체 방문" 이었다. 그대로 재면
529 / 530 = 99.8% 가 나오는데 아무 뜻이 없다 — 직접 유입 방문도
TouchpointResolver 규칙 4에 따라 last 접점을 하나 받기 때문이다.
고친 정의는 "광고 유입 방문 중 접점이 보존된 비율" 이고, 그것으로 20/20 이다.
지표는 정의를 먼저 검증해야 한다. 99.8% 는 시스템이 건강하다는 뜻이 아니라 분모를 잘못 골랐다는 뜻이었다. 목표치를 처음부터 넘고 있는 지표는 대개 이 경우다.
재시도 후 최종 성공률의 분모는 아직 99.9%가 NoopChannel 이다.
실패 경로 자체는 D-2 에서 끝까지 확인했다.
지표를 자동 집계로 만들 때 이 장의 교훈을 화면에 새겨야 한다. 채널별로 쪼개고(Noop 과 GA4 를 섞지 않는다), 도달과 반영을 다른 줄로 낸다. 하나로 뭉치면 "성공률 100%" 라는 화면이 뜨고, 그게 무엇의 100%인지 아무도 모르게 된다.
t3.small(vCPU 2 · RAM 1909MB 가용 · swap 2GB)에 MySQL 2대를 올린 구성이 성립하는지가 이 절의 질문이다. 유휴와 부하(워커 4개 · 3000건)를 같은 방식으로 찍었다.
| 컨테이너 | 상한 | 유휴 | 부하 | 부하 CPU |
|---|---|---|---|---|
mysql-primary |
384MB | 376.0MB (97.9%) | 378.0MB (98.4%) | 42.2% |
mysql-replica |
320MB | 312.6MB (97.7%) | 314.5MB (98.3%) | 42.1% |
app (PHP-FPM) |
256MB | 12.9MB (5.1%) | 8.9MB (3.5%) | 0.0% |
openresty |
128MB | 6.6MB (5.1%) | 6.6MB (5.1%) | 0.0% |
worker ×4 |
128MB 각 | — | 8.2~8.5MB 각 (6.6%) | 2.3~2.8% 각 |
| 호스트 | 유휴 | 부하 |
|---|---|---|
used |
1034MB | 1100MB |
available |
676MB | 611MB |
swap 사용 |
141MB | 141MB |
| load average (1분) | 0.09 | 2.29 |
① 성립한다. 다만 여유가 없다.
부하 중에도 swap 이 1MB도 늘지 않았다. 이미 나가 있던 141MB 는
mysqld 두 프로세스의 콜드 페이지(108MB + 34MB)이고, 활성 작업셋은
RAM 안에 들어간다. 스왑이 부하에 반응하지 않는다는 것이 이 구성의
합격 기준이었다.
반대로 load average 는 2.29 / vCPU 2 다. 포화 직전이다. 메모리는 버티고 CPU 가 먼저 벽에 닿는다.
② MySQL 두 대가 사실상 전부다. 메모리의 약 90%, 부하 CPU 의 약 84%. 나머지 넷(웹·엣지·워커 4개)을 다 합쳐도 60MB 남짓이다.
③ 복제본이 프라이머리만큼 CPU 를 쓴다 (42.1% vs 42.2%). 이게 이 절에서 제일 덜 직관적인 숫자다. 복제본은 읽기 요청을 거의 받지 않는데도 프라이머리와 같은 CPU 를 썼다 — 프라이머리에 들어간 쓰기를 전부 다시 적용해야 하기 때문이다.
읽기 분산은 공짜가 아니다. 읽기를 떼어 주는 대신 쓰기 부하를 한 벌 더 감당한다. 쓰기가 많은 구간에서는 복제본을 늘려도 전체 처리량이 늘지 않고, 오히려 같은 호스트에 있으면 서로를 갉아먹는다. 이 프로젝트는 둘이 같은 t3.small 에 있어서 그 손해가 그대로 보인다.
④ 워커 상한 128MB 는 실사용의 15배다.
실사용이 8.5MB 라 상한을 낮출 수 있지만 낮추지 않았다. Docker 의
mem_limit 은 예약이 아니라 상한이라, 낮춰도 남는 RAM 이 생기지 않는다.
상한의 값어치는 폭주한 워커가 MySQL 을 밀어내지 못하게 막는 것뿐이다.
--scale worker=4를 8로 올려도 메모리는 34MB 더 쓸 뿐이다. 막는 것은 메모리가 아니라 vCPU 2개다.
측정 방법
docker stats --no-stream --format "{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}"
free -m
# 프로세스별 스왑 — 컨테이너 합계로는 누가 나갔는지 안 보인다
awk '/^Name:/{n=$2} /^VmSwap:/{if ($2+0 > 1024) print n, $2" kB"}' /proc/*/status
docker stats만 보면 스왑을 놓친다. 컨테이너 메모리 사용량에는 스왑아웃된 페이지가 잡히지 않아서, 97.9% 라는 숫자만 보고 "상한에 거의 닿았지만 버티는 중" 으로 읽게 된다. 실제로는 이미 108MB 를 디스크로 내보낸 뒤의 97.9% 다.
EXPLAIN결과는 원문 그대로 붙인다. 요약하지 않는다- 측정은 3회 이상, p50·p95를 함께 적는다. 평균만 적지 않는다
- 예상과 다르면 예상을 지우지 말고 둘 다 남긴다
- 개선 전후를 비교할 때는 한 번에 한 가지만 바꾼다