[TIL] Day6 | SQL 3
JOIN · 서브쿼리 · 윈도우 함수
1. 오늘 사용할 데이터셋
1~5일차에 만든 BeatWave 데이터셋에 테이블 2개를 추가했다.
| 테이블 | 내용 | 상태 |
|---|---|---|
ad_campaigns | 광고 캠페인 성과 | 기존 |
users | 가입 사용자 정보 | 기존 |
daily_revenue | 일별 매출 | 기존 |
user_events | 사용자 행동 이벤트 (play / like / share / upgrade / cancel) | ✅ 오늘 추가 |
referral_codes | 친구 추천 코드 정보 | ✅ 오늘 추가 |
데이터를 하나의 거대한 테이블에 다 넣으면 중복이 심해지고 관리가 어렵다. 역할별로 분리해두고 필요할 때 JOIN해서 합치는 것이 관계형 데이터베이스의 핵심 설계 원칙이다.
2. JOIN — 여러 테이블 연결해서 분석하기
"어떤 가입 경로로 들어온 사용자가 앱 내 활동이 많은가?" 같은 분석은 users와 user_events를 연결해야 비로소 가능하다. 이때 쓰는 것이 JOIN이다.
| JOIN 종류 | 설명 | 실무 사용빈도 |
|---|---|---|
INNER JOIN | 양쪽 테이블 모두에 있는 데이터만 | ⭐⭐⭐ 매우 자주 |
LEFT JOIN | 왼쪽 전부 + 오른쪽 일치하는 것 (없으면 NULL) | ⭐⭐⭐ 매우 자주 |
RIGHT JOIN | 오른쪽 전부 + 왼쪽 일치하는 것 | LEFT JOIN으로 대체 가능 |
FULL OUTER JOIN | 양쪽 전부, 없으면 NULL | 거의 안 씀 |
CROSS JOIN | 모든 행의 조합 (곱집합) | 거의 안 씀 |
JOIN만 써도 INNER JOIN과 동일하게 동작한다. 실무에서는 INNER JOIN과 LEFT JOIN이 전체의 90% 이상.
📌 기본 문법
SELECT 테이블A.컬럼, 테이블B.컬럼
FROM 테이블A AS a
JOIN 테이블B AS b ON a.공통컬럼 = b.공통컬럼;
FROM users u → 이제 u.컬럼으로 짧게 쓸 수 있다. 실무에서는 항상 Alias를 붙인다.
실습 1 — INNER JOIN: 사용자 + 이벤트 합치기
어떤 유저가 어떤 행동을 했는지 한눈에 보기.
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: 앱을 한 번도 안 쓴 유저
가입했지만 아무 행동도 안 한 유저 찾기. 이 패턴은 그로스 분석에서 정말 자주 쓴다.
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;
왼쪽에는 있지만 오른쪽에는 없는 데이터를 찾는 강력한 패턴. "가입 후 미전환 유저", "구독 후 미사용 유저"처럼 "없는 것"을 찾을 때 자주 쓴다.
실습 3 — 추천 코드를 보낸 사용자 확인 (INNER JOIN)
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)
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은 특정 조건에 해당하는 행만 카운트하는 테크닉이다.
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개도 연결할 수 있다.
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 문을 괄호 ( ) 안에 작성하는 것이다. "평균보다 높은 캠페인만 보고 싶다"처럼 먼저 어떤 값을 구하고, 그 결과를 조건으로 써야 할 때 유용하다.
| 위치 | 이름 | 특징 |
|---|---|---|
WHERE 절 | 조건 서브쿼리 | 단일 값 비교 (=, >) 또는 목록 비교 (IN, NOT IN) |
FROM 절 | 인라인 뷰 | 임시 테이블처럼 사용. 반드시 AS 별명 필요! |
SELECT 절 | 스칼라 서브쿼리 | 행마다 단일 값을 반환 |
실습 7 — 평균 예산보다 높은 캠페인만 보기
직접 숫자를 입력하면 데이터가 바뀔 때마다 다시 수정해야 한다. 서브쿼리를 쓰면 항상 최신 평균을 자동 참조한다.
SELECT campaign_name, channel, budget
FROM ad_campaigns
WHERE budget > (SELECT AVG(budget) FROM ad_campaigns)
ORDER BY budget DESC;
실습 8 — 전환수 최고 캠페인 찾기
LIMIT 1로도 되지만, 서브쿼리 방식은 동점 1위가 여러 명일 때도 전부 가져올 수 있어 더 안전하다.
SELECT campaign_name, channel, conversions
FROM ad_campaigns
WHERE conversions = (SELECT MAX(conversions) FROM ad_campaigns);
실습 9 — 업그레이드한 사용자만 보기 (IN 서브쿼리)
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 서브쿼리) ⭐ 중요
무료 플랜이면서 업그레이드를 한 번도 시도 안 한 유저 → 이 결과가 바로 리마케팅 타겟 리스트다!
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'
);
실습 11 — 채널별 평균과 각 캠페인 비교 (FROM 서브쿼리)
내 캠페인이 같은 채널의 평균보다 높은지 낮은지 한눈에 확인.
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;
(...) AS ch_avg 처럼. 빠뜨리면 바로 오류 발생.
4. 윈도우 함수 — 순위 매기기 & 누적 합계
GROUP BY는 행을 묶어서 원본 행이 사라진다. 반면 윈도우 함수는 행을 유지하면서 순위, 누적 합계 같은 추가 계산값을 옆에 붙여주는 함수다. MySQL 8.0 이상부터 지원한다.
함수명() OVER (
PARTITION BY 그룹기준컬럼 -- 어떤 기준으로 나눠서?
ORDER BY 정렬기준컬럼 -- 어떤 순서로?
)
| 함수 | 설명 |
|---|---|
ROW_NUMBER() | 순번 부여. 동점도 무조건 다른 번호 |
RANK() | 순위 부여. 동점 → 같은 순위, 다음 순위 건너뜀 |
DENSE_RANK() | 순위 부여. 동점 → 같은 순위, 다음 순위 이어짐 |
SUM() OVER() | 누적 합계 (ORDER BY와 함께) 또는 전체 합계 |
LAG(col, 1) | 이전 행 값을 현재 행으로 가져오기 |
LEAD(col, 1) | 다음 행 값을 현재 행으로 가져오기 |
RANK vs DENSE_RANK — 뭐가 다른가?
실습 12 — 캠페인 순위 매기기
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가 있으면 누적 합계, 없으면 전체 합계다.
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) ⭐ 중요
어제 매출과 비교해서 오늘이 얼마나 늘었는지 확인. 전날 대비 성과 리포트에 필수다.
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_revenue가 NULL로 나온다. 마찬가지로 LEAD의 마지막 행도 NULL.
실습 15 — 다음날 매출 미리 보기 (LEAD)
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과 서브쿼리를 실전처럼 엮었다.
Step 1 — 단계별 유저 수 따로 조회
-- 전체 가입자 수
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') : 직접 순서를 지정해서 정렬할 수 있다.
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이 많은지 확인.
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;
Step 1 — 이탈한 유저 목록 보기
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 경험 유무로 구분하면 대응 전략이 완전히 달라진다.
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 — 채널별 이탈 현황
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;
Step 4 — 이탈까지 얼마나 걸렸는지 확인
짧을수록 온보딩에서 막힌 것, 길수록 서비스 불만족일 가능성이 높다.
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:
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 서브쿼리로 구분하면 대응 전략이 달라진다.
📌 다음 강의에서 배울 것: 데이터 시각화 + 지표 대시보드 설계
'부트캠프' 카테고리의 다른 글
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 24일차_260413 (0) | 2026.04.13 |
|---|---|
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 23일차_260410 (0) | 2026.04.10 |
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 21일차_260408 (2) | 2026.04.08 |
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 20일차(2)_260407 (0) | 2026.04.07 |
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 18일차_260402(다른버전) (0) | 2026.04.07 |