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

반응형

SQL을 배우면 SELECT, WHERE, GROUP BY까지는 금방 익히는데 막상 실무에서 데이터를 뽑으려면 "이걸 어떻게 짜지?" 하는 순간이 옵니다.

기본 문법은 알지만 윈도우 함수나 실무 패턴은 아직 익숙하지 않은 분들을 위해 이 글에서는 실무에서 반복적으로 쓰게 되는 SQL 쿼리 패턴 7가지를 정리했습니다. 예제는 MySQL/PostgreSQL 공통으로 작성했고 차이가 있는 부분은 따로 표시했어요.

각 쿼리마다 샘플 데이터와 실행 결과도 함께 넣었으니 흐름을 따라가면서 읽어보시면 됩니다.


목차

  1. 날짜별/월별 집계
  2. 순위 매기기 (RANK, ROW_NUMBER)
  3. 직전 값과 비교 (LAG)
  4. 중복 데이터 찾기/제거
  5. NULL 처리
  6. 조건별 집계 (CASE WHEN)
  7. 누적합 구하기

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 email 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은 그룹핑 후에 필터링한다는 차이가 있어요.

실행 결과:

email 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       -- 이메일별로 묶어서
);

이 쿼리는 이렇게 동작합니다:

  1. 안쪽 서브쿼리: 이메일별로 가장 작은 id를 구함 → {1, 2, 4}
  2. 바깥 쿼리: 그 id에 포함되지 않는 행을 찾음 → id 3, 5가 삭제 대상

확인 결과:

id email 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)가 낯설 수 있는데 몇 번 써보면 금방 손에 익을 거예요.

수정해야 하는 부분이 있거나 궁금한 점 있으면 댓글 남겨주시고 도움이 되셨다면 공감 부탁드려요!

반응형