Skip to content

필사 모드: SQL 실무 치트시트 — 매일 쓰는 명령어 총정리

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
SQL Cheatsheet

이 치트시트의 기준 엔진

치트시트를 복사해 쓰기 전에 어느 엔진 기준인지부터 알아야 합니다. 이 글의 예시는 PostgreSQL 18 기준으로 쓰였습니다. 문법을 보면 알 수 있습니다. ON CONFLICT ... DO UPDATE, FULL OUTER JOIN, GROUP BY ROLLUP, INTERVAL '30 days', created_at::date 캐스트, || 문자열 결합, DATE_TRUNC, WITH RECURSIVE, WHERE 절이 붙은 부분 인덱스 — 전부 PostgreSQL 계열 문법입니다.

아래 절에서 다루는 다음 기능들은 PostgreSQL 공식 문서로 확인한 PostgreSQL 기능입니다.

  • 부분 인덱스와 표현식 인덱스
  • CREATE INDEX CONCURRENTLY
  • EXPLAIN ANALYZE의 출력 형식과 각 필드 이름
  • ALTER TABLE 하위 명령별 락 레벨과 NOT VALID / VALIDATE CONSTRAINT
  • pg_blocking_pids() 함수

MySQL을 쓰고 있다면 이 절들이 그대로 적용되지 않습니다. 특히 실행 계획은 출력 형식 자체가 다릅니다. 본문의 EXPLAIN FORMAT=JSON 예시가 그 증거이고, 아래 "실행 계획 읽는 법"은 전적으로 PostgreSQL 출력을 기준으로 설명합니다. MySQL 쪽 락 레벨과 온라인 DDL 동작도 다르므로 MySQL 매뉴얼에서 따로 확인해야 합니다. 본문에 이미 나와 있는 UPSERT와 JOIN UPDATE / JOIN DELETE는 두 엔진 문법이 나란히 적혀 있으니 그대로 참고하면 됩니다.

기본 CRUD

SELECT (조회)

-- 기본 조회
SELECT * FROM users WHERE age >= 20 ORDER BY created_at DESC LIMIT 10;

-- 특정 컬럼만
SELECT id, name, email FROM users WHERE status = 'active';

-- 별칭 (Alias)
SELECT
    u.name AS user_name,
    COUNT(o.id) AS order_count,
    SUM(o.amount) AS total_spent
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;

-- DISTINCT (중복 제거)
SELECT DISTINCT department FROM employees;

-- BETWEEN, IN, LIKE
SELECT * FROM products
WHERE price BETWEEN 10000 AND 50000
  AND category IN ('electronics', 'books')
  AND name LIKE '%갤럭시%';

-- NULL 처리
SELECT name, COALESCE(phone, '미등록') AS phone
FROM users
WHERE email IS NOT NULL;

INSERT (삽입)

-- 단건 삽입
INSERT INTO users (name, email, age) VALUES ('김영주', 'yj@example.com', 30);

-- 다건 삽입
INSERT INTO users (name, email, age) VALUES
    ('홍길동', 'hong@example.com', 25),
    ('이순신', 'lee@example.com', 35),
    ('세종대왕', 'sejong@example.com', 45);

-- SELECT 결과를 INSERT (테이블 복사)
INSERT INTO users_backup (name, email, age)
SELECT name, email, age FROM users WHERE status = 'active';

-- UPSERT (있으면 UPDATE, 없으면 INSERT)
-- PostgreSQL
INSERT INTO users (email, name, login_count)
VALUES ('yj@example.com', '김영주', 1)
ON CONFLICT (email)
DO UPDATE SET
    login_count = users.login_count + 1,
    last_login = NOW();

-- MySQL
INSERT INTO users (email, name, login_count)
VALUES ('yj@example.com', '김영주', 1)
ON DUPLICATE KEY UPDATE
    login_count = login_count + 1,
    last_login = NOW();

UPDATE (수정)

-- 기본 수정
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';

-- 여러 컬럼 동시 수정
UPDATE products
SET price = price * 1.1,           -- 10% 인상
    updated_at = NOW()
WHERE category = 'electronics';

-- JOIN UPDATE (다른 테이블 참조하여 수정)
-- PostgreSQL
UPDATE orders o
SET status = 'cancelled'
FROM users u
WHERE o.user_id = u.id
  AND u.status = 'banned';

-- MySQL
UPDATE orders o
JOIN users u ON o.user_id = u.id
SET o.status = 'cancelled'
WHERE u.status = 'banned';

-- CASE를 사용한 조건부 UPDATE
UPDATE employees
SET salary = CASE
    WHEN department = 'engineering' THEN salary * 1.15
    WHEN department = 'sales' THEN salary * 1.10
    ELSE salary * 1.05
END
WHERE hire_date < '2024-01-01';

-- ⚠️ 실수 방지: WHERE 없이 UPDATE하면 전체 수정!
-- 항상 SELECT로 먼저 확인!
SELECT * FROM users WHERE last_login < '2025-01-01';  -- 먼저 확인
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';

DELETE (삭제)

-- 기본 삭제
DELETE FROM sessions WHERE expired_at < NOW();

-- JOIN DELETE
-- PostgreSQL
DELETE FROM orders
USING users
WHERE orders.user_id = users.id AND users.status = 'deleted';

-- MySQL
DELETE o FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'deleted';

-- TRUNCATE (전체 삭제, 빠름, AUTO_INCREMENT 리셋)
TRUNCATE TABLE logs;

-- ⚠️ 소프트 삭제 패턴 (권장)
UPDATE users SET deleted_at = NOW() WHERE id = 123;
-- 조회 시:
SELECT * FROM users WHERE deleted_at IS NULL;

JOIN (결합)

-- INNER JOIN (양쪽 모두 있는 것만)
SELECT u.name, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

-- LEFT JOIN (왼쪽 전부 + 오른쪽 매칭)
SELECT u.name, COALESCE(COUNT(o.id), 0) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
-- → 주문이 없는 사용자도 포함 (order_count = 0)

-- RIGHT JOIN (오른쪽 전부 + 왼쪽 매칭)
-- 잘 안 씀, LEFT JOIN을 뒤집는 게 가독성 좋음

-- FULL OUTER JOIN (양쪽 전부)
SELECT u.name, o.amount
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;

-- CROSS JOIN (모든 조합, 카테시안 곱)
SELECT s.size, c.color
FROM sizes s CROSS JOIN colors c;
-- 3 sizes × 4 colors = 12 combinations

-- SELF JOIN (자기 자신과 결합)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
JOIN 다이어그램:

