부트캠프

멋쟁이사자처럼 부트캠프 그로스마케팅 4기 22일차_260409

Yuuma 2026. 4. 9. 20:31
[TIL] Day6 | SQL 3 — JOIN · 서브쿼리 · 윈도우 함수

[TIL] Day6 | SQL 3
JOIN · 서브쿼리 · 윈도우 함수

JOIN Subquery Window Function MySQL 8.0 BeatWave
💡 오늘 한 줄 요약 하나의 테이블로 할 수 없는 분석을 JOIN으로 연결하고, 서브쿼리로 조건을 중첩하고, 윈도우 함수로 순위와 누적값을 구하면 — 데이터 분석의 90%는 커버된다.

1. 오늘 사용할 데이터셋

1~5일차에 만든 BeatWave 데이터셋에 테이블 2개를 추가했다.

테이블내용상태
ad_campaigns광고 캠페인 성과기존
users가입 사용자 정보기존
daily_revenue일별 매출기존
user_events사용자 행동 이벤트 (play / like / share / upgrade / cancel)✅ 오늘 추가
referral_codes친구 추천 코드 정보✅ 오늘 추가
💡 왜 테이블을 나눠서 관리할까?
데이터를 하나의 거대한 테이블에 다 넣으면 중복이 심해지고 관리가 어렵다. 역할별로 분리해두고 필요할 때 JOIN해서 합치는 것이 관계형 데이터베이스의 핵심 설계 원칙이다.

2. JOIN — 여러 테이블 연결해서 분석하기

"어떤 가입 경로로 들어온 사용자가 앱 내 활동이 많은가?" 같은 분석은 usersuser_events를 연결해야 비로소 가능하다. 이때 쓰는 것이 JOIN이다.

JOIN 종류 벤다이어그램 INNER JOIN · LEFT JOIN · LEFT JOIN + WHERE IS NULL — 실무에서 이 세 가지가 90% 이상
JOIN 종류설명실무 사용빈도
INNER JOIN양쪽 테이블 모두에 있는 데이터만⭐⭐⭐ 매우 자주
LEFT JOIN왼쪽 전부 + 오른쪽 일치하는 것 (없으면 NULL)⭐⭐⭐ 매우 자주
RIGHT JOIN오른쪽 전부 + 왼쪽 일치하는 것LEFT JOIN으로 대체 가능
FULL OUTER JOIN양쪽 전부, 없으면 NULL거의 안 씀
CROSS JOIN모든 행의 조합 (곱집합)거의 안 씀
MySQL에서는 JOIN만 써도 INNER JOIN과 동일하게 동작한다. 실무에서는 INNER JOIN과 LEFT JOIN이 전체의 90% 이상.

📌 기본 문법

SQL — JOIN 기본 구조
SELECT 테이블A.컬럼, 테이블B.컬럼
FROM 테이블A AS a
JOIN 테이블B AS b ON a.공통컬럼 = b.공통컬럼;
💡 테이블 별명(Alias)은 필수 습관!
FROM users u → 이제 u.컬럼으로 짧게 쓸 수 있다. 실무에서는 항상 Alias를 붙인다.

실습 1 — INNER JOIN: 사용자 + 이벤트 합치기

어떤 유저가 어떤 행동을 했는지 한눈에 보기.

SQL
SELECT
    u.username, u.signup_channel, u.plan,
    e.event_type, e.event_date
FROM users u
INNER JOIN user_events e ON u.user_id = e.user_id
ORDER BY e.event_date ASC;

실습 2 — LEFT JOIN + WHERE IS NULL: 앱을 한 번도 안 쓴 유저

가입했지만 아무 행동도 안 한 유저 찾기. 이 패턴은 그로스 분석에서 정말 자주 쓴다.

SQL
SELECT
    u.user_id, u.username, u.signup_channel,
    e.event_type  -- 활동 없으면 NULL로 표시됨
FROM users u
LEFT JOIN user_events e ON u.user_id = e.user_id
WHERE e.event_id IS NULL;
🔑 LEFT JOIN + WHERE IS NULL 패턴
왼쪽에는 있지만 오른쪽에는 없는 데이터를 찾는 강력한 패턴. "가입 후 미전환 유저", "구독 후 미사용 유저"처럼 "없는 것"을 찾을 때 자주 쓴다.

실습 3 — 추천 코드를 보낸 사용자 확인 (INNER JOIN)

SQL
SELECT
    u.username, u.email,
    r.code, r.referred_email, r.used_at, r.reward_given
FROM users u
JOIN referral_codes r ON u.user_id = r.referrer_id
ORDER BY u.username;

실습 4 — 추천을 한 번도 안 한 유저 찾기 (LEFT JOIN)

SQL
SELECT
    u.user_id, u.username, u.plan,
    r.referral_id  -- NULL이면 추천 안 함
