클릭 기록을 하나씩 보내지 않고 모았다가 한 번에 보내요. 10,000개를 보내는 데 18.7초 걸리던 게 0.29초가 됐어요.
개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
직접 만든 SDK가 큐에 모았다가 개수나 시간이 차면 POST /events/batch로 보내고(데모 화면은 10개 또는 10초), 서버는 createMany 한 문장으로 저장해요.
페이지를 떠날 땐 sendBeacon(text/plain이라 CORS preflight 없음), 실패한 묶음은 localStorage에 보관했다가 다시 보내요. 차트는 원본 대신 5분마다 KST 날짜별로 정리한 집계표(EventDailyStat)를 읽고, 입구는 IP당 10초 200요청으로 제한해요.
포기한 방법과 이유
하나씩 바로 보내기: 가장 실시간이지만 같은 10,000개에 요청 10,000번, 18.7초(동시 10).
차트를 볼 때마다 원본 세기: 늘 정확하지만 원본이 쌓일수록 느려져요. 최대 5분 늦는 집계를 택했어요.
묶음은 한 문장이라 일부만 실패할 수 없어요. 하나가 문제면 100개가 함께 실패하는 걸 받아들이고, SDK가 묶음 단위로 재시도해요.
배운 것
하나씩 보내기의 병목은 HTTP보다 DB 커밋 횟수였어요. DB에 직접 create 10,000번(순차) 70.4초 vs createMany 100개씩 1.21초, 다시 재면 75.0초 vs 1.04초(58~72배). MySQL 기본값(innodb_flush_log_at_trx_commit=1)이라 커밋마다 디스크에 써요.
HTTP 단건(동시 10) 18.7초가 DB 단건(동시 10) 20.6초·15.2초와 비슷했어요. 요청을 줄이는 것보다 커밋을 줄이는 게 본질이었어요.
버튼 세 번에 앱의 자동 재시도까지 요청 4개가 나가면, 번호표가 없을 땐 4번 결제됐어요. 같은 번호표를 붙이니 1번.
개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
앱이 결제 시도마다 UUID를 만들어 Idempotency-Key 헤더로 보내고, 재시도에도 같은 값을 써요.
서버는 조회보다 INSERT Payment(status=processing)를 먼저 시도해요. idempotencyKey가 UNIQUE라 동시에 와도 한 행만 성공하고(P2002), 진 요청은 50ms마다 조회해 paid가 되면 같은 결제를 돌려줘요(Idempotent-Replayed: true). 같은 키에 다른 주문·금액이면 422.
포기한 방법과 이유
버튼 잠금만: 한 번만 눌러도 앱의 자동 재시도 때문에 2번 결제됐어요.
"먼저 조회해 보고 없으면 저장": 동시 요청이 둘 다 "없음"을 봐서 막지 못해요. 그래서 DB의 UNIQUE 제약에 맡겼어요.
배운 것
중복 결제는 사용자 실수보다 타임아웃 재시도에서 생겨요. 데모에서 앱은 0.6초 안에 답이 없으면 다시 보내는데, 결제사 승인은 0.9초 걸려요.
통합 테스트로 같은 키 5개를 동시에 보내 행 1개, 응답 5개 모두 같은 결제 id인 걸 확인했어요. 결제사 호출 중 서버가 죽으면 그 키가 processing으로 남는데, 매분 도는 정리 작업이 5분 넘은 processing을 실패(failed)로 닫아요. 데모의 가짜 결제사는 승인 기록이 없으니 "돈이 안 나갔다"로 보고, 진짜 결제사라면 여기서 승인 여부를 다시 물어봐야 해요.
부하 테스트에서 Node 프로세스 하나가 CPU 코어 하나를 꽉 채워 초당 약 620건에서 멈췄어요. 코어가 2개라 API를 2개로 늘리고 Caddy가 나눠 보내게 했어요.
개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
색(blue/green)마다 API를 2개씩 정의하고, Caddy reverse_proxy에 둘 다 적어 least_conn(진행 중 요청이 적은 쪽)으로 보내요. 30초마다 /health를 확인해 죽은 쪽은 빼요.
무중단 배포는 그대로예요. 쉬던 색 2개를 함께 켜고, 둘 다 200일 때만 Caddy를 바꿔요.
응답 헤더 x-instance-id(환경 변수 INSTANCE_NAME)로 어느 API가 받았는지 화면에서 바로 보여요.
포기한 방법과 이유
Node cluster로 컨테이너 하나에서 코어 2개 쓰기: 컨테이너를 나누면 한쪽이 죽어도 Caddy가 빼고 다른 쪽이 받아서 이쪽을 골랐어요.
초당 요청 수 같은 넉넉한 한도는 API마다 따로 세는 채로 뒀어요(사실상 2배). 요청마다 Redis를 한 번 더 다녀오는 비용보다 작다고 봤어요.
배운 것
"나 혼자 돈다"고 믿은 코드가 처음엔 세 군데인 줄 알았는데 실제로는 네 군데였어요. 5분 집계 cron은 5분 칸마다 Redis SET NX PX를 잡은 쪽만, 쿠폰·포인트 실험의 "한 번에 하나"는 메모리에서 Redis 잠금으로 옮겼어요. 3장 SSE 알림은 프로세스 안 Subject라 다른 API로 간 배치를 못 들어서, Redis PUBLISH/SUBSCRIBE(bv:events:ingested)로 바꿨어요. 결제 정리 cron은 조건부 UPDATE라 두 번 돌아도 한 번만 바뀌어서 그대로 뒀어요.
마이그레이션은 _1만 하고 _2는 RUN_MIGRATIONS=false로 건너뛰어요. expand → contract 규칙이라 _2가 먼저 떠도 괜찮아요.
같은 조건(800 VU)으로 다시 재 보니 처리량은 초당 약 620건 → 640건으로 거의 그대로였고, 800 VU의 p95가 7~10초 → 1.9초, 시간 초과가 0.89% → 0%로 줄었어요. 이번엔 서버 CPU 전체가 꽉 찼어요(컨테이너 합 약 180% / 200%). API 둘이 각각 50~65%, Caddy 35~50%, MySQL 25~40%를 썼어요. 그래서 다음 병목은 API 개수가 아니라 Caddy · DB와 나눠 쓰는 서버 CPU예요.