본문으로 건너뛰기
버튼 하나 뒤에서

3장

묶음배송

클릭 하나마다 서버에 바로 보내면 어떻게 될까요? 모았다가 한 번에 보내면 얼마나 달라질까요?

  1. 1사이트에서 누르는 클릭 하나하나가 서버로 보낼 택배 상자예요.

  2. 2상자마다 트럭을 한 대씩 부르면, 도로가 트럭으로 꽉 막혀요.

  3. 3물류센터에 모았다가, 100개가 차면 트럭 한 대에 다 실어 보내요.

  4. 4매일 저녁엔 오늘 들어온 상자를 종류별로 세어 장부에 적어 둬요.

하나씩 보내기 · 트럭 100대 · 0.12초

묶어서 보내기 · 트럭 1대 · 0.03초

상자 100개

지난 실측 기록 (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 로컬 실측).
출처: docs/benchmarks/delivery-batching.md — 맥북(M1 Pro) 한 대에서 Docker MySQL로 측정한 상대 비교라 실행마다 ±30% 정도 흔들려요. 위 그림의 100개짜리 기록은 양이 적어서 차이가 덜 보여요.
개발자용 자세히7단계 파이프라인(SDK 큐 → API → MySQL → 집계 → SSE), 요청 기록, 단건 vs 배치 실험, 일별 집계 차트

흐름 따라가기

비유 ↔ 용어

택배 상자
이벤트 1개 (page_view, button_click)
트럭
HTTP 요청 1건
물류센터
브라우저 SDK의 메모리 큐. 이 페이지는 10개가 차거나 10초마다 보내요 (SDK 기본값은 20개·5초)
묶음배송
POST /events/batch(1~100개) + Prisma createMany (INSERT 한 문장, 커밋 한 번)
게이트
ThrottlerGuard: IP당 10초에 200요청, 넘으면 429
저녁 장부 정리
5분마다 도는 일별 롤업 잡 (EventDailyStat upsert, KST 날짜 기준)

요청 흐름

  1. 브라우저 tracker.track('button_click')은 네트워크를 안 타고 큐에만 쌓아요. sessionId는 sessionStorage에 있어요.
  2. 브라우저 → API 10개가 차거나 10초가 되면 fetch로 POST /events/batch { events: [...] }를 보내요 (keepalive: true).
  3. API 가드와 검증을 차례로 지나요.
    • IP당 10초 200요청 초과: 429
    • 서버 전체 분당 30,000개 초과: 429 global_ingest_limit + Retry-After
    • 모양이 틀리면 (예: name 없음, 101개 이상): 400
  4. API → MySQL EventsService.createBatch가 createMany로 Event에 한 번에 넣고 202 { inserted: n }를 돌려줘요.
  5. API → 브라우저 EventBus가 알리면 SSE GET /events/stream으로 { added, at }가 push돼요. 15초마다 ping도 와요.
  6. SDK 응답에 따라 배치를 처리해요.
    • 2xx: 끝
    • 429가 아닌 4xx: 다시 보내도 소용없어서 버려요
    • 429, 5xx, 네트워크 오류: localStorage에 보관(최대 500개) → online 이벤트나 다음 방문 때 다시 보내요
    • 페이지를 떠날 땐 navigator.sendBeacon(text/plain)으로 보내요
  7. API → MySQL 5분마다 AggregationService가 어제~오늘(KST)을 INSERT … SELECT … ON DUPLICATE KEY UPDATE 한 문장으로 다시 집계해요. 여러 번 돌려도 결과가 같아요.
    • "지금 집계" 버튼: POST /events/demo/aggregate (IP당 1분 6번, 서버 전체 10초 쿨다운)
  8. 브라우저 → API 일별 차트는 GET /events/stats/daily로 EventDailyStat을 읽어요.

DB에는 이렇게 남아요

Event 행 (한 번 실행한 예)
idnamepropertiessessionIdcreatedAt
126226page_view{"path":"/delivery"}devdocs-sa…2026-10-20 05:00:14.645
126227button_click{"button":"single"}devdocs-sa…2026-10-20 05:00:14.645
126228button_click{"button":"burst"}devdocs-sa…2026-10-20 05:00:14.645
126229button_click{"button":"burst"}devdocs-sa…2026-10-20 05:00:14.645

배치 1건(이벤트 4개)을 보낸 뒤 읽었어요. createdAt은 UTC예요.

EventDailyStat 행 (같은 날)
datenamecountuniqueSessionsupdatedAt
2026-10-20button_click4322026-10-20 05:00:00.019
2026-10-20page_view1432026-10-20 05:00:00.019
2026-10-19button_click40552026-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)이 없어요.

코드·문서

10개가 쌓이면 즉시, 아니면 10초마다 자동 전송
SSE push ← 저장 완료 알림
각 단계에 마우스를 올리면 왜 필요한지 설명이 나옵니다.

네트워크 요청 로그 (SDK onSend 훅)

아직 전송된 배치가 없습니다.

레이트 리밋 체험

API는 IP당 10초에 200요청까지만 받습니다. 요청 210개를 몰아서 보내면 초과분은 429로 거절됩니다. 수집 API(/events/batch)도 같은 방식으로 막히고, SDK는 이런 배치를 버리지 않고 localStorage에 보관했다가 재전송합니다. 이 버튼은 공개 서버에 쓰레기 데이터가 쌓이지 않도록 DB에 쓰지 않는 체험용 엔드포인트로 보냅니다.

단건 요청 vs 배치 요청 — 같은 이벤트 50개

이벤트마다 POST /eventsHTTP 요청 0/50
POST /events/batch (100개씩)HTTP 요청 0/1

벤치마크 (docs/benchmarks/delivery-batching.md, 10,000 이벤트, 로컬 M1 Pro)

HTTP 단건 POST /events × 10,00016~19s
HTTP 배치 100개 × 100 요청0.25~0.29s
DB create() 순차 × 10,000~70s
DB createMany() 100개 × 100~1s

HTTP 기준 약 65배(18.7s → 0.29s). 요청 수가 1/100로 줄고, DB도 행마다 커밋하는 대신 한 문장·한 커밋으로 저장하기 때문입니다. 위 실측은 이 브라우저↔로컬 API 기준이라 수치가 다를 수 있습니다.

집계 단계의 산출물 — 최근 7일 (KST, EventDailyStat)

크론이 5분마다 어제~오늘 원본 이벤트를 일별로 GROUP BY 해서 집계 테이블에 upsert 합니다. 방금 보낸 이벤트는 다음 집계(또는 버튼) 이후 차트에 반영됩니다.

불러오는 중…