FROM users u
LEFT JOIN referral_codes r ON u.user_id = r.referrer_id
WHERE r.referral_id IS NULL;

실습 5 — 가입 경로별 업그레이드 수 집계 (JOIN + GROUP BY)

어떤 채널로 들어온 유저가 업그레이드를 많이 하는지 분석. CASE WHEN ... THEN 1 END은 특정 조건에 해당하는 행만 카운트하는 테크닉이다.

SQL
SELECT
    u.signup_channel,
    COUNT(DISTINCT u.user_id) AS total_users,
    COUNT(CASE WHEN e.event_type = 'upgrade' THEN 1 END) AS upgrade_count
FROM users u
LEFT JOIN user_events e ON u.user_id = e.user_id
GROUP BY u.signup_channel
ORDER BY upgrade_count DESC;

실습 6 — 유저별 총 활동 + 추천 수 (다중 JOIN)

한 사람의 앱 활동 횟수와 추천 코드 발행 수를 한 번에. JOIN을 여러 번 이어 쓰면 테이블을 3개, 4개도 연결할 수 있다.

SQL
SELECT
    u.username, u.plan,
    COUNT(DISTINCT e.event_id)    AS total_events,
    COUNT(DISTINCT r.referral_id) AS referral_count
FROM users u
LEFT JOIN user_events e   ON u.user_id = e.user_id
LEFT JOIN referral_codes r ON u.user_id = r.referrer_id
GROUP BY u.user_id, u.username, u.plan
ORDER BY total_events DESC;

3. 서브쿼리 — 쿼리 안에 쿼리 넣기

서브쿼리란 SQL 문 안에 또 다른 SELECT 문을 괄호 ( ) 안에 작성하는 것이다. "평균보다 높은 캠페인만 보고 싶다"처럼 먼저 어떤 값을 구하고, 그 결과를 조건으로 써야 할 때 유용하다.

서브쿼리 3가지 위치 WHERE절 · FROM절 · SELECT절 — 위치에 따라 역할이 다르다
위치이름특징
WHERE조건 서브쿼리단일 값 비교 (=, >) 또는 목록 비교 (IN, NOT IN)
FROM인라인 뷰임시 테이블처럼 사용. 반드시 AS 별명 필요!
SELECT스칼라 서브쿼리행마다 단일 값을 반환

실습 7 — 평균 예산보다 높은 캠페인만 보기

직접 숫자를 입력하면 데이터가 바뀔 때마다 다시 수정해야 한다. 서브쿼리를 쓰면 항상 최신 평균을 자동 참조한다.

SQL
SELECT campaign_name, channel, budget
FROM ad_campaigns
WHERE budget > (SELECT AVG(budget) FROM ad_campaigns)
ORDER BY budget DESC;

실습 8 — 전환수 최고 캠페인 찾기

LIMIT 1로도 되지만, 서브쿼리 방식은 동점 1위가 여러 명일 때도 전부 가져올 수 있어 더 안전하다.

SQL
SELECT campaign_name, channel, conversions
FROM ad_campaigns
WHERE conversions = (SELECT MAX(conversions) FROM ad_campaigns);

실습 9 — 업그레이드한 사용자만 보기 (IN 서브쿼리)

SQL
SELECT user_id, username, signup_channel, plan
FROM users
WHERE user_id IN (
    SELECT DISTINCT user_id
    FROM user_events
    WHERE event_type = 'upgrade'
);

실습 10 — 리마케팅 타겟 추출 (NOT IN 서브쿼리) ⭐ 중요

무료 플랜이면서 업그레이드를 한 번도 시도 안 한 유저 → 이 결과가 바로 리마케팅 타겟 리스트다!

SQL
SELECT
    user_id, username, signup_channel, signup_date
FROM users
WHERE plan = 'free'
  AND user_id NOT IN (
      SELECT DISTINCT user_id
      FROM user_events
      WHERE event_type = 'upgrade'
  );
🎯 그로스 활용: 위 결과에 해당하는 유저들에게 "지금 업그레이드하면 첫 달 50% 할인!" 같은 전환 유도 캠페인을 보내면 된다.

실습 11 — 채널별 평균과 각 캠페인 비교 (FROM 서브쿼리)

내 캠페인이 같은 채널의 평균보다 높은지 낮은지 한눈에 확인.

SQL
SELECT
    ac.campaign_name, ac.channel, ac.conversions,
    ch_avg.avg_conversions,
    ac.conversions - ch_avg.avg_conversions AS diff_from_avg
FROM ad_campaigns ac
JOIN (
    -- 채널별 평균 전환수를 임시 테이블처럼 만들기
    SELECT channel, ROUND(AVG(conversions), 0) AS avg_conversions
    FROM ad_campaigns
    GROUP BY channel
) AS ch_avg ON ac.channel = ch_avg.channel
ORDER BY diff_from_avg DESC;
⚠️ FROM 서브쿼리는 반드시 AS 별명을 붙여야 한다! (...) AS ch_avg 처럼. 빠뜨리면 바로 오류 발생.

