---
title: "GA 대신 볼 수 있는 자체 이벤트 수집기를 만들었다"
date: 2026-09-18
tags: ["backend", "infra", "analytics", "rust"]
url: https://sub2n.github.io/posts/2026/pixel-analytics-collector/
---

# GA 대신 볼 수 있는 자체 이벤트 수집기를 만들었다

9월 7일에 시작해서 18일에 마무리했다. 시작할 때 나는 API도 서버도 Docker도 모르는 프론트 개발자였다.

## 이전 상황

앱을 50개 넘게 운영하는데 이벤트는 GA랑 Amplitude 두 군데로만 보내고 있었다. 클라이언트 진입점은 `emitLogEvent()` 하나, 서버는 푸시 보낼 때만 GA를 썼다.

문제는 보고 싶은 걸 못 본다는 거였다.

- 앱별, 유저별 실시간 지표를 우리 어드민에서 볼 수가 없다. 원본 조회도 사용자 단위 추적도 안 된다.
- 탈퇴, DM·그룹챗 생성, 채팅방 참여자 수, 푸시 도달. 이건 GA에 아예 없는 지표다.
- Firebase는 완전한 데이터가 이틀 뒤에 나온다.

## 하려고 했던 것

GA랑 똑같이 수집하는 게 1차 목표다. 대시보드는 2차. 수집만 제대로 되면 기획자는 DB에 직접 붙어서 보면 되고, 화면은 나중에 원하는 모양으로 만들 수 있다.

구조는 이렇게 잡았다. 클라이언트 → Rust axum API(검증하고 Redis에 `XADD` 한 다음 바로 204) → Redis Streams → 워커(bulk write, 일/주/월 집계) → 전용 Mongo. 수집기는 앱이 전부 죽어도 멀쩡해야 하니까 완전히 분리했다.

유실 방어는 세 겹으로 뒀다. 클라이언트 재전송, API 디스크 스풀, Redis PEL.

## 내가 몰랐던 것

**Docker가 뭔지 몰랐다.** "클라우드에 올리면 클라우드에서 만들어지는 건지, 이미지는 아무 데서나 만들어둬도 되는 건지"를 물어봤다. 서버는 항상 켜져 있는 남의 컴퓨터, 이미지는 빌드 산출물을 zip으로 묶은 것, 레지스트리는 이미지용 npm, compose는 package.json의 scripts. 이렇게 번역해서 듣고 나니 이해가 됐다.

**서버에 붙는 것과 인프라를 바꾸는 건 다른 권한이었다.** ssh로 서버에는 들어가는데 LB랑 ACG랑 DNS는 왜 내가 직접 못 하는지 한참 헷갈렸다. 그건 콘솔 권한이었다.

**`127.0.0.1`과 `0.0.0.0`.** "api가 127.0.0.1:14000이어야 하는 거 아냐?"라고 물었다가 배웠다. LB는 사설망에서 들어오니까 0.0.0.0으로 열어야 한다.

**인덱스가 데이터보다 클 수 있다.** events 문서 하나가 404B인데 그중 인덱스가 220B, 실제 데이터는 100B였다. 이 숫자를 보고 좀 놀랐다.

## 과정과 시행착오

**첫 배포부터 막혔다.** 워커가 `GLIBC_2.38 not found`로 재시작을 무한 반복했다. 빌더 이미지가 새 Debian으로 올라갔는데 런타임은 옛 버전이라 그랬다. 런타임 버전을 고정해서 해결했다. `docker ps`에 보인다고 살아 있는 게 아니라는 걸 여기서 배웠다.

**유실을 0으로 만드는 데 제일 오래 걸렸다.** `XADD MAXLEN`이 아직 ACK 안 된 항목까지 잘라내는 경로가 있었다. 백프레셔가 걸리면 스풀로 전환하고, 워커가 ACK한 것만 지우게 고쳤다. 그러다 **appKey를 주입 안 해서 한 건도 못 보내고 있던 내부 앱 8개**를 발견했다. 이건 좀 아찔했다. 전수 spec을 붙여서 다시는 조용히 빠지지 않게 했다.

**전용 서버에 백업도 감시도 없었다.** 04:00 백업이랑 5분 watchdog을 넣었는데, 넣고 나서 또 터졌다. 백업 경로에 phase가 없어서 prod 덤프가 dev를 덮고, `rsync --delete`가 watchdog 상태 파일을 지웠다. 백업 넣다가 서비스를 흔드는 게 좀 웃겼다.

**열흘 뒤에 21시간 구멍을 발견했다.** 시간당 12~17만 건 들어오던 게 0.7만으로 떨어져 있었다. 약 200만 건이 날아갔다. 원인은 내가 만든 아카이브 잡이었다. 가장 큰 앱 차례에서 하루치를 String으로 통째로 푸는데 1GB가 넘었고, 8GB짜리 호스트가 응답 불능이 됐다. 이 호스트는 앱 워커 15개랑 공유하고 있었다. 줄 단위 스트리밍으로 바꿨다.

**앱 워커가 죽지도 못한 채 20시간을 버텼다.** 가장 큰 앱의 워커가 Node 힙 고갈에 빠졌는데 `restart: always`가 안 떴다. 그 앱의 서버 이벤트가 최대 43시간 늦게 도착했다. 수집기 쪽은 멀쩡했는데도 그랬다. 서버 이벤트를 앱 워커의 큐로 보내던 게 원인이라, 이벤트가 생긴 프로세스가 수집기 API를 직접 부르게 바꿨다.

## 결과

- **클라이언트**: `emitLogEvent()` 한 번이면 Amplitude, 수집기, GA로 다 간다. 20건/5초 배치, 오프라인 큐, 세션 30분.
- **서버**: 앱 프로세스가 수집기 API를 직접 호출한다. 기다리지 않고, 메모리 버퍼랑 64MB 재시도 큐가 받친다.
- **수집기**: api → Redis Streams → worker(bulk write, DLQ, 01:10 일 집계, 30분마다 오늘 갱신, 02:30 아카이브) → 외부 Mongo.
- **보존**: 핫 데이터 90일, 아카이브 3년, 집계는 영구. 04:00 백업, 5분마다 watchdog이 큐·지연·디스크 예상일·24시간 수집량을 본다.

prod에 1,860만 건이 쌓였고 31개 앱에서 3플랫폼이 다 들어온다. 빠져 있던 날짜를 백필하니 가장 큰 앱의 친구신청이 179건에서 13,687건으로, DAU가 2,057에서 2,854로 올라갔다. 그만큼 안 보이고 있었다.

## 배운 것

- **"컨테이너가 떴다"와 "다 떴다"는 다르다.** `docker ps`는 프로세스가 살아 있다는 뜻이지 일이 되고 있다는 뜻이 아니다.
- **아무것도 안 들어오는 상태가 가장 건강해 보인다.** 담당이 한 말인데 계속 머리에 남는다. 그래서 24시간 수집량이 임계 밑으로 떨어지면 경고하게 만들었다. 감시는 "이상이 있을 때"가 아니라 "조용할 때"를 잡아야 한다.
- **내가 만든 안전장치가 서비스를 죽일 수 있다.** 백업이랑 아카이브 둘 다 그랬다. 데이터를 지키려고 만든 잡이 데이터를 날렸다.
- **의존성 방향을 잘못 잡으면 멀쩡한 시스템도 같이 죽는다.** 수집기는 아무 문제가 없었는데 앱 워커에 의존하는 바람에 43시간이 밀렸다. 독립적으로 만들겠다고 해 놓고 전송 경로 하나로 다시 묶어 놓은 셈이었다.