- Real User Monitoring ( RUM ) 개발2026년 03월 10일 10시 46분 41초에 업로드 된 글입니다.작성자: zinok
"페이지가 느려요"라는 CS가 들어오면 제일 먼저 드는 생각이 있습니다. 진짜로 느린 건지, 그 사람 네트워크가 느린 건지, 아니면 우리 서버가 느린 건지. Synthetic 모니터링(봇이 주기적으로 찍는 방식)으로는 이걸 구분하기가 쉽지 않습니다. 실제 사용자의 브라우저에서 일어나는 일을 직접 봐야 합니다.
그래서 RUM(Real User Monitoring)을 직접 만들었습니다. 외부 솔루션(Datadog RUM, New Relic 등)도 있지만, 우리에겐 한 가지 특수한 요구사항이 있었습니다 -- Edge 내부 데이터(캐시 히트 여부, Edge PoP 정보, Edge RTT)를 브라우저 메트릭과 합쳐서 봐야 한다는 것. 이걸 해줄 수 있는 외부 서비스는 없었습니다.
Problem"느리다"는 말의 N가지 원인
사용자가 "느리다"고 할 때, 실제로는 완전히 다른 원인들이 뒤섞여 있습니다. 서버가 느릴 수도 있고, CDN 캐시가 빠져서 Origin까지 갔다 올 수도 있고, 사용자의 네트워크 경로 자체가 문제일 수도 있습니다.
기존 모니터링만으로는 이 세 가지를 깔끔하게 분리할 수 없었습니다. 서버 사이드 로그에는 사용자의 체감 속도가 안 찍히고, Synthetic 모니터링은 특정 PoP에서 봇이 쏘는 거라 실제 사용자 분포와 다르죠. 그래서 결국 브라우저 + Edge + 서버 데이터를 한 곳에 모아야 한다는 결론에 도달했습니다.
외부 RUM의 한계Datadog RUM이나 New Relic Browser는 브라우저 메트릭은 잘 수집합니다. 하지만 Edge 계층의 내부 데이터(어떤 PoP에서 서빙했는지, 캐시 HIT인지 MISS인지)까지는 알 수 없습니다. 이 정보 없이는 "왜 느린지"의 절반밖에 답하지 못합니다.
///Architecture3계층 수집 구조
전체 구조를 간단히 요약하면 이렇습니다. 브라우저에서 SDK가 메트릭을 모으고, Edge(HAProxy)가 자기 정보를 끼워넣고, Go Collector가 받아서 ClickHouse에 저장합니다.
RUM 수집 파이프라인 -- 3-Layer Architecture[ Browser ] rum.js SDK Navigation Timing, Web Vitals, Errors, Resources │ │ POST /rum/v1/collect (JSON beacon) ▼ [ Edge -- HAProxy ] Lua filter: HTML 응답에 <script> 자동 삽입 Server-Timing 헤더로 Edge IP, Service ID 전달 │ │ beacon + edge metadata ▼ [ Go Collector ] beacon 수신 → GeoIP 조회 → ClickHouse 적재 /rum/v1/rum.js 서빙, /rum/v1/report 분석 │ ▼ [ ClickHouse ] rum.beacons 테이블 (42 columns) 월별 파티션, (service_id, timestamp) 정렬이 구조에서 가장 마음에 드는 점은, 서비스 코드를 한 줄도 안 고친다는 것입니다. HAProxy Lua 필터가 HTML 응답의
<head>태그를 찾아서 SDK 스크립트를 알아서 주입합니다. 서비스 개발자 입장에서는 RUM이 붙어있는지도 모릅니다.///SDKrum.js -- 브라우저에서 뭘 수집하나
rum.js는 약 7.8KB짜리 경량 SDK입니다. gzip 하면 3KB 근처까지 줄어듭니다. 페이지 로드 후 자동으로 수집을 시작하고, 사용자에게는 아무 영향이 없습니다.
Category Metrics Source Navigation Timing DNS, TCP, TLS, TTFB, Content Download, DOM Interactive, Page Load Performance API Core Web Vitals LCP, INP, CLS PerformanceObserver Connection Info Effective Type (4g/3g), Downlink (Mbps), RTT (ms) Navigator API Resource Timing Top 10 slowest resources (duration, size) Performance API Error Tracking JS errors + unhandled promise rejections (max 10) window.onerror Edge Context dest_ip (Edge PoP), service_id Server-Timing 한 가지 고민이 있었습니다. beacon을 언제 보내야 하는가. 너무 빨리 보내면 LCP 같은 메트릭이 아직 수집이 안 됐을 거고, 너무 늦게 보내면 사용자가 탭을 닫아버릴 수 있습니다.
결론은 이렇습니다. 기본적으로
window.onload + 3초를 기다립니다. 3초면 대부분의 Web Vitals 값이 확정됩니다. 탭을 먼저 닫거나 백그라운드로 보내면visibilitychange/pagehide시점에 즉시 전송합니다. 전송은navigator.sendBeacon()을 쓰고, 안 되면 XHR로 폴백합니다.SPA 지원SPA(Single Page Application)에서 페이지 전환은 실제 navigation이 아니라 pushState/popState입니다. rum.js는 이 이벤트를 감지해서 가상 페이지뷰마다 별도의 beacon을 수집합니다. React Router나 Next.js의 클라이언트 네비게이션도 잡아냅니다.
///EdgeHAProxy Lua -- 코드 수정 없는 자동 삽입
솔직히, 이 프로젝트에서 가장 재미있었던 부분입니다. 보통 RUM을 도입하려면 각 서비스 팀에 "HTML에 이 스크립트 태그 좀 넣어주세요"라고 부탁해야 합니다. 서비스가 10개면 10팀한테 부탁해야 하고, 누가 빼먹으면 데이터가 빠집니다.
그래서 Edge 레벨에서 해결했습니다. HAProxy에 Lua 필터를 달아서, Origin에서 내려오는 HTML 응답을 가로챈 뒤
<head>태그 뒤에 SDK 스크립트를 주입합니다. 서비스 코드는 한 줄도 안 건드립니다.lua-- inject-body.lua (핵심 로직 요약) function inject_rum(applet) local body = applet:receive() local tag = '<script src="/rum/v1/rum.js" async></script>' -- <head> 태그를 찾아서 바로 뒤에 삽입 body = body:gsub("(<head[^>]*>)", "%1\n" .. tag) -- Content-Length 제거 → chunked encoding 전환 applet:remove_header("Content-Length") applet:send(body) end동시에, HAProxy는 Server-Timing 헤더도 끼워넣습니다.
http headerServer-Timing: edge;desc="10.xxx.xx.101", sid;desc="my-service"rum.js는 이 헤더를 파싱해서
dest_ip(어떤 Edge PoP에서 서빙했는지)와service_id(어떤 서비스인지)를 beacon에 포함합니다. 이렇게 하면 브라우저가 수집한 메트릭에 Edge 컨텍스트가 자연스럽게 합쳐집니다.서비스 등록은
rum_services.map파일로 관리합니다. HAProxy Runtime API를 통해 재시작 없이 런타임에 추가/삭제할 수 있습니다. 서비스 온보딩할 때 맵 파일에 한 줄 추가하면 끝입니다.///CollectorGo로 만든 수집 서버
Collector는 Go로 작성했습니다. 역할은 크게 세 가지입니다.
- rum.js 서빙 --
GET /rum/v1/rum.js(Cache: 1시간) - Beacon 수신 + 저장 --
POST /rum/v1/collect - 리포트 생성 --
POST /rum/v1/report(LLM 기반 분석)
Beacon이 들어오면 이런 순서로 처리합니다.
Beacon Processing PipelinePOST /rum/v1/collect │ ├─ 1. Client IP 추출 │ N-Client-IP → X-Forwarded-For → RemoteAddr 순서 │ ├─ 2. Dest IP 추출 │ beacon payload → X-Edge-IP header │ ├─ 3. GeoIP Lookup (MaxMind City + ASN) │ → country, city, lat/lng, ASN, carrier │ ├─ 4. Beacon flatten │ 중첩 JSON → flat columns 변환 │ └─ 5. ClickHouse Async Insert 비동기 삽입 (응답 차단 없음)GeoIP 조회에는 MaxMind의 City + ASN 데이터베이스를 사용합니다. 사용자 IP와 Edge IP 각각에 대해 조회하기 때문에, "서울에 있는 사용자가 도쿄 PoP으로 라우팅됐다" 같은 것도 파악할 수 있습니다.
ClickHouse Insert는 비동기로 돌립니다. beacon 수신 → 즉시 200 응답 → 백그라운드 적재. 사용자에게 추가 지연을 주지 않기 위해서입니다.
///StorageClickHouse 스키마 설계
왜 ClickHouse인가? RUM 데이터는 전형적인 시계열 + 분석 워크로드입니다. 대량의 INSERT가 들어오고, 집계 쿼리로 읽습니다. 이런 패턴에 ClickHouse만큼 빠른 게 없습니다.
rum.beacons테이블은 42개 컬럼으로 구성됩니다. 논리적으로 6개 그룹으로 나뉩니다.Group Columns Example Identification timestamp, service_id, session_id, page_url 4 cols Navigation nav_dns_ms, nav_tcp_ms, nav_tls_ms, nav_ttfb_ms, ... 9 cols Web Vitals wv_lcp_ms, wv_inp_ms, wv_cls 3 cols Connection conn_effective_type, conn_downlink_mbps, conn_rtt_ms 3 cols Context device_type, screen, language, referrer, errors_count, ... 9 cols GeoIP client_ip, dest_ip, geo_country, geo_city, geo_asn, ... 9 cols sqlCREATE TABLE rum.beacons_local ( timestamp DateTime64(3), service_id LowCardinality(String), session_id String, page_url String, -- Navigation Timing nav_ttfb_ms Float32, nav_dns_ms Float32, -- ... (생략) -- Core Web Vitals wv_lcp_ms Float32, wv_inp_ms Float32, wv_cls Float32, -- GeoIP geo_country LowCardinality(String), geo_city String, geo_asn UInt32, raw String -- 원본 JSON 보관 ) ENGINE = MergeTree() PARTITION BY toYYYYMM(timestamp) ORDER BY (service_id, timestamp);LowCardinality타입을 쓴 이유가 있습니다.service_id나geo_country처럼 고유값이 수백 개 이내인 컬럼은 딕셔너리 인코딩으로 저장하면 압축률이 크게 올라갑니다. ClickHouse의 강점 중 하나죠.원본 JSON도
raw컬럼에 통째로 보관합니다. 나중에 스키마를 바꾸거나 새로운 메트릭을 추가할 때, 과거 데이터를 재가공할 수 있어야 하니까요. 보험 같은 겁니다.///ReportLLM 기반 자동 분석
사실 데이터를 쌓는 건 절반이고, 그걸 읽어서 뭔가를 알아내는 게 진짜 일입니다. 대시보드에 그래프를 잔뜩 띄워놔도, 결국 누군가가 "이 서비스 TTFB가 어제보다 30% 올라갔네"를 눈으로 발견해야 합니다. 서비스가 많아지면 이것도 일입니다.
그래서 Claude API를 연동해서 자동 분석 리포트를 만들었습니다. 흐름은 이렇습니다.
- Dashboard에서 기간, 서비스, 국가를 선택하고 리포트 요청
- Go Collector가 ClickHouse에 3개 쿼리를 날림 (현재 vs 이전 기간, 국가별 breakdown, PoP별 분석)
- 쿼리 결과를 Claude에 넘기면, 마크다운 리포트가 생성됨
리포트가 잡아내는 것들TTFB나 LCP가 전 기간 대비 10% 이상 악화된 서비스를 자동으로 표시합니다. 특정 국가에서만 느려졌다거나, 특정 PoP에서 성능이 떨어진다거나 하는 패턴도 잡아냅니다. 사람이 하면 30분 걸릴 분석이 몇 초면 끝납니다.
Langfuse를 연동해서 LLM 호출을 트레이싱하고 있습니다. 입력/출력 토큰, 레이턴시, 비용을 추적할 수 있어서 "리포트 한 번에 토큰 얼마나 쓰지?" 같은 질문에 바로 답할 수 있습니다.
///DashboardNext.js 대시보드
대시보드는 Next.js + Tailwind CSS + Radix UI로 만들었습니다. 크게 두 가지 화면이 있습니다.
- 메인 대시보드 -- 실시간 메트릭 현황
- 리포트 화면 -- 기간/서비스/국가 필터를 걸고 LLM 분석 리포트를 생성, 과거 리포트 조회
필터 옵션은 Go Collector의
/rum/v1/report/options엔드포인트에서 동적으로 가져옵니다. 서비스나 국가가 새로 추가되면 별도 작업 없이 자동으로 필터에 나타납니다.Dashboard → Go Collector 통신은 Next.js의 API Route를 프록시로 씁니다. 브라우저에서 Collector로 직접 요청하지 않으니, Collector를 내부망에 둘 수 있습니다.
///Insight실제로 이런 게 보인다
RUM을 실제로 운영하면서 예상치 못한 것들을 발견하게 됩니다. 몇 가지 사례를 소개합니다.
Page Load Breakdown (p50, 특정 서비스)Page Load2,340 msDOM Complete1,680 msDOM Interactive1,120 msTTFB650 msTLS280 msDNS45 ms위 수치는 예시지만, 실제로 이런 breakdown을 서비스별 / 국가별 / PoP별로 잘라볼 수 있습니다. "인도네시아에서 접속하는 사용자의 TTFB가 유독 높다" -- 이런 걸 발견하면 해당 리전의 캐시 정책이나 Origin 응답 시간을 점검하게 됩니다.
또 하나 유용한 건,
conn_effective_type을 보면 사용자의 네트워크 품질 분포를 알 수 있다는 겁니다. 모바일 사용자 중 3G 비율이 생각보다 높다면, 이미지 최적화나 lazy loading의 우선순위가 달라질 수 있죠.Edge PoP 라우팅 확인dest_ip 덕분에 "이 사용자가 어떤 Edge PoP에서 서빙받았는지"를 정확히 알 수 있습니다. 서울 사용자가 도쿄 PoP으로 빠지고 있다면, DNS 기반 라우팅에 문제가 있다는 뜻입니다. 이런 건 서버 로그만 봐서는 절대 발견하지 못합니다.
///Deploy배포 구성
로컬 개발은
podman-compose(docker-compose도 가능)로 전체 스택을 한 번에 띄웁니다. HAProxy, Origin(nginx), Go Collector, ClickHouse가 컨테이너로 올라갑니다.shell# 로컬 개발 환경 실행 podman-compose up -d --build # 접속 포인트 HAProxy → http://localhost:18080 # SDK 자동 삽입된 HTML Dashboard → http://localhost:3001 # Next.js UI Collector API → http://localhost:3000 # Go 서버 Origin → http://localhost:8080 # nginx프로덕션은 Kubernetes 위에서 돌립니다. Collector는 Deployment(replica 2)로 배포하고, CPU 100m~500m / Memory 128~256Mi 정도면 충분합니다. beacon 하나하나가 가벼운 JSON이라 리소스를 많이 안 먹습니다.
yaml# k8s 배포 (요약) kubectl apply -f namespace.yaml kubectl create secret generic rum-collector-config \ --from-literal=CH_HOST=clickhouse.internal \ --from-literal=CH_PASSWORD=******** \ -n rum kubectl apply -f deployment.yaml kubectl apply -f service.yaml///Wrap-up돌아보며
처음에는 "그냥 Datadog RUM 쓰면 되지 않나?" 하는 생각도 있었습니다. 그런데 Edge 내부 데이터를 합칠 수 없다는 게 결정적이었습니다. 캐시 HIT/MISS인지, 어떤 PoP에서 서빙했는지를 모르면 "왜 느린지"를 끝까지 추적할 수 없습니다.
직접 만들면서 좋았던 점은, 필요한 메트릭을 자유롭게 추가할 수 있다는 것입니다. 외부 솔루션은 정해진 스키마 안에서만 움직여야 하는데, 우리 상황에 맞는 컬럼을 추가하고, 쿼리를 원하는 대로 짜고, LLM 리포트까지 붙일 수 있었습니다.
물론 트레이드오프는 있습니다. 유지보수 부담이 우리에게 있다는 것. ClickHouse 클러스터 관리, GeoIP DB 업데이트, SDK 호환성 관리 같은 것들이요. 그래도 "느린 이유를 정확히 설명할 수 있다"는 건 꽤 가치 있는 일이라고 생각합니다.
다음에는 Grafana 대시보드를 붙여서 시각화를 더 강화하고, Self Portal과 연동해서 서비스별 RUM on/off를 토글할 수 있게 만들 생각입니다. rum.js 번들도 5KB 이하로 줄이는 작업이 남아 있고요.
핵심 정리RUM은 "실제 사용자가 얼마나 느린지"를 측정합니다. 여기에 Edge 내부 데이터(PoP, 캐시 상태)를 합치면, "왜 느린지"까지 알 수 있습니다. HAProxy Lua 필터로 서비스 코드 수정 없이 SDK를 삽입하고, Go Collector + ClickHouse로 수집/저장하고, Claude API로 자동 분석 리포트를 생성합니다. 전체 파이프라인이 서비스 팀의 개입 없이 동작합니다.
'IT' 카테고리의 다른 글
Edge service 란 ? (1) 2026.03.10 공공데이터 + LLM으로<br>아파트 입찰 낙찰 분석 시스템 만들기 (0) 2026.03.09 영상은 어떻게 인터넷을 타고 흐르는가 — HTTP 스트리밍 기술 해부 (0) 2026.03.09 다음글이 없습니다.이전글이 없습니다.댓글 - rum.js 서빙 --