INNER JOIN:    AB (교집합만)
LEFT JOIN:     A + (AB)
RIGHT JOIN:    (AB) + B
FULL OUTER:    AB (합집합)

GROUP BY + 집계 함수

-- 기본 집계
SELECT
    department,
    COUNT(*) AS emp_count,
    AVG(salary) AS avg_salary,
    MAX(salary) AS max_salary,
    MIN(salary) AS min_salary,
    SUM(salary) AS total_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 50000000  -- 평균 연봉 5천만 이상 부서만
ORDER BY avg_salary DESC;

-- ROLLUP (소계 + 총계)
SELECT
    COALESCE(department, '=== 전체 ===') AS department,
    COALESCE(position, '--- 소계 ---') AS position,
    COUNT(*) AS count,
    AVG(salary) AS avg_salary
FROM employees
GROUP BY ROLLUP(department, position);

서브쿼리 (Subquery)

-- WHERE 서브쿼리
SELECT * FROM users
WHERE id IN (
    SELECT user_id FROM orders
    WHERE amount > 1000000
);

-- FROM 서브쿼리 (인라인 뷰)
SELECT dept_name, avg_salary
FROM (
    SELECT department AS dept_name, AVG(salary) AS avg_salary
    FROM employees
    GROUP BY department
) sub
WHERE avg_salary > 60000000;

-- EXISTS (존재 여부 확인, IN보다 대량 데이터에서 빠름)
SELECT u.name
FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.id AND o.status = 'completed'
);

-- 스칼라 서브쿼리 (SELECT 절)
SELECT
    name,
    salary,
    salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg
FROM employees;

CTE (Common Table Expression) — 가독성의 왕

-- 기본 CTE
WITH active_users AS (
    SELECT id, name, email
    FROM users
    WHERE status = 'active' AND last_login > NOW() - INTERVAL '30 days'
),
user_orders AS (
    SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total
    FROM orders
    WHERE created_at > NOW() - INTERVAL '30 days'
    GROUP BY user_id
)
SELECT
    au.name,
    au.email,
    COALESCE(uo.order_count, 0) AS orders,
    COALESCE(uo.total, 0) AS total_spent
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY total_spent DESC;

-- 재귀 CTE (조직도, 카테고리 트리)
WITH RECURSIVE org_tree AS (
    -- 기저: CEO (manager_id가 NULL)
    SELECT id, name, manager_id, 1 AS level
    FROM employees WHERE manager_id IS NULL

    UNION ALL

    -- 재귀: 부하 직원 탐색
    SELECT e.id, e.name, e.manager_id, ot.level + 1
    FROM employees e
    JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT REPEAT('  ', level - 1) || name AS org_chart, level
FROM org_tree
ORDER BY level, name;

윈도우 함수 (Window Functions)

-- ROW_NUMBER (순번)
SELECT
    name, department, salary,
    ROW_NUMBER() OVER (ORDER BY salary DESC) AS rank_all,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_dept
FROM employees;

-- RANK vs DENSE_RANK
-- RANK: 1, 2, 2, 4 (공동 2등 후 4등)
-- DENSE_RANK: 1, 2, 2, 3 (공동 2등 후 3등)

-- LAG / LEAD (이전/다음 행 참조)
SELECT
    date,
    revenue,
    LAG(revenue) OVER (ORDER BY date) AS prev_day,
    revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
    ROUND(
        (revenue - LAG(revenue) OVER (ORDER BY date))
        / LAG(revenue) OVER (ORDER BY date) * 100, 1
    ) AS change_pct
FROM daily_sales;

-- 누적합 (Running Total)
SELECT
    date, amount,
    SUM(amount) OVER (ORDER BY date) AS running_total,
    AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales;

-- NTILE (분위 나누기)
SELECT
    name, salary,
    NTILE(4) OVER (ORDER BY salary DESC) AS quartile
    -- 1=상위25%, 2=25~50%, 3=50~75%, 4=하위25%
FROM employees;

인덱스 (Index) 전략

-- 인덱스 생성
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);

-- 복합 인덱스 순서가 중요!
-- idx(a, b, c) 는:
--   WHERE a = 1                    ✅ 사용
--   WHERE a = 1 AND b = 2         ✅ 사용
--   WHERE a = 1 AND b = 2 AND c = 3 ✅ 사용
--   WHERE b = 2                    ❌ 미사용! (선두 컬럼 없음)
--   WHERE a = 1 AND c = 3         △ a만 사용 (b 건너뜀)

-- 부분 인덱스 (PostgreSQL)
CREATE INDEX idx_active_users ON users(email) WHERE status = 'active';

-- 커버링 인덱스 (테이블 접근 없이 인덱스만으로 조회)
CREATE INDEX idx_covering ON orders(user_id, status, amount);
SELECT status, SUM(amount) FROM orders WHERE user_id = 123 GROUP BY status;
-- → 인덱스만 읽으면 됨! (테이블 I/O 없음)

실행 계획 (EXPLAIN)

-- PostgreSQL
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.name;

-- 읽는 법:
-- Seq Scan: 풀 테이블 스캔 (인덱스 필요?)
-- Index Scan: 인덱스 사용 ✅
-- Index Only Scan: 커버링 인덱스 ✅✅
-- Nested Loop: 소량 데이터 JOIN
-- Hash Join: 대량 데이터 JOIN
-- Sort: ORDER BY (메모리 초과 시 디스크)
-- Bitmap Heap Scan: 여러 인덱스 조합

-- MySQL
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE user_id = 123 AND status = 'completed';

실행 계획 읽는 법

위 절은 명령어만 보여 줍니다. 정작 중요한 건 출력을 읽는 법입니다.

안쪽부터 읽습니다

실행 계획은 트리입니다. 들여쓰기가 깊은 노드가 먼저 실행되고, 그 결과가 바깥 노드로 올라갑니다. 그래서 위에서부터 읽으면 순서가 거꾸로 보입니다. 가장 깊은 곳에서 어떤 방식으로 행을 꺼내는지부터 확인하고, 그 행이 어디서 걸러지고 어떻게 결합되는지를 따라 올라가면 됩니다.

문서가 명시하는 중요한 성질이 하나 있습니다. 상위 노드의 비용은 하위 노드의 비용을 전부 포함합니다. 그래서 최상단 숫자만 보고 "이 노드가 비싸다"고 말할 수 없습니다. 부모와 자식의 차이를 봐야 그 노드가 실제로 얼마나 썼는지 나옵니다.

괄호 안 숫자 네 개

