DB·SQL 실무 가이드 · Part 5

집계와 윈도우 함수

리포트와 통계 쿼리를 서브쿼리 없이 한 번에 쓰기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

집계의 원리WHERE와 HAVING윈도우 함수순위 함수행 간 비교소계
01

GROUP BY는 행을 접습니다

GROUP BY를 쓰면 같은 값을 가진 행들이 하나로 접힙니다. 그래서 SELECT 절에는 그룹의 기준이 된 컬럼이나 집계 함수만 올 수 있습니다.

접히고 나면 개별 행의 정보는 사라집니다. '등급별 최대 주문금액'은 구할 수 있지만 '그 주문의 주문번호'는 GROUP BY만으로는 못 가져옵니다. 이 한계가 윈도우 함수를 쓰는 이유입니다.

  • 집계 함수는 NULL을 무시합니다. `SUM(amount)` 는 NULL인 행을 건너뜁니다.
  • `COUNT(*)` 와 `COUNT(col)` 은 다릅니다. 후자는 NULL을 세지 않습니다.
  • 조건부 집계는 CASE를 안에 넣습니다. `SUM(CASE WHEN status_cd = 'DONE' THEN amount ELSE 0 END)` 처럼 쓰면 한 번의 스캔으로 여러 지표를 만듭니다.
  • 모든 행이 조건에 안 맞으면 SUM은 0이 아니라 NULL을 반환합니다. NVL로 감싸는 습관이 필요합니다.
WHERE와 HAVING의 차이sql
SELECT c.grade_cd,
       COUNT(*)    AS order_cnt,
       SUM(o.amount) AS total_amount
FROM   orders o
JOIN   customer c ON c.customer_id = o.customer_id
WHERE  o.status_cd = 'DONE'          -- 접기 전에 행을 거릅니다
GROUP BY c.grade_cd
HAVING SUM(o.amount) > 10000000      -- 접은 뒤 그룹을 거릅니다
ORDER BY total_amount DESC;

WHERE는 집계 전, HAVING은 집계 후입니다. 개별 행 조건을 HAVING에 쓰면 불필요하게 많은 행을 집계한 뒤 버리게 되어 느려집니다. 걸러낼 수 있는 것은 최대한 WHERE에서 거릅니다.

02

윈도우 함수는 접지 않고 옆에 붙입니다

윈도우 함수는 집계와 비슷한 계산을 하지만 행을 접지 않습니다. 원래 행은 그대로 두고 계산 결과를 새 컬럼으로 붙여 줍니다.

이 차이 때문에 '각 주문 행에 그 고객의 총 주문금액을 같이 보여 주기' 같은 요구사항이 서브쿼리 없이 한 번에 됩니다.

같은 요구사항, 두 가지 방법sql
-- 서브쿼리로 풀면 테이블을 두 번 읽습니다
SELECT o.order_id, o.customer_id, o.amount,
       (SELECT SUM(x.amount) FROM orders x
        WHERE  x.customer_id = o.customer_id) AS customer_total
FROM   orders o;

-- 윈도우 함수는 한 번의 스캔으로 끝냅니다
SELECT order_id, customer_id, amount,
       SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM   orders;

OVER 절이 윈도우 함수의 표시입니다. PARTITION BY 는 GROUP BY에 해당하는 구간 나누기이고, 생략하면 전체가 하나의 구간이 됩니다.

03

그룹별 최신 1건은 실무에서 가장 많이 나오는 패턴입니다

'고객별 최근 주문 1건', '장비별 마지막 점검 기록', '상품별 최신 가격'. 형태만 다를 뿐 같은 요구사항입니다.

GROUP BY로 최대 날짜를 구한 뒤 다시 조인하는 방법이 전통적이지만, 같은 날짜가 둘이면 중복이 생기고 테이블을 두 번 읽습니다. 순위 함수를 쓰면 한 번에 끝납니다.

ROW_NUMBER
1,2,3,4

동점이어도 순번을 다르게 매깁니다. 무조건 1건만 뽑고 싶을 때 씁니다.

RANK
1,1,3,4

동점이면 같은 등수를 주고 그만큼 건너뜁니다. 실제 순위 개념에 맞습니다.

DENSE_RANK
1,1,2,3

