포스트

LATERAL 조인: 성능 기능이 아니라 스코프 규칙이다

LATERAL이 FROM 절의 스코프를 여는 문법 규칙이라는 정의에서 출발해, 그것이 강제하는 Nested Loop 실행 모양과 비용 모델, 그룹별 상위 N건 같은 고유 용도, 그리고 de-correlate 리라이팅까지 정리한다.

LATERAL 조인: 성능 기능이 아니라 스코프 규칙이다

LATERAL을 “조인을 빠르게 해주는 키워드”로 알고 있으면 처음부터 어긋난다. 정의에 성능은 없다. 문법 규칙이고, 정확히는 스코프 규칙이다.

FROM 절의 서브쿼리가 자기보다 앞에 나온 FROM 항목의 컬럼을 참조할 수 있게 해준다.

이 한 줄에서 실행 모양, 비용 모델, 쓸 수 있는 자리와 없는 자리가 전부 따라 나온다.

FROM 절에는 벽이 있다

일반적인 FROM 절의 서브쿼리는 독립적으로 먼저 평가되어야 하는 단위다. 바깥 행이 무엇인지 모르는 상태에서 자기 결과를 다 만들어 놓아야 한다. 그래서 바깥 컬럼을 참조할 수 없다.

일반 서브쿼리 — 경계가 닫혀 있다 project p 앞선 FROM 항목 WHERE x.project_id = p.project_id 독립적으로 먼저 평가되어야 함 ERROR: invalid reference to FROM-clause entry for table "p" LATERAL — 경계가 열린다 project p 앞선 FROM 항목 WHERE x.project_id = p.project_id 바깥 행 하나마다 다시 평가 참조가 허용된다 = 실행 순서가 고정된다는 뜻이기도 하다 LATERAL을 빼면 "느려지는" 게 아니라 파싱 단계에서 실패한다. 성능 선택지가 아니라 문법 요건이다.

의미가 곧 실행 순서다

“바깥 행 하나마다 서브쿼리를 다시 평가한다”는 의미를 부여받는 순간, 실행 모양이 하나로 고정된다. 다른 방법으로는 그 의미를 만들 수 없기 때문이다.

바깥 입력 — N행 … N행 안쪽 서브쿼리가 행마다 한 번씩 — 총 N회 Nested Loop Left Join -> Index Scan on project (rows=N loops=1) -> Aggregate (loops=N) ← 바깥 행 수와 같다 loops 값이 곧 바깥 행 수다. 그리고 그 노드의 actual time·rows는 1회 평균이라 곱해야 총합이 된다.

LEFT JOIN LATERAL에 ON TRUE가 붙는 이유도 여기서 나온다. 상관 조건은 이미 서브쿼리 안 WHERE에 들어가 있어 ON에 쓸 게 없는데, LEFT JOIN은 문법적으로 ON을 요구한다. 그래서 항등식을 넣는다.

1
2
CROSS JOIN LATERAL (...) c          -- ≡ JOIN LATERAL (...) c ON TRUE
LEFT  JOIN LATERAL (...) c ON TRUE  -- LEFT는 ON을 생략할 수 없음

LATERAL이 아니면 안 되는 일 — 그룹별 상위 N건

LATERAL의 존재 이유를 가장 잘 보여주는 건 성능 최적화가 아니라 표현할 수 없던 걸 표현하게 되는 경우다. 대표가 “각 그룹마다 상위 N건”이다.

1
2
3
4
5
6
7
8
9
SELECT p.project_id, p.name, r.title, r.created_at
FROM project p
LEFT JOIN LATERAL (
    SELECT title, created_at
    FROM report
    WHERE report.project_id = p.project_id    -- 바깥 행 참조
    ORDER BY created_at DESC
    LIMIT 3                                    -- 그룹마다 3건
) r ON TRUE;

LIMIT은 서브쿼리 단위로 걸린다. 즉 바깥 행마다 따로 3건을 가져온다. 윈도우 함수(ROW_NUMBER() OVER (PARTITION BY ...))로도 같은 결과를 낼 수 있지만 동작이 다르다.

 LATERAL + LIMIT윈도우 함수 + 필터
읽는 양그룹마다 상위 N건에서 멈춘다전체를 읽어 번호를 매긴 뒤 버린다
인덱스 활용(group_key, sort_key) 인덱스로 N건만정렬 전체가 필요
그룹이 적고 그룹당 행이 많을 때유리불리
그룹이 아주 많을 때loops가 커진다한 번의 스캔으로 끝

집합 반환 함수에 바깥 컬럼을 넘기는 것도 LATERAL 없이는 안 된다.

1
2
3
SELECT p.project_id, t.tag
FROM project p,
     LATERAL unnest(p.tag_array) AS t(tag);   -- p의 컬럼을 함수 인자로

비용은 반복 횟수가 아니라 안쪽 복잡도가 정한다

