PostgreSQL 기초 강좌 #3 조인과 집계: 읽기 쿼리의 실무 감각
테이블을 만들었으니(2편) 이제 읽을 차례입니다. 실무 읽기 쿼리의 8할은 결국 조인과 집계의 조합이고, 이 조합에서 나는 사고도 정형화되어 있습니다. 이번 편은 문법 나열 대신 그 정형 사고들을 중심으로, users와 orders 두 테이블로 실무 감각을 다지겠습니다.
조인: INNER가 기본, LEFT는 “없어도 나와야 할 때” #
-- 주문이 있는 사용자만: INNER JOIN
SELECT u.name, o.amount_krw
FROM users u
JOIN orders o ON o.user_id = u.id;
-- 주문이 없는 사용자도: LEFT JOIN (없는 쪽은 NULL)
SELECT u.name, o.amount_krw
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;선택 기준은 질문의 형태입니다. “주문한 사용자들의 …“이면 INNER, “모든 사용자의 …(주문이 없으면 없음으로)“이면 LEFT입니다. 그리고 여기서 가장 흔한 사고가 나옵니다. LEFT JOIN을 해 놓고 오른쪽 테이블 조건을 WHERE에 쓰면, NULL 행이 걸러져 INNER JOIN과 같아집니다.
-- 의도: 모든 사용자 + 12월 주문. 실제: 12월 주문이 있는 사용자만 (LEFT가 무력화)
... LEFT JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2026-12-01';
-- 올바른 형태: 오른쪽 테이블 조건은 ON 절로
... LEFT JOIN orders o
ON o.user_id = u.id
AND o.created_at >= '2026-12-01';LEFT JOIN에서 오른쪽 테이블의 필터는 ON에, 왼쪽 테이블의 필터는 WHERE에라는 규칙 하나로 이 계열의 사고가 사라집니다.
집계: GROUP BY와 HAVING의 역할 #
SELECT o.user_id, count(*) AS order_count, sum(o.amount_krw) AS total_krw
FROM orders o
WHERE o.status = 'paid' -- 행 단위 필터: 집계 전에
GROUP BY o.user_id
HAVING sum(o.amount_krw) >= 100000; -- 그룹 단위 필터: 집계 후에WHERE는 집계 전에 행을 거르고, HAVING은 집계 후에 그룹을 거릅니다. “결제 완료 주문만 세되, 합계 10만 원 이상인 사용자만"이라는 문장이 그대로 두 필터로 나뉩니다. 참고로 count(*)는 행 수, count(o.column)은 NULL 제외 수라서, LEFT 조인 결과를 셀 때는 이 차이가 “주문 0건이 0으로 나오느냐"를 가릅니다.
조인 + 집계의 함정: 중복 집계 #
한 사용자에게 주문 3건과 리뷰 2건이 있을 때, users에 orders와 reviews를 동시에 조인하면 행이 3×2 = 6개로 불어나고, 그 위에서 sum(o.amount_krw)을 하면 금액이 2배로 집계됩니다. 다대다 조인 뒤의 집계는 언제나 이 위험이 있습니다. 정석은 각 집계를 서브쿼리(또는 다음 편의 CTE)로 먼저 끝내고 1:1로 조인하는 것입니다.
SELECT u.name, coalesce(os.total_krw, 0) AS total_krw, coalesce(rs.review_count, 0) AS review_count
FROM users u
LEFT JOIN (SELECT user_id, sum(amount_krw) AS total_krw FROM orders GROUP BY user_id) os
ON os.user_id = u.id
LEFT JOIN (SELECT user_id, count(*) AS review_count FROM reviews GROUP BY user_id) rs
ON rs.user_id = u.id;“합계가 이상하게 크다"는 버그 리포트의 상당수가 이 패턴 하나로 설명됩니다.
그룹별 최신 1건: DISTINCT ON #
“사용자별 가장 최근 주문"은 실무 단골 요구인데, 표준 SQL로는 의외로 번거롭습니다. PostgreSQL에는 전용 도구가 있습니다.
SELECT DISTINCT ON (user_id) user_id, id, amount_krw, created_at
FROM orders
ORDER BY user_id, created_at DESC;DISTINCT ON (user_id)는 user_id별로 첫 행만 남기고, ORDER BY가 그 “첫 행"을 정의합니다(사용자별로 created_at 내림차순의 첫 번째, 즉 최신). 같은 일을 하는 표준 도구인 윈도 함수 ROW_NUMBER()는 8편에서 다루며, 상위 N건(최신 3건 등)이 필요하면 그쪽이 답입니다.
서브쿼리와 조인 중 무엇을 쓸지는 대개 가독성의 문제이고, PostgreSQL의 플래너가 동등한 형태로 알아서 변환하는 경우도 많습니다. “어느 쪽이 빠른가"는 추측하지 않고 실행 계획으로 확인하는 것이 정답인데, 그 도구인 EXPLAIN이 5편의 주제입니다.
정리 #
- INNER는 “양쪽에 있는 것만”, LEFT는 “왼쪽은 전부"입니다. 질문의 문장 형태가 곧 선택 기준입니다.
- LEFT JOIN의 오른쪽 테이블 조건은 ON에 씁니다. WHERE에 쓰면 INNER로 무력화되는 것이 이 편 최대의 함정입니다.
- WHERE는 집계 전 행 필터, HAVING은 집계 후 그룹 필터입니다. count(*)와 count(컬럼)의 NULL 차이도 기억해 두세요.
- 여러 자식 테이블을 조인한 뒤의 집계는 중복 집계의 지뢰밭입니다. 집계를 서브쿼리로 먼저 끝내고 1:1로 조인합니다.
- 그룹별 최신 1건은 DISTINCT ON이 PostgreSQL다운 답입니다. 상위 N건은 8편의 윈도 함수에서 이어집니다.