본문으로 건너뛰기
중급약 45분

SQL·제품 분석 도구 실습 — 이벤트 로그로 퍼널과 코호트를 직접 뽑아 보기

제품 분석의 질문은 "가입한 사람 중 몇 %가 첫 결제까지 갔나", "8월에 가입한 사람은 4주 뒤에도 남아 있나" 같은 모양이다. 이벤트 로그 스키마를 정의하고 윈도 함수로 단계 순서를 보장하는 퍼널 쿼리, 가입 주차별 코호트의 N주차 잔존율을 삼각 표로 뽑는 리텐션 쿼리를 직접 쓴다. 숫자를 보고하기 전에 확인할 이벤트 중복·봇·타임존·세션 경계도 함께 다룬다.

  • #SQL
  • #이벤트로그
  • #퍼널분석
  • #코호트
  • #리텐션
  • #윈도함수
  • #제품분석

개념

이 레슨의 경계 — 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. 전환 윈도 1시간. 장바구니를 보고 3일 뒤 결제한 사람은 이 퍼널에서 제외된다. 커머스라면 24시간이나 7일이 나을 수 있다. 윈도를 늘리면 전환율이 올라간다 — 그래서 윈도를 말하지 않은 전환율 비교는 의미가 없다.
  2. 첫 시각 기준(MIN). 같은 사용자가 여러 번 시도했어도 한 번만 센다. "시도 횟수" 를 보고 싶다면 사용자가 아니라 세션이 단위가 되어야 한다.
  3. 엄격한 순서. 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 라 같은 시각의 이벤트를 한 덩어리로 묶어 버린다 — 밀리초 단위로 찍히는 이벤트가 많으면 결과가 달라진다. 프레임은 명시하는 습관을 들인다.

방언 차이도 짚어 둔다.

기능PostgreSQLBigQueryMySQL 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 질문의 대부분을 덮는다.

실습 과제

과제 1 — 퍼널 쿼리 작성과 윈도 바꿔 보기 (30분)

로컬 PostgreSQL(또는 온라인 SQL 실행기)에 본문 스키마를 만들고 사용자 20명 분량의 이벤트를 INSERT 한다. 의도적으로 넣을 것 — 순서가 뒤집힌 사용자 1명, 중복 event_id 2행, is_internal = TRUE 계정 2명, 3일 뒤 결제한 사용자 1명. 단계별 CTE 퍼널을 돌린 뒤 전환 윈도를 1시간 → 24시간 → 7일로 바꿔 최종 전환율 변화를 표로 적는다.

과제 2 — 코호트 삼각 표 (25분)

가입 시각을 3주에 걸쳐 흩뿌리고 app_opened 이벤트를 넣어 코호트 리텐션 쿼리를 돌린다. 결과를 삼각 표로 옮기고 세로로 읽어 어느 코호트부터 W1 잔존율이 꺾였는지 한 문장으로 쓴다. 이어서 분모를 W0 활동자에서 코호트 전체 인원으로 바꿔 두 표를 비교한다.

과제 3 — 데이터 위생 점검 리포트 (20분)

과제 1의 데이터로 중복 점검·봇 의심·세션 경계 쿼리를 돌려 아래 표를 채우고, 마지막에 "이 숫자를 보고할 때 함께 적을 전제" 세 가지를 쓴다.

점검 항목쿼리 결과조치
이벤트 중복률
내부·봇 의심 사용자
집계 시간대 기준
세션 무활동 기준

자가 체크리스트

  • 이 레슨과 모듈 61 레슨 1의 경계를 설명할 수 있다 — 저쪽은 SQL 문법과 조회 검증, 여기는 제품 분석 질문의 번역
  • 이벤트 로그 스키마의 필수 열과 각각이 없을 때 생기는 문제를 말할 수 있다
  • 단계별 COUNT 나열이 왜 퍼널이 아닌지 설명할 수 있다
  • 순서를 보장하는 퍼널 쿼리를 CTE 나 윈도 함수로 작성할 수 있다
  • LAG·FIRST_VALUE 로 단계 간 전환율과 누적 전환율을 함께 낼 수 있다
  • 코호트 리텐션 삼각 표를 만들고 세로로 읽어 해석할 수 있다
  • 이벤트 중복·봇·타임존·세션 경계를 보고 전에 점검하는 쿼리를 쓸 수 있다
  • 윈도 함수 프레임 절과 조건부 집계의 방언 차이를 알고 대체 표현을 쓸 수 있다

흔한 실수

  • 순서 없는 퍼널을 퍼널이라고 부른다. 단계별 COUNT(DISTINCT user_id) 나열은 각 단계의 도달자 수일 뿐이다. 기간 경계에서 앞단이 잘린 사용자까지 섞인다.
  • 전환 윈도를 말하지 않고 전환율을 비교한다. 윈도가 다르면 같은 데이터에서도 다른 숫자가 나온다. 도구의 기본 윈도와 내 쿼리의 윈도가 다를 때 특히 위험하다.
  • 코호트 표를 가로로 읽는다. 시간이 지나면 잔존율은 원래 떨어진다. 변화는 같은 W열을 세로로 볼 때 보인다.
  • 내부 계정을 안 거른다. QA 가 돌린 회귀 테스트가 그대로 전환율에 들어간다. 플래그가 없으면 만들어 달라고 요청하는 것도 QA 의 일이다.
  • UTC 와 로컬 시간을 섞는다. 원천은 UTC, 집계 시점에 변환. 코호트 주 시작 요일도 표에 적는다.

참고 자료

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

SQL — 구조화 질의 언어
저장소에 "이런 조건인 줄을 보여 달라"고 묻는 말. 영어 문장에 가까워서 읽기가 비교적 쉽다.
HEART — 하트 지표
사용자 경험을 다섯 갈래 숫자로 재는 틀.
GSM — 목표·신호·지표
무엇을 이루려는지(목표)를 먼저 적고, 그것이 이뤄지면 어떤 흔적이 남는지(신호), 그 흔적을 무슨 숫자로 셀지(지표)를 차례로 끌어내는 방법.
UTC — 협정 세계시
세계 공통 기준 시각. 한국 시간보다 9시간 느리다.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
JSON — 제이슨
프로그램끼리 값을 주고받을 때 쓰는 글자 형식. 괄호와 따옴표로 이름과 값을 짝지어 적는다.
PostgreSQL — 포스트그레에스큐엘
널리 쓰이는 무료 데이터 저장소 제품 중 하나.
MySQL — 마이에스큐엘
가장 널리 쓰이는 무료 데이터 저장소 제품 중 하나.

용어집 전체 보기