백엔드 데이터 인프라 30편. 테이블 표현식의 깊이 — FROM 절·JOIN 종류·LATERAL·집합 함수·인라인 뷰 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 30편이에요. 28편 쿼리 개요 의 9절 중 — "어디서 가져올지" 정하는 FROM 절 (테이블 표현식) 깊이.
테이블 표현식이 받는 것
FROM 테이블·서브쿼리·함수·VALUES·JOIN 결합
8가지 형태:
| 형태 | 예 |
|---|---|
| 일반 테이블 | FROM users |
| 별칭 | FROM users u |
| 서브쿼리 | FROM (SELECT ...) sub |
| JOIN | FROM t1 JOIN t2 ON ... |
| 함수 | FROM generate_series(1, 10) |
| VALUES | FROM (VALUES ...) v(c1, c2) |
| LATERAL | FROM t1, LATERAL (SELECT ... WHERE col = t1.col) |
| ONLY | FROM ONLY parent (상속 자식 제외) |
JOIN 6가지 풀
12편 JOIN 에서 다룬 INNER·LEFT·RIGHT·FULL 외에 — CROSS·NATURAL·USING 까지.
CROSS JOIN
SELECT * FROM colors CROSS JOIN sizes;
-- 모든 조합 (데카르트 곱)
가끔 "모든 조합 생성" 에 활용.
NATURAL JOIN
FROM users NATURAL JOIN orders;
-- 같은 이름 컬럼 자동 매칭
위험 — 같은 이름 컬럼이 또 추가되면 의도와 다르게 동작. 거의 안 쓰임.
USING — 같은 이름 컬럼
FROM users JOIN orders USING (user_id);
-- = JOIN orders ON users.user_id = orders.user_id
-- + 결과에 user_id 한 번만
ON vs USING vs NATURAL
| 동작 | 사용 | |
|---|---|---|
| ON | 명시적 조건 | 표준 |
| USING (col) | 같은 이름 단축 | 가끔 |
| NATURAL | 모두 자동 | 비권장 |
LATERAL — 외부 컬럼 참조
18편 윈도우 의 LATERAL 깊이.
일반 서브쿼리 vs LATERAL
-- 일반 서브쿼리 — 외부 컬럼 참조 X
SELECT u.name, (SELECT COUNT(*) FROM orders) AS total_orders
FROM users u;
-- LATERAL — 외부 컬럼 참조 OK
SELECT u.name, recent.count
FROM users u
JOIN LATERAL (
SELECT COUNT(*) AS count FROM orders WHERE user_id = u.id
) recent ON true;
LATERAL = "FROM 절 안 서브쿼리가 외부 컬럼 보임".
Top-N per group
-- 각 사용자별 최근 주문 5건
SELECT u.name, o.*
FROM users u
LEFT JOIN LATERAL (
SELECT * FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC
LIMIT 5
) o ON true;
window function 의 ROW_NUMBER 보다 깔끔.
동적 함수 호출
SELECT u.id, json_data.*
FROM users u
CROSS JOIN LATERAL jsonb_to_record(u.meta) AS json_data(name TEXT, age INT);
각 사용자의 JSON 필드를 컬럼으로.
함수 호출 — FROM 안
-- 시리즈 생성
SELECT * FROM generate_series(1, 100) AS n;
SELECT * FROM generate_series('2026-05-01'::DATE, '2026-05-31', '1 day');
-- 문자열 분리
SELECT * FROM regexp_split_to_table('a,b,c', ',');
-- 키-값 함수
SELECT * FROM each(hstore('a=>1,b=>2'));
함수가 "여러 행 반환" 하면 — 테이블처럼 사용 가능. SRF (Set-Returning Function).
사용자 정의 SRF
CREATE FUNCTION recent_orders(uid BIGINT, n INT)
RETURNS TABLE(id BIGINT, amount INTEGER) AS $$
SELECT id, amount FROM orders
WHERE user_id = uid
ORDER BY created_at DESC LIMIT n;
$$ LANGUAGE sql;
SELECT * FROM recent_orders(1, 5);
복잡한 쿼리를 함수로 캡슐화.
인라인 뷰 — 서브쿼리 FROM
SELECT u.name, agg.cnt
FROM users u
JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
) agg ON u.id = agg.user_id;
16편 VIEW 만들지 않고도 — 한 쿼리 안에 "임시 뷰". CTE (Common Table Expression, 이름 붙은 임시 결과) 가 더 가독성 좋을 때 많음.
-- 위와 동등 — CTE
WITH agg AS (
SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id
)
SELECT u.name, agg.cnt FROM users u JOIN agg ON u.id = agg.user_id;
VALUES — 임시 테이블
-- FROM 안 임시 데이터
FROM (VALUES
('A', 100),
('B', 200),
('C', 300)
) AS t(label, score);
-- 매핑 표
SELECT u.name, code_map.label
FROM users u
JOIN (VALUES
('admin', '관리자'),
('user', '일반'),
('guest', '게스트')
) AS code_map(code, label) ON u.role = code_map.code;
TABLESAMPLE — 샘플링
대용량 데이터의 "일부만" 빠르게.
SELECT AVG(amount)
FROM orders TABLESAMPLE BERNOULLI(1); -- 1% 무작위 샘플
SELECT *
FROM logs TABLESAMPLE SYSTEM(0.1) REPEATABLE(42); -- 0.1% + 시드
| 메서드 | 의미 |
|---|---|
| BERNOULLI(p) | 각 행 p% 확률 (느리지만 균등) |
| SYSTEM(p) | 페이지 단위 (빠르지만 편향) |
통계·집계 미리 보기에 매우 유용.
ONLY — 상속 자식 제외
CREATE TABLE animals (id BIGSERIAL, name TEXT);
CREATE TABLE dogs (breed TEXT) INHERITS (animals);
SELECT * FROM animals; -- dogs 도 포함
SELECT * FROM ONLY animals; -- dogs 제외
22편 INHERITS 의 옛 패턴. 모던 = 파티션.
TABLEOID — 어느 자식 테이블인가
SELECT tableoid::regclass, * FROM logs;
-- 어느 파티션의 행인지 표시
파티션 디버깅에 자주.
동적 별칭
-- 컬럼 별칭도 함께 지정
FROM (SELECT 1, 2, 3) AS sub(a, b, c);
-- 함수 결과 별칭
FROM unnest(ARRAY[1, 2, 3]) AS num;
JOIN 의 평가 순서
FROM a
JOIN b ON ...
JOIN c ON ...
JOIN d ON ...
PG 계획자가 — "가장 효율적인 순서" 자동 결정. 단 JOIN_COLLAPSE_LIMIT 매개변수 너머 (기본 8) 일 땐 — 작성 순서 그대로 평가. 큰 시스템에서 가끔 문제.
큰 쿼리 — 분해
-- 5개+ JOIN 한 쿼리 = 계획자·인간 모두 어려움
WITH active_users AS (...),
recent_orders AS (...),
top_products AS (...)
SELECT ...
FROM active_users u
JOIN recent_orders o ON ...
JOIN top_products p ON ...;
CTE 로 분해 — 가독성·성능 모두 개선.
함정 5가지
(1) NATURAL JOIN 의도 다른 결과
같은 이름 컬럼 추가되면 — 매칭 룰 바뀜. 안 쓰기.
(2) USING vs ON 혼동
USING 결과 = 매칭 컬럼 한 번만. ON 결과 = 양쪽 다.
(3) LATERAL 없이 외부 컬럼
FROM users u JOIN (SELECT * FROM orders WHERE user_id = u.id) o ON ...
-- ❌ ERROR — u.id 안 보임
LATERAL 키워드 필요.
(4) JOIN_COLLAPSE_LIMIT 너머
SET join_collapse_limit = 12;
대형 쿼리는 한도 늘리기. 단 — 계획 시간도 증가.
(5) TABLESAMPLE SYSTEM 편향
페이지 단위라 — "같은 페이지의 행은 함께". 균등 X. 균등 필요 = BERNOULLI.
(1) JOIN 6가지 — ON·USING 표준. (2) LATERAL = Top-N per group·동적 함수. (3) SRF + FROM = 함수 결과 테이블. (4) VALUES = 임시 매핑. (5) TABLESAMPLE = 빠른 통계 미리 보기.
한 줄 정리 — FROM 8가지 형태. JOIN ON 표준, LATERAL 로 외부 참조, SRF/VALUES 로 임시 테이블, TABLESAMPLE 로 샘플링. CTE 로 큰 쿼리 분해. NATURAL JOIN·SYSTEM 샘플링 함정. JOIN_COLLAPSE_LIMIT 8 너머는 작성 순서.
시험 직전 한 번 더 — FROM 절 깊이 입문자가 매번 헷갈리는 것
- FROM 8형태 = 테이블·별칭·서브쿼리·JOIN·함수·VALUES·LATERAL·ONLY
- JOIN 6가지 = INNER·LEFT·RIGHT·FULL·CROSS·NATURAL
- ON = 명시적 조건 (표준)
- USING (col) = 같은 이름 단축
- NATURAL = 안 권장
- LATERAL = 외부 컬럼 참조 (FROM 절 안 서브쿼리)
- LATERAL + LEFT JOIN ON true = Top-N per group
- SRF = 여러 행 반환 함수
- generate_series·regexp_split_to_table 등
- 사용자 정의 SRF =
RETURNS TABLE(...) - 함수 FROM =
FROM func() AS alias - VALUES = 임시 테이블 in FROM
- 매핑 표 안 만들고 VALUES JOIN
- TABLESAMPLE BERNOULLI(p) = 균등 샘플
- TABLESAMPLE SYSTEM(p) = 빠름·편향
- REPEATABLE(seed) = 재현 가능
- ONLY = 상속 자식 제외 (옛 — 파티션 권장)
- TABLEOID = 어느 자식 테이블
- CTE = 큰 쿼리 분해 (서브쿼리보다 가독)
- 인라인 뷰 = FROM (SELECT ...) sub
- JOIN 계획 = PG 자동 (효율적 순서)
- join_collapse_limit (기본 8) 너머 = 작성 순서
- 큰 쿼리 = CTE 분해
- 컬럼 별칭 =
(c1, c2, c3) - LATERAL 없이 외부 컬럼 = ERROR
- USING 결과 = 매칭 컬럼 한 번
- 한국 백엔드 = LATERAL + Top-N 표준
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 25편 — INSERT 깊이 Bulk·COPY·UPSERT 전략
- 26편 — UPDATE 깊이 HOT·fillfactor·FROM JOIN
- 27편 — DELETE 깊이 bloat·VACUUM·파티션 DROP
- 28편 — 쿼리 개요 SELECT 전체 구조
- 29편 — SELECT 절과 표현식의 깊이
다음 글: