1. 무엇을 뽑나요
결제 완료(paid) 주문만 골라 한국 시간 기준 최근 3개월을 달마다 나누고, 분야별 월 매출(수량 × 한 개 가격의 합)과 주문 수를 3개월 매출이 큰 분야부터 보여 줍니다.
2. SQL
읽기(SELECT)만 하는 쿼리라 그대로 돌려도 데이터는 바뀌지 않습니다. 정보가 모자란 세 곳은 가정을 두고 먼저 썼고, SQL 안에 [확인 필요]로 표시했습니다.
- 「확인 필요 ①」
created_at이 timestamptz(시간대가 붙은 형식)라고 가정했습니다. 아니라면 SQL 아래 「형식이 다르면」대로 바꾸세요.
- 「확인 필요 ②」 「최근 3개월」을 이번 달을 뺀 꽉 찬 3개월로 잡았습니다. 예를 들어 11월 4일에 돌리면 8·9·10월이 나옵니다.
- 「확인 필요 ③: 할인·쿠폰·배송비 금액이나, 주문 중 일부 상품만 환불한 것을 적는 열이 있나요」 없다고 보고
수량 × 한 개 가격의 합을 매출로 썼습니다.
-- PostgreSQL 16 · 최근 3개월 분야별 월 매출과 주문 수 (한국 시간 기준)
WITH period AS ( -- 기간 경계를 한 곳에서 계산
SELECT
(date_trunc('month', now() AT TIME ZONE 'Asia/Seoul') -- 한국 시간 이번 달 1일 0시에서
- interval '3 months') -- 3달을 빼고 [확인 필요 ②]
AT TIME ZONE 'Asia/Seoul' AS start_at, -- 실제 시각으로 바꾼 시작(포함)
date_trunc('month', now() AT TIME ZONE 'Asia/Seoul') -- 한국 시간 이번 달 1일 0시를
AT TIME ZONE 'Asia/Seoul' AS end_at -- 실제 시각으로 바꾼 끝(미포함)
),
sales AS ( -- 주문 상품을 한 줄씩 펼치기
SELECT
o.id AS order_id, -- 주문 수를 셀 주문 번호
date_trunc('month', o.created_at AT TIME ZONE 'Asia/Seoul')::date
AS month_kst, -- 한국 시간 기준 달 [확인 필요 ①]
COALESCE(p.category, '(분야 없음)') AS category, -- 분야가 비면 따로 모음
oi.qty * oi.price AS amount -- 줄 금액 = 수량 × 한 개 가격 [확인 필요 ③]
FROM orders o -- 주문에
CROSS JOIN period pd -- 기간 경계 한 줄을 붙이고
JOIN order_items oi ON oi.order_id = o.id -- 주문 상품을 붙이고
LEFT JOIN products p ON p.id = oi.product_id -- 분야를 붙임(상품이 없어도 줄은 남김)
WHERE o.status = 'paid' -- 결제 완료만(취소·환불 제외)
AND o.created_at >= pd.start_at -- 시작 시각부터
AND o.created_at < pd.end_at -- 끝 시각 전까지
),
monthly AS ( -- 달 × 분야로 묶기
SELECT
month_kst, -- 달
category, -- 분야
SUM(amount) AS revenue, -- 월 매출(원)
COUNT(DISTINCT order_id) AS order_count -- 월 주문 수(같은 주문은 한 번만)
FROM sales -- 펼친 줄에서
GROUP BY month_kst, category -- 달·분야마다 한 줄로
)
SELECT
month_kst, -- 달(그 달 1일)
category, -- 분야
revenue, -- 월 매출
order_count, -- 월 주문 수
SUM(revenue) OVER (PARTITION BY category) AS revenue_3m -- 그 분야의 3개월 합계(정렬 기준)
FROM monthly -- 묶은 결과에서
ORDER BY revenue_3m DESC NULLS LAST, -- 3개월 매출 큰 분야부터
category, -- 합계가 같으면 분야 이름 순
month_kst; -- 분야 안에서는 달 순
「확인 필요 ①」 created_at 형식이 다르면
timestamp without time zone(시간대 없는 형식)이면 위 SQL을 그대로 돌릴 때 달 경계가 9시간 어긋날 수 있습니다. period의 두 경계와 month_kst 한 줄을 바꿉니다.
UTC로 저장돼 있다면 (Prisma, Rails 기본 설정이 이쪽입니다): 두 경계 끝에 AT TIME ZONE 'UTC'를 하나 더 붙이고, 달 계산은 UTC로 읽은 뒤 한국 시간으로 바꿉니다.
-- period 안: 두 경계 끝에 AT TIME ZONE 'UTC' 를 하나 더
(date_trunc('month', now() AT TIME ZONE 'Asia/Seoul')
- interval '3 months')
AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC' AS start_at, -- UTC 시계 값으로
date_trunc('month', now() AT TIME ZONE 'Asia/Seoul')
AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC' AS end_at -- UTC 시계 값으로
-- sales 안: UTC로 읽은 뒤 한국 시간으로
date_trunc('month', (o.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Seoul')::date
AS month_kst,
한국 시간으로 저장돼 있다면: 두 경계에서 바깥쪽 AT TIME ZONE 'Asia/Seoul'을 지우고, 달 계산은 그대로 자릅니다.
-- period 안: 바깥쪽 AT TIME ZONE 'Asia/Seoul' 을 지움
(date_trunc('month', now() AT TIME ZONE 'Asia/Seoul')
- interval '3 months') AS start_at,
date_trunc('month', now() AT TIME ZONE 'Asia/Seoul') AS end_at
-- sales 안: 이미 한국 시간이라 그대로 자름
date_trunc('month', o.created_at)::date AS month_kst,
「확인 필요 ②」 이번 달도 넣고 싶다면
9·10월에 11월(오늘까지)을 더하려면 period를 이렇게 바꿉니다. 위 ①을 함께 적용할 때도 같은 규칙대로 붙이거나 지우면 됩니다.
-- period 안
(date_trunc('month', now() AT TIME ZONE 'Asia/Seoul')
- interval '2 months') -- 2달 전 1일 0시부터
AT TIME ZONE 'Asia/Seoul' AS start_at,
(date_trunc('month', now() AT TIME ZONE 'Asia/Seoul')
+ interval '1 month') -- 다음 달 1일 0시 전까지
AT TIME ZONE 'Asia/Seoul' AS end_at
이번 달은 아직 덜 찬 달이라 숫자가 작게 나옵니다. 「오늘에서 딱 3개월 전부터」로 자르면 첫 달이 잘려서 월별 비교가 어긋나니 권하지 않습니다.
3. 한 줄씩 풀이
period: 한국 시간으로 「이번 달 1일 0시」를 구해 3달을 빼고, 다시 실제 시각으로 바꿔 시작·끝 두 값을 만듭니다. AT TIME ZONE 'Asia/Seoul'이 두 번 나오는데, 안쪽은 실제 시각을 한국 달력의 날짜·시각으로, 바깥쪽은 한국 달력의 날짜·시각을 실제 시각으로 바꿉니다. 그래서 DB 서버 시간대가 UTC여도 결과가 같습니다.
month_kst: 주문 시각을 한국 시간으로 바꾼 뒤 그 달 1일로 자르고, ::date로 시각을 떼어 2026-08-01 같은 날짜만 남깁니다.
COALESCE(...)와 LEFT JOIN products: 상품이 지워졌거나 분야가 빈 줄도 버리지 않고 「(분야 없음)」으로 모읍니다. 그래서 분야별 매출을 다 더하면 전체 매출과 맞습니다.
oi.qty * oi.price: price가 한 개 가격이라 수량을 곱해야 그 줄의 금액이 됩니다.
CROSS JOIN period pd: 경계값이 든 한 줄짜리 표를 모든 주문 옆에 붙여 pd.start_at처럼 쓰게 합니다. 한 줄이라 줄 수는 늘지 않습니다.
status = 'paid': 뺄 것(cancelled, refunded)을 나열하지 않고 넣을 것만 적었습니다. 나중에 새 상태(예: 결제 대기)가 생겨도 매출에 섞이지 않고, status가 NULL인 줄도 저절로 빠집니다.
created_at >= start_at AND created_at < end_at: 시작은 넣고 끝은 빼서 1일 0시 정각 주문이 두 달에 겹치지 않게 합니다. 열을 함수로 감싸지 않고 그대로 비교해야 인덱스를 탑니다(5번). 달 계산은 걸러 낸 뒤 SELECT에서 합니다.
GROUP BY month_kst, category와 SUM(amount): 달과 분야가 같은 줄을 한 줄로 묶고 금액을 더합니다.
COUNT(DISTINCT order_id): 한 주문 안에 같은 분야 상품이 여러 줄 있어도 주문 하나로 셉니다.
SUM(revenue) OVER (PARTITION BY category): 줄 수는 그대로 두고 각 줄 옆에 그 분야의 3개월 합계를 붙입니다.
ORDER BY: 그 합계가 큰 분야부터, 합계가 같으면 분야 이름 순, 분야 안에서는 달 순으로 나옵니다. 「달마다 그 달 매출 큰 분야부터」로 보고 싶으면 마지막 줄을 ORDER BY month_kst, revenue DESC NULLS LAST;로 바꾸세요.
결과를 틀리게 만드는 함정
- 시간대: 세션 시간대가 UTC이면
created_at::date나 date_trunc('month', created_at)은 UTC 기준으로 잘려, 한국 시간 1일 0시~9시 주문이 전달로 넘어갑니다. 그래서 'Asia/Seoul'을 직접 적었습니다. '+09'처럼 숫자로 쓰면 PostgreSQL이 부호를 거꾸로(UTC-9로) 읽으니 꼭 이름으로 쓰세요.
- 기간 끝:
BETWEEN '2026-08-01' AND '2026-10-31'로 쓰면 31일 0시 정각까지만 들어가 마지막 날이 거의 통째로 빠집니다. 위처럼 「다음 달 1일 0시 전까지」로 자르세요.
- 조인으로 줄이 불어남: 주문 한 줄에 상품 여러 줄이 붙습니다. 같은 분야 상품이 3개인 주문을
COUNT(*)로 세면 3건이 됩니다. 매출은 상품 줄 금액을 더하니 괜찮지만, 나중에 주문 단위 금액(예: 배송비)을 상품 줄에 붙여 더하면 상품 수만큼 불어납니다.
- 분야별 주문 수의 합은 전체 주문 수가 아닙니다: 가전과 식품을 함께 산 주문은 두 분야에서 한 번씩 셉니다. 달마다 전체 주문 수가 필요하면 분야 없이 따로 세야 합니다.
- NULL: qty나 price가 NULL인 줄은 금액도 NULL이 되고,
SUM은 그 줄을 0으로도 세지 않고 조용히 건너뜁니다. 또 PostgreSQL은 내림차순 정렬에서 NULL을 맨 위에 두므로 NULLS LAST를 붙였습니다. 그런 줄이 있는지는 6번 세 번째로 확인하세요.
- 취소·환불: status가 주문 단위라 일부 상품만 환불한 경우는 가려낼 수 없습니다(확인 필요 ③). 또 환불된 주문은 환불한 달이 아니라 주문한 달에서 빠집니다. 같은 쿼리를 다음 달에 다시 돌리면 지난달 숫자가 줄어들 수 있습니다.
- 팔린 게 없는 달: 0원 줄이 나오는 게 아니라 줄 자체가 없습니다. 그래프로 그릴 때는 빈 달을 0으로 채워야 합니다.
4. 결과 모양
아래 숫자는 가상의 값입니다(11월에 돌렸다고 가정).
| month_kst | category | revenue | order_count | revenue_3m |
|---|
| 2026-08-01 | 가전 | 52300000 | 412 | 161250000 |
| 2026-09-01 | 가전 | 47800000 | 380 | 161250000 |
| 2026-10-01 | 가전 | 61150000 | 455 | 161250000 |
| … | (다음 분야) | … | … | (가전보다 작은 값) |
month_kst: 한국 시간 기준 달입니다. 그 달 1일 날짜로 나옵니다. 2026-08처럼 보고 싶으면 to_char(month_kst, 'YYYY-MM')로 바꾸세요.
category: 분야입니다. 상품이 없거나 분야가 비면 (분야 없음)으로 나옵니다.
revenue: 그 달 매출(원)입니다.
order_count: 그 달 그 분야 상품이 들어간 주문 수입니다.
revenue_3m: 그 분야의 3개월 매출 합계입니다. 정렬 기준이라 같은 분야 줄에는 같은 값이 반복됩니다.
5. 빨라지게 하려면
orders가 200만 줄이면 인덱스를 걸어 둘 만합니다. 먼저 이미 있는지 봅니다.
SELECT tablename, indexname, indexdef -- 지금 걸린 인덱스 보기
FROM pg_indexes
WHERE tablename IN ('orders', 'order_items', 'products');
없는 것만 만듭니다.
-- 기간으로 주문 고르기
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
-- 주문에 주문 상품 붙이기(PostgreSQL은 외래 키 열에 인덱스를 자동으로 만들지 않습니다)
CREATE INDEX CONCURRENTLY idx_order_items_order_id
ON order_items (order_id);
products.id가 기본 키라면 이미 인덱스가 있습니다.
- 취소·환불 주문 비중이 크면 첫 번째 대신 결제 완료 줄만 담는
ON orders (created_at) WHERE status = 'paid'(부분 인덱스)가 더 작습니다.
CONCURRENTLY는 만드는 동안 주문 쓰기를 막지 않습니다. 대신 BEGIN … COMMIT 안에서는 돌릴 수 없고, 도중에 실패하면 쓸 수 없는(INVALID) 인덱스가 남으니 지우고 다시 만드세요.
- 되돌리기:
DROP INDEX CONCURRENTLY idx_orders_created_at;처럼 지우면 됩니다. 인덱스는 속도만 바꾸고 데이터는 건드리지 않아서, 지워도 결과는 같습니다.
- 효과 확인: 쿼리 맨 앞에
EXPLAIN (ANALYZE, BUFFERS)를 붙여 돌리면 걸린 시간과 인덱스를 탔는지(Index Scan, Bitmap Index Scan)가 보입니다. 3개월이 전체 데이터의 큰 몫이면 PostgreSQL이 표를 통째로 읽는 쪽을 고르기도 하는데, 그쪽이 더 빠르다고 판단한 것이라 정상입니다.
6. 직접 확인할 것
1. created_at 형식 (확인 필요 ①)
SELECT table_schema, data_type -- created_at 형식 보기
FROM information_schema.columns
WHERE table_name = 'orders'
AND column_name = 'created_at';
timestamp with time zone이면 그대로 쓰면 됩니다. timestamp without time zone이면 방금 들어온 주문 하나의 created_at을 실제 주문 시각(한국 시간)과 견줘 보세요. 9시간 이르게 적혀 있으면 「UTC로 저장돼 있다면」을, 같으면 「한국 시간으로 저장돼 있다면」을 쓰세요.
2. status 값이 정말 세 가지뿐인지
SELECT status, COUNT(*) AS cnt -- 상태별 주문 수
FROM orders
GROUP BY status
ORDER BY cnt DESC;
paid, cancelled, refunded 세 줄만 나와야 합니다. PAID, paid (뒤에 공백), NULL 같은 줄이 보이면 그 주문은 = 'paid'에서 빠지고 있는 것입니다.
3. 매출에서 조용히 빠지거나 「(분야 없음)」으로 가는 줄
SELECT
COUNT(*) FILTER (WHERE oi.qty IS NULL OR oi.price IS NULL) AS null_amount, -- SUM 에서 빠지는 줄
COUNT(*) FILTER (WHERE p.id IS NULL) AS no_product, -- 상품이 지워진 줄
COUNT(*) FILTER (WHERE p.id IS NOT NULL AND p.category IS NULL) AS no_category -- 분야가 빈 상품의 줄
FROM order_items oi -- 주문 상품 전체에서
LEFT JOIN products p ON p.id = oi.product_id; -- 상품을 붙여 봄
셋 다 0이면 안심하셔도 됩니다. 0이 아니면 그 줄을 매출에 어떻게 셀지 먼저 정해야 합니다. 주문 상품 표 전체를 한 번 훑으니 몇 초 걸릴 수 있습니다.
created_at 형식(6번 첫 번째 결과), 「최근 3개월」에 이번 달을 넣을지, 할인·부분 환불을 적는 열이 있는지 알려 주시면 「확인 필요」를 지운 최종본으로 다듬어 드릴게요.