WITH 로 복잡한 쿼리를 단계로 쪼개 가독성을 높이고, WITH RECURSIVE 로 계층·연속 데이터를 다룬다.
CTE(공통 테이블 표현식)는 WITH 이름 AS (...) 형태로 쿼리 앞에 임시 결과셋을 정의하는 문법이다. 세 가지 이점이 있다. (1) 가독성 — 중첩 서브쿼리를 위에서 아래로 읽히는 단계로 펼친다. (2) 재사용 — 하나의 CTE 를 본문에서 여러 번 참조할 수 있다(파생테이블은 매번 다시 써야 함). (3) 재귀 — WITH RECURSIVE 로 계층·연속 데이터를 반복 전개할 수 있다.
성능 면에서 MySQL 8 의 비재귀 CTE 는 대부분 파생테이블(derived table)로 머티리얼라이즈되거나 병합(merge) 되어 실행되며, 같은 쿼리를 파생테이블로 쓴 것과 성능이 사실상 동일하다. 즉 CTE 로 바꿨다고 느려지지 않으니, 복잡한 쿼리는 가독성을 위해 적극적으로 CTE 로 쪼개도 좋다.
링크: https://leetcode.cn/problems/tree-node/
학습 포인트: 비재귀 CTE 로 "자식을 가진 노드 집합"을 먼저 정의해 CASE 를 단순화한다.
Tree 테이블. 각 노드는 id 와 부모 p_id 를 가진다.
| 컬럼 | 설명 |
|---|---|
| id | 노드 ID (PK) |
| p_id | 부모 노드 ID (루트면 NULL) |
각 노드를 다음 세 종류로 분류한다.
p_id IS NULL).p_id IS NOT NULL), 자기를 부모로 삼는 노드가 없다(자식 없음).id, type 두 컬럼으로 출력한다.
분류 규칙의 핵심은 "이 노드가 다른 누군가의 부모인가?" 이다. 이것만 알면 Inner 와 Leaf 가 갈린다.
parents CTE 에서 p_id 컬럼에 등장하는 값들을 DISTINCT 로 모은다. 이 집합에 든 id 는 "자식을 가진 노드"다.Tree 에 parents 를 LEFT JOIN 한다. 매칭되면(p.id IS NOT NULL) 자식이 있는 노드, 아니면 자식이 없는 노드다.p_id IS NULL → Root, 그다음 자식 있으면 Inner, 나머지는 Leaf.CTE 없이 하면 CASE 안에서 id IN (SELECT p_id FROM Tree WHERE p_id IS NOT NULL) 서브쿼리를 매 행 개념적으로 반복 참조하게 된다. "자식 가진 노드 집합"이라는 개념에 parents 라는 이름을 붙여두면 의도가 한눈에 드러난다.
IN (SELECT ...) 상관 서브쿼리를 LEFT JOIN + NULL 판정으로 바꾸는 패턴.p_id IS NULL 을 최우선으로 두지 않으면 Leaf 로 잘못 분류된다.parents 를 만들 때 WHERE p_id IS NOT NULL 을 빠뜨리면 NULL 이 집합에 섞여 JOIN 결과가 어긋날 수 있다.CASE WHEN p_id IS NULL THEN 'Root' WHEN id IN (SELECT p_id FROM Tree WHERE p_id IS NOT NULL) THEN 'Inner' ELSE 'Leaf' END.parents 를 SELECT p_id AS id, COUNT(*) AS child_cnt ... GROUP BY p_id 로 확장한다.링크: https://leetcode.cn/problems/report-contiguous-dates/
학습 포인트: gaps-and-islands. 연속된 날짜 구간을 CTE 단계로 압축한다.
두 테이블이 있다.
Failed(fail_date): 실패한 날짜들.Succeeded(success_date): 성공한 날짜들.기간은 2019-01-01 ~ 2019-12-31. 상태(failed / succeeded)가 같고 날짜가 연속인 구간을 하나로 묶어, 각 구간의 period_state, start_date, end_date 를 출력한다. start_date 오름차순 정렬.
예: 실패가 1/1, 1/2, 1/3 연속이면 한 행 ('failed', 2019-01-01, 2019-01-03).
이 문제는 gaps-and-islands 의 교과서적 예다. CTE 각 단계가 어떤 중간결과인지 그림처럼 따라가 보자.
(1) logs — 실패/성공을 하나의 테이블로 합친다. period_state 라벨을 붙여 UNION ALL 로 세로로 쌓는다. 결과는 (period_state, dt) 목록.
(2) numbered — 상태별로 날짜순 순번(rn)을 매긴다. PARTITION BY period_state 로 failed 는 failed 끼리, succeeded 는 succeeded 끼리 1,2,3... 을 센다.
(3) grouped — 핵심 트릭. 날짜가 연속이면 dt - rn 이 일정한 값이 된다. 날짜가 1일씩 증가하고 rn 도 1씩 증가하므로, 둘의 차이는 같은 섬(island) 안에서 상수다. 중간에 날짜가 끊기면(gap) 이 값이 달라진다.
(4) 최종 — (period_state, grp) 로 그룹핑해 MIN(dt) 를 start_date, MAX(dt) 를 end_date 로 뽑는다. grp 는 그룹 키로만 쓰고 출력하지 않는다.
CTE 로 4단계를 분리하니 "합치기 → 순번 → 그룹키 → 집계"라는 사고 흐름이 그대로 코드가 된다. 한 쿼리에 서브쿼리로 3중 중첩하면 안쪽부터 거꾸로 읽어야 해서 이해가 훨씬 어렵다.
날짜 - ROW_NUMBER = 상수 원리.ROW_NUMBER() 는 정수 순번이므로 DATE_SUB(dt, INTERVAL rn DAY) 로 날짜에서 빼서 앵커 날짜를 만든다.grp)는 연산의 매개일 뿐 최종 출력에는 넣지 않는다 — GROUP BY 에만 사용.PARTITION BY period_state 를 빼면 failed/succeeded 순번이 뒤섞여 서로 다른 상태가 한 구간으로 묶인다.GROUP BY 에 period_state 를 빠뜨리고 grp 만 넣으면, 우연히 grp 값이 겹치는 다른 상태가 한 그룹이 될 수 있다. 반드시 period_state, grp 둘 다 넣는다.dt - rn 같은 정수 뺄셈으로 처리하면 안 되고, 날짜 타입은 DATE_SUB(..., INTERVAL rn DAY) 를 써야 한다.grp 를 날짜 대신 정수로 만들려면 DATEDIFF(dt, '2019-01-01') - rn 처럼 일수 차이를 써도 된다(구간 판별에는 상대값이면 충분).링크: https://leetcode.cn/problems/product-sales-analysis-iii/
학습 포인트: CTE 로 "상품별 첫 판매연도"를 정의해 조인 조건을 명확히 한다.
Sales 테이블.
| 컬럼 | 설명 |
|---|---|
| sale_id | (product_id 와 함께) PK 일부 |
| product_id | 상품 ID |
| year | 판매 연도 |
| quantity | 수량 |
| price | 단가 |
각 상품에 대해 가장 이른 판매연도(first_year) 의 판매 기록을 모두 찾아 product_id, first_year, quantity, price 를 출력한다. (한 상품이 첫해에 여러 건 팔렸다면 여러 행이 나올 수 있다.)
first_year CTE 에서 상품별 최소 연도를 계산한다. (product_id, first_year) 한 행씩.Sales 에 이 CTE 를 조인한다. 조인 조건이 product_id 일치 그리고 year = first_year 이므로, 각 상품의 첫해 기록만 살아남는다.여기서 WHERE year = (SELECT MIN(year) ...) 상관 서브쿼리로도 풀 수 있지만, 첫 판매연도라는 개념에 first_year 라는 이름을 붙이면 조인이 무엇을 걸러내는지 즉시 읽힌다.
product_id + year 두 컬럼으로 잡아 "그 상품의 첫해"를 정확히 지정.s.year = f.first_year 를 빠뜨리고 product_id 만 맞추면 모든 연도 행이 살아 첫해 필터가 무의미해진다.GROUP BY product_id 로 집계하면서 quantity, price 를 같이 SELECT 하려는 시도 — 집계 단계에서는 상세값을 특정할 수 없다. 그래서 집계는 CTE 로 분리하고 상세는 되조인으로 가져온다.WITH ranked AS (SELECT *, RANK() OVER (PARTITION BY product_id ORDER BY year) AS rk FROM Sales) SELECT product_id, year AS first_year, quantity, price FROM ranked WHERE rk = 1; — RANK 는 동점 연도(같은 최소 연도 여러 건)를 모두 1로 매겨 그대로 통과시킨다.링크: https://leetcode.cn/problems/restaurant-growth/
학습 포인트: CTE 로 일별 합계를 정리한 뒤 윈도우 프레임 ROWS 6 PRECEDING 으로 7일 이동합/이동평균을 구한다.
Customer 테이블.
| 컬럼 | 설명 |
|---|---|
| customer_id | 고객 ID |
| name | 이름 |
| visited_on | 방문일 |
| amount | 결제액 |
식당은 매일 영업한다. 각 날짜에 대해 그날을 포함한 직전 7일의 합계(amount)와 그 7일 평균(소수 둘째 자리 반올림)을 구한다. 단, 7일치 데이터가 확보되는 날부터 출력한다(첫 6일은 제외). visited_on 오름차순.
출력: visited_on, amount(7일 합), average_amount(7일 평균, 반올림 2자리).
(1) daily — 같은 날짜에 손님이 여러 명일 수 있으므로 먼저 visited_on 별로 amount 를 합쳐 "하루=한 행"으로 만든다. 이 정리를 안 하면 윈도우가 "행 7개"를 세는데 그게 "날짜 7일"과 어긋난다.
(2) rolling — 정렬된 일별 행 위에서 윈도우를 연다. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 는 현재 행 + 앞의 6행 = 총 7행(=7일) 을 프레임으로 잡는다. SUM 은 7일 합, AVG 는 7일 평균이다. WINDOW w AS (...) 절로 프레임 정의를 한 번만 쓰고 재사용한다.
(3) 최종 — 처음 6일은 프레임에 7일치가 안 차므로 ROW_NUMBER() >= 7 로 걸러 7일째부터 출력한다.
일별 정리(CTE 1) → 이동창 계산(CTE 2) → 앞부분 컷(본문)으로 단계가 나뉘어 흐름이 분명하다.
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW = 현재 포함 7행 슬라이딩 윈도우. ROWS(물리적 행)와 RANGE(값 범위)의 차이를 이해할 것.RANGE INTERVAL 이나 날짜 채우기가 필요하다.WINDOW 절로 프레임을 명명해 SUM, AVG 가 공유하도록 하면 중복이 줄고 읽기 쉽다.ROWS ... 생략) 기본값이 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 라 누적 합이 되어 7일 이동합이 되지 않는다.ROUND(..., 2) 를 빠뜨려 average_amount 자릿수가 요구와 다르게 나온다.b.visited_on BETWEEN a.visited_on - 6일 AND a.visited_on 조건으로 self-join 후 GROUP BY 하는 상관/셀프조인 풀이가 가능하지만, 날짜 산술과 조인이 얽혀 읽기 어렵다.ROWS 6 PRECEDING)로 이동합을 직접 표현해 의도가 가장 명확하다. CTE 는 여기에 "일별 정리 → 윈도우 → 필터"의 단계 구조를 더해 준다.이 STEP 의 4문제는 재귀가 필수가 아니지만, WITH RECURSIVE 는 CTE 의 진짜 강력한 무기다. 구조는 항상 앵커부(anchor) + UNION ALL + 재귀부(recursive) 다.
같은 원리로 빠진 날짜를 채울 수 있다. 예를 들어 2019-01-01 ~ 2019-01-31 을 모두 생성한 뒤, 실제 데이터를 LEFT JOIN 하면 데이터가 없는 날도 0 으로 표시할 수 있다.
Employee(id, name, manager_id) 에서 특정 관리자 밑의 모든 하위 직원을 깊이와 함께 전개한다.
UNION ALL(또는 UNION)로 연결한다.CONCAT) 값이 커지면 앵커에서 미리 넉넉한 타입으로 CAST 해 둬야 잘림(truncation)을 막는다. 예: 앵커에서 CAST(name AS CHAR(1000)).WHERE 로 재귀를 멈추지 않으면 무한 반복이 된다. 안전장치로 시스템 변수 cte_max_recursion_depth(기본 1000) 가 있어, 깊이가 이를 넘으면 에러로 중단된다. 정당하게 더 깊게 가야 하면 SET SESSION cte_max_recursion_depth = ... 로 조정한다.