명령어/DB

[PostgreSQL] LATERAL 조인 — 행마다 상관 서브쿼리를 조인처럼 펼친다

jykim23 2026. 7. 22. 23:17
반응형

설치·접속: PostgreSQL 설치와 접속

부제: "상품마다 최근 주문 2건", "카테고리별 최신 N개"처럼 바깥 행 값을 참조하는 Top-N을 조인 한 번으로 뽑을 때

-- 상품마다, 그 상품의 최근 주문 2건씩 (per-row Top-N)
SELECT p.id, p.name, o.ord_id, o.qty
FROM products p
CROSS JOIN LATERAL (
    SELECT id AS ord_id, qty
    FROM orders
    WHERE product_id = p.id      -- ← 바깥 행 p를 참조
    ORDER BY id DESC
    LIMIT 2
) o
WHERE p.id <= 3
ORDER BY p.id, o.ord_id DESC;
 id | name  | ord_id | qty
----+-------+--------+-----
  1 | item1 |     72 |   2
  1 | item1 |     65 |   5
  2 | item2 |    173 |   5
  2 | item2 |    150 |   3
  3 | item3 |    181 |   3
  3 | item3 |    160 |   2
(6 rows)

보통 서브쿼리는 바깥 쿼리의 컬럼을 FROM 절 안에서 참조할 수 없다. LATERAL을 붙이면 그 제약이 풀려, 서브쿼리가 바깥에서 이미 만들어진 행(p)을 볼 수 있고 planner는 바깥 각 행마다 서브쿼리를 한 번씩 실행한다 — 상관 서브쿼리를 조인처럼 쓰는 것이다. 결정적 이점은 서브쿼리 안에서 ORDER BY ... LIMIT n을 쓸 수 있다는 점. 스칼라 상관 서브쿼리는 값 하나만 돌려주지만, LATERAL은 행 여러 개(Top-N)를 돌려줘 조인 결과로 펼쳐진다. "그룹마다 상위 N개"를 윈도우 함수(row_number() <= n) 서브쿼리로 푸는 것보다 의도가 직접 드러난다.

CROSS JOIN LATERAL은 서브쿼리가 빈 결과면 그 바깥 행도 사라진다. 주문 없는 상품까지 남기려면 LEFT JOIN LATERAL ... ON true를 쓴다. 같은 패턴을 "카테고리 목록 × 카테고리별 최신 N개"로 확장하면 흔한 per-category Top-N이 된다.

-- 카테고리별 최신 2개: 카테고리 목록을 바깥에 두고, 각 카테고리마다 LATERAL Top-N
SELECT c.category, t.name, t.launched
FROM (SELECT DISTINCT category FROM prod) c
CROSS JOIN LATERAL (
    SELECT name, launched
    FROM prod p
    WHERE p.category = c.category
    ORDER BY launched DESC
    LIMIT 2
) t
ORDER BY c.category, t.launched DESC;
 category | name |  launched
----------+------+------------
 kbd      | K4   | 2026-06-01
 kbd      | K3   | 2026-05-20
 monitor  | D2   | 2026-06-15
 monitor  | D1   | 2026-05-05
 mouse    | M3   | 2026-06-30
 mouse    | M2   | 2026-04-09
(6 rows)

카테고리 3개 각각에 대해 서브쿼리가 최신 2개를 뽑아 6행으로 펼쳐졌다. 바깥의 "그룹 목록"과 안쪽의 "그룹별 Top-N"을 분리해 쓰는 이 구조가 LATERAL의 전형이다. 그룹 수가 적고 각 그룹의 상위 몇 개만 필요할 때, 인덱스(category, launched DESC)와 맞물리면 전체를 정렬하는 윈도우 방식보다 훨씬 적게 읽는다.

이렇게도 쓴다

주문 없는 상품도 남긴다. 매칭이 없으면 우측 컬럼은 NULL. (조합: LEFT JOIN)

SELECT p.id, p.name, o.ord_id
FROM products p
LEFT JOIN LATERAL (
    SELECT id AS ord_id FROM orders WHERE product_id = p.id ORDER BY id DESC LIMIT 1
) o ON true;

 

앞에서 계산한 파생값을 뒤 컬럼에서 재사용한다(중복 식 제거). (조합: 파생 컬럼)

SELECT p.name, calc.subtotal, calc.subtotal*0.1 AS vat
FROM products p
CROSS JOIN LATERAL (SELECT p.price*10 AS subtotal) calc;

 

집합 반환 함수(generate_series, unnest, jsonb_to_recordset)를 행 값으로 펼친다. FROM의 함수 호출은 암묵적으로 LATERAL. (조합: SRF)

SELECT p.id, g AS month
FROM products p, generate_series(1,3) g
WHERE p.id <= 2;

 

LIMIT 1이면 "그룹별 대표 1행". DISTINCT ON의 대안으로 비교된다. (조합: Top-1)

SELECT p.id, o.ord_id
FROM products p
CROSS JOIN LATERAL (
    SELECT id AS ord_id FROM orders WHERE product_id=p.id ORDER BY ordered_at DESC, id DESC LIMIT 1
) o;

 

바깥 행별 집계(카운트/합)를 상관 서브쿼리 대신 LATERAL로. (조합: 집계)

SELECT p.id, p.name, s.cnt, s.total_qty
FROM products p
CROSS JOIN LATERAL (
    SELECT count(*) cnt, sum(qty) total_qty FROM orders WHERE product_id=p.id
) s
WHERE p.id <= 5;

 

LATERAL이 정말 행마다 실행되는지 EXPLAIN으로 확인한다(Nested Loop 아래 서브플랜). (조합: EXPLAIN)

EXPLAIN (COSTS OFF)
SELECT p.id, o.ord_id FROM products p
CROSS JOIN LATERAL (SELECT id ord_id FROM orders WHERE product_id=p.id LIMIT 2) o;
반응형