Skip to main content
Summary

원본 장부를 보고서로 바꾸는 생각

주문 테이블에 1만 건의 주문이 있다고 생각해 봅시다. 원본 데이터는 한 줄에 주문 한 건씩 들어 있습니다. 이 상태로는 "이번 달 매출이 얼마인가?", "지역별 고객 수는 몇 명인가?", "가장 많이 팔린 상품은 무엇인가?" 같은 질문에 바로 답하기 어렵습니다.

엑셀이라면 피벗 테이블을 만들거나 SUM, AVERAGE, COUNT 함수를 사용합니다. PostgreSQL에서도 같은 일을 할 수 있습니다. 핵심은 두 가지입니다.

첫째, 여러 행을 하나의 값으로 요약하는 집계 함수를 사용합니다.
둘째, 월별, 지역별, 상품별처럼 기준을 정해 묶는 GROUP BY를 사용합니다.

이 장에서는 원본 데이터를 하나하나 세지 않고, SQL로 업무 보고서에 필요한 요약표를 만드는 방법을 익힙니다.

합계와 평균으로 업무 보고서 만들기AI 생성 이미지

집계 함수: 여러 줄을 하나의 값으로 요약하기

집계 함수는 여러 행을 읽어서 하나의 결과값을 만드는 함수입니다. PostgreSQL의 대표적인 집계 함수에는 COUNT, SUM, AVG, MIN, MAX가 있습니다.1

함수뜻업무 예시
COUNT(*)행 개수 세기주문이 몇 건인지 세기
COUNT(컬럼)해당 컬럼에 값이 있는 행만 세기전화번호가 입력된 고객 수 세기
SUM(컬럼)합계 구하기총매출 구하기
AVG(컬럼)평균 구하기평균 주문금액 구하기
MIN(컬럼)가장 작은 값 구하기가장 낮은 주문금액 구하기
MAX(컬럼)가장 큰 값 구하기가장 높은 주문금액 구하기

여기서 COUNT(*)와 COUNT(컬럼)은 다릅니다. COUNT(*)는 행 자체를 셉니다. 반면 COUNT(phone)처럼 컬럼을 넣으면 phone 값이 NULL이 아닌 행만 셉니다. 쉽게 말해 "전화번호 칸에 값이 있는 고객만 세겠다"는 뜻입니다. 다만 빈 문자열처럼 겉보기에는 비어 보여도 데이터베이스 안에서는 NULL이 아닌 값으로 저장된 경우가 있을 수 있습니다. 처음에는 이 정도만 기억해도 충분합니다.

PostgreSQL에서 SUM, AVG, MIN, MAX도 NULL, 즉 값이 없는 칸은 계산에서 제외합니다.1 그래서 집계 결과를 읽을 때는 "0"과 "값이 없음"을 구분해서 봐야 합니다.

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

결제 완료된 주문만 대상으로 전체 주문 수, 총매출, 평균 주문금액, 가장 작은 주문금액, 가장 큰 주문금액을 보고 싶다.

SELECT
    COUNT(*) AS order_count,
    SUM(total_amount) AS total_sales,
    AVG(total_amount) AS average_order_amount,
    MIN(total_amount) AS smallest_order_amount,
    MAX(total_amount) AS largest_order_amount
FROM orders
WHERE order_status = '결제완료';

예상 결과 모양은 다음과 같습니다.

order_counttotal_salesaverage_order_amountsmallest_order_amountlargest_order_amount
12818450000144140.6212000980000

이 결과는 주문 128건의 원본 장부를 한 줄짜리 요약표로 바꾼 것입니다.

WHERE order_status = '결제완료'는 계산 전에 원본 행을 먼저 걸러냅니다. 취소 주문이나 결제 대기 주문을 제외하고 매출을 계산하려는 의도입니다. 보고서에서는 이 조건이 매우 중요합니다. "매출"이라고 말할 때 취소 주문을 포함할지 제외할지에 따라 숫자가 달라지기 때문입니다.

GROUP BY: 기준별 요약표 만들기

집계 함수만 쓰면 전체를 한 번에 요약합니다. 하지만 실무 보고서는 보통 기준이 있습니다.

  • 월별 매출

  • 지역별 고객 수

  • 상품별 판매량

  • 카테고리별 평균 주문금액