가장 흔한 오해가 “LATERAL은 반복되니까 느리다”는 것이다. 비용 모델은 O(N × 안쪽 비용)이므로, 안쪽이 상수 시간이면 N이 커도 싸다. 무서운 건 반복이 아니라 안쪽이 비싼 경우다.

같은 loops = 500 — 안쪽 노드만 다를 때 · 가로축 로그 눈금 안쪽 = Index Scan 1회 0.02 ms 10 ms 0.02 × 500 — 안쪽이 O(1)이면 반복은 싸다 안쪽 = Seq Scan 1회 2.9 ms 1,450 ms 2.9 × 500 — 안쪽이 O(M)이면 N × M이 된다 10 ms 100 ms 1 s 10 s 반복 횟수는 같은데 145배 차이가 난다. 진단할 곳은 loops가 아니라 안쪽 노드다.

그래서 LATERAL이 느릴 때의 처방은 “LATERAL을 없애기”가 아니라 “안쪽을 인덱스로 O(1)에 가깝게 만들기” 가 먼저다. 안쪽이 Seq Scan이면 그 인덱스를 만들고, 필요하면 출력 컬럼까지 INCLUDE에 넣어 힙 접근을 없앤다.

대신 플래너의 자유를 잃는다

LATERAL이 일반 조인보다 본질적으로 빠른 건 아니다. 오히려 대부분은 일반 조인이 빠르다. 이유는 선택지의 수다.

 일반 조인LATERAL
조인 알고리즘Nested Loop / Hash / Merge 중 선택Nested Loop 고정
조인 순서플래너가 재배치 가능바깥 → 안쪽으로 고정
안쪽이 비쌀 때다른 전략으로 회피 가능회피 불가, 그대로 곱해짐

조인 알고리즘 세 가지의 비용 모델과 선택 기준은 조인은 어떻게 실행되는가에서 따로 다뤘다.

그럼 언제 이기는가. 같은 데이터를 여러 번 훑던 것을 한 번으로 줄일 때다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 전: 구분 코드별 EXISTS 4개 → 매핑 테이블을 4번 탐색
SELECT p.*,
       EXISTS (SELECT 1 FROM project_member WHERE project_id = p.project_id AND role_type='OWNER')    AS has_owner,
       EXISTS (SELECT 1 FROM project_member WHERE project_id = p.project_id AND role_type='MANAGER')  AS has_manager,
       EXISTS (SELECT 1 FROM project_member WHERE project_id = p.project_id AND role_type='REVIEWER') AS has_reviewer,
       EXISTS (SELECT 1 FROM project_member WHERE project_id = p.project_id AND role_type='VIEWER')   AS has_viewer
FROM project p;

-- 후: LATERAL 집계 1개 → 한 번 탐색해 값 4개를 동시에
SELECT p.*, c.*
FROM project p
LEFT JOIN LATERAL (
    SELECT MAX(CASE WHEN role_type='OWNER'    THEN 1 ELSE 0 END) AS has_owner,
           MAX(CASE WHEN role_type='MANAGER'  THEN 1 ELSE 0 END) AS has_manager,
           MAX(CASE WHEN role_type='REVIEWER' THEN 1 ELSE 0 END) AS has_reviewer,
           MAX(CASE WHEN role_type='VIEWER'   THEN 1 ELSE 0 END) AS has_viewer
    FROM project_member
    WHERE project_id = p.project_id
) c ON TRUE;

이득의 정체는 “LATERAL이 빨라서”가 아니라 “같은 테이블을 네 번 읽던 걸 한 번으로 줄여서” 다. 이 구분을 못 하면 다음 최적화에서 틀린 선택을 한다.

바깥이 커지면 — de-correlate 리라이팅

바깥 행이 수천~수만으로 커지면 N × 안쪽의 N 자체가 문제가 된다. 이때는 상관관계 자체를 제거한다. WHERE의 상관 조건을 GROUP BY로 바꾸고, 필터를 안쪽으로 밀어 넣는다.

LATERAL — 바깥 행마다 안쪽을 다시 … 바깥 행 수만큼 반복 Nested Loop Left Join -> Index Scan on project -> Aggregate (loops = N) O(N × 안쪽비용) de-correlate — GROUP BY로 상관 제거, 안쪽을 한 번만 단 1회 — 그룹 키로 한꺼번에 집계 Hash Left Join -> Index Scan on project -> Hash -> HashAggregate (loops = 1) O(N + M)
1
2
3
4
5
6
7
8
9
FROM project p
LEFT JOIN (
    SELECT project_id,
           MAX(CASE WHEN role_type='OWNER' THEN 1 ELSE 0 END) AS has_owner
           -- …
      FROM project_member
     WHERE project_id IN (...)      -- 필터를 안쪽으로 밀어넣는다
     GROUP BY project_id            -- 상관관계를 GROUP BY로 대체
) c ON c.project_id = p.project_id