4. 윈도우 함수 — 순위 매기기 & 누적 합계

GROUP BY는 행을 묶어서 원본 행이 사라진다. 반면 윈도우 함수는 행을 유지하면서 순위, 누적 합계 같은 추가 계산값을 옆에 붙여주는 함수다. MySQL 8.0 이상부터 지원한다.

SQL — 윈도우 함수 기본 구조
함수명() OVER (
    PARTITION BY 그룹기준컬럼  -- 어떤 기준으로 나눠서?
    ORDER BY    정렬기준컬럼   -- 어떤 순서로?
)
함수설명
ROW_NUMBER()순번 부여. 동점도 무조건 다른 번호
RANK()순위 부여. 동점 → 같은 순위, 다음 순위 건너뜀
DENSE_RANK()순위 부여. 동점 → 같은 순위, 다음 순위 이어짐
SUM() OVER()누적 합계 (ORDER BY와 함께) 또는 전체 합계
LAG(col, 1)이전 행 값을 현재 행으로 가져오기
LEAD(col, 1)다음 행 값을 현재 행으로 가져오기

RANK vs DENSE_RANK — 뭐가 다른가?

RANK vs DENSE_RANK 비교표 동점 처리 방식이 핵심. 실무에서는 DENSE_RANK를 더 자주 쓴다

실습 12 — 캠페인 순위 매기기

SQL
SELECT
    campaign_name, channel, conversions,
    RANK()       OVER (ORDER BY conversions DESC) AS rank_no,
    DENSE_RANK() OVER (ORDER BY conversions DESC) AS dense_rank_no,
    ROW_NUMBER() OVER (ORDER BY conversions DESC) AS row_num
FROM ad_campaigns;

실습 13 — 일별 매출 누적 합계 ⭐ 중요

"이번 달 누적 매출이 얼마?" 를 날짜별로 추적할 때 쓴다. ORDER BY가 있으면 누적 합계, 없으면 전체 합계다.

SQL
SELECT
    revenue_date,
    total_revenue,
    SUM(total_revenue) OVER (
        ORDER BY revenue_date
    ) AS cumulative_revenue
FROM daily_revenue
ORDER BY revenue_date;

실습 14 — 전날 대비 매출 증감 (LAG) ⭐ 중요

어제 매출과 비교해서 오늘이 얼마나 늘었는지 확인. 전날 대비 성과 리포트에 필수다.

SQL
SELECT
    revenue_date,
    total_revenue,
    LAG(total_revenue, 1) OVER (ORDER BY revenue_date) AS prev_revenue,
    total_revenue - LAG(total_revenue, 1) OVER (ORDER BY revenue_date) AS diff
FROM daily_revenue
ORDER BY revenue_date;
⚠️ 첫 번째 행은 이전 행이 없으므로 prev_revenueNULL로 나온다. 마찬가지로 LEAD의 마지막 행도 NULL.

실습 15 — 다음날 매출 미리 보기 (LEAD)

SQL
SELECT
    revenue_date,
    total_revenue,
    LEAD(total_revenue, 1) OVER (ORDER BY revenue_date) AS next_revenue
FROM daily_revenue
ORDER BY revenue_date;

5. 최종 시나리오 — BeatWave 이탈 분석

BeatWave 그로스팀이 이탈률을 줄이고 전환율을 높이기 위한 분석을 진행했다. 오늘 배운 JOIN과 서브쿼리를 실전처럼 엮었다.

BeatWave 유저 퍼널 분석 가입 → play → upgrade → cancel 단계별 유저 수 추적
시나리오 1
유저 퍼널 분석 (가입 → play → upgrade → cancel)

Step 1 — 단계별 유저 수 따로 조회

SQL
-- 전체 가입자 수
SELECT COUNT(*) AS 가입자수 FROM users;

-- play 유저 수
SELECT COUNT(DISTINCT user_id) AS play_유저수
FROM user_events WHERE event_type = 'play';

-- upgrade 유저 수
SELECT COUNT(DISTINCT user_id) AS upgrade_유저수
FROM user_events WHERE event_type = 'upgrade';

-- cancel 유저 수
SELECT COUNT(DISTINCT user_id) AS cancel_유저수
FROM user_events WHERE event_type = 'cancel';

Step 2 — 한 쿼리로 합치기

FIELD(값, 'a', 'b', 'c') : 직접 순서를 지정해서 정렬할 수 있다.

SQL
SELECT
    event_type,
    COUNT(DISTINCT user_id) AS user_count
FROM user_events
WHERE event_type IN ('play', 'upgrade', 'cancel')
GROUP BY event_type
ORDER BY FIELD(event_type, 'play', 'upgrade', 'cancel');