-- 예시 출력: EXPLAIN (ANALYZE, BUFFERS) 결과
 Hash Join  (cost=1082.00..3418.55 rows=4821 width=48) (actual time=8.412..41.903 rows=4795.00 loops=1)
   Hash Cond: (o.user_id = u.id)
   Buffers: shared hit=1204 read=812
   ->  Seq Scan on orders o  (cost=0.00..2015.00 rows=100000 width=24) (actual time=0.011..12.204 rows=100000.00 loops=1)
         Buffers: shared hit=200 read=815
   ->  Hash  (cost=1021.00..1021.00 rows=4880 width=32) (actual time=8.301..8.302 rows=4795.00 loops=1)
         Buckets: 8192  Batches: 1  Memory Usage: 384kB
         ->  Seq Scan on users u  (cost=0.00..1021.00 rows=4880 width=32) (actual time=0.019..7.104 rows=4795.00 loops=1)
               Filter: (status = 'active'::text)
               Rows Removed by Filter: 45205
               Buffers: shared hit=1004
 Planning Time: 0.312 ms
 Execution Time: 43.115 ms

한 줄씩 봅니다.

  • cost=1082.00..3418.55 — 앞은 시작 비용, 뒤는 총 비용입니다. 시작 비용은 출력이 시작되기 전까지 드는 비용입니다. 정렬 노드라면 정렬을 끝내는 데 드는 시간이 여기 들어갑니다. 총 비용은 그 노드를 끝까지 다 돌린다고 가정한 값입니다. 단위는 플래너의 비용 파라미터가 정하는 임의 단위이고, 관례적으로 디스크 페이지 읽기를 1.0으로 두고 나머지를 그에 맞춰 잡습니다. 즉 밀리초가 아닙니다.
  • rows=4821 — 이 노드가 내보낼 것으로 추정한 행 수입니다. 스캔한 행 수가 아니라 걸러지고 남은 행 수입니다. 문서가 헷갈리기 쉽다고 따로 짚는 부분입니다.
  • width=48 — 이 노드가 내보내는 행의 평균 폭(바이트) 추정치입니다.
  • actual time=8.412..41.903 — 실제 시작 시각과 종료 시각이고, 단위는 실제 시간 기준 밀리초 입니다. cost는 임의 단위이므로 두 값은 서로 맞아떨어지지 않습니다. 비교하려 하지 마세요.
  • loops=1 — 이 노드가 몇 번 실행됐는지입니다. Nested Loop의 안쪽 노드처럼 여러 번 실행되는 노드에서는 actual timerows실행당 평균값 으로 표시됩니다. 총 시간을 알려면 loops 를 곱해야 합니다. 이걸 모르고 "안쪽 인덱스 스캔이 0.003ms니까 문제없다"고 판단하는 게 흔한 함정입니다. loops=10000이면 실제로는 30ms입니다.
  • Rows Removed by Filter: 45205 — 필터가 버린 행 수입니다. 적어도 한 행이라도 걸러졌을 때만 나옵니다. 5만 행을 읽어서 4795행만 남겼다는 뜻이니, 이 필터에 인덱스를 붙이면 이득이 있을지 판단할 근거가 됩니다.

추정 행 수와 실제 행 수의 차이가 1번 신호입니다

문서가 직접 이렇게 적습니다. "보통 가장 중요하게 봐야 할 것은 추정 행 수가 현실에 충분히 가까운지 여부다."

플래너가 계획을 고르는 근거는 전부 이 추정치입니다. 추정이 크게 빗나가면 그다음 결정이 전부 잘못됩니다. 100행이 나올 줄 알고 Nested Loop을 골랐는데 실제로 100만 행이 나오면, 100만 번의 인덱스 조회가 일어납니다. 반대로 100만 행을 예상하고 Hash Join을 골랐는데 실제로 10행이면 해시 테이블을 짓느라 낭비합니다.

그래서 계획을 볼 때 첫 번째로 하는 일은 각 노드에서 rows= 추정치와 actual ... rows= 실측치를 나란히 비교하는 것입니다. 한 자릿수 차이는 무시해도 되고, 두 자릿수 이상 벌어지는 노드를 찾으면 거기가 문제의 시작점입니다. 그 노드에서 왜 추정이 틀렸는지를 봐야 합니다. 보통은 통계가 낡았거나, 컬럼 간 상관관계를 플래너가 모르거나, 조건이 함수로 감싸여 있어 선택도를 추정할 수 없는 경우입니다.

큰 테이블에 선택적인 조건인데 Seq Scan이 뜬다면

위 예시의 users 스캔이 딱 그 모양입니다. 5만 행을 읽어 4795행을 남겼습니다. 이 정도 선택도라면 인덱스가 이길 가능성이 있습니다. 반대로 5만 행 중 4만 행이 남는 조건이라면 인덱스를 타는 게 오히려 손해입니다. 인덱스를 타면 인덱스와 테이블을 오가며 임의 접근을 하게 되는데, 어차피 대부분의 페이지를 다 읽을 거라면 순차로 쭉 읽는 편이 빠릅니다.

그러니 Seq Scan을 봤다고 반사적으로 인덱스를 만들면 안 됩니다. 판단 근거는 두 가지입니다. 테이블이 실제로 큰가(Buffers 의 읽기 블록 수를 봅니다), 그리고 조건이 실제로 선택적인가(Rows Removed by Filter 와 남은 rows 의 비율을 봅니다).

EXPLAIN ANALYZE는 쿼리를 진짜로 실행합니다

이 점을 놓치면 사고가 납니다. 문서 표현 그대로, EXPLAIN ANALYZE는 쿼리를 실제로 실행하므로 부수 효과도 평소대로 일어납니다. 결과 행은 버려지지만 UPDATE, DELETE, INSERT는 진짜로 데이터를 바꿉니다.

데이터를 바꾸지 않고 데이터 변경 쿼리의 계획을 보려면 트랜잭션으로 감싸고 롤백하면 됩니다. 문서가 권하는 방식이 이것입니다.

BEGIN;

EXPLAIN ANALYZE
UPDATE orders SET status = 'cancelled' WHERE created_at < '2025-01-01';

ROLLBACK;

프로덕션에서 실행 계획을 볼 일이 있다면 이 습관을 몸에 붙여 두세요. EXPLAIN만 쓰면 실행하지 않고 추정치만 보여 주지만, 그러면 실측 행 수를 얻을 수 없어 위에서 말한 1번 신호를 확인할 수 없습니다.

인덱스를 만들었는데 왜 안 쓰이나

인덱스 전략 절에서 만든 인덱스가 계획에 나타나지 않을 때, 원인은 거의 항상 아래 네 가지 중 하나입니다. 순서대로 확인하면 빠릅니다.

1. 복합 인덱스의 선두 컬럼 규칙

