Summary

  • LATERAL은 조인 오른쪽 서브쿼리가 왼쪽 테이블의 값을 참조할 수 있게 해주는 키워드다.
  • 동작은 왼쪽 행마다 오른쪽을 반복 실행하는 FOR 루프이며, 실행계획에서 Nested Loop으로 나타난다.
  • ON trueLEFT 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

idname
1오타니
2손흥민
3이강인

orders

idcustomer_idorder_dateamount
10112026-01-1532,000
10212026-03-0215,000
10312026-05-2048,000
10422026-02-119,000
10522026-06-0527,000

이강인(id=3)은 가입만 하고 주문이 없다

LEFT JOINCROSS JOIN의 차이를 보여줄 때 쓰인다.

목적에 맞는 정답 테이블은 아래와 같다.

nameorder_dateamount
오타니2026-05-2048,000
손흥민2026-06-0527,000
이강인NULLNULL

간단해 보이지만 일반 JOIN으로는 깔끔하게 안 된다. LATERAL을 이해하기 위해 일반 JOIN이 왜 안 되는지부터 확인한다.

일반 JOIN으로 시도

필요한 것은 고객별 가장 최신 주문 1건뿐이다. 그러나 단순히 고객ID 기준으로 조인하면 조건에 맞는 짝을 전부 만들어서 행을 늘린다. 즉, 일반 JOIN에는 고객별 1건이라는 개념을 표현할 문법이 없다.

일반 JOIN 쿼리

select c.name, o.order_date, o.amount
from customer c
left join orders o on o.customer_id = c.id;
nameorder_dateamount
오타니2026-01-1532,000
오타니2026-03-0215,000
오타니2026-05-2048,000
손흥민2026-02-119,000
손흥민2026-06-0527,000
이강인NULLNULL

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_date
from 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.amount
from customer c
left 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;
nameorder_dateamount
오타니2026-05-2048,000
손흥민2026-06-0527,000
이강인NULLNULL

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=1where customer_id = 1 order by date desc limit 1(2026-05-20, 48000)
c.id=2where customer_id = 2 order by date desc limit 1(2026-06-05, 27000)
c.id=3where 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.id
HINT:  There is an entry for table "c", but it cannot be referenced
       from this part of the query.

LEFT와 CROSS의 차이

CROSS JOIN LATERALON 절이 필요 없어 더 짧지만 동작이 다르다. 목적에 맞게 적절한 조인 방법을 이용하면 된다.

왼쪽 행 보존ON언제 쓰나
LEFT JOIN LATERAL … ON true항상 보존필요 (true)기본값으로 사용. 데이터 손실 없음
CROSS JOIN LATERAL매칭 없으면 탈락불필요매칭이 반드시 있어야만 유효한 행일 때

CROSS — 매칭 없는 왼쪽 행은 사라진다

-- CROSS: 매칭 없는 왼쪽 행은 사라짐 (INNER JOIN처럼)
from customer c
cross join lateral ( ... ) recent;
nameorder_dateamount
오타니2026-05-2048,000
손흥민2026-06-0527,000

이강인이 사라졌다.

LEFT — 매칭이 없어도 왼쪽 행을 보존한다

-- LEFT: 매칭 없어도 왼쪽 행 보존, 오른쪽만 NULL
from customer c
left join lateral ( ... ) recent on true;
nameorder_dateamount
오타니2026-05-2048,000
손흥민2026-06-0527,000
이강인NULLNULL

활용

Top-N per group

위의 “고객별 최근 1건”이 N=1인 경우다. 최근 2건으로 늘리고 싶으면 limit 숫자만 바꾸면 되는 가장 흔한 활용 방법이다. 이는 SELECT 절 상관 서브쿼리로는 절대 불가능하다. LATERAL의 가장 큰 존재 이유다.

최근 2건 조회

select c.name, recent.order_date, recent.amount
from customer c
left 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;
nameorder_dateamount
오타니2026-05-2048,000
오타니2026-03-0215,000
손흥민2026-06-0527,000
손흥민2026-02-119,000
이강인NULLNULL

계산식 재사용

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 서브쿼리가 될 수 있다.
  • 계산식 전용 LATERALFROM이 없는 상수 select이므로 항상 정확히 1행을 반환한다.
  • 즉, 매칭 실패라는 개념 자체가 없어 행이 탈락할 수 없으니 LEFTON true도 불필요하다.
  • 따라서 LEFT JOIN LATERAL이 아닌 CROSS JOIN LATERAL을 이용한다.
오른쪽이 반환하는 행 수CROSS 사용 시
테이블 조회 LATERAL0행일 수 있음왼쪽 행 탈락(위험)
계산식 LATERAL (FROM 없음)항상 1행안전, 더 짧다

계산식을 LATERAL로 재사용

-- LATERAL로 이름 붙여 재사용
select calc.세금포함
     , calc.세금포함 - o.amount as 세액
     , case when calc.세금포함 > 30000 then '고액' else '일반' end as 등급
from orders o
cross 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을 하나 더 이어 붙이면 된다.

calc2calc오른쪽에 있으므로 참조가 성립한다. 단, 순서를 바꾸면 에러가 발생한다(아래 주의사항 참고).

LATERAL 두 번 연결

select calc2.세액, calc2.등급
from orders o
cross join lateral (select o.amount * 1.1 as 세금포함) calc
cross 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 o
cross join lateral (select o.amount * 1.1 as 세금포함) calc
where calc.세금포함 > 30000;

GROUP BY, HAVING, ORDER BY에도 동일하게 쓸 수 있다. 계산식을 필터·정렬 조건으로도 걸어야 할 때 LATERAL의 값어치가 가장 크다.

배열·집합 반환 함수 펼치기

배열 컬럼을 행으로 펼칠 때도 LATERAL을 쓴다. o.tags가 왼쪽 행의 값이므로 LATERAL이 필요하다. PostgreSQL은 FROM unnest(o.tags)처럼 쓰면 LATERAL을 자동으로 붙여주지만, 명시하는 습관이 좋다.

배열 펼치기

select o.id, tag
from orders o
cross join lateral unnest(o.tags) as tag;

주의사항

의존성 순서

왼쪽만 참조가 가능하므로 순서가 중요하다. FROM 절은 왼쪽에서 오른쪽으로 순서대로 해석되며, LATERAL은 “이미 지나온 것”만 볼 수 있다.

참조 방향에 따른 정상·에러

-- 정상: b가 자기 왼쪽 a를 참조
from a
cross 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 APPLYLEFT JOIN LATERAL ... ON true에 해당한다.

DBMS문법
PostgreSQL, MySQL 8.0.14+, Oracle 12c+CROSS JOIN LATERAL / LEFT JOIN LATERAL
SQL ServerCROSS 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.amount
from customer c
left join orders o on o.customer_id = c.id
order by c.id, o.order_date desc;

정리

  1. LATERAL은 오른쪽 서브쿼리가 왼쪽 테이블 값을 참조하게 해주는 키워드다.
  2. 동작은 왼쪽 행마다 오른쪽을 반복 실행하는 FOR 루프다(= Nested Loop).
  3. ON true는 문법 요구사항일 뿐, 실제 조건은 서브쿼리 WHERE 안에 있다.
  4. LEFT JOIN LATERAL ... ON true기본으로 쓰면 행이 사라지지 않는다.
  5. 최대 강점은 **Top-N per group**이며, 오른쪽에 인덱스만 있으면 매우 빠르다.

Reference