이건 사람이 직접 해야 하는 리라이팅이다. PostgreSQL은 단순한 상관 서브쿼리는 자동으로 풀어내지만, 집계가 들어 있는 LATERAL은 스스로 de-correlate 하지 않는다.

LEFT의 성격이 뒤집히는 지점

마지막이 가장 미묘하다. 원래 LATERAL 집계에서 LEFT는 사실 의미상 중복이었다.

1
2
SELECT MAX(...) FROM project_member WHERE <아무것도 매칭 안 됨>
→ 0행이 아니라, NULL 하나를 담은 1행을 반환

GROUP BY 없는 집계 쿼리는 입력이 0행이어도 항상 정확히 1행을 반환한다. 그래서 매칭 여부와 무관하게 늘 1행이 오고, LEFT든 INNER든 바깥 행이 사라질 일이 없다. 호출부에 COALESCE(c.has_owner, 0)가 필요하다는 것 자체가 “행은 오는데 값이 NULL”인 경우를 이미 다루고 있다는 증거다.

그런데 위 리라이팅에서는 GROUP BY가 생겼다. 그 순간 매칭 없는 그룹은 서브쿼리 결과에 아예 행이 없다.

 원래 LATERAL 집계de-correlate 후 (GROUP BY)
매칭 0건일 때NULL 한 행이 온다행 자체가 없다
INNER로 바꾸면결과 동일바깥 행이 조용히 사라진다
LEFT의 성격미래 변경 대비 보험필수

같은 리팩터링 안에서 LEFT의 성격이 “중복”에서 “필수”로 뒤집힌다. 목록에서 항목이 조용히 빠지는 버그는 재현도 어렵고 발견도 늦다.

운영 함정

함정 1 — LATERAL을 성능 기능으로 설명하기. 없으면 에러가 나는 스코프 규칙이다. 성능 이득은 “같은 테이블을 N번 읽던 걸 1번으로 줄였을 때” 따라오는 결과지, 키워드 자체의 속성이 아니다.

함정 2 — loops가 크다고 LATERAL을 걷어내기. 비용은 loops가 아니라 안쪽 노드의 복잡도가 정한다. 안쪽을 인덱스로 O(1)에 가깝게 만드는 게 먼저다.

함정 3 — loops를 안 곱해서 병목을 놓치기. loops > 1인 노드의 actual time·rows는 1회 평균이다. 곱하지 않으면 전체의 80%를 먹는 노드가 3ms짜리로 보인다. 실행계획 읽는 법에서 다룬 함정이 여기서 그대로 나온다.

함정 4 — de-correlate하면서 LEFT를 빼기. GROUP BY가 생긴 순간 LEFT는 선택이 아니라 필수가 된다.

함정 5 — 그룹이 아주 많은데 LATERAL + LIMIT을 쓰기. 그룹별 상위 N건은 그룹이 적고 그룹당 행이 많을 때 유리하다. 그룹이 수만 개면 윈도우 함수 한 번의 스캔이 낫다.

면접 한 줄 Q&A

  • Q. LATERAL이 일반 서브쿼리와 결정적으로 다른 점은? A. FROM 절 서브쿼리가 앞선 FROM 항목의 컬럼을 참조할 수 있게 하는 스코프 규칙이다. 없으면 느려지는 게 아니라 invalid reference to FROM-clause entry 에러가 난다.
  • Q. LATERAL은 일반 조인보다 빠른가? A. 아니다. “바깥 행마다 한 번”이라는 의미가 실행 순서를 고정해서 Nested Loop에 묶이고 플래너가 Hash/Merge Join이나 조인 순서 재배치를 못 고른다. 이득이 나는 건 같은 테이블 반복 탐색을 1회로 줄일 때다.
  • Q. LATERAL이 아니면 못 하는 일은? A. 그룹별 상위 N건(LIMIT이 서브쿼리 단위로 걸린다), 그리고 집합 반환 함수에 바깥 컬럼을 인자로 넘기는 것이다.
  • Q. LATERAL이 느릴 때 어디를 보나? A. loops가 아니라 안쪽 노드다. 비용은 O(N × 안쪽 비용)이라 안쪽이 인덱스 스캔이면 500회 반복도 10ms지만, Seq Scan이면 같은 500회가 1.4초가 된다.
  • Q. 바깥 행이 수만 건이면? A. 상관 조건을 GROUP BY로 대체하고 필터를 안쪽으로 밀어 넣어 de-correlate한다. O(N × 안쪽)이 O(N + M)이 된다. 집계가 든 LATERAL은 플래너가 자동으로 풀어주지 않아서 사람이 다시 써야 한다.
  • Q. de-correlate 후에 주의할 점은? A. GROUP BY가 생기면서 매칭 없는 그룹의 행이 사라진다. 원래는 중복이던 LEFT가 이때부터 필수가 된다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.