3장
묶음배송
클릭 하나마다 서버에 바로 보내면 어떻게 될까요? 모았다가 한 번에 보내면 얼마나 달라질까요?
1사이트에서 누르는 클릭 하나하나가 서버로 보낼 택배 상자예요.
2상자마다 트럭을 한 대씩 부르면, 도로가 트럭으로 꽉 막혀요.
3물류센터에 모았다가, 100개가 차면 트럭 한 대에 다 실어 보내요.
4매일 저녁엔 오늘 들어온 상자를 종류별로 세어 장부에 적어 둬요.
하나씩 보내기 · 트럭 100대 · 0.12초
묶어서 보내기 · 트럭 1대 · 0.03초
지난 실측 기록 (2026-10-19, 로컬 MySQL)
65배
클릭 기록 10,000개를 묶어 보냈을 때 빨라진 정도 (18.7초 → 0.29초, 측정 기록)
실제 서버로 클릭 기록 100개를 하나씩 한 번, 묶어서 한 번 보내요. 데이터베이스에 실제로 저장돼요.
같은 100개를 옮기는 데 트럭 100대와 1대. 이번엔 묶어서 보낸 쪽이 4.8배 빨랐어요. 양이 많을수록 차이가 커져서, 10,000개면 65배예요.
게이트 통행 제한
물류센터 게이트는 한 곳에서 오는 트럭을 10초에 200대까지만 들여보내요. 누가 트럭을 한꺼번에 쏟아부어도 창고(데이터베이스)가 멈추지 않게요. 돌려보낸 묶음은 브라우저가 버리지 않고 보관했다가 다시 보내요.
체험용 트럭은 빈 트럭이라 데이터베이스에 아무것도 남기지 않아요.
매일 저녁 장부 정리
차트를 볼 때마다 창고의 상자를 하나하나 세면 느려요. 그래서 "날짜별·종류별 몇 개"를 장부에 미리 적어 두고, 차트는 장부만 읽어요. 그림에선 매일 저녁이라고 했지만, 여기선 바로 확인할 수 있게 5분마다 정리해요. 방금 보낸 클릭은 다음 정리 뒤에 차트에 보여요.
숫자로 본 결과
- 65배
- 묶어 보내기가 하나씩 보내기보다 빠름
- 클릭 기록 10,000개를 서버로: 18.7초 → 0.29초 (요청 10,000번 → 100번)
- 58~72배
- 묶어 저장하기가 하나씩 저장하기보다 빠름
- 데이터베이스에 10,000줄: 70.4초 → 1.21초 (두 번 측정)
- 200 / 210
- 게이트를 통과한 트럭
- 10초 안에 210대를 몰아 보내면 200대만 들어가고 10대는 돌려보냈어요 (2026-10-19 로컬 실측).
개발자용 자세히7단계 파이프라인(SDK 큐 → API → MySQL → 집계 → SSE), 요청 기록, 단건 vs 배치 실험, 일별 집계 차트
흐름 따라가기
비유 ↔ 용어
- 택배 상자
- 이벤트 1개 (
page_view,button_click) - 트럭
- HTTP 요청 1건
- 물류센터
- 브라우저 SDK의 메모리 큐. 이 페이지는 10개가 차거나 10초마다 보내요 (SDK 기본값은 20개·5초)
- 묶음배송
POST /events/batch(1~100개) + PrismacreateMany(INSERT 한 문장, 커밋 한 번)- 게이트
- ThrottlerGuard: IP당 10초에 200요청, 넘으면 429
- 저녁 장부 정리
- 5분마다 도는 일별 롤업 잡 (
EventDailyStatupsert, KST 날짜 기준)
요청 흐름
- 브라우저
tracker.track('button_click')은 네트워크를 안 타고 큐에만 쌓아요. sessionId는 sessionStorage에 있어요. - 브라우저 → API 10개가 차거나 10초가 되면
fetch로POST /events/batch{ events: [...] }를 보내요 (keepalive: true). - API 가드와 검증을 차례로 지나요.
- IP당 10초 200요청 초과: 429
- 서버 전체 분당 30,000개 초과: 429
global_ingest_limit+Retry-After - 모양이 틀리면 (예: name 없음, 101개 이상): 400
- API → MySQL
EventsService.createBatch가createMany로Event에 한 번에 넣고 202{ inserted: n }를 돌려줘요. - API → 브라우저
EventBus가 알리면 SSEGET /events/stream으로{ added, at }가 push돼요. 15초마다 ping도 와요. - SDK 응답에 따라 배치를 처리해요.
- 2xx: 끝
- 429가 아닌 4xx: 다시 보내도 소용없어서 버려요
- 429, 5xx, 네트워크 오류: localStorage에 보관(최대 500개) → online 이벤트나 다음 방문 때 다시 보내요
- 페이지를 떠날 땐
navigator.sendBeacon(text/plain)으로 보내요
- API → MySQL 5분마다
AggregationService가 어제~오늘(KST)을INSERT … SELECT … ON DUPLICATE KEY UPDATE한 문장으로 다시 집계해요. 여러 번 돌려도 결과가 같아요.- "지금 집계" 버튼:
POST /events/demo/aggregate(IP당 1분 6번, 서버 전체 10초 쿨다운)
- "지금 집계" 버튼:
- 브라우저 → API 일별 차트는
GET /events/stats/daily로EventDailyStat을 읽어요.
DB에는 이렇게 남아요
| id | name | properties | sessionId | createdAt |
|---|---|---|---|---|
| 126226 | page_view | {"path":"/delivery"} | devdocs-sa… | 2026-10-20 05:00:14.645 |
| 126227 | button_click | {"button":"single"} | devdocs-sa… | 2026-10-20 05:00:14.645 |
| 126228 | button_click | {"button":"burst"} | devdocs-sa… | 2026-10-20 05:00:14.645 |
| 126229 | button_click | {"button":"burst"} | devdocs-sa… | 2026-10-20 05:00:14.645 |
배치 1건(이벤트 4개)을 보낸 뒤 읽었어요. createdAt은 UTC예요.
| date | name | count | uniqueSessions | updatedAt |
|---|---|---|---|---|
| 2026-10-20 | button_click | 43 | 2 | 2026-10-20 05:00:00.019 |
| 2026-10-20 | page_view | 14 | 3 | 2026-10-20 05:00:00.019 |
| 2026-10-19 | button_click | 405 | 5 | 2026-10-20 05:00:00.019 |
5분 롤업 잡이 만든 행이에요. date는 KST 날짜예요.
표를 이렇게 읽어요
- 네 행의 createdAt이 1ms까지 같아요. INSERT 한 문장으로 들어갔다는 뜻이에요.
- createdAt은 UTC로 저장돼요. 롤업은
DATE(createdAt + INTERVAL 9 HOUR)로 KST 날짜에 묶어요. 그래서 UTC 15시 이후 이벤트는 다음 날로 가요.
한계·운영에선
- 배치는 한 덩어리예요. 이벤트 하나만 모양이 틀려도 400이 나고, SDK는 그 배치를 통째로 버려요.
- DB 오류(5xx)면 배치가 통째로 실패하고, SDK가 보관했다가 나중에 배치 단위로 다시 보내요.
- 저장 시각은 서버 기준이에요. SDK가 보내는
occurredAt은 지금은 참고용이에요. - EventBus와 전역 한도는 API 한 대의 메모리에 있어요. 여러 대로 늘리면 Redis pub/sub 같은 공유 저장소가 필요해요.
개발자 도구로 보면
POST /events/batch가 클릭마다가 아니라 10개나 10초마다 한 번씩 보이고, 상태는 202예요./events/stream은 Type이 eventsource예요. 눌러 보면 EventStream 탭에 push 메시지가 쌓여요.- 탭을 닫거나 다른 탭으로 가면 beacon(ping) 요청이 보여요. text/plain이라 사전 확인(OPTIONS)이 없어요.
코드·문서
네트워크 요청 로그 (SDK onSend 훅)
아직 전송된 배치가 없습니다.
레이트 리밋 체험
API는 IP당 10초에 200요청까지만 받습니다. 요청 210개를 몰아서 보내면 초과분은 429로 거절됩니다. 수집 API(/events/batch)도 같은 방식으로 막히고, SDK는 이런 배치를 버리지 않고 localStorage에 보관했다가 재전송합니다. 이 버튼은 공개 서버에 쓰레기 데이터가 쌓이지 않도록 DB에 쓰지 않는 체험용 엔드포인트로 보냅니다.
단건 요청 vs 배치 요청 — 같은 이벤트 50개
벤치마크 (docs/benchmarks/delivery-batching.md, 10,000 이벤트, 로컬 M1 Pro)
HTTP 기준 약 65배(18.7s → 0.29s). 요청 수가 1/100로 줄고, DB도 행마다 커밋하는 대신 한 문장·한 커밋으로 저장하기 때문입니다. 위 실측은 이 브라우저↔로컬 API 기준이라 수치가 다를 수 있습니다.
집계 단계의 산출물 — 최근 7일 (KST, EventDailyStat)
크론이 5분마다 어제~오늘 원본 이벤트를 일별로 GROUP BY 해서 집계 테이블에 upsert 합니다. 방금 보낸 이벤트는 다음 집계(또는 버튼) 이후 차트에 반영됩니다.
불러오는 중…