EXPLAIN: AGG SYSTEM
Overview·Compare 를 움직이는 /series·/series/by-variant 집계 로직 해부
대시보드의 Overview 와 Compare 화면은 결국 하나의 질문에 답하기 위해 존재한다 —
"variant 를 바꿨더니, 우리 서비스의 숫자가 정말 좋아졌는가?"
이 문서는 그 답을 만들어내는 분석서버의 집계 API, 그중에서도 /series 와 /series/by-variant 가
Exposure·Call·Biz Event 세 종류의 데이터를 어떻게 조합해 전환율과 지표 값을 계산하는지 해부한다.
1. Background
1.1 깊은 배경 — ABTO 는 무엇을 재는 물건인가
ABTO 는 LLM 을 쓰는 서비스가 프롬프트·모델 구성을 A/B 테스트할 수 있게 해주는 플랫폼이다. 시스템은 세 조각으로 나뉜다.
- Gateway (Go) — 테넌트 앱의 LLM 호출을 대신 수행하는 데이터 플레인. 요청이 올 때마다 어떤 variant 로 서빙할지 배정하고, 그 사실을 telemetry 로 남긴다.
- 분석서버
apps/analytics(Kotlin/Spring) — telemetry 를 인입해 PostgreSQL 에 쌓고, 대시보드가 읽는 집계 API 를 제공하는 컨트롤 플레인. - 대시보드
apps/dashboard(Next.js) — 사람이 보는 화면. Overview·Compare 가 이 문서의 주 무대다.
어휘 네 개만 잡고 가자.
- project — 데이터 격리 단위. 모든 집계는 project 스코프에서 출발한다.
- node — 앱 안의 LLM 호출 지점 하나 (예:
chat.support). 실험이 벌어지는 단위다. - variant — node 안의 후보 구성 하나 (모델·시스템 프롬프트·파라미터 묶음). 원본 구성은 origin 이라 부른다.
- device — 엔드유저 기기 (
device_id). 사람을 세는 단위다.
세 개의 이벤트 평면
분석의 원료는 전부 이벤트다. 서로 다른 세 출처에서, 서로 다른 시점에 도착한다.
| 평면 | 테이블 | 누가 쏘나 | 언제 | 집계에 쓰이는 핵심 컬럼 |
|---|---|---|---|---|
| Exposure (노출) | exposure_event | Gateway | 요청당 1회, variant 배정 순간 | device_id, node_key, variant_id, occurred_at |
| Call (호출 결말) | call_event | Gateway | 서빙 시도(attempt)마다 1회 | variant_id, outcome, cost_usd, latency_ms, total_tokens |
| Biz Event (비즈 신호) | biz_event | Client SDK (엔드유저 브라우저/앱) | 엔드유저가 행동한 순간 | device_id, event_name, value, scale, occurred_at |
Biz Event
Biz Event
add_to_cart 가 Biz Event 로 도착한다.여기서 이 문서 전체를 관통하는 비대칭 하나가 나온다.
Exposure 와 Call 은 게이트웨이가 쏘기 때문에 variant_id 가 처음부터 박혀 있다.
반면 Biz Event 는 테넌트 앱이 쏘기 때문에 variant 를 모른다 —
"장바구니에 담았다"는 신호에는 그 유저가 어떤 프롬프트를 경험했는지가 실려 있지 않다.
두 세계를 잇는 유일한 끈은 device_id 다.
이 간극을 메우는 작업이 귀속(attribution) 이고, /series/by-variant 의 존재 이유다.
variant_id 는 공개 식별자 문자열 그대로 저장되고,
화면에 보이는 variant 이름은 애플리케이션이 운영 스키마에서 따로 해석해 붙인다.1.2 좁은 배경 — 집계 API 지도
집계 API 네 개는 두 축으로 직교한다.
무엇을 재나 (호출 지표 vs 라인 평가) × 어떻게 나누나 (전체 vs variant 별).
정본 스펙은 docs/specs/dashboard-read-api.md.
| 전체 | variant 별 분해 | |
|---|---|---|
| 호출 지표 호출수·비용·p50 지연·에러율 |
① GET /v1/projects/{id}/call-stats | ② …/call-stats/by-variant |
| 라인 평가 Success Metric 또는 이벤트 전환율 |
③ …/series | ④ …/series/by-variant |
- ①③ 은 project 전체가 기본 스코프이고
node=로 좁힐 수 있다. ②④ 는 variant 가 node 에 바인딩되므로node=가 필수다. - 기간은 프리셋 3종 —
24h(버킷 1시간)·7d(6시간)·30d(1일). 서버가 range 로 버킷 폭을 결정한다. - 조연으로 ⑤
/metrics(project 전체 Success Metric 목록), ⑥/events(인입된 biz event 이름 카탈로그), ⑦/event-settings(별칭·숨김)가 있다.
라인(line) 이라는 말은 차트에 그려지는 선 하나를 뜻한다. ③④가 평가하는 라인은 정확히 둘 중 하나다 — 상호배타이고 하나는 필수다.
metricId=— 저장된 Success Metric. node 소유물이며 두 형식이 있다. ratio(전환율 1~5개의 가중 혼합)와 value(집계 operand 1~2개의 사칙연산 — operand 는 biz event 의value집계 또는 call 의cost_usd/total_tokens/latency_ms집계).event=— 저장 없이 원재료 이벤트 하나의 전환율을 바로 평가. 내부적으로는 operand 1개짜리 ratio 와 동일한 수식이다.
화면과의 연결은 이렇다.
| 화면 | 부르는 API | 용도 |
|---|---|---|
| Overview | ① | KPI 카드 4종 + 트래픽 차트 뼈대 |
| Overview | ⑤⑥⑦ → ③ ×N (병렬) | 라인 피커를 구성하고, 선택된 라인마다 ③을 호출해 Trends 차트에 겹침 |
| Compare | ② | variant 비교 표 (호출 지표 + 노출 코호트 N) 와 variant 선택지 |
| Compare | ④ | 지표 비교 행·전환율 시계열 차트·구성 요소 분해 — 드롭다운이 바뀌면 이것만 재조회 |
2. Intuition
집계의 본질은 두 가지 질문으로 압축된다. "몇 명이 경험했고(분모), 그중 몇 명이 행동했나(분자)?" 그리고 "그 행동은 어느 variant 의 공로인가?" 토이 데이터로 직접 계산해 보자.
node chat.support 에 variant A(gpt-5.4)와
B(haiku-4.5)가 있고, 7월 22일 오전에 device 세 대가 다녀갔다.
| 시각 | 평면 | device | 내용 |
|---|---|---|---|
| 10:00 | Exposure | d1 | A 노출 |
| 10:01 | Call | d1 | A 호출 성공 — $0.002, 800ms |
| 10:05 | Biz | d1 | add_to_cart |
| 10:10 | Exposure | d2 | B 노출 |
| 10:12 | Biz | d2 | add_to_cart |
| 10:20 | Exposure | d2 | A 노출 |
| 10:30 | Biz | d2 | purchase |
| 10:40 | Exposure | d3 | B 노출 |
| — | d3 | 이후 행동 없음 |
2.1 전체 전환율 (③ /series?event=add_to_cart)
- 분모 = 노출 코호트: 창 안에 노출된 distinct device — {d1, d2, d3} = 3
- 분자 = 전환 device:
add_to_cart를 남겼고, 그 이벤트 이전에 노출된 적이 있는 distinct device — {d1, d2} = 2 - 전환율 = 2/3 ≈ 66.7%
왜 "이벤트 이전에 노출" 조건이 붙는가? 이 조건(코드에서는 멤버십 EXISTS)이 없으면, AI 를 한 번도 거치지 않은 유입 — 예컨대 광고에서 바로 들어와 장바구니에 담은 device — 까지 분자에 들어간다. 그 순간 분자가 분모의 부분집합이 아니게 되어 전환율이 100% 를 넘을 수 있다. 멤버십 조건은 분자 ⊆ 분모, 즉 전환율의 0~100 유계를 구조적으로 보장한다.
2.2 variant 귀속 (④ /series/by-variant) — as-of carry-forward
d2 를 보자. B 를 보고, 담고, A 를 보고, 샀다. purchase 는 누구의 공로인가?
Exposure B
add_to_cartExposure A
purchase규칙은 하나다 — 이벤트 시점 직전의 마지막 노출이 공로를 가져간다 (as-of carry-forward).
add_to_cart(10:12) 직전의 노출은 10:10 의 B 이므로 B 에,
purchase(10:30) 직전의 노출은 10:20 의 A 이므로 A 에 귀속된다.
"carry-forward" 라는 이름처럼, 새 노출이 없는 동안은 직전 노출이 계속 유효하게 이월된다 —
10:20 이후 d2 가 밤에 행동했어도 (창 안이라면) 여전히 A 의 공로다.
그래서 ④의 결과는 이렇게 된다.
| variant | 코호트 N (노출 distinct device) | add_to_cart 전환 | purchase 전환 |
|---|---|---|---|
| A | 2 (d1, d2) | 1 (d1) → 50% | 1 (d2) → 50% |
| B | 2 (d2, d3) | 1 (d2) → 50% | 0 → 0% |
주의 깊게 보면 d2 는 A 의 코호트에도 B 의 코호트에도 들어 있다 — 창 안에서 둘 다 노출됐기 때문이다. 코호트는 "그 variant 를 경험한 device 집합"이지 배타적 분할이 아니다.
한편 Call 지표(호출수·비용·지연·에러율)는 귀속이 아예 필요 없다.
call_event 에는 게이트웨이가 variant_id 를 이미 박아 놓았으므로,
그냥 group by variant_id 하면 끝이다.
귀속이라는 우회로는 오직 variant 를 모르는 Biz Event 를 위한 것이다.
2.3 나머지 조각들
가중 혼합 — Success Metric ratio 형식은 이벤트 전환율 여러 개의 가중 평균이다.
예: engagement = add_to_cart 40% + purchase 60%.
variant A 라면 50%×0.4 + 50%×0.6 = 50%.
weight 는 읽기 시점에 정규화하므로 합이 100 이 아니어도 된다.
value 형식 — 전환율이 아니라 값을 재는 라인.
예: purchase 의 sum(value) (매출), call 의 avg(latency_ms),
또는 두 operand 의 사칙연산 (sum(cost_usd) ÷ count(call) = 건당 비용).
biz operand 는 as-of 귀속을 거치고, call operand 는 직접 그룹핑한다.
버킷-로컬 시계열 — 차트의 점 하나하나는 창 전체가 아니라 그 버킷 안에서 분모(그 버킷에 노출된 device)와 분자(그 버킷에 전환한 device)를 따로 계산한다. 그래서 트래픽이 희소한 버킷에서는 값이 요동하고, 노출이 없는 버킷은 0 이 아니라 null 이다. 창 전체 값과 점들의 평균이 일치하지 않는 것은 버그가 아니라 정의다.
3. Code
이제 요청 하나가 통과하는 경로를 따라 내려가 보자.
등장 파일은 전부 apps/analytics/src/main/kotlin/com/abto/analytics/analysis/ 아래에 있다.
3.1 입구 — AnalysisController (infrastructure/web)
컨트롤러는 계약 문지기다. 핵심 규칙 세 개가 여기서 걸러진다.
metricId와event는 XOR — 둘 다거나 둘 다 없으면 400./series에서metricId는 단독으로 온다. metric 은 node 소유물이라 서버가 소속 node 를 해석하므로,node=동시 지정은 모순 여지가 있어 400./series/by-variant는node=필수 (variant 가 node 에 바인딩되므로).
range 는 RangePreset (domain) 으로 해석된다 —
24h/1h, 7d/6h, 30d/1d 의 (창, 버킷) 쌍.
3.2 해석 — AnalysisService (application)
서비스는 "무엇을 평가할지"를 확정한다.
metricId경로 —StoredMetricReader로 저장 metric 을 찾고, 소속 node 를 해석하는 행위가 곧 테넌시 검증이다. 남의 project 의 metric 은 여기서 404 로 떨어진다. by-variant 라면 metric 이 요청된 node 소유인지까지 맞아야 한다.event경로 — project(·node) 소유만 검증하고MetricSpec.ofEvent(event)로 승격한다. operand 1개·weight 1.0 짜리 ratio 로 만들어, 저장 metric 과 같은 평가 경로를 태운다. 원재료 전환율이 별도 코드 경로 없이 공짜로 얻어지는 이유다.- 창 계산 —
(now − range.window, now]. 그리고bucketStarts()가 버킷 축을 Kotlin 쪽에서 미리 만든다. epoch(1970-01-01 UTC) 기준 내림이라, 뒤에 나올 SQLdate_bin의 버킷 경계와 정확히 일치한다. 축을 서버가 소유하므로 트래픽 없는 버킷도 응답에 행이 있고(값 null), 클라이언트는 축을 재구성하지 않는다.
MetricSpec (domain) 은 저장된 JSONB definition 을 평가용 타입으로 파싱한 것이다 —
Ratio(operands: [{event, weight}]) 또는 Value(op, operands: [{source, event|field, agg}]).
형태 검증은 저장 경계가 이미 끝냈으므로 여기서는 잠근 형태를 그대로 읽는다.
3.3 심장부 — JooqMetricValueReader (infrastructure/persistence)
SQL 원문 어댑터다. LATERAL·EXISTS 가 얽혀 DSL 빌더보다 원문이 읽기 쉬워 resultQuery 를 쓰고,
보간되는 조각(집계 함수·컬럼·버킷 간격)은 전부 enum 산출이라 사용자 입력이 SQL 에 닿지 않는다.
버킷팅 — 모든 시계열 쿼리는 같은 함수로 시각을 버킷에 접는다.
date_bin('6 hours', occurred_at, timestamptz '1970-01-01 00:00:00+00')
date_trunc 로는 6시간 같은 임의 간격이 안 되기 때문에 date_bin 이다.
origin 이 epoch 라 6h 버킷은 00·06·12·18시로 정렬되고, Kotlin bucketStarts() 와 경계가 맞는다.
③ 전체 스코프 ratio (scopedRatio) — 쿼리 네 종류로 원료를 뽑는다:
창 전체 코호트, 버킷별 코호트 (둘 다 exposure_event 의 count(distinct device_id)),
이벤트별 창 전체 전환, 이벤트별 버킷 전환. 전환 쿼리의 분자 판정이 2.1 에서 본 멤버십이다.
select count(distinct b.device_id) from biz_event b
where b.project_id = ? and b.event_name = ?
and b.occurred_at >= ? and b.occurred_at < ?
and exists (select 1 from exposure_event e
where e.project_id = b.project_id
and e.device_id = b.device_id
and e.occurred_at >= ? -- 창 시작 (from)
and e.occurred_at <= b.occurred_at)
node= 필터가 있으면 EXISTS 안에 e.node_key = ? 가 추가되고,
없으면 "project 안 아무 node 노출"이 멤버십이 된다.
버킷 포인트용 변형은 노출 하한을 greatest(버킷 시작, from) 으로 바꾼다 —
분자의 시간 하한을 그 버킷의 분모와 맞추는 버킷-로컬 판정인데,
첫 버킷은 경계가 창 시작보다 앞설 수 있어 from 으로 클램프한다.
이 클램프가 없으면 첫 부분버킷에서 분자가 분모보다 넓은 창을 보게 된다.
④ variant 분해 ratio (variantRatio) — 코호트는 exposure_event 를
group by variant_id 로. 전환은 2.2 의 as-of 귀속을 LATERAL 로 구현한 조각을 biz_event 에 붙인다.
cross join lateral (
select e.variant_id from exposure_event e
where e.project_id = b.project_id
and e.device_id = b.device_id
and e.node_key = ?
and e.occurred_at >= ? -- 창 시작 (from)
and e.occurred_at <= b.occurred_at -- 이벤트 시점까지
order by e.occurred_at desc, e.event_id desc
limit 1
) attr
order by … desc limit 1 이 "직전 마지막 노출"이고,
동일 occurred_at 노출이 둘일 때는 event_id (UUIDv7, 시간순) tie-break 로
귀속을 결정적으로 만든다 — 실행 계획이나 저장 순서에 따라 귀속이 흔들리지 않는다.
variant 우주 — ④가 돌려주는 variant 집합은
노출 코호트의 variant ∪ 귀속에 등장한 variant ∪ 창 안 call_event 에만 등장한 variant 다.
마지막 항이 미묘한데, 노출 없이 호출 결말만 남은 variant 도 행으로 내야
Compare 화면에서 ② call-stats/by-variant 표와 행이 어긋나지 않는다.
그런 variant 의 전환율은 null, N 은 0 으로 남는다.
value 형식 — operand 별로 총합·버킷 쿼리를 낸다.
biz operand 는 위와 같은 as-of 귀속 후 agg(b.value),
call operand 는 call_event 를 group by variant_id 직접 그룹핑 (귀속 불필요 — 2.2 참조).
집계 함수는 enum 매핑이다: count/sum/avg 와 percentile_cont(0.5|0.95|0.99).
3.4 접기 — SeriesMath (domain) 와 응답 조립
SQL 이 낸 원료를 순수 함수 둘이 값으로 접는다.
ratioPercent(operands, convertedByEvent, cohort)— weight 정규화 후 가중 합 × 100. cohort ≤ 0 이면 null (0% 가 아니다 — 분모가 없으면 전환율이라는 개념 자체가 성립하지 않는다).apply(op, values)— value 형식의 사칙연산. operand 값이 null 이거나 0 나눗셈이면 null 전파.
조립된 SeriesResult 는 value(창 전체 스냅샷),
n(노출 코호트 — ratio 만, value 형식은 null), points(전 버킷 축),
components(합성식 operand 별 중간값 — 계산 과정에서 어차피 나온 값의 echo 라 추가 쿼리가 없다.
Compare 하단 구성 요소 분해가 이걸로 표를 채운다) 를 담는다.
단위 라벨도 여기서 정해진다 — call field 는 고정 라벨(USD·tokens·ms),
biz value 는 인입된 scale 이 만장일치일 때만 채택
(having count(distinct scale) = 1 — KRW 와 USD 가 섞였다면 라벨을 고르는 게 아니라
집계 자체가 이미 의미를 잃은 것이므로 null), count 는 null (점수를 세면 점이 아니라 건수다).
3.5 화면에서의 소비
- Overview (
overview/analytics-overview.tsx) — ①⑤⑥⑦을 병렬로 받아 KPI 카드와 라인 피커를 세우고, 선택된 라인마다 ③을 병렬 호출한다. 응답들의points를 버킷 시각(t) 키로 병합해 Trends 차트 한 장에 겹친다 — 서버가 축을 소유하므로 병합은 Map 삽입으로 끝난다. - Compare (
compare/analytics-compare.tsx) — node 하나를 고정하고 ②로 variant 표·선택지를 만든 뒤, 선택된 지표(event XOR metricId — Primary metric 이 기본값)로 ④를 호출한다. A/B 두 variant 의points를 겹친 것이 전환율 차트(conv-rate-chart.tsx)이고,components를 편 것이 구성 요소 분해(event-decomposition.tsx·success-metric-compare.tsx)다.
"__origin__" 으로 기록된다.
Compare 가 variant 이름·모델을 이을 때 이 리터럴을 별도 매핑하는 이유다.4. Quiz
선택지를 클릭하면 정답 여부와 해설이 나온다. 본문을 이해했다면 네 개 중 하나를 자신 있게 고를 수 있을 것이다.
1. ③ /series?event=… 전체 스코프 전환율에서, 분자 판정에 붙는 멤버십 EXISTS("이벤트 이전에 스코프 안 노출이 존재")가 구조적으로 보장하는 것은?
2. device d2 의 타임라인이 [10:10 B 노출 → 10:12 add_to_cart → 10:20 A 노출 → 10:30 purchase] 일 때, ④ 에서 purchase 는 어디에 귀속되는가?
3. ④ 응답의 variant 목록에 "코호트 N=0, 전환율 null" 인 행이 나타날 수 있다. 어떤 경우인가?
4. Success Metric value 형식에서 call operand(예: avg(latency_ms))는 as-of 귀속 없이 계산된다. 왜인가?
5. 버킷 시계열의 전환 쿼리는 노출 인정 하한을 greatest(버킷 시작, from) 으로 클램프한다. 이 클램프가 막는 것은?