Step 3 — 채널별로 쪼개서 보기

어떤 채널에서 온 유저가 upgrade를 많이 하고, 어디서 cancel이 많은지 확인.

SQL
SELECT
    u.signup_channel,
    COUNT(DISTINCT u.user_id) AS total_users,
    COUNT(DISTINCT CASE WHEN e.event_type = 'upgrade' THEN u.user_id END) AS upgrade_users,
    COUNT(DISTINCT CASE WHEN e.event_type = 'cancel'  THEN u.user_id END) AS cancel_users
FROM users u
LEFT JOIN user_events e ON u.user_id = e.user_id
GROUP BY u.signup_channel
ORDER BY upgrade_users DESC;
시나리오 2
이탈 유저 분석 — cancel 공통점 찾기

Step 1 — 이탈한 유저 목록 보기

SQL
SELECT u.username, u.signup_channel, u.plan
FROM users u
JOIN user_events e ON u.user_id = e.user_id
WHERE e.event_type = 'cancel'
ORDER BY u.signup_channel;

Step 2 — 유료 이탈 vs 무료 이탈 구분하기 ⭐ 핵심

이탈 유저를 upgrade 경험 유무로 구분하면 대응 전략이 완전히 달라진다.

SQL
SELECT
    u.username, u.signup_channel, u.plan,
    CASE
        WHEN u.user_id IN (
            SELECT DISTINCT user_id FROM user_events
            WHERE event_type = 'upgrade'
        )
        THEN '유료 이탈'
        ELSE '무료 이탈'
    END AS churn_type
FROM users u
JOIN user_events e ON u.user_id = e.user_id
WHERE e.event_type = 'cancel'
GROUP BY u.user_id, u.username, u.signup_channel, u.plan;
🔑 유료 이탈 : 결제까지 했다가 해지한 케이스 → 이탈 원인 파악이 특히 중요 (서비스 불만족?)
무료 이탈 : 앱을 써봤지만 가치를 못 느끼고 떠난 케이스 → 온보딩 개선 필요

Step 3 — 채널별 이탈 현황

SQL
SELECT
    u.signup_channel,
    COUNT(DISTINCT u.user_id) AS cancel_count
FROM users u
JOIN user_events e ON u.user_id = e.user_id
WHERE e.event_type = 'cancel'
GROUP BY u.signup_channel
ORDER BY cancel_count DESC;
📊 데이터 기준: YouTube 유입 유저의 이탈이 가장 많다. → YouTube 광고 타겟팅이나 온보딩 메시지를 재검토할 필요가 있다.

Step 4 — 이탈까지 얼마나 걸렸는지 확인

짧을수록 온보딩에서 막힌 것, 길수록 서비스 불만족일 가능성이 높다.

SQL
SELECT
    u.username, u.signup_channel, u.plan,
    u.signup_date,
    e.event_date AS cancel_date,
    DATEDIFF(e.event_date, u.signup_date) AS days_until_cancel
FROM users u
JOIN user_events e ON u.user_id = e.user_id
WHERE e.event_type = 'cancel'
ORDER BY days_until_cancel ASC;
💡 MySQL vs Oracle 날짜 차이 계산
MySQL: DATEDIFF(날짜1, 날짜2)  |  Oracle: 날짜1 - 날짜2 로 바로 계산

📌 오늘의 핵심 정리

  • INNER JOIN — 양쪽 모두에 있는 것만. ON 뒤에 연결 조건 필수. MySQL에서 JOIN만 써도 동일.
  • LEFT JOIN + WHERE IS NULL — "없는 것" 찾기 패턴. 미전환 유저, 미사용 유저 추출에 강력.
  • 다중 JOIN — JOIN을 여러 번 이어 쓰면 테이블 3개 이상 연결 가능.
  • WHERE 서브쿼리 — 단일 값 비교 =, 목록 비교 IN / NOT IN. 리마케팅 타겟 추출에 직접 활용.
  • FROM 서브쿼리 (인라인 뷰) — 임시 테이블처럼 사용. 반드시 AS 별명 필요!
  • RANK vs DENSE_RANK — 동점 처리 방식이 다름. 실무에서는 DENSE_RANK를 더 자주 씀.
  • SUM() OVER(ORDER BY 날짜) — 누적 합계. ORDER BY 없으면 전체 합계 (비중 계산용).
  • LAG / LEAD — 이전/다음 행 참조. 전날 대비 매출 증감 계산에 필수. 첫/마지막 행은 NULL.
  • 유료 이탈 vs 무료 이탈 — 이탈 유저를 CASE + IN 서브쿼리로 구분하면 대응 전략이 달라진다.

📌 다음 강의에서 배울 것: 데이터 시각화 + 지표 대시보드 설계