이때 사용하는 것이 GROUP BY입니다. 엑셀 피벗 테이블에서 "행" 영역에 지역이나 월을 넣는 것과 비슷합니다. SQL에서는 "이 컬럼을 기준으로 묶어서 계산해 달라"는 뜻으로 GROUP BY를 씁니다.

지역별 고객 수 구하기

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

고객 테이블에서 지역별로 고객이 몇 명인지 세고, 고객 수가 많은 지역부터 보고 싶다.

SELECT
    region,
    COUNT(*) AS customer_count
FROM customers
GROUP BY region
ORDER BY customer_count DESC;

예상 결과 모양은 다음과 같습니다.

regioncustomer_count
서울340
경기215
부산88
대전41

읽는 순서는 이렇습니다.

  1. FROM customers : 고객 장부를 연다.

  2. GROUP BY region : 지역별로 고객을 묶는다.

  3. COUNT(*) : 각 지역 묶음 안에 고객이 몇 명인지 센다.

  4. ORDER BY customer_count DESC : 고객 수가 많은 순서로 정렬한다.

GROUP BY를 쓸 때 중요한 규칙이 있습니다. SELECT에 쓰는 컬럼은 보통 두 종류여야 합니다.

  • GROUP BY에 적은 기준 컬럼

  • COUNT, SUM, AVG 같은 집계 함수로 계산한 값

예를 들어 지역별 고객 수를 구하면서 customer_name을 그냥 같이 보여 달라고 하면 문제가 생깁니다. 서울 그룹 안에는 고객 이름이 수백 개 있을 수 있습니다. PostgreSQL 입장에서는 그중 어떤 이름을 한 칸에 보여 줘야 할지 알 수 없습니다. 그래서 요약표에서는 "묶는 기준"과 "계산 결과"를 구분해야 합니다.

월별 매출 보고서 만들기

이번에는 주문 테이블로 월별 매출을 만들어 보겠습니다. 날짜를 월 단위로 묶기 위해 date_trunc 함수를 사용합니다. date_trunc('month', order_date)는 주문일을 월의 시작 시점으로 잘라서 같은 달 주문끼리 묶기 쉽게 만듭니다.

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

결제 완료 주문을 월별로 묶어서 주문 건수와 매출 합계를 보고 싶다.

SELECT
    date_trunc('month', order_date)::date AS order_month,
    COUNT(*) AS order_count,
    SUM(total_amount) AS monthly_sales
FROM orders
WHERE order_status = '결제완료'
GROUP BY date_trunc('month', order_date)::date
ORDER BY order_month;

예상 결과 모양은 다음과 같습니다.

order_monthorder_countmonthly_sales
2026-01-01425820000
2026-02-01384970000
2026-03-01517650000

여기서 2026-01-01은 "1월 1일 매출"이라는 뜻이 아닙니다. 1월 전체를 대표하는 표시로 보면 됩니다. 보고서 화면에서는 이 값을 2026-01처럼 바꿔 보여 줄 수도 있습니다. 지금은 월별로 묶는 원리를 이해하는 것이 더 중요합니다.

이 SQL에서 가장 중요한 부분은 다음 두 줄입니다.

SELECT date_trunc('month', order_date)::date AS order_month
GROUP BY date_trunc('month', order_date)::date

월별로 보여 주려면, 월별로 묶어야 합니다. SELECT에서 월을 보여 주고, GROUP BY에서도 같은 기준으로 묶습니다.

상품별 판매량과 매출 구하기

상품별 보고서는 보통 주문 상세 테이블과 상품 테이블을 함께 봐야 합니다. 주문 상세 테이블에는 상품 ID와 수량이 있고, 상품 테이블에는 상품명이 있습니다. 앞 장에서 배운 JOIN을 이용해 두 테이블을 연결합니다.

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

주문 상세와 상품 정보를 연결해서 상품별 판매 수량과 판매금액을 구하고, 많이 팔린 상품부터 보고 싶다.

SELECT
    p.product_name,
    SUM(oi.quantity) AS total_quantity,
    SUM(oi.quantity * oi.unit_price) AS sales_amount
FROM order_items oi
JOIN products p
    ON oi.product_id = p.product_id
GROUP BY p.product_id, p.product_name
ORDER BY total_quantity DESC;

