SQL 실무에서 매일 쓰는 쿼리 패턴 7가지

SQL을 배우면 SELECT, WHERE, GROUP BY까지는 금방 익히는데 막상 실무에서 데이터를 뽑으려면 "이걸 어떻게 짜지?" 하는 순간이 옵니다.
기본 문법은 알지만 윈도우 함수나 실무 패턴은 아직 익숙하지 않은 분들을 위해 이 글에서는 실무에서 반복적으로 쓰게 되는 SQL 쿼리 패턴 7가지를 정리했습니다. 예제는 MySQL/PostgreSQL 공통으로 작성했고 차이가 있는 부분은 따로 표시했어요.
각 쿼리마다 샘플 데이터와 실행 결과도 함께 넣었으니 흐름을 따라가면서 읽어보시면 됩니다.
목차
1. 날짜별/월별 집계
가장 많이 쓰는 패턴입니다. 매출, 가입자 수, 주문 수 등을 기간별로 볼 때 필수예요.
* 일별 집계
이런 orders 테이블이 있다고 해볼게요.
| id | created_at | amount |
| 1 | 2024-01-15 09:30:00 | 15000 |
| 2 | 2024-01-15 14:20:00 | 23000 |
| 3 | 2024-01-16 11:00:00 | 8000 |
SELECT
DATE(created_at) AS date, -- 날짜+시간에서 날짜만 추출
COUNT(*) AS count -- 해당 날짜의 주문 건수
FROM orders
GROUP BY DATE(created_at) -- 같은 날짜끼리 묶기
ORDER BY date; -- 날짜 오름차순 정렬
created_at에는 2024-01-15 09:30:00처럼 시간까지 들어있는데
DATE() 함수를 씌우면 2024-01-15처럼 날짜만 뽑아줍니다.
이렇게 해야 같은 날짜의 데이터끼리 GROUP BY로 묶을 수 있어요.
실행 결과:
| date | count |
| 2024-01-15 | 2 |
| 2024-01-16 | 1 |
* 월별 집계
-- MySQL
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month, -- '2024-01' 형태로 변환
SUM(amount) AS total -- 월별 총 매출
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month;
-- PostgreSQL
SELECT
TO_CHAR(created_at, 'YYYY-MM') AS month, -- PostgreSQL은 TO_CHAR 사용
SUM(amount) AS total
FROM orders
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month;
날짜를 '2024-01' 같은 월 단위 문자열로 바꿔서 GROUP BY 하는 방식입니다.
날짜 포맷 함수가 MySQL(DATE_FORMAT)과 PostgreSQL(TO_CHAR)에서 다르다는 점만 기억하면 돼요.
실행 결과:
| month | total |
| 2024-01 | 46000 |
| 2024-02 | 52000 |
2. 순위 매기기 (RANK, ROW_NUMBER)
"매출 TOP 10", "부서별 성과 1등"처럼 순위를 매겨야 할 때 씁니다. 여기서 처음 등장하는 게 윈도우 함수입니다.
어렵게 생각할 필요 없이 "각 행마다 전체 데이터를 기준으로 계산해 주는 함수"라고 보면 됩니다.
이런 sales 테이블이 있다고 해볼게요.
| product_name | amount |
| 노트북 | 1500000 |
| 키보드 | 85000 |
| 모니터 | 450000 |
| 마우스 | 85000 |
SELECT
product_name,
amount,
RANK() OVER (ORDER BY amount DESC) AS ranking
-- RANK(): 순위를 매기는 함수
-- OVER (...): "어떤 기준으로 순위를 매길지" 정하는 부분
-- ORDER BY amount DESC: 금액이 높은 순서대로
FROM sales;
OVER()는 윈도우 함수에서 항상 함께 쓰이는 키워드예요. "이 기준으로 계산해줘"라는 뜻입니다.
여기서는 amount가 큰 순서대로 순위를 매기라는 의미가 돼요.
실행 결과:
| product_name | amount | ranking |
| 노트북 | 1500000 | 1 |
| 모니터 | 450000 | 2 |
| 키보드 | 85000 | 3 |
| 마우스 | 85000 | 3 |
키보드와 마우스가 같은 금액이라 둘 다 3등이 됐습니다. 그런데 순위 함수마다 이 동점 처리 방식이 달라요.
* RANK vs DENSE_RANK vs ROW_NUMBER
| 함수 | 동점 처리 | 예시 (90, 90, 80점) |
| RANK() | 동점 다음 순위 건너뜀 | 1, 1, 3 |
| DENSE_RANK() | 동점 다음 순위 안 건너뜀 | 1, 1, 2 |
| ROW_NUMBER() | 동점이어도 고유 번호 | 1, 2, 3 |
상황에 따라 골라 쓰면 되는데 보통 "TOP N"을 뽑을 때는 ROW_NUMBER()
공동 순위를 보여줘야 할 때는 RANK()나 DENSE_RANK()가 적합합니다.
* 그룹 내 순위 (부서별 TOP 3)
이런 employees 테이블이 있을 때:
| department | employee_name | sales_amount |
| 영업1팀 | 김민수 | 5000 |
| 영업1팀 | 이지현 | 8000 |
| 영업1팀 | 박서준 | 3000 |
| 영업2팀 | 최유리 | 7000 |
| 영업2팀 | 정하늘 | 9000 |
SELECT *
FROM (
SELECT
department,
employee_name,
sales_amount,
RANK() OVER (
PARTITION BY department -- 부서별로 나눠서
ORDER BY sales_amount DESC -- 매출 높은 순으로 순위
) AS rk
FROM employees
) ranked -- 서브쿼리에 별칭을 붙여야 FROM에서 쓸 수 있어요
WHERE rk <= 3; -- 각 부서에서 3등 이내만 필터링
핵심은 PARTITION BY입니다. 이걸 넣으면 전체가 아니라 그룹별로 순위를 매겨요.
PARTITION BY department라고 하면 부서가 바뀔 때마다 순위가 1부터 다시 시작됩니다.
그리고 바깥에 서브쿼리로 한 번 감싼 이유는 WHERE rk <= 3처럼 순위를 필터링하려면 순위가 먼저 계산되어야 하기 때문이에요. 윈도우 함수는 WHERE보다 나중에 실행되거든요.
실행 결과:
| department | employee_name | sales_amount | rk |
| 영업1팀 | 이지현 | 8000 | 1 |
| 영업1팀 | 김민수 | 5000 | 2 |
| 영업1팀 | 박서준 | 3000 | 3 |
| 영업2팀 | 정하늘 | 9000 | 1 |
| 영업2팀 | 최유리 | 7000 | 2 |
3. 직전 값과 비교 (LAG)
"전월 대비 매출 증감률", "이전 주문과의 간격" 같은 걸 구할 때 씁니다.
이런 monthly_revenue 테이블이 있다고 해볼게요.
| month | revenue |
| 2024-01 | 1000 |
| 2024-02 | 1500 |
| 2024-03 | 1200 |
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
-- LAG(revenue): 바로 이전 행의 revenue 값을 가져옴
-- OVER (ORDER BY month): month 순서 기준으로 "이전"을 판단
revenue - LAG(revenue) OVER (ORDER BY month) AS diff
-- 현재 매출 - 이전 매출 = 증감액
FROM monthly_revenue;
LAG()는 "이전 행의 값을 가져와"라는 뜻이에요. OVER (ORDER BY month)와 함께 써야 어떤 순서 기준으로 "이전"인지 알 수 있습니다. 첫 번째 행은 이전 값이 없으니 NULL이 들어가요.
실행 결과:
| month | revenue | prev_revenue | diff |
| 2024-01 | 1000 | NULL | NULL |
| 2024-02 | 1500 | 1000 | 500 |
| 2024-03 | 1200 | 1500 | -300 |
첫 행의 prev_revenue가 NULL인 건 2024-01 이전 데이터가 없기 때문입니다.
| 함수 | 설명 |
| LAG(컬럼) | 이전 행의 값 |
| LEAD(컬럼) | 다음 행의 값 (LAG의 반대) |
| LAG(컬럼, 2) | 2행 전의 값 |
* 전월 대비 증감률
SELECT
month,
revenue,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month)) -- 증감액
/ LAG(revenue) OVER (ORDER BY month) * 100 -- 이전 매출 대비 비율(%)
, 1) AS growth_rate_pct
FROM monthly_revenue;
증감률 공식은 (현재 - 이전) / 이전 * 100입니다. ROUND(..., 1)은 소수점 첫째 자리까지 반올림해줘요.
실행 결과:
| month | revenue | growth_rate_pct |
| 2024-01 | 1000 | NULL |
| 2024-02 | 1500 | 50.0 |
| 2024-03 | 1200 | -20.0 |
4. 중복 데이터 찾기/제거
데이터 정리할 때 자주 필요합니다. 같은 이메일로 가입한 유저가 여러 명 있다거나 중복 주문이 들어간 경우에 쓰게 돼요.
* 중복 찾기
이런 users 테이블이 있다고 해볼게요.
| id | name | |
| 1 | kim@test.com | 김민수 |
| 2 | lee@test.com | 이지현 |
| 3 | kim@test.com | 김민수 |
| 4 | park@test.com | 박서준 |
| 5 | lee@test.com | 이지현 |
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email -- 이메일별로 묶고
HAVING COUNT(*) > 1; -- 2건 이상인 것만 필터링
HAVING은 GROUP BY 결과에 조건을 거는 키워드입니다.
WHERE는 그룹핑 전에 HAVING은 그룹핑 후에 필터링한다는 차이가 있어요.
실행 결과:
| cnt | |
| kim@test.com | 2 |
| lee@test.com | 2 |
* 중복 제거 (가장 오래된 것만 남기기)
-- 1단계: 삭제 대상 먼저 확인 (SELECT로 안전하게)
SELECT *
FROM users
WHERE id NOT IN (
SELECT MIN(id) -- 각 이메일별로 가장 작은 id (= 가장 먼저 가입한 것)
FROM users
GROUP BY email -- 이메일별로 묶어서
);
이 쿼리는 이렇게 동작합니다:
- 안쪽 서브쿼리: 이메일별로 가장 작은 id를 구함 → {1, 2, 4}
- 바깥 쿼리: 그 id에 포함되지 않는 행을 찾음 → id 3, 5가 삭제 대상
확인 결과:
| id | name | |
| 3 | kim@test.com | 김민수 |
| 5 | lee@test.com | 이지현 |
-- 2단계: 확인 후 삭제 (SELECT를 DELETE로 바꾸기만 하면 됩니다)
DELETE FROM users
WHERE id NOT IN (
SELECT MIN(id)
FROM users
GROUP BY email
);
주의: DELETE 쿼리는 되돌릴 수 없으니
반드시 SELECT로 삭제 대상을 먼저 확인한 후 실행하세요.
5. NULL 처리
실무 데이터에는 NULL이 정말 많습니다. 이걸 처리 안 하면 집계가 틀어지거나 예상과 다른 결과가 나올 수 있어요.
* COALESCE: NULL을 기본값으로
이런 users 테이블이 있을 때:
| id | name | phone |
| 1 | 김민수 | 010-1234-5678 |
| 2 | 이지현 | NULL |
| 3 | 박서준 | 010-9876-5432 |
SELECT
name,
COALESCE(phone, '번호 없음') AS phone
-- phone이 NULL이면 '번호 없음'으로 대체, NULL이 아니면 원래 값 그대로
FROM users;
실행 결과:
| name | phone |
| 김민수 | 010-1234-5678 |
| 이지현 | 번호 없음 |
| 박서준 | 010-9876-5432 |
COALESCE는 첫 번째 NULL이 아닌 값을 반환합니다. 여러 개 넣으면 순서대로 확인해서 NULL이 아닌 첫 번째 값을 돌려줘요:
COALESCE(mobile, office_phone, home_phone, '연락처 없음')
-- mobile이 NULL이면 → office_phone 확인
-- 그것도 NULL이면 → home_phone 확인
-- 전부 NULL이면 → '연락처 없음'
* NULL 포함 집계 주의
SELECT
COUNT(*) AS total_rows, -- 전체 행 수 (NULL 포함)
COUNT(phone) AS has_phone -- phone이 NULL이 아닌 행 수만 카운트
FROM users;
COUNT(*)와 COUNT(컬럼)의 차이를 모르면 숫자가 안 맞아서 헤매는 경우가 많습니다.
실행 결과:
| total_rows | has_phone |
| 3 | 2 |
total_rows는 3인데 has_phone은 2인 이유는 COUNT(phone)은 phone이 NULL인 이지현을 세지 않기 때문이에요.
* NULL 비교
-- 틀린 예: NULL은 = 로 비교하면 안 됩니다
WHERE phone = NULL -- 항상 결과가 없음 (FALSE도 아니고 UNKNOWN)
-- 올바른 예
WHERE phone IS NULL -- NULL인 행 찾기
WHERE phone IS NOT NULL -- NULL이 아닌 행 찾기
NULL은 "값이 없음"이라는 특수한 상태라서 =로 비교할 수 없어요. 반드시 IS NULL / IS NOT NULL을 써야 합니다.
SQL에서 가장 흔한 실수 중 하나예요.
6. 조건별 집계 (CASE WHEN)
"상태별 건수", "구간별 분류" 같은 걸 한 쿼리로 뽑을 때 씁니다. 엑셀의 COUNTIF와 비슷한 역할이에요.
* 조건별 카운트
이런 users 테이블이 있을 때:
| id | name | status |
| 1 | 김민수 | active |
| 2 | 이지현 | inactive |
| 3 | 박서준 | active |
| 4 | 최유리 | pending |
| 5 | 정하늘 | active |
SELECT
COUNT(CASE WHEN status = 'active' THEN 1 END) AS active_count,
-- status가 'active'인 행만 1을 반환 → COUNT가 그 행만 셈
-- 조건에 안 맞으면 NULL이 되어 COUNT에서 제외됨
COUNT(CASE WHEN status = 'inactive' THEN 1 END) AS inactive_count,
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending_count
FROM users;
CASE WHEN 안에서 조건에 맞으면 1을 반환하고 안 맞으면 아무것도 안 써줬으니 자동으로 NULL이 됩니다.
COUNT는 NULL을 세지 않으니까 결국 조건에 맞는 행만 카운트되는 거예요.
실행 결과:
| active_count | inactive_count | pending_count |
| 3 | 1 | 1 |
이걸 CASE WHEN 없이 하려면 쿼리를 3번 따로 돌려야 하는데 이렇게 하면 한 번에 끝나서 편합니다.
* 구간별 분류
이런 users 테이블이 있을 때:
| id | name | age |
| 1 | 김민수 | 25 |
| 2 | 이지현 | 17 |
| 3 | 박서준 | 34 |
| 4 | 최유리 | 42 |
| 5 | 정하늘 | 28 |
SELECT
CASE
WHEN age < 20 THEN '10대'
WHEN age < 30 THEN '20대' -- 위 조건(20 미만)에 안 걸렸으니 20 이상인 상태
WHEN age < 40 THEN '30대'
ELSE '40대 이상' -- 위 조건에 모두 해당 안 되면 여기
END AS age_group,
COUNT(*) AS count
FROM users
GROUP BY age_group
ORDER BY age_group;
CASE WHEN은 위에서부터 순서대로 조건을 확인하고 처음 맞는 조건에서 멈춥니다.
그래서 WHEN age < 30이라고만 써도 앞에서 20 미만은 이미 걸러졌기 때문에 "20~29세"라는 뜻이 돼요.
실행 결과:
| age_group | count |
| 10대 | 1 |
| 20대 | 2 |
| 30대 | 1 |
| 40대 이상 | 1 |
7. 누적합 구하기
"누적 매출", "누적 가입자 수"를 구할 때 윈도우 함수를 씁니다.
이런 daily_sales 테이블이 있다고 해볼게요.
| date | daily_revenue |
| 2024-01-01 | 1000 |
| 2024-01-02 | 1500 |
| 2024-01-03 | 800 |
SELECT
date,
daily_revenue,
SUM(daily_revenue) OVER (ORDER BY date) AS cumulative_revenue
-- SUM() OVER(): 일반 SUM과 달리, 행을 합치지 않고 각 행마다 누적합을 계산
-- ORDER BY date: 날짜 순서대로 하나씩 더해나감
FROM daily_sales;
일반 SUM() + GROUP BY는 결과를 하나로 합쳐버리지만 SUM() OVER()는 각 행을 유지하면서 거기까지의 합계를 보여줍니다. 이게 윈도우 함수의 핵심 특징이에요.
실행 결과:
| date | daily_revenue | cumulative_revenue |
| 2024-01-01 | 1000 | 1000 |
| 2024-01-02 | 1500 | 2500 |
| 2024-01-03 | 800 | 3300 |
1000 → 1000+1500=2500 → 2500+800=3300 이렇게 날짜 순서대로 쌓여가는 게 보이시죠?
* 월 단위로 리셋되는 누적합
SELECT
date,
daily_revenue,
SUM(daily_revenue) OVER (
PARTITION BY DATE_FORMAT(date, '%Y-%m') -- MySQL 기준. 월별로 그룹을 나눔
ORDER BY date
) AS monthly_cumulative
FROM daily_sales;
2편 순위 파트에서 봤던 PARTITION BY가 여기서도 쓰입니다. 월이 바뀔 때마다 누적합이 0부터 다시 시작돼요.
실행 결과 예시:
| date | daily_revenue | monthly_cumulative |
| 2024-01-01 | 1000 | 1000 |
| 2024-01-02 | 1500 | 2500 |
| 2024-02-01 | 2000 | 2000 |
| 2024-02-02 | 1800 | 3800 |
2월로 넘어가면서 누적합이 2000부터 다시 시작된 게 보이시죠? PARTITION BY가 월 단위로 구간을 잘라주기 때문입니다.
정리
| 패턴 | 핵심 함수 | 쓰는 상황 |
| 날짜별 집계 | DATE_FORMAT / TO_CHAR | 기간별 리포트 |
| 순위 | RANK, ROW_NUMBER | TOP N, 그룹별 1등 |
| 직전 비교 | LAG, LEAD | 전월 대비, 증감률 |
| 중복 처리 | GROUP BY + HAVING | 데이터 정리 |
| NULL 처리 | COALESCE, IS NULL | 결측치 대응 |
| 조건별 집계 | CASE WHEN | 상태별/구간별 분류 |
| 누적합 | SUM() OVER() | 누적 매출, 누적 건수 |
이 7가지만 익숙해지면 실무에서 마주치는 대부분의 데이터 추출 요청에 대응할 수 있습니다.
처음에는 윈도우 함수(OVER, PARTITION BY)가 낯설 수 있는데 몇 번 써보면 금방 손에 익을 거예요.
수정해야 하는 부분이 있거나 궁금한 점 있으면 댓글 남겨주시고 도움이 되셨다면 공감 부탁드려요!
'데이터 분석 > SQL' 카테고리의 다른 글
| SQL 기초 — SELECT부터 JOIN까지, 데이터 꺼내는 법 한번에 정리 (0) | 2026.06.01 |
|---|