위 인덱스 절에 이미 표로 정리되어 있는 내용입니다. 세 컬럼짜리 인덱스는 왼쪽부터 이어져야 쓰입니다. 선두 컬럼이 조건에 없으면 그 인덱스는 후보에서 빠집니다. 계획에 인덱스 이름이 아예 안 보이면 여기부터 의심하세요.

2. 인덱스 컬럼을 함수나 캐스트로 감쌌다

가장 흔하고, 가장 눈에 안 띄는 원인입니다.

-- 인덱스는 email 컬럼 위에 있는데
CREATE INDEX idx_users_email ON users(email);

-- 조건은 lower(email) 위에 걸립니다 → 인덱스 못 씀
SELECT * FROM users WHERE lower(email) = 'yj@example.com';

-- 날짜도 마찬가지. created_at 인덱스가 있어도 캐스트하면 못 씁니다
SELECT * FROM orders WHERE created_at::date = '2026-08-16';

-- 해결 1) 표현식 인덱스를 만든다
CREATE INDEX idx_users_email_lower ON users(lower(email));
-- 문서 예시: upper(col) 위의 인덱스가 있으면
-- WHERE upper(col) = 'JIM' 조건이 그 인덱스를 쓸 수 있습니다.

-- 해결 2) 조건 쪽을 범위로 바꿔서 컬럼을 벗겨 낸다
SELECT * FROM orders
WHERE created_at >= '2026-08-16' AND created_at < '2026-08-17';

원리는 단순합니다. 인덱스는 컬럼 값을 정렬해 저장합니다. 그 값에 함수를 씌운 결과는 정렬 순서가 보존된다는 보장이 없으므로 인덱스를 쓸 수 없습니다. 함수를 씌운 결과 자체를 인덱스로 만들면(표현식 인덱스) 다시 쓸 수 있게 됩니다.

3. 선택도가 낮아서 순차 스캔이 정말로 옳은 계획이다

status = 'active'인 행이 전체의 80%라면 인덱스를 타는 게 손해입니다. 이건 버그가 아니라 플래너가 옳게 판단한 것입니다. 위의 "큰 테이블에 선택적인 조건" 절에서 설명한 것과 같은 이야기입니다.

이럴 때 쓸 수 있는 카드가 부분 인덱스입니다. 인덱스 절에 이미 예시가 있습니다. 전체가 아니라 자주 쓰는 일부만 인덱싱하면 인덱스가 작아지고, 그 조건으로 들어오는 쿼리에서는 선택도가 높아집니다.

4. 통계가 낡았다

플래너가 계획을 고르는 근거는 테이블 내용에 대한 통계입니다. 문서 표현 그대로, "합리적으로 정확한 통계를 갖는 것이 중요하며, 그렇지 않으면 잘못된 계획 선택이 데이터베이스 성능을 떨어뜨릴 수 있습니다." 이 통계를 모으는 명령이 ANALYZE이고, VACUUM의 선택적 단계로도 실행됩니다.

autovacuum 데몬은 테이블 내용이 충분히 바뀌면 자동으로 ANALYZE를 실행합니다. 하지만 대량 적재 직후처럼 데이터가 한 번에 크게 바뀐 상황에서는 자동 실행을 기다리는 동안 낡은 통계로 계획이 잡힙니다.

-- 대량 적재 직후에는 수동으로 돌립니다
ANALYZE orders;

-- 이후 계획이 바뀌는지 다시 확인
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 123;

진단 순서로 정리하면 이렇습니다. 계획에 인덱스 이름이 아예 안 나오면 1번과 2번을, 인덱스가 후보에는 오르는데 선택되지 않으면 3번과 4번을 봅니다. 4번인지 확인하는 방법은 간단합니다. ANALYZE를 돌린 뒤 계획이 바뀌면 통계 문제였던 것입니다.

실무 패턴 모음

페이지네이션

-- OFFSET 방식 (간단하지만 대량 데이터에서 느림)
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 40;

-- 커서 기반 (대량 데이터 추천!)
SELECT * FROM posts
WHERE id < 12345  -- 마지막으로 본 id
ORDER BY id DESC
LIMIT 20;

중복 제거

-- 중복 행 찾기
SELECT email, COUNT(*) as cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;

-- 중복 중 최신만 남기고 삭제
DELETE FROM users
WHERE id NOT IN (
    SELECT MIN(id) FROM users GROUP BY email
);

-- 또는 CTE로
WITH ranked AS (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn
    FROM users
)
DELETE FROM users WHERE id IN (SELECT id FROM ranked WHERE rn > 1);

날짜 관련

-- 오늘/이번달/올해
SELECT * FROM orders WHERE created_at::date = CURRENT_DATE;
SELECT * FROM orders WHERE DATE_TRUNC('month', created_at) = DATE_TRUNC('month', NOW());

-- 최근 7일 일별 통계
SELECT
    DATE(created_at) AS date,
    COUNT(*) AS orders,
    SUM(amount) AS revenue
FROM orders
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
ORDER BY date;

-- 시간대별 분포
SELECT
    EXTRACT(HOUR FROM created_at) AS hour,
    COUNT(*) AS count
FROM orders
GROUP BY hour
ORDER BY hour;

락(Lock) 주의

-- SELECT FOR UPDATE (비관적 락)
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 다른 트랜잭션 대기
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- 낙관적 락 (version 컬럼)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;  -- version이 맞을 때만 수정
-- 영향 받은 행 = 0이면 → 다른 사람이 먼저 수정!

키셋 페이지네이션

위 페이지네이션 패턴을 제대로 설명하면 이 글에서 가장 자주 써먹게 될 절이 됩니다.

OFFSET이 왜 뒤로 갈수록 느려지나

문서 한 줄로 끝납니다. "OFFSET 절이 건너뛰는 행들도 서버 내부에서는 여전히 계산되어야 하므로, 큰 OFFSET은 비효율적일 수 있습니다."

OFFSET 100000은 10만 행을 건너뛰는 게 아니라, 10만 행을 만들어 낸 다음 버립니다. 1페이지는 20행만 만들면 되지만 5001페이지는 100020행을 만들어야 합니다. 비용이 페이지 번호에 비례해 선형으로 늘어납니다. 목록 화면 뒷페이지가 느려진다는 제보는 거의 항상 이것입니다.

시크 방식으로 다시 쓰기

-- OFFSET 방식: 5001페이지를 보려면 100020행을 만들어서 100000행을 버립니다
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- 키셋(시크) 방식: 마지막으로 본 행의 값부터 이어서 읽습니다
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-05-01 12:00:00', 84213)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 이 인덱스가 있어야 의미가 있습니다
CREATE INDEX idx_posts_created_id ON posts(created_at DESC, id DESC);

핵심은 동점 처리용 컬럼 입니다. created_at만으로 정렬하면 같은 시각의 행들 사이 순서가 정해지지 않아, 페이지 경계에서 행이 빠지거나 중복됩니다. 문서도 이 점을 명시합니다. "LIMIT을 쓸 때는 결과 행을 유일한 순서로 제약하는 ORDER BY를 쓰는 것이 중요합니다. 그렇지 않으면 쿼리 행의 예측 불가능한 부분집합을 얻게 됩니다."

그래서 정렬 키 끝에 유일한 컬럼, 보통 기본 키를 붙입니다. 위 예시의 id가 그 역할입니다. 튜플 비교 (created_at, id) < (...) 를 쓰면 두 컬럼을 한 번에 비교할 수 있고, 위 복합 인덱스와 정렬 방향이 맞아떨어집니다.

계획이 어떻게 달라지나

-- 예시 출력: OFFSET 방식
 Limit  (cost=8421.55..8423.24 rows=20 width=48) (actual time=182.401..182.408 rows=20.00 loops=1)
   ->  Index Scan Backward using idx_posts_created_id on posts
         (cost=0.42..84210.33 rows=1000000 width=48)
         (actual time=0.028..170.552 rows=100020.00 loops=1)
 Execution Time: 182.443 ms

-- 예시 출력: 키셋 방식
 Limit  (cost=0.42..2.11 rows=20 width=48) (actual time=0.031..0.052 rows=20.00 loops=1)
   ->  Index Scan Backward using idx_posts_created_id on posts
         (cost=0.42..84210.33 rows=899980 width=48)
         (actual time=0.029..0.047 rows=20.00 loops=1)
         Index Cond: (ROW(created_at, id) < ROW('2026-05-01 12:00:00'::timestamp, 84213))
 Execution Time: 0.081 ms

두 계획 모두 같은 인덱스를 씁니다. 차이는 안쪽 노드의 actual ... rows 에 있습니다. OFFSET 쪽은 100020행을 실제로 꺼내 올렸고, 키셋 쪽은 20행만 꺼냈습니다. Index Cond 가 있느냐 없느냐가 그 차이를 만듭니다. 조건이 인덱스 안으로 내려가면 시작 지점으로 바로 점프할 수 있고, 없으면 처음부터 세면서 와야 합니다.

키셋의 대가

공짜는 아닙니다. 임의의 페이지 번호로 점프할 수 없습니다. "1, 2, 3 ... 5001" 같은 페이지 번호 UI는 만들 수 없고, "더 보기" 또는 "다음" 방식만 가능합니다. 총 페이지 수를 보여 주려면 별도의 COUNT 쿼리가 필요한데, 그건 아래 함정 절에서 다루는 문제로 이어집니다.

그래서 판단 기준은 간단합니다. 사용자가 실제로 뒷페이지로 점프하는가? 대부분의 목록 화면에서는 아무도 5001페이지로 가지 않습니다. 그렇다면 키셋이 맞습니다.

락과 스키마 변경 — 실무에서 진짜 위험한 곳

위 락 절은 SELECT FOR UPDATE 와 낙관적 락만 다룹니다. 정작 장애를 만드는 건 그쪽이 아닙니다.

WHERE 없는 UPDATE / DELETE가 하는 일

행이 지워지는 것만 문제가 아닙니다. UPDATE, DELETE, INSERT, MERGE는 대상 테이블에 ROW EXCLUSIVE 락을 잡습니다. 이 모드 자체는 서로 충돌하지 않으므로 다른 쓰기와 나란히 진행됩니다. 문제는 행 수준입니다. WHERE 없는 UPDATE는 테이블의 모든 행에 행 락을 잡고, 그 트랜잭션이 끝날 때까지 그 행들을 건드리려는 모든 트랜잭션이 대기합니다. 100만 행짜리 테이블이라면 사실상 테이블 전체가 멈춥니다.

그리고 ROW EXCLUSIVE는 SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE 와 충돌합니다. 즉 그 긴 UPDATE가 도는 동안 CREATE INDEX(SHARE 락)나 대부분의 ALTER TABLE(ACCESS EXCLUSIVE 락)은 시작조차 못 합니다.

진짜 문제는 긴 트랜잭션입니다

락은 트랜잭션이 끝나야 풀립니다. 그래서 5분짜리 트랜잭션은 5분짜리 락입니다. 여기에 하나가 더 붙습니다. PostgreSQL은 MVCC를 위해 UPDATEDELETE가 예전 행 버전을 즉시 지우지 않습니다. 문서 표현대로 "다른 트랜잭션에서 여전히 보일 가능성이 있는 동안에는 행 버전을 삭제해서는 안 되기" 때문입니다. 오래 열려 있는 트랜잭션이 하나라도 있으면 그동안 쌓인 죽은 행들을 정리할 수 없고, 테이블과 인덱스가 계속 부풉니다.

그래서 "커넥션을 열어 두고 사람이 생각하는" 패턴, 애플리케이션이 트랜잭션 안에서 외부 API를 호출하는 패턴, 배치가 한 트랜잭션으로 전체를 처리하는 패턴이 전부 위험합니다.

누가 막고 있는지 찾기

-- 현재 대기 중인 세션과 그 세션을 막고 있는 PID
SELECT
    a.pid,
    a.state,
    now() - a.xact_start AS xact_age,
    now() - a.query_start AS query_age,
    a.wait_event_type,
    a.wait_event,
    pg_blocking_pids(a.pid) AS blocked_by,
    left(a.query, 120) AS query
FROM pg_stat_activity a
WHERE a.backend_type = 'client backend'
ORDER BY xact_age DESC NULLS LAST;

pg_blocking_pids(integer) 는 지정한 프로세스가 락을 얻지 못하도록 막고 있는 세션들의 프로세스 ID 배열을 돌려줍니다. 막는 세션이 없으면 빈 배열입니다. 충돌하는 락을 실제로 쥐고 있는 경우(하드 블록)와, 충돌할 락을 기다리면서 대기열에서 앞에 있는 경우(소프트 블록)를 모두 포함합니다. 문서는 이 함수가 락 매니저의 공유 상태에 잠깐 배타 접근을 하므로 자주 호출하면 성능에 영향이 있을 수 있다고 경고합니다. 모니터링에서 초당 호출할 함수는 아닙니다.

xact_age 를 내림차순으로 정렬한 이유가 있습니다. 대부분의 락 사고에서 범인은 가장 오래 열려 있는 트랜잭션이고, 그 트랜잭션은 대기 중이 아니라 그냥 idle in transaction 상태로 놀고 있는 경우가 많습니다.

데드락을 막는 규칙

문서의 처방은 한 문장입니다. "여러 객체에 락을 거는 모든 애플리케이션이 일관된 순서로 락을 획득하도록 보장하는 것이 데드락에 대한 최선의 방어입니다."

여기에 하나가 더 붙습니다. "어떤 객체에 대해 트랜잭션에서 처음 획득하는 락은, 그 객체에 대해 필요하게 될 가장 강한 모드여야 합니다." 즉 나중에 UPDATE 할 행을 먼저 SELECT 로 읽어 두고 나중에 락을 올리는 패턴이 데드락을 만듭니다. 처음부터 SELECT ... FOR UPDATE 로 잡아야 합니다.

미리 검증할 수 없다면, 데드락으로 중단된 트랜잭션을 재시도하는 방식으로 처리하라고 문서는 덧붙입니다. 실무에서는 두 가지를 다 합니다. 순서를 고정하고, 그래도 나는 데드락은 재시도합니다.

인덱스 생성 — CREATE INDEX는 쓰기를 막습니다

-- 이건 SHARE 락을 잡습니다. 다른 트랜잭션은 읽을 수는 있지만
-- INSERT / UPDATE / DELETE 는 인덱스 생성이 끝날 때까지 블록됩니다.
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);

-- 이건 SHARE UPDATE EXCLUSIVE 락을 잡습니다. 쓰기를 막지 않습니다.
CREATE INDEX CONCURRENTLY idx_orders_user_date ON orders(user_id, created_at DESC);

-- 실패하면 invalid 인덱스가 남습니다. psql 에서 \d 로 보면 INVALID 로 표시됩니다.
SELECT indexrelid::regclass AS index_name, indrelid::regclass AS table_name
FROM pg_index
WHERE NOT indisvalid;

-- 권장 복구 방법은 지우고 다시 만드는 것입니다.
DROP INDEX CONCURRENTLY idx_orders_user_date;

CONCURRENTLY 가 공짜는 아닙니다. 문서에 따르면 테이블을 두 번 스캔해야 하고, 인덱스를 수정하거나 사용할 가능성이 있는 기존 트랜잭션이 전부 끝나기를 기다려야 합니다. 그래서 훨씬 오래 걸립니다. 또 일반 CREATE INDEX 와 달리 트랜잭션 블록 안에서 실행할 수 없습니다. 마이그레이션 도구가 모든 DDL을 하나의 트랜잭션으로 감싸는 경우 이게 걸림돌이 됩니다.

실패했을 때의 동작도 알아 둬야 합니다. 스캔 중 데드락이나 유니크 위반 같은 문제가 생기면 명령은 실패하지만 "invalid" 인덱스가 남습니다. 이 인덱스는 불완전할 수 있어 조회에는 쓰이지 않지만, 갱신 오버헤드는 그대로 먹습니다. 즉 아무 이득 없이 쓰기만 느려집니다. 위 쿼리로 주기적으로 확인하고, 발견하면 지우고 다시 만드세요.

ALTER TABLE — 기본은 ACCESS EXCLUSIVE입니다

이게 가장 중요한 한 줄입니다. 문서가 직접 이렇게 적습니다. "명시적으로 언급되지 않는 한 ACCESS EXCLUSIVE 락이 획득됩니다."

ACCESS EXCLUSIVE는 모든 모드와 충돌합니다. 단순 SELECT 가 잡는 ACCESS SHARE 와도 충돌합니다. 그래서 ALTER TABLE 이 락을 기다리는 동안 그 뒤에 도착한 모든 쿼리가 대기열에 쌓입니다. DDL 하나가 서비스 전체를 멈추는 고전적인 장애가 이 구조입니다. 문 앞에서 한 명이 멈춰 서면 뒤에 줄이 생기는 것과 같습니다.

문서가 예외로 명시한, 더 약한 락을 잡는 형태들입니다.

  • SET STATISTICS — SHARE UPDATE EXCLUSIVE
  • 컬럼별 옵션 변경 — SHARE UPDATE EXCLUSIVE
  • 클러스터 옵션 변경 — SHARE UPDATE EXCLUSIVE
  • VALIDATE CONSTRAINT — SHARE UPDATE EXCLUSIVE
  • ADD FOREIGN KEY — SHARE ROW EXCLUSIVE (대부분의 제약 추가는 ACCESS EXCLUSIVE 지만 이건 예외입니다)
  • DISABLE / ENABLE TRIGGER — SHARE ROW EXCLUSIVE
  • ATTACH PARTITION — 부모 테이블에 SHARE UPDATE EXCLUSIVE, 붙이는 테이블과 기본 파티션에는 ACCESS EXCLUSIVE

테이블 재작성 여부도 따로 봐야 합니다. 락 레벨과 별개로, 재작성이 일어나면 그동안 락이 오래 유지됩니다.

  • ADD COLUMN with DEFAULT — 비휘발성 기본값이면 값이 테이블 메타데이터에 저장되고 기존 행 접근 시 반환되므로, 큰 테이블에서도 매우 빠릅니다. 재작성이 필요 없습니다. 반대로 clock_timestamp() 같은 휘발성 기본값, 저장형 생성 컬럼, identity 컬럼, 제약이 있는 도메인 타입 컬럼을 추가하면 테이블과 인덱스 전체가 재작성됩니다.
  • ALTER COLUMN TYPE — 보통 테이블과 인덱스 전체가 재작성됩니다. 예외는 USING 절이 내용을 바꾸지 않고 옛 타입이 새 타입으로 바이너리 호환일 때입니다. 콜레이션이 바뀌면 정렬 순서가 달라질 수 있어 인덱스 재구축이 필요합니다. 콜레이션 변경이 없다면 textvarchar 사이 변경은 인덱스를 다시 만들지 않아도 됩니다.
  • SET NOT NULL — 보통 테이블 전체를 스캔해 확인합니다. 단 NULL 이 존재할 수 없음을 증명하는 유효한 CHECK 제약이 있으면 스캔을 건너뜁니다.

안전한 제약 추가 패턴

-- 나쁜 방법: 큰 테이블 전체를 스캔하는 동안 다른 갱신이 전부 잠깁니다
ALTER TABLE orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0);

-- 좋은 방법 1단계: NOT VALID 로 즉시 커밋 (테이블 스캔 없음)
ALTER TABLE orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;

-- 좋은 방법 2단계: 나중에 검증 (SHARE UPDATE EXCLUSIVE 락만 잡습니다)
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;