동점에 같은 등수를 주되 건너뛰지 않습니다. 등급 구간을 나눌 때 유용합니다.

그룹별 상위 N건
응용

위 쿼리의 조건을 `rn <= 3` 으로만 바꾸면 고객별 최근 주문 3건이 됩니다. 같은 틀로 확장됩니다.

ROW_NUMBER로 그룹별 최신 1건sql
SELECT customer_id, order_id, order_dt, amount
FROM (
  SELECT customer_id, order_id, order_dt, amount,
         ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY order_dt DESC, order_id DESC
         ) AS rn
  FROM   orders
  WHERE  status_cd = 'DONE'
)
WHERE rn = 1;

ORDER BY에 order_id 를 함께 넣은 이유는 같은 시각의 주문이 있을 때 결과가 매번 달라지는 것을 막기 위해서입니다. 정렬 기준이 유일하지 않으면 실행할 때마다 다른 행이 뽑힐 수 있습니다.

04

행과 행을 비교할 때는 LAG와 LEAD

'전월 대비 증감', '이전 상태에서 무엇으로 바뀌었나' 같은 요구사항은 원래 SQL로 표현하기 까다로웠습니다. 행끼리 비교해야 하기 때문입니다.

LAG는 이전 행, LEAD는 다음 행의 값을 가져옵니다. 자기 자신과 조인할 필요가 없어집니다.

전월 대비 증감 계산sql
SELECT month_ym,
       total_amount,
       LAG(total_amount) OVER (ORDER BY month_ym) AS prev_amount,
       total_amount - LAG(total_amount) OVER (ORDER BY month_ym) AS diff,
       ROUND(
         (total_amount - LAG(total_amount) OVER (ORDER BY month_ym))
         / NULLIF(LAG(total_amount) OVER (ORDER BY month_ym), 0) * 100
       , 1) AS growth_pct
FROM (
  SELECT TO_CHAR(order_dt, 'YYYY-MM') AS month_ym,
         SUM(amount) AS total_amount
  FROM   orders
  WHERE  status_cd = 'DONE'
  GROUP BY TO_CHAR(order_dt, 'YYYY-MM')
)
ORDER BY month_ym;

첫 행은 이전 행이 없어 LAG가 NULL을 반환합니다. 증감률 계산에서 NULLIF로 0 나누기를 막은 것과 함께, 첫 행 처리를 어떻게 보여 줄지 정해야 합니다.

누적 합계sql
SELECT order_dt, amount,
       SUM(amount) OVER (
         ORDER BY order_dt
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM   orders
WHERE  status_cd = 'DONE';

OVER 절에 ORDER BY를 넣으면 처음부터 현재 행까지가 기본 범위가 되어 누적 합계가 됩니다. ROWS BETWEEN 을 명시하면 의도가 분명해집니다.

05

소계와 총계는 ROLLUP으로 한 번에

리포트에서 항목별 합계 아래 소계와 총계를 붙이는 일이 잦습니다. UNION ALL로 각각의 집계를 이어 붙이면 테이블을 여러 번 읽게 됩니다.

ROLLUP을 쓰면 한 번의 집계로 계층적 소계까지 만들어 줍니다.

등급별, 상태별 소계와 총계sql
SELECT NVL(c.grade_cd, '전체')   AS grade_cd,
       NVL(o.status_cd, '소계')  AS status_cd,
       SUM(o.amount)             AS total_amount,
       GROUPING(c.grade_cd)      AS is_grade_total,
       GROUPING(o.status_cd)     AS is_status_total
FROM   orders o
JOIN   customer c ON c.customer_id = o.customer_id
GROUP BY ROLLUP (c.grade_cd, o.status_cd)
ORDER BY c.grade_cd, o.status_cd;

GROUPING 함수는 그 행이 소계 행인지(1) 실제 데이터 행인지(0) 알려 줍니다. 원래 값이 NULL인 경우와 소계라서 NULL인 경우를 구분해야 하므로 NVL만으로는 부족합니다.

트랙 A 마무리

여기까지가 SQL로 요구사항을 정확하게 표현하는 부분입니다. 이제 같은 결과를 얼마나 빠르게 얻을 것인가로 넘어갑니다. 트랙 B는 지금까지 쓴 쿼리들이 실제로 어떻게 실행되는지를 다룹니다.

체크

이 파트 완료 기준