
개발자를 위한 SQL 치트 시트: 2026년 기초부터 고급 쿼리까지
📷 Myburgh Roux / Pexels개발자를 위한 SQL 치트 시트: 2026년 기초부터 고급 쿼리까지
SELECT, JOIN, 서브쿼리, 윈도우 함수, CTE, 인덱싱, 최적화 팁을 다루는 포괄적인 SQL 레퍼런스 가이드입니다.
SQL은 2026년에도 개발자가 갖출 수 있는 가장 중요한 기술 중 하나입니다. REST API를 구축하든, 분석 파이프라인을 다루든, 프로덕션 데이터를 디버깅하든, SQL을 작성하고 이해하는 것은 필수입니다. 이 치트 시트는 기초를 넘어 간단한 SELECT 문부터 고급 윈도우 함수와 쿼리 최적화 전략까지 모든 것을 다루는 실용적인 레퍼런스를 제공합니다.
기본 SELECT 쿼리
모든 SQL 여정은 SELECT 문에서 시작합니다. 이것은 모든 데이터 조회 작업의 기초입니다.
컬럼 선택하기
-- Select specific columns
SELECT first_name, last_name, email
FROM users;
-- Select all columns (avoid in production code)
SELECT *
FROM users;
-- Aliasing columns for clarity
SELECT
first_name AS "First Name",
last_name AS "Last Name",
created_at AS "Registration Date"
FROM users;
-- Selecting distinct values
SELECT DISTINCT department
FROM employees;
WHERE로 필터링하기
WHERE 절을 사용하면 조건에 따라 행을 필터링할 수 있습니다. 연산자 우선순위와 NULL 처리를 이해하는 것이 올바른 쿼리를 작성하는 데 중요합니다.
-- Basic comparison
SELECT * FROM products
WHERE price > 50.00;
-- Multiple conditions with AND / OR
SELECT * FROM orders
WHERE status = 'shipped'
AND total_amount > 100
AND created_at >= '2026-01-01';
-- IN operator for multiple values
SELECT * FROM users
WHERE country IN ('US', 'CA', 'GB', 'DE');
-- BETWEEN for ranges (inclusive)
SELECT * FROM events
WHERE event_date BETWEEN '2026-01-01' AND '2026-12-31';
-- Pattern matching with LIKE
SELECT * FROM products
WHERE name LIKE '%wireless%'; -- contains 'wireless'
-- Handling NULLs (= does NOT work with NULL)
SELECT * FROM users
WHERE phone IS NULL;
SELECT * FROM users
WHERE phone IS NOT NULL;
흔한 실수는 WHERE column IS NULL 대신 WHERE column = NULL을 작성하는 것입니다. 후자는 항상 UNKNOWN으로 평가되어 어떤 행도 반환하지 않습니다.
ORDER BY로 정렬하기
-- Single column sort
SELECT * FROM products
ORDER BY price DESC;
-- Multiple column sort
SELECT * FROM employees
ORDER BY department ASC, salary DESC;
-- Sort by column position (less readable, but valid)
SELECT first_name, last_name, hire_date
FROM employees
ORDER BY 3 DESC;
-- NULLS FIRST / NULLS LAST (PostgreSQL, Oracle)
SELECT * FROM tasks
ORDER BY due_date ASC NULLS LAST;
결과 제한하기
-- PostgreSQL / MySQL
SELECT * FROM logs
ORDER BY created_at DESC
LIMIT 100;
-- With offset for pagination
SELECT * FROM products
ORDER BY id
LIMIT 20 OFFSET 40; -- Page 3 (20 items per page)
-- SQL Server
SELECT TOP 100 * FROM logs
ORDER BY created_at DESC;
-- Standard SQL (FETCH FIRST)
SELECT * FROM logs
ORDER BY created_at DESC
FETCH FIRST 100 ROWS ONLY;
집계 함수와 GROUP BY
집계 함수는 여러 행을 하나의 요약 값으로 축소합니다. 거의 항상 GROUP BY와 함께 사용됩니다.
-- Common aggregate functions
SELECT
department,
COUNT(*) AS employee_count,
AVG(salary) AS avg_salary,
MIN(salary) AS min_salary,
MAX(salary) AS max_salary,
SUM(salary) AS total_payroll
FROM employees
GROUP BY department;
-- Filtering groups with HAVING
SELECT
category,
COUNT(*) AS product_count,
AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 10
ORDER BY avg_price DESC;
-- COUNT variations
SELECT
COUNT(*) AS total_rows, -- counts all rows including NULLs
COUNT(email) AS has_email, -- counts non-NULL emails
COUNT(DISTINCT city) AS cities -- counts unique cities
FROM users;
핵심 차이점: WHERE는 집계 전에 개별 행을 필터링하고, HAVING은 집계 후에 그룹을 필터링합니다. WHERE 절에서 집계 함수를 참조할 수 없습니다.
JOIN: 테이블 결합하기
JOIN은 SQL이 진정으로 강력해지는 부분입니다. 관계형 데이터를 다루기 위해 다양한 유형을 이해하는 것이 필수입니다.
INNER JOIN
두 테이블 모두에서 일치하는 값이 있는 행만 반환합니다.
SELECT
o.id AS order_id,
o.order_date,
c.name AS customer_name,
c.email
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id;
LEFT JOIN (LEFT OUTER JOIN)
왼쪽 테이블의 모든 행과 오른쪽 테이블의 일치하는 행을 반환합니다. 일치하지 않는 오른쪽 컬럼은 NULL을 반환합니다.
-- Find all customers and their orders (including customers with no orders)
SELECT
c.name,
c.email,
o.id AS order_id,
o.total_amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;
-- Find customers who have never placed an order
SELECT c.name, c.email
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
RIGHT JOIN과 FULL OUTER JOIN
-- RIGHT JOIN: all rows from the right table
SELECT
e.name AS employee,
d.name AS department
FROM employees e
RIGHT JOIN departments d ON e.department_id = d.id;
-- FULL OUTER JOIN: all rows from both tables
SELECT
s.name AS student,
c.title AS course
FROM students s
FULL OUTER JOIN enrollments e ON s.id = e.student_id
FULL OUTER JOIN courses c ON e.course_id = c.id;
CROSS JOIN
두 테이블의 카테시안 곱을 생성합니다. 첫 번째 테이블의 모든 행이 두 번째 테이블의 모든 행과 결합됩니다.
-- Generate all possible size-color combinations
SELECT
s.size_name,
c.color_name
FROM sizes s
CROSS JOIN colors c;
Self JOIN
자기 자신과 조인하는 테이블로, 계층적이거나 비교 데이터에 유용합니다.
-- Find employees and their managers
SELECT
e.name AS employee,
m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
서브쿼리
서브쿼리는 다른 쿼리 안에 중첩된 쿼리입니다. SELECT, FROM, WHERE, HAVING 절에 나타날 수 있습니다.
WHERE에서의 서브쿼리
-- Find products priced above the average
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- Find customers who placed orders in the last 30 days
SELECT name, email
FROM customers
WHERE id IN (
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
);
상관 서브쿼리
상관 서브쿼리는 외부 쿼리의 컬럼을 참조하며 외부 행마다 한 번씩 실행됩니다. 강력하지만 큰 데이터셋에서는 JOIN보다 느릴 수 있습니다.
-- Find employees who earn more than their department average
SELECT e.name, e.salary, e.department_id
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id
);
-- EXISTS check
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
AND o.total_amount > 1000
);
FROM에서의 서브쿼리 (파생 테이블)
SELECT
dept_stats.department,
dept_stats.avg_salary,
dept_stats.employee_count
FROM (
SELECT
department,
AVG(salary) AS avg_salary,
COUNT(*) AS employee_count
FROM employees
GROUP BY department
) AS dept_stats
WHERE dept_stats.employee_count > 5
ORDER BY dept_stats.avg_salary DESC;
CTE (Common Table Expressions)
CTE는 모듈식이고 읽기 쉬운 SQL을 작성하는 방법을 제공합니다. 단일 쿼리의 실행 기간 동안 존재하는 임시 이름이 지정된 결과 집합을 정의합니다.
기본 CTE
WITH active_customers AS (
SELECT
c.id,
c.name,
c.email,
COUNT(o.id) AS order_count,
SUM(o.total_amount) AS total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.order_date >= '2026-01-01'
GROUP BY c.id, c.name, c.email
)
SELECT *
FROM active_customers
WHERE total_spent > 500
ORDER BY total_spent DESC;
다중 CTE
WITH
monthly_revenue AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
WHERE order_date >= '2025-01-01'
GROUP BY DATE_TRUNC('month', order_date)
),
revenue_with_growth AS (
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month) * 100, 2
) AS growth_pct
FROM monthly_revenue
)
SELECT *
FROM revenue_with_growth
ORDER BY month;
재귀 CTE
재귀 CTE는 조직도, 카테고리 트리, 그래프 탐색과 같은 계층적 데이터에 적합합니다.
WITH RECURSIVE org_chart AS (
-- Base case: top-level managers (no manager)
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case: employees with a manager already in the result
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT
REPEAT(' ', level - 1) || name AS org_tree,
level
FROM org_chart
ORDER BY level, name;
윈도우 함수
윈도우 함수는 현재 행과 관련된 행 집합에 대해 계산을 수행하면서도 단일 출력 행으로 축소하지 않습니다. 현대 SQL에서 가장 강력한 기능 중 하나입니다.
ROW_NUMBER, RANK, DENSE_RANK
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;
차이점: 두 직원이 2위에서 동점이면, ROW_NUMBER는 임의로 2와 3을 할당하고, RANK는 둘 다 2를 할당하고 4로 건너뛰며, DENSE_RANK는 둘 다 2를 할당하고 3으로 이어갑니다.
파티션된 윈도우 함수
-- Top 3 earners per department
WITH ranked AS (
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS dept_rank
FROM employees
)
SELECT * FROM ranked
WHERE dept_rank <= 3;
LAG와 LEAD
이 함수들은 이전 또는 다음 행의 값에 접근할 수 있게 해줍니다.
SELECT
order_date,
total_amount,
LAG(total_amount, 1) OVER (ORDER BY order_date) AS prev_day_amount,
LEAD(total_amount, 1) OVER (ORDER BY order_date) AS next_day_amount,
total_amount - LAG(total_amount, 1) OVER (ORDER BY order_date) AS daily_change
FROM daily_sales;
누적 합계와 이동 평균
SELECT
order_date,
total_amount,
SUM(total_amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
AVG(total_amount) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS seven_day_avg
FROM daily_sales;
NTILE을 이용한 버킷팅
-- Divide customers into 4 quartiles by spending
SELECT
name,
total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS spending_quartile
FROM customer_summary;
인덱싱 전략
효율적인 SQL을 작성하는 것은 쿼리 구문뿐만 아니라 데이터베이스가 쿼리를 빠르게 실행할 수 있도록 보장하는 것이기도 합니다. 인덱스는 이를 위한 주요 메커니즘입니다.
인덱스 유형
-- B-tree index (default, good for equality and range queries)
CREATE INDEX idx_users_email ON users (email);
-- Composite index (column order matters)
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, order_date DESC);
-- Unique index (also enforces uniqueness)
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);
-- Partial index (PostgreSQL - index only a subset of rows)
CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
-- GIN index for full-text search (PostgreSQL)
CREATE INDEX idx_articles_search
ON articles USING GIN (to_tsvector('english', title || ' ' || body));
인덱스를 생성해야 할 때
인덱스는 읽기를 빠르게 하지만 쓰기를 느리게 합니다. WHERE 절, JOIN 조건, ORDER BY 절에 자주 등장하는 컬럼에 인덱스를 생성하세요. 부분 인덱스를 사용하는 경우가 아니라면 카디널리티가 매우 낮은 컬럼(예: boolean 컬럼)에는 인덱스를 만들지 마세요.
(a, b, c)에 대한 복합 인덱스는 a, a AND b, 또는 a AND b AND c를 필터링하는 쿼리를 처리할 수 있지만, b 단독이나 c 단독은 처리할 수 없습니다. 컬럼 순서가 중요합니다.
EXPLAIN으로 쿼리 성능 확인하기
-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 42
AND order_date >= '2026-01-01';
-- MySQL
EXPLAIN
SELECT * FROM orders
WHERE customer_id = 42
AND order_date >= '2026-01-01';
큰 테이블에서 순차 스캔(Seq Scan)이 보이면 인덱스가 누락된 것입니다. Index Scan 또는 Index Only Scan이 보이는 것이 바람직합니다.
쿼리 최적화 팁
1. SELECT *를 피하세요
필요한 컬럼만 명시하세요. 이렇게 하면 I/O, 메모리 사용량이 줄어들고, 인덱스 전용 스캔이 가능해질 수 있습니다.
-- Bad
SELECT * FROM users WHERE id = 42;
-- Good
SELECT id, name, email FROM users WHERE id = 42;
2. 큰 서브쿼리에는 IN 대신 EXISTS를 사용하세요
-- Slower with large subquery results
SELECT * FROM customers
WHERE id IN (SELECT customer_id FROM orders);
-- Faster: stops at first match
SELECT * FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
3. 인덱스된 컬럼에 함수 사용을 피하세요
-- Bad: index on created_at is not used
SELECT * FROM orders
WHERE YEAR(created_at) = 2026;
-- Good: index-friendly range scan
SELECT * FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01';
4. 가능하면 UNION 대신 UNION ALL을 사용하세요
UNION은 중복을 제거하여 정렬이 필요하지만, UNION ALL은 그렇지 않습니다. 결과 집합이 이미 고유하다는 것을 알고 있다면 항상 UNION ALL을 선호하세요.
-- Slower (deduplicates)
SELECT name FROM employees
UNION
SELECT name FROM contractors;
-- Faster (no deduplication)
SELECT name FROM employees
UNION ALL
SELECT name FROM contractors;
5. 대량 데이터에는 배치 작업을 사용하세요
-- Instead of deleting millions of rows at once
-- Delete in batches to avoid locking the table
DELETE FROM logs
WHERE created_at < '2025-01-01'
LIMIT 10000;
-- Repeat until 0 rows affected
6. 적절한 데이터 타입을 사용하세요
더 작은 데이터 타입은 메모리와 인덱스 페이지에 더 많은 행이 들어간다는 것을 의미합니다. 범위가 허용한다면 BIGINT 대신 INTEGER를 사용하세요. 인덱스된 컬럼에는 TEXT보다 적절한 길이의 VARCHAR를 사용하세요.
유용한 SQL 패턴
UPSERT (INSERT ON CONFLICT)
-- PostgreSQL
INSERT INTO user_settings (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON CONFLICT (user_id)
DO UPDATE SET
theme = EXCLUDED.theme,
language = EXCLUDED.language;
-- MySQL
INSERT INTO user_settings (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON DUPLICATE KEY UPDATE
theme = VALUES(theme),
language = VALUES(language);
CASE로 데이터 피벗하기
SELECT
product_id,
SUM(CASE WHEN month = 1 THEN revenue ELSE 0 END) AS jan,
SUM(CASE WHEN month = 2 THEN revenue ELSE 0 END) AS feb,
SUM(CASE WHEN month = 3 THEN revenue ELSE 0 END) AS mar
FROM monthly_revenue
GROUP BY product_id;
날짜 시리즈 생성하기
-- PostgreSQL: generate a series of dates
SELECT generate_series(
'2026-01-01'::date,
'2026-12-31'::date,
'1 day'::interval
)::date AS date;
-- Use with LEFT JOIN to fill gaps in time series data
SELECT
d.date,
COALESCE(s.total, 0) AS daily_total
FROM generate_series('2026-01-01'::date, '2026-03-31'::date, '1 day') AS d(date)
LEFT JOIN daily_sales s ON s.sale_date = d.date
ORDER BY d.date;
빠른 레퍼런스 표
| 작업 | 구문 |
|---|---|
| 행 필터링 | WHERE condition |
| 결과 정렬 | ORDER BY column ASC/DESC |
| 행 제한 | LIMIT n 또는 FETCH FIRST n ROWS ONLY |
| 중복 제거 | SELECT DISTINCT |
| 그룹화 및 집계 | GROUP BY ... HAVING |
| 테이블 결합 | JOIN ... ON |
| 임시 결과 집합 | WITH cte AS (...) |
| 행 번호 매기기 | ROW_NUMBER() OVER (...) |
| 이전/다음 행 | LAG() / LEAD() OVER (...) |
| 누적 합계 | SUM() OVER (ORDER BY ... ROWS ...) |
| 삽입 또는 업데이트 | INSERT ... ON CONFLICT DO UPDATE |
결론
SQL은 방대한 언어이지만, 이 치트 시트의 패턴을 마스터하면 개발자로서 마주칠 대부분의 실제 시나리오를 다룰 수 있습니다. 탄탄한 SELECT와 JOIN 기초부터 시작한 다음, CTE와 윈도우 함수를 점진적으로 툴킷에 추가하세요. 특히 데이터가 늘어날수록 인덱싱과 쿼리 성능을 항상 염두에 두세요.
SQL Formatter를 사용하여 쿼리를 깔끔하게 정리해 보세요.
이 페이지를 북마크하고 빠른 레퍼런스가 필요할 때마다 돌아오세요. SQL을 체화하는 가장 좋은 방법은 자신의 데이터베이스에 쿼리를 작성하거나, SQLite 인메모리 샌드박스와 같은 플랫폼을 사용하거나, 오픈 데이터셋을 탐색하는 등 꾸준히 연습하는 것입니다. 오늘 구축하는 패턴은 전체 커리어에 걸쳐 도움이 될 것입니다.