개념
이 레슨의 경계 — SQL 문법은 여기서 가르치지 않는다
SELECT·WHERE·JOIN·GROUP BY·NULL 처리와 조회로 화면 값을 검증하는 방법은 모듈 61 레슨 1(s3-m61-l01) 이 이미 다뤘다. 아직 안 봤다면 먼저 보고 오는 편이 빠르다.
여기서 하는 일은 다르다. 제품 분석 질문을 쿼리로 바꾸는 일 이다. 질문은 이런 모양으로 온다.
"지난주 가입자 중 첫 결제까지 간 비율이 얼마예요?"
"온보딩 3단계에서 사람들이 빠진다는데 정말인가요?"
"8월 신규 가입자는 한 달 뒤에도 남아 있나요? 7월보다 나아졌나요?"이 질문들은 한 행을 조회하는 문제가 아니라 사용자별로 사건의 순서를 따라가는 문제다. 그래서 집합 조회가 아니라 윈도 함수와 날짜 버킷팅이 주인공이 된다. 지표를 무엇으로 정의할지 는 레슨 4(HEART·GSM)가 맡는다. 이 레슨은 그 정의를 실제 숫자로 만드는 손기술이다.
이벤트 로그 스키마 — 제품 분석의 공통 모양
Amplitude·Mixpanel·GA4·자체 구축 어느 쪽이든 원천 데이터의 모양은 거의 같다. 한 행이 한 사용자가 한 시점에 한 사건을 일으켰다 는 뜻이다.
CREATE TABLE events (
event_id BIGINT PRIMARY KEY, -- 이벤트 고유 식별자(중복 제거 키)
user_id VARCHAR(64) NOT NULL, -- 로그인 사용자 식별자
anonymous_id VARCHAR(64), -- 로그인 전 기기 식별자
event_name VARCHAR(64) NOT NULL, -- 'signup_completed', 'checkout_started' …
event_time TIMESTAMP NOT NULL, -- UTC 로 저장하는 것이 원칙
session_id VARCHAR(64), -- 세션 단위 묶음
platform VARCHAR(16), -- 'ios' | 'android' | 'web'
app_version VARCHAR(16),
properties JSONB -- 이벤트별 부가 속성
);
CREATE TABLE users (
user_id VARCHAR(64) PRIMARY KEY,
signup_time TIMESTAMP NOT NULL,
channel VARCHAR(32), -- 유입 채널
is_internal BOOLEAN DEFAULT FALSE -- 사내·테스트 계정 표시
);몇 가지가 중요하다.
| 열 | 왜 필요한가 | 없으면 생기는 일 |
|---|---|---|
event_id | 같은 이벤트가 두 번 적재됐는지 판별 | 재전송·재시도로 수치가 부풀어도 모른다 |
anonymous_id | 로그인 전후 행동을 잇는다 | 가입 퍼널의 앞단이 통째로 사라진다 |
event_time (UTC) | 시간대 기준 통일 | 일자별 집계가 하루씩 밀린다 |
session_id | 세션 경계 정의 | "한 번의 시도" 를 셀 수 없다 |
is_internal | 사내 계정 제외 | QA 가 돌린 테스트가 전환율에 섞인다 |
properties 는 JSON 이라 편하지만 자주 쓰는 속성은 열로 승격해 두는 편이 낫다. 매번 파싱하면 느리고, 무엇보다 오타가 조용히 통과한다 — order_amout 로 적힌 속성은 조회하면 그냥
NULL 이다.
퍼널 쿼리 — 순서를 보장한다
많은 사람이 처음에 WHERE event_name IN (…) GROUP BY event_name 으로 단계별 COUNT(DISTINCT user_id) 를 세고 그것을 퍼널이라 부른다. 틀리지는 않지만 퍼널이 아니다. 순서를
보장하지 않아 payment_succeeded 를 한 사람이 그 전에 cart_viewed 를 했다는 보장이 없고, 어제 장바구니를 보고 오늘 결제한 사람도 섞이며, 기간 경계에서 앞단이 잘린 사람이 분모에 든다.
제대로 된 퍼널은 사용자별로 첫 단계 시각을 잡고, 그 이후에 다음 단계가 있었는지 를 본다. 윈도 함수로 사용자별 첫 시각을 구한 뒤 단계별로 이어 붙인다.
WITH base AS (
SELECT e.user_id, e.event_name, e.event_time
FROM events e
JOIN users u ON u.user_id = e.user_id
WHERE e.event_time >= TIMESTAMP '2026-09-01 00:00:00'
AND e.event_time < TIMESTAMP '2026-09-08 00:00:00'
AND u.is_internal = FALSE
AND e.event_name IN ('cart_viewed','checkout_started','payment_selected','payment_succeeded')
),
step1 AS ( -- 퍼널 진입 시각 = 각 사용자의 첫 cart_viewed
SELECT user_id, MIN(event_time) AS t1
FROM base WHERE event_name = 'cart_viewed'
GROUP BY user_id
),
step2 AS (
SELECT s.user_id, MIN(b.event_time) AS t2
FROM step1 s
JOIN base b ON b.user_id = s.user_id
AND b.event_name = 'checkout_started'
AND b.event_time >= s.t1
AND b.event_time < s.t1 + INTERVAL '1 hour' -- 전환 윈도
GROUP BY s.user_id
),
step3 AS ( -- step2 와 같은 모양: 앞 단계 step2, 이벤트 'payment_selected'
SELECT s.user_id, MIN(b.event_time) AS t3
FROM step2 s
JOIN base b ON b.user_id = s.user_id AND b.event_name = 'payment_selected'
AND b.event_time >= s.t2 AND b.event_time < s.t2 + INTERVAL '1 hour'
GROUP BY s.user_id
),
step4 AS ( -- 앞 단계 step3, 이벤트 'payment_succeeded'
SELECT s.user_id, MIN(b.event_time) AS t4
FROM step3 s
JOIN base b ON b.user_id = s.user_id AND b.event_name = 'payment_succeeded'
AND b.event_time >= s.t3 AND b.event_time < s.t3 + INTERVAL '1 hour'
GROUP BY s.user_id
)
SELECT
(SELECT COUNT(*) FROM step1) AS s1_cart,
(SELECT COUNT(*) FROM step2) AS s2_checkout,
(SELECT COUNT(*) FROM step3) AS s3_payment_selected,
(SELECT COUNT(*) FROM step4) AS s4_paid;여기서 세 가지 설계 결정을 했고, 셋 다 보고서에 적어야 한다.
- 전환 윈도 1시간. 장바구니를 보고 3일 뒤 결제한 사람은 이 퍼널에서 제외된다. 커머스라면 24시간이나 7일이 나을 수 있다. 윈도를 늘리면 전환율이 올라간다 — 그래서 윈도를 말하지 않은 전환율 비교는 의미가 없다.
- 첫 시각 기준(
MIN). 같은 사용자가 여러 번 시도했어도 한 번만 센다. "시도 횟수" 를 보고 싶다면 사용자가 아니라 세션이 단위가 되어야 한다. - 엄격한 순서.
payment_selected없이 바로payment_succeeded가 찍힌 사용자는 최종 단계에서 빠진다. 이런 사용자가 많다면 그것 자체가 계측 결함의 신호다.
퍼널 쿼리 3 — 윈도 함수로 한 번에
CTE 를 단계마다 쌓는 대신 윈도 함수로 각 사용자의 단계 순서를 한 번에 계산할 수도 있다. 이벤트에 단계 번호를 붙이고, 사용자별로 시간순 누적 최대 단계 를 구한다.
WITH base AS ( /* 위 쿼리의 base 와 동일 — 기간·내부계정·이벤트 목록 필터 */ ),
ranked AS (
SELECT user_id, event_time,
CASE event_name WHEN 'cart_viewed' THEN 1 WHEN 'checkout_started' THEN 2
WHEN 'payment_selected' THEN 3 WHEN 'payment_succeeded' THEN 4 END AS step_no
FROM base
),
progress AS (
SELECT user_id,
MAX(step_no) OVER (
PARTITION BY user_id ORDER BY event_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS reached_so_far
FROM ranked
),
max_step AS (
SELECT user_id, MAX(reached_so_far) AS deepest FROM progress GROUP BY user_id
)
SELECT s.step_no, COUNT(*) FILTER (WHERE m.deepest >= s.step_no) AS reached_users
FROM max_step m CROSS JOIN (VALUES (1),(2),(3),(4)) AS s(step_no)
GROUP BY s.step_no ORDER BY s.step_no;MAX(...) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) 는 "이 시점까지 이 사용자가 도달한 최고 단계" 다. 프레임 절을 생략하면 기본값이
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 라 같은 시각의 이벤트를 한 덩어리로 묶어 버린다 — 밀리초 단위로 찍히는 이벤트가 많으면 결과가 달라진다. 프레임은 명시하는 습관을 들인다.
방언 차이도 짚어 둔다.
| 기능 | PostgreSQL | BigQuery | MySQL 8.0+ |
|---|---|---|---|
| 조건부 집계 | COUNT(*) FILTER (WHERE …) | COUNTIF(…) | SUM(cond) |
| 기간 더하기 | t + INTERVAL '1 hour' | TIMESTAMP_ADD(t, INTERVAL 1 HOUR) | t + INTERVAL 1 HOUR |
| 주 시작으로 자르기 | date_trunc('week', t) | DATE_TRUNC(d, WEEK(MONDAY)) | DATE_SUB(d, INTERVAL WEEKDAY(d) DAY) |
| 시간대 변환 | t AT TIME ZONE 'Asia/Seoul' | DATETIME(t,'Asia/Seoul') | CONVERT_TZ(t,'UTC','Asia/Seoul') |
FILTER 절은 SQL 표준이지만 지원이 갈린다. 이식성이 필요하면 SUM(CASE WHEN cond THEN 1 ELSE 0 END) 가 어디서나 돈다.
단계 간 이탈률
도달 수만 보면 어디가 문제인지 안 보인다. 직전 단계 대비 전환율 과 최초 대비 누적 전환율 을 같이 낸다.
SELECT step_no, step_name, users,
ROUND(100.0 * users / LAG(users) OVER (ORDER BY step_no), 1) AS step_conv_pct,
ROUND(100.0 * users / FIRST_VALUE(users) OVER (ORDER BY step_no), 1) AS cum_conv_pct,
LAG(users) OVER (ORDER BY step_no) - users AS dropped
FROM funnel_counts -- 위 퍼널 쿼리의 결과를 (step_no, step_name, users) 로 펼친 것
ORDER BY step_no;step name users step_conv cum_conv dropped
1 cart_viewed 100,000 — 100.0% —
2 checkout_started 42,000 42.0% 42.0% 58,000 ← 가장 큰 이탈
3 payment_selected 38,600 91.9% 38.6% 3,400
4 payment_succeeded 33,400 86.5% 33.4% 5,200 ← 전주 90.9%절대 이탈 수가 가장 큰 곳(2단계)과 전주 대비 전환율이 떨어진 곳(4단계)은 다르다. 품질 문제는 보통 후자로 나타난다. 앞단의 큰 이탈은 대개 제품·가격 문제고, 갑자기 몇 %p 떨어진 단계가 결함 냄새가 난다. 여기서부터는 레슨 4의 세분화(플랫폼·앱 버전·결제 수단)로 넘어간다.
코호트 리텐션 쿼리
퍼널이 "한 번의 여정" 을 본다면 코호트는 같은 시기에 시작한 사람들이 시간이 지나도 남아 있는가 를 본다. 가입 주차로 묶고, 각 주차에 활동이 있었는지를 센다.
WITH cohort AS ( -- 사용자별 코호트 주차
SELECT u.user_id,
CAST(date_trunc('week', u.signup_time AT TIME ZONE 'Asia/Seoul') AS DATE) AS cohort_week
FROM users u
WHERE u.is_internal = FALSE AND u.signup_time >= TIMESTAMP '2026-08-03'
),
activity AS ( -- 사용자별 활동 주차(중복 제거)
SELECT DISTINCT e.user_id,
CAST(date_trunc('week', e.event_time AT TIME ZONE 'Asia/Seoul') AS DATE) AS active_week
FROM events e WHERE e.event_name = 'app_opened'
),
joined AS (
SELECT c.cohort_week, c.user_id,
CAST((a.active_week - c.cohort_week) / 7 AS INT) AS week_no
FROM cohort c
LEFT JOIN activity a ON a.user_id = c.user_id AND a.active_week >= c.cohort_week
)
SELECT
cohort_week,
COUNT(DISTINCT user_id) FILTER (WHERE week_no = 0) AS w0,
COUNT(DISTINCT user_id) FILTER (WHERE week_no = 1) AS w1,
COUNT(DISTINCT user_id) FILTER (WHERE week_no = 2) AS w2,
COUNT(DISTINCT user_id) FILTER (WHERE week_no = 3) AS w3,
COUNT(DISTINCT user_id) FILTER (WHERE week_no = 4) AS w4
FROM joined
GROUP BY cohort_week
ORDER BY cohort_week;week_no 는 가입 후 경과 주차 다. 코호트 주차마다 관측 가능한 최대 주차가 다르기 때문에 결과가 삼각형 모양이 된다.
[주차별 코호트 리텐션 — 분모는 각 코호트의 W0]
cohort W0 W1 W2 W3 W4
2026-08-03 12,400 41.2% 29.8% 24.1% 21.6%
2026-08-10 13,100 40.6% 29.1% 23.7% 21.0%
2026-08-17 12,900 38.2% 26.4% 21.3% — ← W1 부터 꺾임
2026-08-24 13,500 37.9% 26.1% — —
2026-08-31 14,200 38.4% — — —오른쪽 아래가 빈 삼각 표를 읽는 법은 세로로 읽는 것 이다. 같은 W1 열을 위에서 아래로 보면 41.2% → 40.6% → 38.2% → 37.9% 로 8월 17일 코호트부터 꺾였다. 그 주에 무엇을 배포했는지, 그 코호트가 어떤 유입 채널·앱 버전으로 들어왔는지를 본다. 가로로 읽으면 자연 감소와 구분이 안 된다.
분모도 결정 사항이다. 위 표는 W0(가입 주 활동자)를 분모로 썼다. 가입만 하고 앱을 안 연 사람까지 포함하려면 분모를 코호트 전체 인원으로 바꾼다 — 숫자가 한참 낮아지고, 두 정의를 섞어
비교하면 없는 개선이 보인다.
숫자를 믿기 전에 확인할 것
퍼널과 코호트 쿼리는 문법이 맞아도 조용히 틀린 답을 낸다. 제품 팀에 보고하기 전에 이 넷은 반드시 확인한다.
이벤트 중복. 재시도·재전송·클라이언트 버그로 같은 사건이 여러 번 적재된다. event_id 로 중복률을 먼저 잰다.
SELECT event_name, COUNT(*) AS rows_total, COUNT(DISTINCT event_id) AS rows_unique,
COUNT(*) - COUNT(DISTINCT event_id) AS dup_rows
FROM events WHERE event_time >= TIMESTAMP '2026-09-01'
GROUP BY event_name ORDER BY dup_rows DESC;중복률이 1% 를 넘으면 집계 전에 DISTINCT event_id 로 정제하고 계측 담당에게 결함으로 등록한다. COUNT(DISTINCT user_id) 는 중복을 어느 정도 흡수하지만 "결제 시도 횟수" 같은 건수
지표는 그대로 부푼다.
봇·내부 트래픽. 사내 QA 계정, 모니터링 헬스체크, 크롤러가 섞인다. is_internal 플래그와 함께 행동 패턴으로도 거른다 — 하루에 같은 이벤트를 수백 번 찍거나 단계 간 간격이 1초 미만으로
일정하면 사람이 아닐 가능성이 높다.
GROUP BY user_id HAVING COUNT(*) > 50 로 의심 사용자를 먼저 뽑아 눈으로 확인한 뒤 제외 목록을 만든다.
타임존. 원천은 UTC 로 저장하고 집계할 때만 Asia/Seoul 로 변환한다. UTC 기준 일자 집계는 한국 시간 오전 9시에 날짜가 바뀌어 "어제" 의 정의가 제품 팀과 달라진다. 코호트 주 시작
요일도 도구마다 다르니 표에 명시한다.
세션 경계. 대부분의 도구는 무활동 30분을 세션 종료로 본다(GA4 기본값도 30분). 도구와 쿼리의 기준이 다르면 세션 단위 지표가 맞지 않는다. 직접 계산한다면 이전 이벤트와의 간격이 30분을 넘을 때 새 세션으로 끊는다.
SELECT user_id, event_time,
CASE WHEN COALESCE(event_time - LAG(event_time) OVER w, INTERVAL '999 days')
> INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_session_start
FROM events
WINDOW w AS (PARTITION BY user_id ORDER BY event_time);마지막으로 도구와 대조한다. 같은 기간·같은 정의로 Amplitude·GA4 의 퍼널과 내 쿼리를 맞춰 본다. 5% 이상 벌어지면 둘 중 하나가 틀렸고, 보통은 정의(윈도·고유 기준·내부 계정 제외)의 차이지 데이터의 차이가 아니다.
실무에서 왜 필요한가
제품 팀 대시보드의 숫자는 누군가 정의한 쿼리의 결과다. QA 가 그 쿼리를 읽을 수 없으면 "전환율이 떨어졌다" 는 보고를 검증 없이 받아들이거나, 자기가 찾은 결함의 영향을 숫자로 말하지 못한다. 직접 뽑아 보면 세 가지가 가능해진다.
첫째, 계측 자체를 테스트한다. 기획서의 이벤트가 실제로 찍히는지, 속성 이름과 형식이 맞는지, 중복은 없는지는 명백한 QA 영역인데 화면만 봐서는 알 수 없다. 릴리스 직후 이벤트 볼륨을 전주와 비교하는 쿼리 하나가 계측 누락을 반나절 안에 잡는다.
둘째, 결함의 영향을 추정한다. "iOS 5.8.0 에서 결제 승인 전환율 10.7%p 하락, 주간 1,650건" 은 심각도 등급보다 강한 근거다. 셋째, 출시 후 관측으로 테스트를 보완한다. 코호트 리텐션이 특정 주부터 꺾였다면 그 주의 배포를 되짚을 수 있다.
채용시장에서의 위치
제품 품질·데이터 QA 공고에서 SQL 은 우대사항이 아니라 자격요건으로 적히는 경우가 많고, 윈도 함수와 코호트 분석 경험을 따로 명시하는 곳도 늘었다. "퍼널 전환율 쿼리를 써 보라" 는 면접 과제도 흔하다. 앞의 퍼널·리텐션 쿼리를 백지에서 쓸 수 있으면 QA 직군 SQL 질문의 대부분을 덮는다.