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 는 무엇을 재는 물건인가

💡이 절은 처음 온보딩하는 독자를 위한 것이다. 게이트웨이·분석서버·대시보드의 역할 분담을 이미 알고 있다면 1.2 로 건너뛰어도 된다.

ABTO 는 LLM 을 쓰는 서비스가 프롬프트·모델 구성을 A/B 테스트할 수 있게 해주는 플랫폼이다. 시스템은 세 조각으로 나뉜다.

어휘 네 개만 잡고 가자.

세 개의 이벤트 평면

분석의 원료는 전부 이벤트다. 서로 다른 세 출처에서, 서로 다른 시점에 도착한다.

평면테이블누가 쏘나언제집계에 쓰이는 핵심 컬럼
Exposure (노출)exposure_eventGateway 요청당 1회, variant 배정 순간 device_id, node_key, variant_id, occurred_at
Call (호출 결말)call_eventGateway 서빙 시도(attempt)마다 1회 variant_id, outcome, cost_usd, latency_ms, total_tokens
Biz Event (비즈 신호)biz_eventClient SDK (엔드유저 브라우저/앱) 엔드유저가 행동한 순간 device_id, event_name, value, scale, occurred_at
테넌트 서비스
앱 백엔드LLM 기능 코드
Client SDK엔드유저 브라우저
LLM 호출 /
Biz Event
 
Gatewayvariant 배정 + 서빙
Exposure + Call
Biz Event
분석서버
인입이벤트 저장
PostgreSQL3개 이벤트 테이블
집계 API/call-stats · /series
JSON
대시보드Overview · Compare
예: 엔드유저 기기 d1 의 요청 하나가 Exposure(A 노출) + Call(성공, $0.002) 를 남기고, 잠시 뒤 그 기기의 add_to_cart 가 Biz Event 로 도착한다.

여기서 이 문서 전체를 관통하는 비대칭 하나가 나온다. Exposure 와 Call 은 게이트웨이가 쏘기 때문에 variant_id 가 처음부터 박혀 있다. 반면 Biz Event 는 테넌트 앱이 쏘기 때문에 variant 를 모른다 — "장바구니에 담았다"는 신호에는 그 유저가 어떤 프롬프트를 경험했는지가 실려 있지 않다. 두 세계를 잇는 유일한 끈은 device_id 다. 이 간극을 메우는 작업이 귀속(attribution) 이고, /series/by-variant 의 존재 이유다.

💡telemetry 스키마는 운영 스키마와 조인하지 않는다 (Snowplow 관례). 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

라인(line) 이라는 말은 차트에 그려지는 선 하나를 뜻한다. ③④가 평가하는 라인은 정확히 둘 중 하나다 — 상호배타이고 하나는 필수다.

화면과의 연결은 이렇다.

화면부르는 API용도
OverviewKPI 카드 4종 + 트래픽 차트 뼈대
Overview⑤⑥⑦ → ③ ×N (병렬)라인 피커를 구성하고, 선택된 라인마다 ③을 호출해 Trends 차트에 겹침
Comparevariant 비교 표 (호출 지표 + 노출 코호트 N) 와 variant 선택지
Compare지표 비교 행·전환율 시계열 차트·구성 요소 분해 — 드롭다운이 바뀌면 이것만 재조회

2. Intuition

집계의 본질은 두 가지 질문으로 압축된다. "몇 명이 경험했고(분모), 그중 몇 명이 행동했나(분자)?" 그리고 "그 행동은 어느 variant 의 공로인가?" 토이 데이터로 직접 계산해 보자.

node chat.support 에 variant A(gpt-5.4)와 B(haiku-4.5)가 있고, 7월 22일 오전에 device 세 대가 다녀갔다.

시각평면device내용
10:00Exposured1A 노출
10:01Calld1A 호출 성공 — $0.002, 800ms
10:05Bizd1add_to_cart
10:10Exposured2B 노출
10:12Bizd2add_to_cart
10:20Exposured2A 노출
10:30Bizd2purchase
10:40Exposured3B 노출
d3이후 행동 없음

2.1 전체 전환율 (③ /series?event=add_to_cart)

왜 "이벤트 이전에 노출" 조건이 붙는가? 이 조건(코드에서는 멤버십 EXISTS)이 없으면, AI 를 한 번도 거치지 않은 유입 — 예컨대 광고에서 바로 들어와 장바구니에 담은 device — 까지 분자에 들어간다. 그 순간 분자가 분모의 부분집합이 아니게 되어 전환율이 100% 를 넘을 수 있다. 멤버십 조건은 분자 ⊆ 분모, 즉 전환율의 0~100 유계를 구조적으로 보장한다.

2.2 variant 귀속 (④ /series/by-variant) — as-of carry-forward

d2 를 보자. B 를 보고, 담고, A 를 보고, 샀다. purchase 는 누구의 공로인가?

10:10
Exposure B
10:12
add_to_cart
B 귀속
10:20
Exposure A
10:30
purchase
A 귀속
device d2 의 타임라인 — 각 Biz Event 는 자기 시점 직전의 마지막 노출로 귀속된다.

규칙은 하나다 — 이벤트 시점 직전의 마지막 노출이 공로를 가져간다 (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 전환
A2 (d1, d2)1 (d1) → 50%1 (d2) → 50%
B2 (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 이다. 창 전체 값과 점들의 평균이 일치하지 않는 것은 버그가 아니라 정의다.

💡없음은 0 이 아니다. 집계할 행이 없는 셀은 전부 null 로 내려간다 — 0 은 "측정했더니 없었다", null 은 "측정 대상 자체가 없었다"로 구분된다. 건수 성격(호출수, count)만 0 을 쓴다.

3. Code

이제 요청 하나가 통과하는 경로를 따라 내려가 보자. 등장 파일은 전부 apps/analytics/src/main/kotlin/com/abto/analytics/analysis/ 아래에 있다.

대시보드 컴포넌트
AnalysisController파라미터 계약 검증
AnalysisService선택 해석 · 소유 검증 · 창 계산
JooqMetricValueReaderSQL 로 원료 추출
SeriesMath원료를 값으로 접기
SeriesResultvalue · n · points · components

3.1 입구 — AnalysisController (infrastructure/web)

컨트롤러는 계약 문지기다. 핵심 규칙 세 개가 여기서 걸러진다.

rangeRangePreset (domain) 으로 해석된다 — 24h/1h, 7d/6h, 30d/1d 의 (창, 버킷) 쌍.

3.2 해석 — AnalysisService (application)

서비스는 "무엇을 평가할지"를 확정한다.

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_eventcount(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_eventgroup 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_eventgroup by variant_id 직접 그룹핑 (귀속 불필요 — 2.2 참조). 집계 함수는 enum 매핑이다: count/sum/avg 와 percentile_cont(0.5|0.95|0.99).

3.4 접기 — SeriesMath (domain) 와 응답 조립

SQL 이 낸 원료를 순수 함수 둘이 값으로 접는다.

조립된 SeriesResultvalue(창 전체 스냅샷), 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 화면에서의 소비

💡telemetry 의 origin variant 는 UUID 가 아니라 리터럴 "__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) 으로 클램프한다. 이 클램프가 막는 것은?