조인은 나뉜 데이터를 다시 읽는 방법이다
관계형 데이터베이스는 데이터를 일부러 나누어 저장한다. 고객 정보는 customers, 상품 정보는 products, 주문의 머리 정보는 orders에 둔다. 한 주문에 들어간 상품 목록은 별도의 주문 상세 테이블에 둔다. 이렇게 나누면 고객 이름이 바뀌어도 주문마다 고객 이름을 모두 수정할 필요가 없다. 상품 가격, 재고, 주문 상태도 각자의 위치에서 관리할 수 있다.
하지만 화면에는 보통 나뉜 데이터가 함께 필요하다.
주문 내역 화면: 주문 번호, 고객 이름, 주문 시각, 주문 상태
주문 상세 화면: 주문 번호, 상품명, 수량, 주문 당시 단가
고객 관리 화면: 고객 정보와 최근 주문일
상품 관리 화면: 상품별 판매 여부와 판매 수량
이때 사용하는 것이 조인이다. PostgreSQL 문서에서는 조인을 두 테이블의 행을 조건에 따라 짝지어 만든 테이블 표현식으로 설명한다1. 핵심은 "어떤 열과 어떤 열이 같은 행을 뜻하는가"를 ON 조건에 명확히 쓰는 것이다.
앞 장의 테이블에 주문 상세 테이블을 하나 추가하자.
CREATE TABLE IF NOT EXISTS order_items (
order_item_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(order_id),
line_no integer NOT NULL,
product_id bigint NOT NULL REFERENCES products(product_id),
quantity integer NOT NULL,
unit_price numeric(12, 2) NOT NULL,
line_amount numeric(12, 2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
CONSTRAINT order_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT order_items_unit_price_non_negative CHECK (unit_price >= 0),
CONSTRAINT order_items_order_line_unique UNIQUE (order_id, line_no)
);orders는 주문 한 건의 대표 정보다. order_items는 그 주문에 담긴 상품 목록이다. 한 주문에 상품이 세 개 들어가면 orders에는 한 행, order_items에는 세 행이 생긴다. 이것이 조인을 이해할 때 가장 중요한 감각이다. 조인을 하면 "주문 한 건"이 아니라 "주문 상품 한 줄" 단위로 결과가 늘어날 수 있다.
AI 생성 이미지
INNER JOIN으로 주문 내역 만들기
INNER JOIN은 양쪽 테이블에서 조건에 맞는 행만 남긴다. PostgreSQL에서 JOIN이라고만 쓰면 보통 INNER JOIN과 같은 뜻으로 동작한다. 주문과 고객을 연결하려면 orders.customer_id와 customers.customer_id를 비교한다.
SELECT
o.order_no,
c.name AS customer_name,
c.email,
o.status,
o.ordered_at,
o.total_amount
FROM orders AS o
JOIN customers AS c
ON c.customer_id = o.customer_id
ORDER BY o.ordered_at DESC;여기서 o와 c는 테이블 별칭이다. 조인 쿼리에서는 같은 이름의 열이 자주 등장한다. 예를 들어 여러 테이블에 created_at, name, status 같은 열이 있을 수 있다. 그래서 o.status, c.name처럼 어느 테이블의 열인지 붙이는 습관이 좋다.
주문 상세 화면은 네 테이블을 연결한다.
SELECT
o.order_no,
c.name AS customer_name,
o.status,
o.ordered_at,
oi.line_no,
p.sku,
p.name AS product_name,
oi.quantity,
oi.unit_price,
oi.line_amount
FROM orders AS o
JOIN customers AS c
ON c.customer_id = o.customer_id
JOIN order_items AS oi
ON oi.order_id = o.order_id
JOIN products AS p
ON p.product_id = oi.product_id
WHERE o.order_no = 'ORD-20260530-101'
ORDER BY oi.line_no;읽는 순서는 다음과 같다.
orders에서 주문을 읽는다.주문의
customer_id로 고객을 찾는다.주문의
order_id로 주문 상세 행을 찾는다.주문 상세의
product_id로 상품을 찾는다.특정 주문 번호만 남기고 줄 번호 순서로 정렬한다.
INNER JOIN은 연결되는 행이 없으면 결과에서 빠진다. 외래 키가 제대로 걸려 있으면 주문의 고객이나 주문 상세의 상품은 반드시 존재한다. 이런 관계에서는 INNER JOIN이 자연스럽다. 반대로 "연결된 행이 없어도 왼쪽 테이블의 행은 보여야 한다"면 LEFT JOIN을 써야 한다.
LEFT JOIN으로 없는 관계 찾기
LEFT JOIN은 왼쪽 테이블의 행을 먼저 보존한다. 오른쪽 테이블에 맞는 행이 없으면 오른쪽 열은 NULL로 채워진다. 이 특성 덕분에 "아직 구매하지 않은 고객", "한 번도 팔리지 않은 상품"을 찾을 수 있다.
먼저 구매 이력이 없는 고객을 조회해 보자. 여기서는 결제 완료 또는 배송 완료 주문만 구매로 본다.
SELECT
c.customer_id,
c.name,
c.email,
c.created_at
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status IN ('paid', 'shipped')
WHERE o.order_id IS NULL
ORDER BY c.created_at;중요한 부분은 주문 상태 조건이 ON 안에 있다는 점이다. LEFT JOIN에서 오른쪽 테이블 조건을 WHERE에 잘못 쓰면, 오른쪽 행이 없는 고객의 NULL 값이 걸러져 버린다.
다음 쿼리는 의도와 다르게 동작한다.
-- 잘못된 예: 미구매 고객을 찾지 못한다
SELECT c.customer_id, c.name, c.email
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status IN ('paid', 'shipped')
AND o.order_id IS NULL;o.status IN (...) 조건은 o가 없는 행에서 참이 될 수 없다. 게다가 o.order_id IS NULL은 오른쪽 주문 행이 붙지 않은 경우를 찾는 조건이다. 두 조건을 WHERE에서 동시에 쓰면 "주문 상태가 결제 완료 또는 배송 완료이면서 주문 행은 없는 고객"을 찾으라는 뜻이 되어, 보통은 빈 결과가 나온다. LEFT JOIN에서는 오른쪽 테이블을 제한하는 조건이 ON에 있어야 하는지, 최종 결과를 제한하는 조건이 WHERE에 있어야 하는지 구분해야 한다.
상품별 판매 여부도 LEFT JOIN으로 확인할 수 있다.
SELECT
p.product_id,
p.sku,
p.name,
CASE
WHEN count(oi.order_item_id) > 0 THEN true
ELSE false
END AS has_sales,
coalesce(sum(oi.quantity), 0) AS sold_quantity
FROM products AS p
LEFT JOIN order_items AS oi
ON oi.product_id = p.product_id
GROUP BY p.product_id, p.sku, p.name
ORDER BY p.product_id;팔리지 않은 상품도 결과에 남는다. 이때 sum(oi.quantity)는 값이 없으면 NULL이 될 수 있으므로 coalesce로 0을 표시했다.
조인 결과가 갑자기 늘어나는 이유
조인은 행과 행을 짝짓는다. 조건이 정확하면 필요한 만큼만 연결된다. 조건이 빠지거나 너무 넓으면 결과 행 수가 폭증한다.
가장 위험한 경우는 모든 조합을 만드는 것이다.
-- 의도하지 않은 전체 조합
SELECT count(*)
FROM orders AS o
CROSS JOIN order_items AS oi;주문이 1,000건이고 주문 상세가 3,000건이면 결과는 3,000,000행이 된다. 이것을 카테시안 곱이라고 한다. CROSS JOIN 자체가 나쁜 것은 아니다. 다만 의도한 경우가 아니라면 대부분 조인 조건을 빠뜨린 것이다.
다음처럼 일반 조인에서 ON true를 쓰면 모든 행이 서로 연결된다.
-- 매우 주의해야 하는 예
SELECT count(*)
FROM orders AS o
JOIN order_items AS oi
ON true;이 쿼리도 사실상 CROSS JOIN처럼 동작한다. 두 테이블 사이에 어떤 열을 기준으로 연결할지 말하지 않았기 때문이다. ON true가 항상 틀린 문법은 아니지만, 일반적인 테이블 조인에서 습관처럼 쓰면 의도하지 않은 전체 조합을 만들기 쉽다.
조인 쿼리를 작성할 때는 단계별로 행 수를 확인한다.
SELECT count(*) AS orders_count
FROM orders;
SELECT count(*) AS order_items_count
FROM order_items;
SELECT count(*) AS joined_count
FROM orders AS o
JOIN order_items AS oi
ON oi.order_id = o.order_id;orders와 order_items를 정상적으로 조인하면 결과 행 수는 보통 주문 상세 행 수와 같다. 한 주문에 여러 상품이 있기 때문이다. 여기서 "주문 수보다 결과가 많다"는 사실만으로 오류는 아니다. 결과의 단위가 주문에서 주문 상세로 바뀐 것이다.
중복처럼 보이는 결과를 확인하려면 count(*)와 count(DISTINCT ...)를 함께 본다.
SELECT
count(*) AS result_rows,
count(DISTINCT o.order_id) AS distinct_orders,
count(DISTINCT oi.order_item_id) AS distinct_items
FROM orders AS o
JOIN order_items AS oi
ON oi.order_id = o.order_id;화면이 주문 한 건당 한 줄이어야 한다면 주문 상세를 그대로 조인하면 안 된다. 먼저 주문 단위로 합쳐야 한다.
SELECT
o.order_no,
c.name AS customer_name,
count(oi.order_item_id) AS item_count,
sum(oi.line_amount) AS calculated_amount
FROM orders AS o
JOIN customers AS c
ON c.customer_id = o.customer_id
JOIN order_items AS oi
ON oi.order_id = o.order_id
GROUP BY o.order_id, o.order_no, c.name
ORDER BY o.order_no;조인 결과가 이상할 때는 다음을 확인한다.
ON조건이 기본 키와 외래 키를 제대로 비교하는가?조인 조건 없이
CROSS JOIN이나ON true가 들어가지 않았는가?결과의 단위가 주문인지, 주문 상세인지 명확한가?
한쪽 테이블에 같은 기준 값이 여러 행 있을 수 있는가?
필요한 경우
GROUP BY로 다시 원하는 단위로 모았는가?
SELF JOIN: 같은 테이블을 두 번 읽기
SELF JOIN은 특별한 문법이 아니다. 같은 테이블을 서로 다른 별칭으로 두 번 조인하는 방식이다. 대표적인 예는 상하 관계다.
상품 카테고리가 부모-자식 구조를 가진다고 하자.
CREATE TABLE IF NOT EXISTS categories (
category_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_category_id bigint REFERENCES categories(category_id),
name text NOT NULL
);자식 카테고리와 부모 카테고리를 함께 보려면 같은 테이블을 두 번 읽는다.
SELECT
child.category_id,
child.name AS category_name,
parent.name AS parent_category_name
FROM categories AS child
LEFT JOIN categories AS parent
ON parent.category_id = child.parent_category_id
ORDER BY child.category_id;루트 카테고리는 부모가 없으므로 parent_category_id가 NULL이다. 그래서 LEFT JOIN을 사용했다. INNER JOIN을 쓰면 부모가 없는 최상위 카테고리는 결과에서 빠진다.
CROSS JOIN: 모든 조합이 필요할 때만 쓰기
CROSS JOIN은 모든 조합을 만든다. 실수로 쓰면 위험하지만, 보고서의 빈칸을 채울 때는 유용하다. 예를 들어 "최근 7일 × 활성 상품"의 기준판을 먼저 만들고, 여기에 실제 판매 데이터를 붙이면 판매가 없는 날도 0으로 표시할 수 있다.
WITH days AS (
SELECT d::date AS sales_date
FROM generate_series(
current_date - interval '6 days',
current_date,
interval '1 day'
) AS g(d)
)
SELECT
days.sales_date,
p.product_id,
p.name AS product_name
FROM days
CROSS JOIN products AS p
WHERE p.is_active = true
ORDER BY days.sales_date, p.product_id;이 쿼리는 날짜 수와 활성 상품 수를 곱한 만큼 행을 만든다. 활성 상품이 1,000개이고 날짜가 30일이면 30,000행이다. 의도한 기준판이면 괜찮다. 하지만 큰 테이블끼리 무심코 CROSS JOIN하면 메모리와 실행 시간이 급격히 늘어난다.
LATERAL JOIN: 왼쪽 행마다 다른 조회 실행하기
LATERAL JOIN은 오른쪽의 서브쿼리가 왼쪽 테이블의 열을 참조할 수 있게 한다. 쉽게 말해 "고객 한 명마다 그 고객의 최근 주문 1건을 조회한다" 같은 쿼리에 적합하다. PostgreSQL에서는 LATERAL 항목이 왼쪽 행의 값을 사용해 행마다 평가될 수 있다1.
SELECT
c.customer_id,
c.name,
latest_order.order_no,
latest_order.ordered_at,
latest_order.total_amount
FROM customers AS c
LEFT JOIN LATERAL (
SELECT
o.order_no,
o.ordered_at,
o.total_amount
FROM orders AS o
WHERE o.customer_id = c.customer_id
ORDER BY o.ordered_at DESC
LIMIT 1
) AS latest_order ON true
ORDER BY c.name;여기서 LIMIT 1은 전체 주문 중 1건이 아니다. 고객 한 명마다 최근 주문 1건이다. LEFT JOIN LATERAL을 썼기 때문에 주문이 없는 고객도 결과에 남고, 주문 열은 NULL로 표시된다.
이 예제의 ON true는 앞에서 본 위험한 조인과 의미가 조금 다르다. 실제 연결 조건은 서브쿼리 안의 WHERE o.customer_id = c.customer_id에 들어 있다. 바깥의 ON true는 "그 고객에 대해 서브쿼리가 반환한 최근 주문 행이 있으면 그대로 붙인다"는 뜻에 가깝다. 그래도 처음 보는 독자라면 낯설 수 있으니, LATERAL을 사용할 때는 연결 조건이 서브쿼리 안에 있는지 바깥 ON에 있는지 꼭 확인하는 습관이 좋다.
처음부터 LATERAL을 자주 쓸 필요는 없다. 대부분의 업무 조회는 INNER JOIN, LEFT JOIN, GROUP BY로 해결된다. 하지만 "각 행마다 상위 N개", "각 고객의 최근 1건", "각 게시글의 최신 댓글 3개"처럼 왼쪽 행에 의존하는 제한 조회가 필요하면 LATERAL이 쿼리를 단순하게 만든다.
조인 쿼리 작성 순서
조인은 한 번에 완성하려고 하면 헷갈린다. 다음 순서로 작성하면 실수를 줄일 수 있다.
기준 테이블을 정한다. 주문 목록이면
orders, 고객 목록이면customers가 기준이다.필요한 테이블을 하나씩 추가한다.
각 조인마다 기본 키와 외래 키를
ON에 쓴다.SELECT *대신 화면에 필요한 열만 고른다.조인 전후
count(*)를 비교한다.결과의 단위가 무엇인지 확인한다.
관계형 데이터베이스를 제대로 쓰려면 조인을 두려워하지 않아야 한다. 데이터가 여러 테이블에 나뉘어 있는 것은 불편함이 아니라 중복과 불일치를 줄이기 위한 설계다. 조인은 그 설계를 읽기 가능한 결과로 되돌리는 방법이다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.