문서가 설명하는 원리는 이렇습니다. 큰 테이블을 스캔해 새 외래 키나 체크, not-null 제약을 검증하는 데는 오래 걸리고, ALTER TABLE ADD CONSTRAINT 가 커밋될 때까지 그 테이블에 대한 다른 갱신이 잠깁니다. NOT VALID 옵션의 주된 목적이 바로 이 영향을 줄이는 것입니다. NOT VALID 를 붙이면 ADD CONSTRAINT 는 테이블을 스캔하지 않고 즉시 커밋할 수 있습니다.

그 뒤 VALIDATE CONSTRAINT 로 기존 행이 제약을 만족하는지 확인합니다. 이 검증 단계는 동시 갱신을 잠글 필요가 없습니다. 이후 다른 트랜잭션이 삽입하거나 갱신하는 행에는 이미 제약이 적용되고 있으므로 기존 행만 확인하면 되기 때문입니다. 그래서 SHARE UPDATE EXCLUSIVE 락만 잡습니다. 제약이 외래 키라면 참조되는 테이블에 ROW SHARE 락도 함께 필요합니다.

실패 사례와 함정

1. 어제까지 빠르던 쿼리가 오늘 느리다

증상: 코드도 데이터 크기도 크게 안 바뀌었는데 갑자기 응답이 몇 배 느려졌습니다.

진단 순서:

  1. 지금의 실행 계획을 뜹니다. 어제 빠를 때의 계획을 저장해 두지 않았다면 지금부터라도 저장하세요.
  2. 각 노드에서 추정 행 수와 실측 행 수를 비교합니다. 크게 벌어진 노드가 있으면 통계 문제입니다.
  3. ANALYZE 를 돌리고 다시 계획을 뜹니다. 계획이 원래대로 돌아오면 원인 확정입니다.
  4. 계획이 그대로라면 조인 순서나 스캔 방식이 바뀌었는지 봅니다. 데이터 분포가 임계점을 넘어 플래너가 Nested Loop에서 Hash Join으로(또는 반대로) 갈아탄 경우입니다. 이건 정상 동작이며, 진짜 원인은 대개 인덱스가 없거나 조건이 인덱스를 못 타는 것입니다.

바인드 파라미터를 쓰는 쿼리라면 값에 따라 좋은 계획이 달라지는데도 캐시된 계획이 재사용되는 경우가 있습니다. 파라미터를 상수로 바꿔 넣고 EXPLAIN 을 떠 보면 계획이 다른지 바로 확인할 수 있습니다.

2. SELECT *가 조인에서 행 폭을 부풀린다

증상: 조인 결과 행 수는 적은데 쿼리가 느리고, 정렬이 디스크로 흘러넘칩니다.

진단: 계획에서 width= 를 봅니다. 이 값은 그 노드가 내보내는 행의 평균 바이트 수 추정치입니다. 세 테이블을 조인하면서 전부 SELECT * 로 가져오면 필요 없는 컬럼까지 전부 따라옵니다. 정렬이나 해시 노드는 이 행들을 메모리에 들고 있어야 하므로, 행 폭이 크면 작업 메모리를 넘겨 디스크를 쓰게 됩니다.

고치는 법: 필요한 컬럼만 나열합니다. 부수 효과로 커버링 인덱스가 가능해지는 경우도 많습니다. 인덱스 절의 Index Only Scan 이야기가 이것입니다.

3. NOT IN 서브쿼리에 NULL이 하나 있어서 결과가 0건

가장 조용하고 가장 위험한 함정입니다. 에러가 나지 않고 그냥 빈 결과를 돌려줍니다.

원인: 문서 표현 그대로입니다. "왼쪽 표현식이 널이거나, 같은 오른쪽 값이 없으면서 오른쪽 행 중 최소 하나가 널을 내면, NOT IN 구문의 결과는 참이 아니라 널이 됩니다. 이는 널 값의 불리언 결합에 대한 SQL의 통상적인 규칙에 따른 것입니다."

-- 위험: orders.user_id 에 NULL 이 한 건이라도 있으면 결과가 0건입니다
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

-- 안전 1) NOT EXISTS 로 바꾼다 (권장)
SELECT * FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- 안전 2) NOT IN 을 유지해야 한다면 NULL 을 명시적으로 걸러 낸다
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);

NOT EXISTS 는 행이 반환되는지만 보므로 널에 걸려 넘어지지 않습니다. 습관적으로 NOT EXISTS 를 쓰는 편이 안전합니다.

4. 암묵적 타입 캐스트

증상: 인덱스가 있는 컬럼인데 계획에 Seq Scan 이 뜹니다. 조건도 단순합니다.

진단: 계획의 Filter: 또는 Index Cond: 줄에 캐스트가 보이는지 확인합니다. 위 예시 계획의 Filter: (status = 'active'::text) 처럼 ::타입 이 붙어 있으면 형 변환이 일어난 것입니다. 컬럼 쪽에 캐스트가 붙었다면 위의 "인덱스를 만들었는데 왜 안 쓰이나" 2번과 같은 상황입니다. 상수 쪽에만 붙었다면 대개 문제없습니다.

고치는 법: 애플리케이션에서 보내는 파라미터 타입을 컬럼 타입에 맞춥니다. 숫자 컬럼에 문자열을 보내거나, timestamp 컬럼에 date 를 보내는 경우가 흔합니다.

5. 큰 테이블의 COUNT

증상: 목록 화면의 전체 건수 표시 때문에 페이지 로딩이 느립니다.

원인: 조건 없는 COUNT 는 결국 행을 다 세야 합니다. 계획을 떠 보면 순차 스캔이나 인덱스 전체 스캔이 잡히는 게 보입니다. 위 키셋 페이지네이션 절과 함께 나오는 문제인 이유가 이것입니다.

선택지:

  1. 전체 건수를 정말 보여 줘야 하는지 되묻습니다. 대부분의 목록 화면에서 정확한 총 건수는 아무도 안 봅니다. "다음 페이지 있음" 여부만 있으면 됩니다. 그건 LIMIT 21 로 21행을 가져와서 21번째가 있는지 보는 것으로 해결됩니다.
  2. 대략적인 값으로 충분하다면 플래너의 추정치를 씁니다. EXPLAIN 출력의 최상단 rows= 가 그 값입니다.
  3. 정확한 값이 꼭 필요하고 자주 조회된다면 별도 카운터 테이블을 두고 갱신합니다. 이건 스키마 설계 문제이지 쿼리 튜닝 문제가 아닙니다.

언제 쓰지 않나

