LATERAL은 조인 오른쪽 서브쿼리가 왼쪽 테이블의 값을 참조할 수 있게 해주는 키워드다.
동작은 왼쪽 행마다 오른쪽을 반복 실행하는 FOR 루프이며, 실행계획에서 Nested Loop으로 나타난다.
ON true는 LEFT JOIN의 문법 요구사항일 뿐이고, 실제 연결 조건은 서브쿼리 WHERE 안에 있다.
최대 강점은 Top-N per group — 오른쪽에 인덱스가 있으면 LIMIT으로 조기 종료되어 매우 빠르다.
오른쪽 테이블이 왼쪽보다 훨씬 클 때 유리하고, 반대 상황에서는 윈도우 함수나 DISTINCT ON이 낫다.
들어가며
LATERAL은 조인하려는 오른쪽 서브쿼리가 조인 대상인 왼쪽 테이블의 값을 들여다볼 수 있게 해주는 키워드다.
일반 JOIN에서는 오른쪽 서브쿼리가 왼쪽 테이블 컬럼을 참조하면 에러가 나지만, LATERAL을 붙이면 참조가 허용된다. 이게 전부이고, 나머지는 모두 이 한 가지 능력에서 파생된다. 아래에서 다룰 “고객별 최근 주문 1건” 같은 문제도 결국 이 참조 능력 하나로 풀린다.
일반 JOIN으로는 풀리지 않는 문제
목적: 온라인 쇼핑몰 데이터에서 고객별 가장 최근 주문 1건의 주문일과 금액을 출력한다.
예제 데이터
우선 온라인 쇼핑몰 샘플 데이터를 생성한다. customer는 고객ID가 담긴 고객 정보 테이블이고, orders는 고객별 주문이력 테이블로 주문 날짜와 총액 정보가 담겨 있다.
샘플 데이터 생성
create table customer ( id int primary key, name varchar(20));create table orders ( id int primary key, customer_id int, order_date date, amount int);insert into customer values (1, '오타니'), (2, '손흥민'), (3, '이강인');insert into orders values (101, 1, '2026-01-15', 32000), (102, 1, '2026-03-02', 15000), (103, 1, '2026-05-20', 48000), (104, 2, '2026-02-11', 9000), (105, 2, '2026-06-05', 27000);
customer
id
name
1
오타니
2
손흥민
3
이강인
orders
id
customer_id
order_date
amount
101
1
2026-01-15
32,000
102
1
2026-03-02
15,000
103
1
2026-05-20
48,000
104
2
2026-02-11
9,000
105
2
2026-06-05
27,000
이강인(id=3)은 가입만 하고 주문이 없다
LEFT JOIN과 CROSS JOIN의 차이를 보여줄 때 쓰인다.
목적에 맞는 정답 테이블은 아래와 같다.
name
order_date
amount
오타니
2026-05-20
48,000
손흥민
2026-06-05
27,000
이강인
NULL
NULL
간단해 보이지만 일반 JOIN으로는 깔끔하게 안 된다. LATERAL을 이해하기 위해 일반 JOIN이 왜 안 되는지부터 확인한다.
일반 JOIN으로 시도
필요한 것은 고객별 가장 최신 주문 1건뿐이다. 그러나 단순히 고객ID 기준으로 조인하면 조건에 맞는 짝을 전부 만들어서 행을 늘린다. 즉, 일반 JOIN에는 고객별 1건이라는 개념을 표현할 문법이 없다.
일반 JOIN 쿼리
select c.name, o.order_date, o.amountfrom customer cleft join orders o on o.customer_id = c.id;
name
order_date
amount
오타니
2026-01-15
32,000
오타니
2026-03-02
15,000
오타니
2026-05-20
48,000
손흥민
2026-02-11
9,000
손흥민
2026-06-05
27,000
이강인
NULL
NULL
limit 1을 붙이면 되지 않나?
안 된다. 전체 결과에서 1행만 나올 뿐, 고객별로 1행이 아니다. 완전히 다른 얘기다.
상관 서브쿼리로 시도
아래 SELECT 절 서브쿼리 안의 c.id는 바깥 행의 값을 참조하는데, 이를 상관 서브쿼리(correlated subquery)라고 한다. 즉, 고객 1명씩 가져와서 해당 `id`로 서브쿼리를 한 번씩 실행한 것이다.
상관 서브쿼리 — 컬럼 1개
select c.name , (select max(o.order_date) from orders o where o.customer_id = c.id) as order_datefrom customer c;
그러나 상관 서브쿼리의 문제는 컬럼을 하나밖에 못 가져온다는 점이다.
고객별 최신 주문건에 대해 필요한 정보는 주문날짜(order_date)와 총액(amount) 2가지다. 즉, 2가지 정보를 가져오려면 아래와 같이 상관 서브쿼리를 2번 수행해야 한다.
상관 서브쿼리 — 컬럼 2개
select c.name , (select max(o.order_date) from orders o where o.customer_id = c.id) as order_date , (select o.amount from orders o where o.customer_id = c.id order by o.order_date desc limit 1) as amount -- 서브쿼리 또 실행from customer c;
상관 서브쿼리의 문제점은 다음과 같다.
같은 테이블을 두 번 훑는다
컬럼이 5개면 서브쿼리 5개로 관리 효율성과 가독성이 떨어진다.
“최근 3건”처럼 여러 행이 필요하면 아예 불가능하다.
SELECT 절 서브쿼리는 반드시 1행 1열만 반환해야 한다.
상관 서브쿼리는 목적의 일부분은 만족하지만 확장이 불가능하다. 즉, “상관 서브쿼리의 편리함”과 “JOIN의 여러 컬럼·여러 행”을 동시에 갖기 위해LATERAL을 사용한다.
LATERAL
해결 쿼리
목적에 맞는 LATERAL 활용 쿼리는 다음과 같다. 서브쿼리 안에서 order by ... limit 1 같은 조건을 추가하고, 원하는 컬럼만큼 가져오고, 테이블 스캔을 한 번만 하는 효율적인 쿼리다.
LEFT JOIN LATERAL 쿼리
select c.name , recent.order_date , recent.amountfrom customer cleft join lateral ( select o.order_date , o.amount from orders o where o.customer_id = c.id -- 왼쪽 테이블 c를 참조 order by o.order_date desc limit 1) recent on true;
name
order_date
amount
오타니
2026-05-20
48,000
손흥민
2026-06-05
27,000
이강인
NULL
NULL
on true를 쓰는 이유
LEFT JOIN은 문법 규칙상 반드시 ON 절이 있어야 한다.
그러나 LATERAL에서는 연결 조건이 이미 서브쿼리 WHERE 안에 들어가 있어서ON에 쓸 조건이 남아 있지 않다.
그래서 “조건 없음 = 항상 참”을 뜻하는 on true를 관용적으로 쓴다. 의미 없는 형식적 장치로 on 1=1도 같다.
작동 방식
LATERAL의 작동 방식을 FOR 루프 모델로 파이썬에 옮기면 다음과 같다. 앞에서 말한 조인 대상인 왼쪽 테이블(customer)의 값 c.id를 하나씩 가져와 반복한 것이다.
FOR 루프로 옮긴 동작
결과 = []for c in customer: # 왼쪽 테이블을 한 행씩 recent = SQL실행(f""" select order_date, amount from orders where customer_id = {c.id} # c 값이 여기 꽂힘 order by order_date desc limit 1 """) if recent: 결과.append( (c.name, recent.order_date, recent.amount) ) else: 결과.append( (c.name, None, None) ) # LEFT라서 빈 값으로 보존
실제로 펼쳐지는 모습
왼쪽 행
서브쿼리가 실제로 실행되는 형태
결과
c.id=1
where customer_id = 1 order by date desc limit 1
(2026-05-20, 48000)
c.id=2
where customer_id = 2 order by date desc limit 1
(2026-06-05, 27000)
c.id=3
where customer_id = 3 order by date desc limit 1
없음 → NULL
핵심 원리
일반 JOIN은 양쪽 테이블을 독립적으로 평가한 뒤 합친다.
LATERAL은 오른쪽이 왼쪽에 의존하므로, 왼쪽 행마다 오른쪽을 다시 실행한다.
이러한 이유로 실행계획에서 Nested Loop로 나타난다.
LATERAL을 빠뜨렸을 때
LATERAL을 빼면 에러 메시지가 나온다. 이 메시지는 기억해둘 만하다. 실무에서 이 에러를 만나면 십중팔구 LATERAL이 빠진 것이다.
LATERAL 누락 쿼리
left join ( select ... where o.customer_id = c.id -- LATERAL 없음) recent on true;
에러 메시지
ERROR: invalid reference to FROM-clause entry for table "c"LINE 5: where o.customer_id = c.idHINT: There is an entry for table "c", but it cannot be referenced from this part of the query.
LEFT와 CROSS의 차이
CROSS JOIN LATERAL은 ON 절이 필요 없어 더 짧지만 동작이 다르다. 목적에 맞게 적절한 조인 방법을 이용하면 된다.
왼쪽 행 보존
ON 절
언제 쓰나
LEFT JOIN LATERAL … ON true
항상 보존
필요 (true)
기본값으로 사용. 데이터 손실 없음
CROSS JOIN LATERAL
매칭 없으면 탈락
불필요
매칭이 반드시 있어야만 유효한 행일 때
CROSS — 매칭 없는 왼쪽 행은 사라진다
-- CROSS: 매칭 없는 왼쪽 행은 사라짐 (INNER JOIN처럼)from customer ccross join lateral ( ... ) recent;
name
order_date
amount
오타니
2026-05-20
48,000
손흥민
2026-06-05
27,000
이강인이 사라졌다.
LEFT — 매칭이 없어도 왼쪽 행을 보존한다
-- LEFT: 매칭 없어도 왼쪽 행 보존, 오른쪽만 NULLfrom customer cleft join lateral ( ... ) recent on true;
name
order_date
amount
오타니
2026-05-20
48,000
손흥민
2026-06-05
27,000
이강인
NULL
NULL
활용
Top-N per group
위의 “고객별 최근 1건”이 N=1인 경우다. 최근 2건으로 늘리고 싶으면 limit숫자만 바꾸면 되는 가장 흔한 활용 방법이다. 이는 SELECT 절 상관 서브쿼리로는 절대 불가능하다. LATERAL의 가장 큰 존재 이유다.
최근 2건 조회
select c.name, recent.order_date, recent.amountfrom customer cleft join lateral ( select o.order_date, o.amount from orders o where o.customer_id = c.id order by o.order_date desc limit 2 -- 최근 2건) recent on true;
name
order_date
amount
오타니
2026-05-20
48,000
오타니
2026-03-02
15,000
손흥민
2026-06-05
27,000
손흥민
2026-02-11
9,000
이강인
NULL
NULL
계산식 재사용
LATERAL은 테이블 조회뿐 아니라 계산식에 이름을 붙이는 용도로도 쓴다. 같은 수식을 여러 번 반복해서 쓰게 되는 문제(별칭 재활용)와, 계산 결과를 WHERE에서 참조할 수 없는 문제(조건문 활용) 두 가지를 함께 해결한다.
별칭 재활용
하나의 테이블 SELECT 절에서 동일한 수식을 여러 번 사용해야 하는 경우 CROSS JOIN LATERAL을 이용할 수 있다.
안 좋은 예시 — 같은 식을 3번 반복
select amount * 1.1 as 세금포함 , amount * 1.1 - amount as 세액 , case when amount * 1.1 > 30000 then '고액' else '일반' end as 등급from orders;
CROSS JOIN LATERAL을 쓰면 계산식에 이름을 붙여 재사용할 수 있다.
여기서는 테이블 조회가 아니라 값 하나를 만드는 용도다. FROM이 없는 select도 LATERAL 서브쿼리가 될 수 있다.
계산식 전용 LATERAL은 FROM이 없는 상수 select이므로 항상 정확히 1행을 반환한다.
즉, 매칭 실패라는 개념 자체가 없어 행이 탈락할 수 없으니 LEFT도 ON true도 불필요하다.
따라서 LEFT JOIN LATERAL이 아닌 CROSS JOIN LATERAL을 이용한다.
오른쪽이 반환하는 행 수
CROSS 사용 시
테이블 조회 LATERAL
0행일 수 있음
왼쪽 행 탈락(위험)
계산식 LATERAL (FROM 없음)
항상 1행
안전, 더 짧다
계산식을 LATERAL로 재사용
-- LATERAL로 이름 붙여 재사용select calc.세금포함 , calc.세금포함 - o.amount as 세액 , case when calc.세금포함 > 30000 then '고액' else '일반' end as 등급from orders ocross join lateral (select o.amount * 1.1 as 세금포함) calc;
SELECT 절 안에서 별칭 재활용이 불가능한 이유
select amount*1.1 as t, t-amount ...처럼 별칭을 바로 못 쓰는 이유는
SQL에서 같은 SELECT 절 안의 별칭은 서로 참조할 수 없기 때문이다. LATERAL이 이 제약을 우회한다.
고급(2단 계산)
위 SELECT 절의 별칭 상호참조 제약 때문에, 계산 결과를 다시 재료로 쓰려면 같은 calc 안에서는 불가능하다. 이럴 때는 LATERAL을 하나 더 이어 붙이면 된다.
calc2가 calc의 오른쪽에 있으므로 참조가 성립한다. 단, 순서를 바꾸면 에러가 발생한다(아래 주의사항 참고).
LATERAL 두 번 연결
select calc2.세액, calc2.등급from orders ocross join lateral (select o.amount * 1.1 as 세금포함) calccross join lateral (select calc.세금포함 - o.amount as 세액 , case when calc.세금포함 > 30000 then '고액' else '일반' end as 등급) calc2;
조건문 활용
위와 같이 select ... as 세금포함으로 만든 별칭은 같은 쿼리 블록의 WHERE에서 참조할 수 없다. SQL의 작동 방식이 FROM → WHERE → SELECT 순이므로, WHERE가 실행되는 시점에 별칭은 아직 존재하지 않기 때문이다.
그러나 LATERAL은 FROM 절의 산출물이므로 WHERE보다 먼저 확정된다.
계산 결과를 WHERE에서 참조
-- 에러: 별칭은 WHERE에서 보이지 않는다select amount * 1.1 as 세금포함 from orders where 세금포함 > 30000;-- ERROR: column "세금포함" does not exist-- 정상: LATERAL 컬럼은 WHERE에서 참조 가능select calc.세금포함from orders ocross join lateral (select o.amount * 1.1 as 세금포함) calcwhere calc.세금포함 > 30000;
GROUP BY, HAVING, ORDER BY에도 동일하게 쓸 수 있다. 계산식을 필터·정렬 조건으로도 걸어야 할 때 LATERAL의 값어치가 가장 크다.
배열·집합 반환 함수 펼치기
배열 컬럼을 행으로 펼칠 때도 LATERAL을 쓴다. o.tags가 왼쪽 행의 값이므로 LATERAL이 필요하다. PostgreSQL은 FROM unnest(o.tags)처럼 쓰면 LATERAL을 자동으로 붙여주지만, 명시하는 습관이 좋다.
배열 펼치기
select o.id, tagfrom orders ocross join lateral unnest(o.tags) as tag;
주의사항
의존성 순서
왼쪽만 참조가 가능하므로 순서가 중요하다.FROM 절은 왼쪽에서 오른쪽으로 순서대로 해석되며, LATERAL은 “이미 지나온 것”만 볼 수 있다.
참조 방향에 따른 정상·에러
-- 정상: b가 자기 왼쪽 a를 참조from across join lateral (select ... where x = a.id) b;-- 에러: a가 자기 오른쪽 b를 참조from (select ... where y = b.id) a -- b는 아직 정의 안 됨cross join lateral b;
DBMS별 문법 차이
개념은 동일하다. SQL Server의 OUTER APPLY가 LEFT JOIN LATERAL ... ON true에 해당한다.
DBMS
문법
PostgreSQL, MySQL 8.0.14+, Oracle 12c+
CROSS JOIN LATERAL / LEFT JOIN LATERAL
SQL Server
CROSS APPLY / OUTER APPLY
성능과 사용 시기
성능 특성
LATERAL은 Nested Loop을 강제한다. 플래너가 Hash Join 같은 다른 전략을 선택할 수 없게 된다. 이는 곧 장점이자 단점이다.
비용 모델
비용 ≈ 왼쪽 행 수 × (오른쪽 1회 조회 비용)
이 공식에서 계산식 LATERAL은 테이블을 읽지 않는다. 오른쪽 비용이 순수 CPU 연산뿐이기 때문이다. 즉 “계산식 재사용” 목적의 LATERAL은 성능 부담을 걱정할 필요가 없다. 이는 조회용 LATERAL과 결정적으로 다른 점이다.
판단 기준
LATERAL을 쓸지 말지는 양쪽 테이블의 크기, 오른쪽 인덱스 유무, 오른쪽에서 실제로 필요한 행의 비율을 함께 보고 정한다.
판단 기준
오른쪽 인덱스가 있고 왼쪽이 오른쪽보다 훨씬 작다 → LATERAL
양쪽이 비슷하게 크고 어차피 전부 읽어야 한다 → 윈도우 함수 / DISTINCT ON
LATERAL이 유리한 경우
왼쪽 행이 적고, 오른쪽에 인덱스가 있을 때
아래 인덱스가 있으면 where customer_id = 1 order by order_date desc limit 1은 인덱스에서 첫 행만 읽고 즉시 멈춘다.
오른쪽 테이블 인덱스
create index ix_orders_cust_date on orders (customer_id, order_date desc);
오른쪽 테이블이 왼쪽보다 압도적으로 클 때
오른쪽을 통째로 스캔하는 비용이 감당 불가라면, 필요한 행만 집어오는 LATERAL이 유일한 답이다.
직관의 함정 — "오른쪽이 크면 LATERAL이 불리한 것 아닌가?"
정반대다. 큰 오른쪽 테이블은 LATERAL이 아니라 대안(Hash Join / Window function)에게 부담이다.
대안: lead()나 해시 테이블 구축을 위해 오른쪽을 전부 읽어야 한다. 크기 M에 비례한다.
LATERAL: 인덱스로 필요한 행만 집어온다. 오른쪽 크기 M과 거의 무관하다.
비용 비교
LATERAL ≈ N(왼쪽 행 수) × C(인덱스 1회 프로브)대안 ≈ M(오른쪽 전체) × C(순차 읽기) + 정렬 비용
M이 커질수록 우변만 커지므로, 오른쪽이 클수록 LATERAL의 상대적 이득이 커진다.
크기 비교만으로 판단하지 말 것 — 4가지 요인을 함께 본다
요인
LATERAL 유리
대안 유리
왼쪽 행 수 N
적다
많다
오른쪽 크기 M
크다
작다
오른쪽 인덱스
있다
없어도 됨
오른쪽에서 실제 필요한 비율
극히 일부
대부분
마지막 항목이 특히 중요하다. 오른쪽의 대부분을 어차피 읽어야 한다면 LATERAL의 조기 종료 이점이 사라지고, 행별 랜덤 I/O 오버헤드만 남는다.
LATERAL이 불리한 경우
왼쪽 행이 매우 많고(수백만), 오른쪽 인덱스가 없을 때
오른쪽 인덱스가 없으면 행마다 오른쪽 풀스캔을 해서 비효율적이다. 이럴 땐 DISTINCT ON이나 윈도우 함수로 한 번에 훑는 방식이 낫다.
DISTINCT ON 대안
-- 대안: DISTINCT ON (PostgreSQL 전용)select distinct on (c.id) c.name, o.order_date, o.amountfrom customer cleft join orders o on o.customer_id = c.idorder by c.id, o.order_date desc;
정리
LATERAL은 오른쪽 서브쿼리가 왼쪽 테이블 값을 참조하게 해주는 키워드다.
동작은 왼쪽 행마다 오른쪽을 반복 실행하는 FOR 루프다(= Nested Loop).
ON true는 문법 요구사항일 뿐, 실제 조건은 서브쿼리 WHERE 안에 있다.
LEFT JOIN LATERAL ... ON true를 기본으로 쓰면 행이 사라지지 않는다.
최대 강점은 **Top-N per group**이며, 오른쪽에 인덱스만 있으면 매우 빠르다.