포스트

[Data] SQL 실전 심화 — JOIN·윈도우 함수·CTE로 데이터 다루기

SELECT를 넘어서 — INNER/LEFT/FULL JOIN 패턴, 윈도우 함수(ROW_NUMBER, RANK, LAG), CTE와 서브쿼리, 실행 계획 분석까지 현업에서 바로 쓰는 SQL 심화.

[Data] SQL 실전 심화 — JOIN·윈도우 함수·CTE로 데이터 다루기

[NextX_Data_Solution] · 주식회사 넥스트엑스(NEXT X) 정식 데이터 솔루션

DB와 DBA 편에서 데이터베이스의 구조, ACID, 그리고 SQL 기본기를 다뤘습니다. 이 글에서는 SELECT를 넘어, 현업에서 매일 마주치는 JOIN·윈도우 함수·CTE·실행 계획 분석까지 한 호흡으로 정리합니다.


0. 예제 스키마 – partners / assignments

이 글의 모든 예제는 파트너스 매칭 매니저 프로젝트의 실제 스키마를 사용합니다.

erDiagram
    PARTNERS {
        uuid id PK
        text name
        text region
        boolean is_active
    }
    ASSIGNMENTS {
        uuid id PK
        uuid partner_id FK
        text client_name
        date assignment_date
        text status "대기 | 완료 | 종료"
        text notes
    }
    PARTNERS ||--o{ ASSIGNMENTS : "1:N 배정"

파트너(partners)는 정리수납 프리랜서, 배정(assignments)은 고객 현장에 파트너를 연결한 기록입니다. status는 '대기' → '완료' → '종료' 흐름을 따릅니다.


1. SELECT 리뷰 – 기본기 빠른 복습

심화로 넘어가기 전에 SELECT 절의 실행 순서를 짚고 갑니다.

1
2
3
4
5
6
7
SELECT   컬럼, 집계함수       -- (5) 무엇을 보여줄까
FROM     테이블               -- (1) 어디서 가져올까
WHERE    조건                 -- (2) 어떤 행을 남길까
GROUP BY 그룹 기준            -- (3) 어떻게 묶을까
HAVING   집계 조건            -- (4) 묶은 뒤 어떤 그룹을 남길까
ORDER BY 정렬 기준            -- (6) 어떤 순서로
LIMIT                     -- (7) 몇 개만

SQL은 작성 순서실행 순서가 다릅니다. WHERE가 SELECT보다 먼저 실행되기 때문에, SELECT에서 만든 별칭(alias)을 WHERE에서 쓸 수 없습니다.

1
2
3
4
5
6
-- 지역별 활성 파트너 수
SELECT   region, COUNT(*) AS partner_count
FROM     partners
WHERE    is_active = true
GROUP BY region
ORDER BY partner_count DESC;

2. JOIN 패턴 4가지 – 테이블 연결의 핵심

하나의 테이블만으로는 “파트너 이름 + 배정 현장 + 상태”를 한 번에 볼 수 없습니다. 정규화된 DB에서 테이블 간 연결은 JOIN으로 해결합니다.

2-1. INNER JOIN – 양쪽 모두 있는 것만

1
2
3
4
SELECT p.name, a.client_name, a.status, a.assignment_date
FROM   partners   p
       INNER JOIN assignments a ON a.partner_id = p.id
ORDER BY a.assignment_date DESC;

배정이 0건인 파트너는 결과에서 빠집니다.

2-2. LEFT JOIN – 왼쪽은 전부, 오른쪽은 있으면

1
2
3
4
SELECT p.name, a.client_name, a.status
FROM   partners   p
       LEFT JOIN assignments a ON a.partner_id = p.id
ORDER BY p.name;

배정이 없는 파트너도 포함됩니다. 오른쪽 컬럼은 NULL로 채워집니다.

2-3. RIGHT JOIN / FULL OUTER JOIN

RIGHT JOIN은 LEFT JOIN의 거울이며, 테이블 순서를 바꿔 LEFT JOIN으로 쓰는 것이 관례입니다. FULL OUTER JOIN은 양쪽 모두 전부 포함합니다.

JOIN 비교 요약

JOIN 유형왼쪽 전부오른쪽 전부교집합만실무 빈도
INNER JOIN  O매우 높음
LEFT JOINO O높음
RIGHT JOIN OO낮음
FULL OUTER JOINOOO낮음

LEFT JOIN + IS NULL – “없는 것” 찾기

현업에서 가장 자주 쓰는 패턴입니다.

1
2
3
4
5
-- 배정을 한 번도 받지 못한 파트너
SELECT p.name, p.region
FROM   partners   p
       LEFT JOIN assignments a ON a.partner_id = p.id
WHERE  a.id IS NULL;

3. GROUP BY + HAVING + 집계 함수

집계 함수 요약

함수의미NULL 처리
COUNT(*)전체 행 수NULL 포함
COUNT(col)NULL 제외 행 수NULL 제외
SUM(col) / AVG(col)합계 / 평균NULL 제외
MIN(col) / MAX(col)최솟값 / 최댓값NULL 제외

GROUP BY + HAVING 예제

WHERE는 행 단위 필터, HAVING그룹 단위 필터입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 배정 5건 이상인 파트너만, 상태별 집계 포함
SELECT   p.name,
         COUNT(a.id)                                  AS total,
         COUNT(*) FILTER (WHERE a.status = '완료')     AS completed,
         COUNT(*) FILTER (WHERE a.status = '대기')     AS pending,
         MIN(a.assignment_date)                        AS first_date,
         MAX(a.assignment_date)                        AS last_date
FROM     partners p
         INNER JOIN assignments a ON a.partner_id = p.id
WHERE    p.is_active = true
GROUP BY p.name
HAVING   COUNT(a.id) >= 5
ORDER BY total DESC;

FILTER (WHERE ...) 구문은 PostgreSQL 전용입니다. MySQL에서는 SUM(CASE WHEN status = '완료' THEN 1 ELSE 0 END)로 대체합니다.


4. 서브쿼리 – 쿼리 안의 쿼리

서브쿼리는 SQL 문 안에 중첩된 또 다른 SELECT입니다. 위치에 따라 역할이 달라집니다.

WHERE 절 – 필터로 쓰기

1
2
3
4
5
6
7
8
-- "완료" 배정이 가장 많은 파트너
SELECT name FROM partners
WHERE  id = (
    SELECT   partner_id FROM assignments
    WHERE    status = '완료'
    GROUP BY partner_id
    ORDER BY COUNT(*) DESC LIMIT 1
);

FROM 절 – 인라인 뷰로 쓰기

1
2
3
4
5
6
7
8
9
10
-- 파트너별 완료율 → 50% 이상만
SELECT sub.name, sub.total, sub.completed,
       ROUND(sub.completed * 100.0 / NULLIF(sub.total, 0), 1) AS rate
FROM (
    SELECT p.name, COUNT(a.id) AS total,
           COUNT(*) FILTER (WHERE a.status = '완료') AS completed
    FROM   partners p LEFT JOIN assignments a ON a.partner_id = p.id
    GROUP BY p.name
) sub
WHERE sub.total > 0 AND (sub.completed * 100.0 / sub.total) >= 50;

서브쿼리가 3단 이상 중첩되면 가독성이 급격히 떨어집니다. 이 문제를 해결하는 것이 CTE입니다.


5. CTE(Common Table Expression) – 서브쿼리의 진화

WITH 절로 임시 결과 집합에 이름을 붙여 위에서 아래로 읽히는 쿼리를 만듭니다.

서브쿼리 vs CTE

1
2
3
4
5
6
7
8
9
10
11
12
-- 서브쿼리: 안에서 밖으로 읽어야 함
SELECT * FROM (
    SELECT partner_id, COUNT(*) AS cnt
    FROM assignments WHERE status = '완료' GROUP BY partner_id
) sub WHERE sub.cnt >= 3;

-- CTE: 위에서 아래로 자연스럽게 읽힘
WITH completed_counts AS (
    SELECT partner_id, COUNT(*) AS cnt
    FROM assignments WHERE status = '완료' GROUP BY partner_id
)
SELECT * FROM completed_counts WHERE cnt >= 3;

CTE 체이닝 – 여러 단계 연결

CTE의 진정한 힘은 파이프라인처럼 연결할 수 있다는 점입니다. 아래 예제는 섹션 9 종합 예제에서 더 자세히 다룹니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
WITH
partner_stats AS (
    SELECT p.name, COUNT(a.id) AS total,
           COUNT(*) FILTER (WHERE a.status = '완료') AS completed
    FROM   partners p LEFT JOIN assignments a ON a.partner_id = p.id
    WHERE  p.is_active = true
    GROUP BY p.name
),
with_rate AS (
    SELECT *, ROUND(completed * 100.0 / NULLIF(total, 0), 1) AS rate
    FROM partner_stats WHERE total > 0
)
SELECT name, total, completed, rate
FROM   with_rate WHERE rate >= 70 ORDER BY rate DESC;

CTE vs 서브쿼리 비교

항목서브쿼리CTE
가독성안에서 밖으로위에서 아래로
재사용같은 서브쿼리를 복사이름으로 여러 번 참조
재귀불가WITH RECURSIVE 가능
디버깅단계별 확인 어려움CTE 하나씩 실행 가능

CTE는 “읽는 사람을 배려하는 SQL”입니다. 다른 팀원이 내 쿼리를 유지보수할 수 있으려면 서브쿼리보다 CTE를 우선 사용하세요.


6. 윈도우 함수 – 행 간 계산의 끝판왕

GROUP BY가 여러 행을 하나로 합치는 반면, 윈도우 함수는 행을 유지하면서 그룹 단위 계산을 추가합니다.

flowchart LR
    subgraph GROUP_BY ["GROUP BY"]
        G1["행 5개 → 1개<br/>(그룹 축소)"]
    end
    subgraph WINDOW ["윈도우 함수"]
        W1["행 5개 → 5개<br/>(원본 유지 + 계산 추가)"]
    end
    GROUP_BY ---|"vs"| WINDOW

기본 구문

1
2
3
4
함수() OVER (
    PARTITION BY 그룹_기준    -- 어떤 단위로 나눌까
    ORDER BY     정렬_기준    -- 그 안에서 어떤 순서로
)

6-1. ROW_NUMBER, RANK, DENSE_RANK

1
2
3
4
5
6
7
8
SELECT p.name,
       COUNT(a.id) AS total,
       ROW_NUMBER() OVER (ORDER BY COUNT(a.id) DESC) AS rn,
       RANK()       OVER (ORDER BY COUNT(a.id) DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY COUNT(a.id) DESC) AS dense_rnk
FROM   partners p
       INNER JOIN assignments a ON a.partner_id = p.id
GROUP BY p.name;
nametotalROW_NUMBERRANKDENSE_RANK
김정리12111
이수납10222
박깨끗10322
최청소7443
  • ROW_NUMBER: 동점이어도 순번이 다름 (유일한 번호)
  • RANK: 동점이면 같은 순위, 다음을 건너뜀 (2, 2, 4)
  • DENSE_RANK: 동점이면 같은 순위, 건너뛰지 않음 (2, 2, 3)

6-2. LAG / LEAD – 이전-다음 행 참조

1
2
3
4
5
6
7
-- 파트너별 배정 간격(일) 계산
SELECT p.name, a.assignment_date,
       LAG(a.assignment_date) OVER (PARTITION BY p.id ORDER BY a.assignment_date) AS prev_date,
       a.assignment_date - LAG(a.assignment_date)
           OVER (PARTITION BY p.id ORDER BY a.assignment_date) AS days_gap
FROM   partners p INNER JOIN assignments a ON a.partner_id = p.id
ORDER BY p.name, a.assignment_date;

6-3. SUM OVER – 누적 합계

1
2
3
4
SELECT a.assignment_date, COUNT(*) AS daily_count,
       SUM(COUNT(*)) OVER (ORDER BY a.assignment_date) AS running_total
FROM   assignments a
GROUP BY a.assignment_date ORDER BY a.assignment_date;

윈도우 함수 정리표

함수용도활용 예
ROW_NUMBER()유일한 순번페이징, 중복 제거
RANK() / DENSE_RANK()순위랭킹 보드, 등급 산정
LAG(col, n) / LEAD(col, n)이전/이후 행전월 대비 변화량
SUM() OVER / AVG() OVER누적/이동 집계러닝 토탈, 이동 평균
NTILE(n)n등분상위 25% 파트너 구분

7. 실행 계획 – 느린 쿼리 진단

EXPLAIN ANALYZE

쿼리를 실제로 실행하면서 각 단계별 소요 시간과 처리 행 수를 보여줍니다.

1
2
3
4
5
6
7
8
9
10
EXPLAIN ANALYZE
SELECT p.name, COUNT(a.id) AS total
FROM   partners p LEFT JOIN assignments a ON a.partner_id = p.id
GROUP BY p.name ORDER BY total DESC;

-- 결과 예시 (축약)
-- Hash Left Join  (actual time=0.045..0.310 rows=800)
--   -> Seq Scan on assignments a  (rows=800)
--   -> Seq Scan on partners p     (rows=10)
-- Execution Time: 0.573 ms

핵심 키워드 해석

키워드의미주의 시점
Seq Scan테이블 전체 스캔대형 테이블에서 비용 높을 때
Index Scan인덱스 활용 스캔정상적이고 효율적
Hash Join해시 테이블로 JOIN대체로 효율적
Nested Loop중첩 반복 JOIN양쪽 다 클 때 느림
Sort정렬메모리 초과 시 디스크 사용
flowchart TD
    Q["쿼리 작성"] --> E["EXPLAIN ANALYZE"]
    E --> R{"Seq Scan on<br/>대형 테이블?"}
    R -->|Yes| I["인덱스 추가 검토"]
    R -->|No| C{"실행 시간<br/>허용 범위?"}
    C -->|Yes| OK["통과"]
    C -->|No| D["쿼리 구조 개선"]
    I --> E
    D --> E

EXPLAIN ANALYZE실제로 쿼리를 실행합니다. UPDATE/DELETE에 사용할 때는 트랜잭션으로 감싸세요: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;


8. 인덱스 전략 기초

인덱스는 책의 색인과 같습니다. 전체를 넘기지 않고 원하는 페이지로 바로 갈 수 있게 해줍니다.

언제 만들까?

만들어야 할 때만들지 않아도 될 때
WHERE에 자주 쓰는 컬럼행 수백 건 이하의 작은 테이블
JOIN ON의 FK 컬럼거의 모든 행이 같은 값인 컬럼
ORDER BY에 자주 쓰는 컬럼INSERT가 매우 빈번한 테이블

파트너스 프로젝트 적용

1
2
3
4
5
6
7
8
-- FK로 JOIN할 때마다 사용
CREATE INDEX idx_assignments_partner_id ON assignments (partner_id);

-- status 필터링 빈번
CREATE INDEX idx_assignments_status ON assignments (status);

-- 날짜 범위 + status 복합 인덱스 (왼쪽부터 사용됨!)
CREATE INDEX idx_assignments_date_status ON assignments (assignment_date, status);

복합 인덱스는 선두 컬럼부터 사용됩니다. (assignment_date, status) 인덱스에서 WHERE status = '완료'만 쓰면 인덱스를 타지 못할 수 있습니다. 컬럼 순서를 쿼리 패턴에 맞추세요.

인덱스는 “읽기 성능 vs 쓰기 비용”의 트레이드오프입니다. EXPLAIN ANALYZE로 확인한 뒤 필요한 곳에만 추가하세요.


9. 실전 종합 예제 – 이번 달 배정 완료율 TOP 5 파트너

지금까지 배운 기법을 하나의 쿼리에 녹여봅니다.

요구사항: 2026년 7월 기준, 활성 파트너 중 배정 1건 이상인 파트너의 완료율 상위 5명. 동점이면 총 배정이 많은 파트너 우선.

설계 흐름

flowchart TD
    A["1. 이번 달 배정만 필터"] --> B["2. 파트너별 집계"]
    B --> C["3. 완료율 계산"]
    C --> D["4. DENSE_RANK 순위"]
    D --> E["5. TOP 5 추출"]

최종 쿼리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
WITH
-- 1단계: 이번 달 배정만
monthly_assignments AS (
    SELECT partner_id, status
    FROM   assignments
    WHERE  assignment_date >= DATE_TRUNC('month', CURRENT_DATE)
      AND  assignment_date <  DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
),
-- 2단계: 파트너별 통계
partner_stats AS (
    SELECT p.id, p.name, p.region,
           COUNT(ma.partner_id)                        AS total,
           COUNT(*) FILTER (WHERE ma.status = '완료')   AS completed
    FROM   partners p
           INNER JOIN monthly_assignments ma ON ma.partner_id = p.id
    WHERE  p.is_active = true
    GROUP BY p.id, p.name, p.region
    HAVING COUNT(ma.partner_id) >= 1
),
-- 3단계: 완료율 + 순위
ranked AS (
    SELECT name, region, total, completed,
           ROUND(completed * 100.0 / total, 1) AS completion_rate,
           DENSE_RANK() OVER (
               ORDER BY ROUND(completed * 100.0 / total, 1) DESC, total DESC
           ) AS rank
    FROM   partner_stats
)
-- 4단계: TOP 5
SELECT rank, name, region, total, completed,
       completion_rate || '%' AS completion_rate
FROM   ranked
WHERE  rank <= 5
ORDER BY rank;

예상 결과

ranknameregiontotalcompletedcompletion_rate
1김정리서울 강서121191.7%
2이수납서울 마포10880.0%
3최청소경기 성남8675.0%
4박깨끗서울 송파6466.7%
5한빛남인천 남동5360.0%

쿼리 포인트 해설

기법역할
CTE 체이닝4단계로 쪼개 가독성 확보
DATE_TRUNC이번 달 시작일을 동적 계산
FILTER (WHERE)완료 건만 별도 집계
HAVING배정 1건 이상만 남김
DENSE_RANK()동점 처리 + 연속 순위

데이터 클렌징 편에서 강조했듯, 분석 쿼리의 정확도는 원본 데이터 품질에 직결됩니다. status 값이 오염되어 있으면 완료율 자체를 신뢰할 수 없습니다.


현업 SQL 팁 모음

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 1. 별칭은 의미 있게 (a, b 대신 p, a)
SELECT p.name, a.status FROM partners p JOIN assignments a ...

-- 2. NULL 비교는 반드시 IS NULL
WHERE notes IS NULL          -- O
WHERE notes = NULL           -- X (항상 FALSE)

-- 3. COALESCE로 NULL 기본값
SELECT COALESCE(a.notes, '(메모 없음)') AS notes ...

-- 4. 날짜 연산은 함수로
WHERE assignment_date >= CURRENT_DATE - INTERVAL '7 days'
WHERE assignment_date >= DATE_TRUNC('quarter', CURRENT_DATE)

-- 5. 대량 데이터에서 EXISTS > IN
SELECT * FROM partners p
WHERE EXISTS (
    SELECT 1 FROM assignments a
    WHERE a.partner_id = p.id AND a.status = '완료'
);

마무리 – SQL 심화 로드맵

flowchart LR
    A["SELECT 기본기"] --> B["JOIN"]
    B --> C["GROUP BY"]
    C --> D["서브쿼리·CTE"]
    D --> E["윈도우 함수"]
    E --> F["실행 계획"]
    F --> G["인덱스"]
섹션핵심한 줄 요약
SELECT 리뷰실행 순서작성 순서와 실행 순서는 다르다
JOININNER, LEFT, FULL테이블을 연결하는 4가지 방법
GROUP BY / HAVING집계 + 그룹 필터행 필터 vs 그룹 필터를 구분하라
서브쿼리 / CTEWITH, 체이닝이름을 붙여 읽기 쉬운 쿼리를 만든다
윈도우 함수RANK, LAG, SUM OVER행을 유지하며 그룹 계산을 추가한다
실행 계획EXPLAIN ANALYZE느린 쿼리의 병목을 찾는다
인덱스복합 인덱스, 선두 컬럼읽기 성능과 쓰기 비용의 트레이드오프

다음 글 데이터 거버넌스에서는 이렇게 쌓인 데이터를 조직 차원에서 관리하고 보호하는 체계를 다룹니다.


NEXT X R&D · Data Engineering

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.