치트시트의 정직한 경계는 이렇습니다.

  • 계획을 안 보고 스니펫만 복사할 때. 이 글의 모든 예시는 특정한 테이블 크기와 데이터 분포를 가정합니다. 여러분의 데이터에서는 같은 쿼리가 다른 계획을 탑니다. 붙여 넣기 전에 EXPLAIN ANALYZE 를 한 번 떠 보는 데 드는 시간은 30초입니다.
  • 답이 스키마 변경이나 캐시일 때. 정규화가 잘못돼서 매번 다섯 테이블을 조인해야 한다면, 더 영리한 쿼리를 찾는 것보다 컬럼 하나를 비정규화하는 편이 낫습니다. 초당 수천 번 도는 동일한 집계라면 쿼리를 고칠 게 아니라 결과를 캐시해야 합니다. 쿼리 튜닝은 쿼리가 문제일 때만 답입니다.
  • 윈도우 함수와 CTE는 읽기 좋지만 공짜가 아닙니다. 윈도우 함수는 파티션마다 정렬을 요구할 수 있고, 그 정렬이 작업 메모리를 넘으면 디스크로 흘러넘칩니다. CTE도 마찬가지로 중간 결과를 만듭니다. 가독성을 위해 쓰는 건 좋지만, 느려졌을 때 "CTE라서 느릴 수도 있다"는 가설을 목록에 넣어 두세요. 확인 방법은 언제나 같습니다. 계획을 뜨고 어느 노드가 시간을 먹는지 봅니다.
  • 한 엔진의 문법을 다른 엔진에 그대로 옮길 때. 위 기준 엔진 절에서 말한 그대로입니다.

참고 자료


📝 퀴즈 — SQL 실무 (클릭해서 확인!)

Q1. LEFT JOIN과 INNER JOIN의 차이는? ||LEFT JOIN: 왼쪽 테이블의 모든 행 포함, 오른쪽 매칭 없으면 NULL. INNER JOIN: 양쪽 모두 매칭되는 행만 반환||

Q2. UPSERT를 PostgreSQL과 MySQL에서 각각 어떻게 쓰나? ||PostgreSQL: INSERT ... ON CONFLICT (key) DO UPDATE SET ... MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...||

Q3. 복합 인덱스 idx(a, b, c)에서 WHERE b = 2만 쓰면? ||인덱스를 사용하지 못함. 복합 인덱스는 왼쪽부터 순서대로 사용. 선두 컬럼(a) 없이는 작동하지 않음||

Q4. ROW_NUMBER와 DENSE_RANK의 차이는? ||ROW_NUMBER: 항상 연속 번호 (동점 없음). DENSE_RANK: 동점 시 같은 순위, 다음 순위는 바로 다음 번호 (1,2,2,3). RANK는 동점 후 건너뜀 (1,2,2,4)||

Q5. OFFSET 페이지네이션이 대량 데이터에서 느린 이유는? ||OFFSET N은 N개를 읽고 버림. OFFSET 100만이면 100만 행을 읽은 후 결과 반환. 커서 기반은 인덱스로 바로 시작점 접근||

Q6. 커버링 인덱스란? ||쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 테이블 접근 없이 인덱스만으로 결과를 반환하는 것. Index Only Scan||

Q7. SELECT FOR UPDATE의 용도와 주의점은? ||비관적 락 — 선택한 행을 다른 트랜잭션이 수정하지 못하게 잠금. 주의: 트랜잭션이 길어지면 다른 트랜잭션이 대기하므로 데드락 위험||

Q8. WHERE 없이 UPDATE를 실행하면? ||테이블의 모든 행이 수정됨! 항상 SELECT로 대상을 먼저 확인한 후 UPDATE 실행. 프로덕션에서는 트랜잭션으로 감싸고 확인 후 COMMIT||

퀴즈

Q1: 실행 계획을 볼 때 가장 먼저 확인해야 할 신호는 무엇인가요? 각 노드의 추정 행 수와 실측 행 수의 차이입니다. 문서도 "보통 가장 중요하게 봐야 할 것은 추정 행 수가 현실에 충분히 가까운지 여부"라고 적고 있습니다. 플래너의 모든 결정이 이 추정치에서 나오므로, 크게 빗나간 노드가 문제의 시작점입니다.

Q2: EXPLAIN ANALYZE로 UPDATE의 계획을 볼 때 주의할 점은? EXPLAIN ANALYZE는 쿼리를 실제로 실행하므로 부수 효과도 그대로 일어납니다. 데이터를 바꾸지 않고 계획만 보려면 BEGIN 으로 시작해 ROLLBACK 으로 끝내는 트랜잭션으로 감싸야 합니다.

Q3: 인덱스가 걸린 컬럼을 함수로 감싸면 왜 인덱스를 못 쓰고, 어떻게 고치나요? 인덱스는 컬럼 값의 정렬 순서를 저장하는데, 함수를 씌운 결과는 그 순서가 보존된다는 보장이 없기 때문입니다. 고치는 방법은 두 가지입니다. 함수를 씌운 결과 자체에 표현식 인덱스를 만들거나, 조건을 범위 비교로 바꿔 컬럼에서 함수를 벗겨 내는 것입니다.

Q4: OFFSET 페이지네이션이 뒤로 갈수록 느려지는 이유와, 키셋 방식이 성립하기 위한 조건은? OFFSET이 건너뛰는 행도 서버 내부에서는 계산된 뒤 버려지므로 비용이 페이지 번호에 비례해 늘어납니다. 키셋 방식은 마지막으로 본 행의 값부터 이어 읽는데, 정렬 순서가 유일해야 하므로 정렬 키 끝에 기본 키 같은 동점 처리용 컬럼이 필요하고, 그 정렬 순서와 맞는 복합 인덱스가 있어야 합니다.

Q5: CREATE INDEX와 CREATE INDEX CONCURRENTLY는 각각 어떤 락을 잡나요? 일반 CREATE INDEX는 SHARE 락을 잡아 인덱스가 만들어지는 동안 삽입, 갱신, 삭제를 블록합니다. CONCURRENTLY는 SHARE UPDATE EXCLUSIVE 락을 잡아 쓰기를 막지 않지만, 테이블을 두 번 스캔하고 기존 트랜잭션이 끝나기를 기다리므로 더 오래 걸리며 트랜잭션 블록 안에서는 실행할 수 없습니다.

현재 단락 (1/562)

치트시트를 복사해 쓰기 전에 어느 엔진 기준인지부터 알아야 합니다. 이 글의 예시는 **PostgreSQL 18** 기준으로 쓰였습니다. 문법을 보면 알 수 있습니다. `ON CO...

작성 글자: 0원문 글자: 23,278작성 단락: 0/562