예상 결과 모양은 다음과 같습니다.

product_nametotal_quantitysales_amount
무선 마우스3206400000
USB-C 케이블2801960000
노트북 거치대1454350000

SUM(oi.quantity)는 판매된 수량을 모두 더합니다.
SUM(oi.quantity * oi.unit_price)는 각 주문 줄의 수량 × 단가를 계산한 뒤 모두 더합니다.

이 예시는 주문 상세 테이블에 들어 있는 줄을 그대로 합산합니다. 실제 매출 보고서에서 취소 주문이나 결제 대기 주문을 빼야 한다면, orders 테이블까지 함께 연결한 뒤 WHERE order_status = '결제완료' 같은 조건을 추가해야 합니다. 보고서 숫자는 SQL 함수만으로 정해지는 것이 아니라, 어떤 업무 기준을 적용했는지에 따라 달라집니다.

GROUP BY p.product_id, p.product_name처럼 상품 ID와 상품명을 함께 묶은 이유도 있습니다. 상품명은 우연히 같을 수 있습니다. 같은 이름의 상품이 나중에 생길 수도 있습니다. 그래서 실제 업무에서는 상품을 구분하는 내부 번호인 product_id까지 함께 기준으로 넣는 습관이 안전합니다.

WHERE와 HAVING의 차이

집계 보고서를 만들 때 가장 헷갈리는 부분이 WHERE와 HAVING입니다.

간단히 말하면 이렇습니다.

  • WHERE는 원본 행을 먼저 거릅니다.

  • HAVING은 GROUP BY로 묶고 계산한 그룹 결과를 거릅니다.

엑셀로 비유하면 WHERE는 원본 장부에서 "결제완료 행만 남기기"입니다. HAVING은 피벗 테이블을 만든 뒤 "매출 합계가 500만 원 이상인 월만 남기기"입니다.

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

2026년 결제 완료 주문 중에서, 월 매출이 500만 원 이상인 달만 보고 싶다.

SELECT
    date_trunc('month', order_date)::date AS order_month,
    SUM(total_amount) AS monthly_sales
FROM orders
WHERE order_status = '결제완료'
  AND order_date >= DATE '2026-01-01'
  AND order_date < DATE '2027-01-01'
GROUP BY date_trunc('month', order_date)::date
HAVING SUM(total_amount) >= 5000000
ORDER BY order_month;

예상 결과 모양은 다음과 같습니다.

order_monthmonthly_sales
2026-01-015820000
2026-03-017650000
2026-05-016120000

이 SQL은 두 번 거릅니다.

먼저 WHERE에서 원본 주문 중 결제 완료이고 2026년에 해당하는 행만 남깁니다. 그다음 월별로 묶어 매출을 계산합니다. 마지막으로 HAVING에서 월 매출 합계가 500만 원 이상인 그룹만 남깁니다.

HAVING SUM(total_amount) >= 5000000처럼 집계 결과에 조건을 걸 때는 SUM, COUNT, AVG 같은 집계 함수를 다시 사용합니다. monthly_sales라는 별칭을 이미 만들었더라도, PostgreSQL의 HAVING에서는 계산식을 그대로 쓰는 편이 안전합니다.

빈 값과 0을 구분하기

집계 결과를 읽을 때는 빈 값도 조심해야 합니다. PostgreSQL에서 SUM은 계산할 행이 하나도 없으면 0이 아니라 NULL을 돌려줍니다.1 계산할 행은 있더라도 더하려는 컬럼 값이 모두 NULL이면 결과 역시 NULL이 될 수 있습니다. NULL은 "계산 결과가 0원"이라는 뜻이 아니라 "계산할 값이 없다"는 뜻입니다.

보고서에서는 빈 값을 0으로 표시하고 싶을 때가 많습니다. 이때는 COALESCE를 사용할 수 있습니다. COALESCE는 앞의 값이 비어 있으면 뒤의 값을 대신 보여 주는 함수입니다.

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

조건에 맞는 매출 합계가 비어 있으면 0으로 표시하고 싶다.

SELECT
    COALESCE(SUM(total_amount), 0) AS total_sales
FROM orders
WHERE order_status = '결제완료'
  AND order_date >= DATE '2030-01-01'
  AND order_date < DATE '2031-01-01';

결과가 0으로 나오더라도, 실제로 2030년에 매출이 0원이었는지 아니면 아직 데이터가 없는 기간인지 업무적으로 확인해야 합니다. SQL은 계산을 도와주지만, 숫자의 의미를 결정하는 것은 업무 기준입니다.

선택 심화: 소계와 총계를 한 번에 만들기

기본 보고서는 GROUP BY만으로 충분합니다. 하지만 월별·지역별 매출을 만들다 보면 소계와 총계가 필요할 때가 있습니다.

예를 들어 이런 보고서입니다.

월지역매출
2026-01서울3000000
2026-01부산1200000
2026-01소계4200000
2026-02서울2800000
2026-02부산1500000
2026-02소계4300000
전체소계8500000

PostgreSQL에는 이런 소계와 총계를 한 번에 만들 수 있는 ROLLUP, GROUPING SETS 같은 기능이 있습니다. ROLLUP은 단계별 소계와 전체 합계를 만들 때, GROUPING SETS는 필요한 요약 기준을 직접 고를 때 사용합니다.2

무엇을 하려는 문장인지 먼저 풀어 보겠습니다.

결제 완료 주문을 월별·지역별로 합산하고, 월별 소계와 전체 합계도 함께 보고 싶다.

SELECT
    CASE
        WHEN GROUPING(date_trunc('month', o.order_date)) = 1 THEN '전체'
        ELSE to_char(date_trunc('month', o.order_date), 'YYYY-MM')
    END AS order_month,
    CASE
        WHEN GROUPING(c.region) = 1 THEN '소계'
        ELSE c.region
    END AS region,
    SUM(o.total_amount) AS sales_amount
FROM orders o
JOIN customers c
    ON o.customer_id = c.customer_id
WHERE o.order_status = '결제완료'
GROUP BY ROLLUP (date_trunc('month', o.order_date), c.region)
ORDER BY date_trunc('month', o.order_date), c.region;

이 SQL은 처음부터 외울 필요가 없습니다. 지금은 개념만 잡아도 충분합니다.

  • GROUP BY date_trunc('month', o.order_date), c.region은 월별·지역별 상세 합계를 만듭니다.

  • ROLLUP은 여기에 월별 소계와 전체 합계를 덧붙입니다.

  • GROUPING은 이 행이 일반 행인지, 소계나 총계 행인지 구분하는 데 사용합니다.

처음 보고서를 만들 때는 일반 GROUP BY로 시작하세요. 그다음 "소계도 같이 필요하다"는 요구가 생기면 ROLLUP을 검토하면 됩니다.

보고서 SQL을 만들 때의 순서

집계 SQL은 복잡해 보이지만, 다음 순서로 생각하면 어렵지 않습니다.

  1. 무엇을 계산할지 정합니다.
    예: 주문 수, 매출 합계, 평균 주문금액

  2. 어떤 원본 테이블을 볼지 정합니다.
    예: orders, customers, order_items

  3. 계산 전에 제외할 원본 행을 정합니다.
    예: 결제 완료 주문만, 2026년 주문만

  4. 어떤 기준으로 묶을지 정합니다.
    예: 월별, 지역별, 상품별

  5. 묶은 뒤 남길 그룹 조건을 정합니다.
    예: 매출 500만 원 이상인 월만

이 순서를 SQL 구조로 바꾸면 다음과 같습니다.

SELECT   보여 줄 기준, 계산 결과
FROM     원본 테이블
WHERE    계산 전에 남길 행 조건
GROUP BY 묶을 기준
HAVING   계산 후 남길 그룹 조건
ORDER BY 정렬 기준;

보고서 SQL의 핵심은 원본 장부를 직접 세지 않는 것입니다. 데이터가 100건이든 100만 건이든, 질문을 잘게 나누면 PostgreSQL이 개수, 합계, 평균을 계산해 줍니다. 이제 우리는 원본 목록을 보는 사람에서, 업무 질문에 맞는 요약표를 만드는 사람으로 한 단계 올라섰습니다.

참고

1PostgreSQL: Documentation: 18: 9.21. Aggregate Functions

2How to Use Grouping Sets, Cube, and Rollup in PostgreSQL

책 목록

비개발자를 위한 PostgreSQL 입문: 업무 데이터를 안전하게 다루는 가장 쉬운 방법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기