Skip to content

SQL 치트시트

관계형 데이터베이스를 관리하고 쿼리하는 표준 언어.

01

SELECT & 쿼리 기본

SELECT, WHERE & ORDER BY

SELECT는 하나 이상의 테이블에서 행을 검색합니다. 성능과 명확성을 위해 * 대신 명시적으로 열을 지정하세요(스키마 변경이 앱을 깨뜨리지 않음). WHERE는 그룹화 전에 행을 필터링합니다. ORDER BY는 결과를 정렬합니다(ASC 기본, DESC 내림차순). LIMIT/OFFSET는 페이지 매김을 구현합니다 — 대용량 데이터셋에는 keyset 페이지 매김(WHERE id > last_id)을 선호하세요.

sql
-- basic query: select specific columns
SELECT id, name, email
FROM users
WHERE age >= 18 AND status = 'active'
ORDER BY name ASC, created_at DESC
LIMIT 10 OFFSET 0;

-- select all columns (avoid in production)
SELECT * FROM products;

-- column aliases with AS
SELECT name AS product_name, price * 1.1 AS price_with_tax
FROM products;

DISTINCT & 별칭

DISTINCT는 결과 집합에서 중복 행을 제거합니다. 개별 열이 아닌 전체 행에 작동합니다 — SELECT DISTINCT city, country는 고유한 city+country 쌍을 반환. 테이블 별칭(u, o)은 쿼리를 짧게 하고, 테이블이 자체 조인할 때 필요. 열 별칭은 가독성을 위해 출력 열 이름을 변경.

sql
-- unique values only
SELECT DISTINCT country FROM users;
SELECT DISTINCT city, country FROM users;  -- unique combos

-- table aliases (essential for joins)
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id;

-- column alias (AS is optional)
SELECT name product_name, COUNT(*) count
FROM products
GROUP BY name;

필터링: BETWEEN, IN, IS NULL

BETWEEN은 양 끝을 포함합니다. IN은 목록이나 서브쿼리의 모든 값과 일치. NULL에는 IS NULL / IS NOT NULL이 필요(= NULL 사용 불가). NOT IN과 서브쿼리에 주의 — 서브쿼리가 NULL을 반환하면, NOT IN은 행을 전혀 반환하지 않습니다. NULL을 올바르게 처리하고 종종 더 빠른 NOT EXISTS를 대신 사용하세요.

sql
SELECT * FROM products
WHERE price BETWEEN 10 AND 100        -- inclusive range
  AND category IN ('tech', 'home')    -- match any value
  AND stock IS NOT NULL               -- exclude NULLs
  AND discount IS NULL;               -- only NULLs

-- NOT IN with NULL caveat: returns nothing if subquery has NULL!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);  -- risky if NULLs

-- safer: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

LIKE & 패턴 매칭

LIKE는 %(0개 이상 문자)와 _(정확히 1개 문자)를 와일드카드로 사용. LIKE는 MySQL을 제외한 대부분의 데이터베이스에서 대소문자 구분(MySQL은 기본 대소문자 무시). 대소문자 무시 매칭을 위해 PostgreSQL에서 ILIKE 사용. 복잡한 패턴을 위해 정규식 사용(PostgreSQL에서 ~, MySQL에서 REGEXP). 선행 %가 있는 LIKE는 인덱스를 사용할 수 없습니다 — 성능을 위해 전문 검색을 고려하세요.

sql
-- LIKE: basic pattern matching
SELECT * FROM users WHERE name LIKE 'A%';     -- starts with A
SELECT * FROM users WHERE name LIKE '%son';    -- ends with son
SELECT * FROM users WHERE name LIKE '%a%';     -- contains a
SELECT * FROM users WHERE name LIKE '_a%';     -- second char is a

-- ILIKE (PostgreSQL): case-insensitive
SELECT * FROM users WHERE name ILIKE 'a%';

-- SIMILAR TO (PostgreSQL): regex-like
SELECT * FROM users WHERE name SIMILAR TO '[AB]%';

-- full regex (PostgreSQL)
SELECT * FROM users WHERE name ~ '^A[a-z]+$';

CASE 표현식

CASE는 SQL의 if-then-else로, 행별로 평가. SELECT, WHERE, ORDER BY, HAVING에 나타날 수 있습니다. 'pivot' 패턴(CASE의 SUM)은 행을 열로 변환 — 보고에 유용. 일치하는 WHEN이 없고 ELSE가 없으면 CASE는 NULL 반환. 예측 가능한 결과를 위해 항상 ELSE를 포함하세요.

sql
-- conditional logic in queries
SELECT
  name,
  price,
  CASE
    WHEN price < 10 THEN 'cheap'
    WHEN price < 50 THEN 'moderate'
    WHEN price < 100 THEN 'expensive'
    ELSE 'luxury'
  END AS price_category
FROM products;

-- CASE in aggregation (pivot table)
SELECT
  category,
  COUNT(*) AS total,
  SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count,
  SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM products
GROUP BY category;
02

JOIN

INNER JOIN

INNER JOIN은 양쪽 테이블에 일치하는 행만 반환. JOIN은 INNER JOIN의 약식. 다중 테이블 쿼리의 경우, 테이블을 단계별로 조인. ON은 조인 조건을 지정; USING(column)은 양쪽 테이블이 같은 열 이름을 가질 때 약식. 내부 조인은 양쪽에서 비일치 행을 제외합니다.

sql
-- only matching rows from both tables
SELECT u.name, o.total, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;

-- multiple joins
SELECT u.name, o.total, p.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- USING (when column names match)
SELECT * FROM users
JOIN profiles ON users.id = profiles.user_id;

LEFT JOIN (LEFT OUTER JOIN)

LEFT JOIN은 왼쪽 테이블의 모든 행을 반환하며, 비일치 오른쪽 행에는 NULL. '모든 것 포함' 쿼리에 필수. anti-join 패턴(WHERE right.id IS NULL)은 오른쪽에 일치가 없는 왼쪽 테이블의 행을 찾습니다 — '주문하지 않은 사용자'에 유용. COUNT(right.id)는 비-NULL 값을 세므로, 주문 없는 사용자에게 0을 반환합니다.

sql
-- all users, with their orders (NULL if no orders)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name;

-- find users with NO orders (anti-join pattern)
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

-- count orders per user (including zero)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

RIGHT & FULL OUTER JOIN

RIGHT JOIN은 모든 오른쪽 테이블 행을 반환; 테이블을 바꾸고 LEFT JOIN을 사용하는 것과 동일(더 읽기 쉬움). FULL OUTER JOIN은 양쪽 테이블의 모든 행을 반환하며, 일치가 없는 곳에 NULL — 데이터 조정에 유용. MySQL은 FULL OUTER JOIN을 직접 지원하지 않음; LEFT JOIN UNION RIGHT JOIN으로 흉내.

sql
-- RIGHT JOIN: all rows from right table
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- returns all orders, even orphaned ones (user_id = NULL)

-- FULL OUTER JOIN: all rows from both tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- returns all users AND all orders, matching where possible

-- Note: RIGHT JOIN is rarely used (just swap tables and use LEFT)
-- FULL OUTER JOIN is useful for finding mismatches between tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
WHERE u.id IS NULL OR o.user_id IS NULL;

CROSS JOIN & Self Join

CROSS JOIN은 데카르트 곱을 생성 — A의 모든 행과 B의 모든 행을 짝지음. 조합 생성(sizes × colors)에 사용. Self join(테이블을 자체에 조인)은 계층적 데이터(직원-관리자), 중복 찾기, 같은 테이블 내 행 비교에 일반적. self join에서 항상 테이블 별칭을 사용하여 두 '사본'을 구별.

sql
-- CROSS JOIN: Cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- produces all size+color combinations

-- Self join: join a table to itself
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

-- self join for hierarchical data
SELECT c.name AS child, p.name AS parent
FROM categories c
JOIN categories p ON c.parent_id = p.id;

-- self join to find duplicates
SELECT a.id, a.email, b.id AS dup_id
FROM users a
JOIN users b ON a.email = b.email AND a.id < b.id;

NATURAL JOIN & JOIN 타입 요약

NATURAL JOIN은 같은 이름의 열에서 자동으로 조인 — 편리하지만 스키마 변경이 조인 동작을 조용히 바꿀 수 있어 위험. 프로덕션에서 피하세요. LATERAL 조인은 서브쿼리가 외부 쿼리의 열을 참조할 수 있게 — '그룹당 상위 N' 쿼리에 강력. 쉼표 구문(FROM a, b)은 CROSS JOIN과 동일.

sql
-- NATURAL JOIN: joins on all matching column names
-- (rarely recommended — implicit, fragile)
SELECT * FROM users NATURAL JOIN profiles;
-- joins on any column that exists in BOTH tables

-- Summary of join types:
-- INNER JOIN : matching rows only
-- LEFT JOIN  : all left + matching right
-- RIGHT JOIN : all right + matching left
-- FULL JOIN  : all from both sides
-- CROSS JOIN : Cartesian product
-- SELF JOIN  : table joined to itself

-- LATERAL JOIN (PostgreSQL): subquery can reference outer query
SELECT u.name, recent.*
FROM users u,
LATERAL (
  SELECT * FROM orders o
  WHERE o.user_id = u.id
  ORDER BY o.created_at DESC
  LIMIT 3
) recent;
03

GROUP BY & 집계

GROUP BY & HAVING

GROUP BY는 행을 그룹으로 붕괴시키며, 그룹당 하나의 행. 집계 함수(COUNT, SUM, AVG, MIN, MAX)는 각 그룹에 작동. WHERE는 그룹화 전에 개별 행을 필터링; HAVING은 집계 후 그룹을 필터링. SELECT의 비집계 열은 GROUP BY에 나타나야 함(표준 SQL). MySQL은 관대하지만 예측 불가 — 항상 모든 비집계 열을 GROUP BY에 포함.

sql
-- aggregate per group
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 5
ORDER BY cnt DESC;

-- HAVING filters groups (after aggregation)
-- WHERE filters rows (before aggregation)
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active'       -- filter rows first
GROUP BY dept
HAVING AVG(salary) > 50000;   -- then filter groups

집계 함수

COUNT(*)는 NULL을 포함한 모든 행을 세고; COUNT(column)는 비-NULL 값만 셈. COUNT(DISTINCT col)는 고유 값을 셈. SUM/AVG는 NULL을 무시. AVG = SUM/COUNT(비-NULL), 따라서 NULL은 평균에 영향. STRING_AGG(PostgreSQL) / GROUP_CONCAT(MySQL)는 그룹별로 문자열을 연결. BOOL_OR/BOOL_AND는 임의/모든 값이 참이면 참을 반환.

sql
SELECT
  COUNT(*) AS total_rows,           -- counts all rows
  COUNT(email) AS emails_filled,    -- counts non-NULL emails
  COUNT(DISTINCT country) AS countries,
  SUM(amount) AS total_revenue,
  AVG(amount) AS avg_order,
  MIN(amount) AS smallest_order,
  MAX(amount) AS largest_order,
  -- string aggregation (PostgreSQL)
  STRING_AGG(name, ', ') AS all_names,
  -- boolean aggregation
  BOOL_OR(is_active) AS any_active,
  BOOL_AND(is_active) AS all_active
FROM orders;

다중 열 GROUP BY

여러 열로 그룹화하면 그룹의 계층이 생성. WITH ROLLUP은 소계와 총계 행을 추가(그룹화된 열에 NULL). GROUPING SETS는 원하는 그룹화 조합을 정확히 지정 — ROLLUP보다 유연. CUBE는 가능한 모든 그룹화 조합을 생성. 보고와 OLAP 쿼리에 필수.

sql
-- multi-level grouping
SELECT
  EXTRACT(YEAR FROM created_at) AS yr,
  EXTRACT(MONTH FROM created_at) AS mon,
  category,
  COUNT(*) AS cnt,
  SUM(total) AS revenue
FROM orders
GROUP BY yr, mon, category
ORDER BY yr DESC, mon DESC, cnt DESC;

-- GROUP BY with ROLLUP (subtotals + grand total)
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category WITH ROLLUP;
-- last row has NULL category = grand total

-- GROUPING SETS (PostgreSQL): specify multiple groupings
SELECT category, status, COUNT(*)
FROM products
GROUP BY GROUPING SETS ((category, status), (category), ());

HAVING vs WHERE

주요 구분: WHERE는 집계 전에 개별 행을 필터링(SUM, COUNT 등 사용 불가), HAVING은 집계 후 그룹을 필터링(집계 함수 사용 가능). 데이터를 일찍 줄이려면 WHERE를 사용(더 나은 성능), 그 다음 집계된 결과를 필터링하려면 HAVING. 둘 다 같은 쿼리에 나타날 수 있습니다 — WHERE 먼저, 그 다음 GROUP BY, 그 다음 HAVING.

sql
-- WHERE: filters rows BEFORE grouping
-- Cannot use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- OK: filter on raw column
GROUP BY category;

-- HAVING: filters groups AFTER grouping
-- Can use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) > 5       -- OK: filter on aggregate
   AND AVG(price) > 20;   -- OK: multiple aggregate filters

-- combining both
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- filter rows
GROUP BY category         -- group
HAVING COUNT(*) > 5;      -- filter groups

날짜/시간 집계

날짜 잘림은 시계열 보고에 필수. DATE(col)는 날짜만 추출; EXTRACT/DATE_PART는 특정 구성 요소(연, 월, 시간)를 가져옴. TO_CHAR는 그룹화와 표시를 위해 날짜를 형식화. 시계열 분석을 위해, 타임스탬프 타입을 유지하는 DATE_TRUNC('month', col)를 고려. 대형 테이블의 성능을 위해 날짜 열을 인덱스.

sql
-- group by date parts
SELECT
  DATE(created_at) AS order_date,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at)
ORDER BY order_date DESC;

-- group by hour
SELECT
  EXTRACT(HOUR FROM created_at) AS hr,
  COUNT(*) AS cnt
FROM orders
WHERE created_at >= CURRENT_DATE
GROUP BY hr
ORDER BY hr;

-- monthly revenue trend
SELECT
  TO_CHAR(created_at, 'YYYY-MM') AS month,
  SUM(total) AS revenue,
  COUNT(*) AS orders,
  AVG(total) AS avg_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY month
ORDER BY month;
04

서브쿼리 & CTE

스칼라 & 열 서브쿼리

스칼라 서브쿼리는 단일 값을 반환하고 값이 예상되는 어디서나 사용 가능. 열 서브쿼리는 하나의 열을 반환하고 IN, ANY, ALL과 사용. SELECT의 서브쿼리(상관)는 외부 행당 한 번 실행 — 대용량 데이터셋에서 느릴 수 있음. 더 나은 성능을 위해 JOIN과 GROUP BY로 재작성을 고려.

sql
-- scalar subquery (returns single value)
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);

-- column subquery (returns one column, multiple rows)
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);

-- subquery in SELECT
SELECT
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

상관 서브쿼리 & EXISTS

상관 서브쿼리는 외부 쿼리를 참조하고 외부 행당 한 번 실행 — 잠재적으로 느림. EXISTS/NOT EXISTS는 단락하므로(첫 일치에서 중지) 효율적. NOT EXISTS가 '일치하는 행이 없는 행'을 찾는 선호되는 방법 — NULL을 올바르게 처리하고 종종 NOT IN보다 빠름. 데이터베이스가 상관 서브쿼리를 조인으로 최적화할 수 있음.

sql
-- correlated: subquery references outer query
SELECT u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.total > 1000
);

-- NOT EXISTS: users without any orders
SELECT u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- correlated subquery in SELECT (runs per row)
SELECT
  u.name,
  (SELECT MAX(o.total)
   FROM orders o
   WHERE o.user_id = u.id) AS max_order
FROM users u;

공통 테이블 표현식 (CTE)

CTE(WITH 절)는 복잡한 쿼리를 읽기 쉽게 만드는 명명된 임시 결과 집합을 생성. 서브쿼리와 달리, CTE는 여러 번 참조 가능하고 위에서 아래로 읽힘. 대부분의 데이터베이스에서 CTE는 인라인됨(최적화는 쿼리 수준에서 발생). PostgreSQL 12+는 MATERIALIZED/NOT MATERIALIZED 힌트를 지원. CTE는 재귀 쿼리에도 필요.

sql
-- CTE: named temporary result set
WITH active_users AS (
  SELECT id, name FROM users WHERE status = 'active'
),
user_orders AS (
  SELECT user_id, COUNT(*) AS cnt, SUM(total) AS revenue
  FROM orders
  GROUP BY user_id
)
SELECT au.name, uo.cnt, uo.revenue
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.revenue DESC NULLS LAST;

-- CTEs improve readability for complex queries
-- They are NOT materialized (just syntactic sugar) in most DBs
-- (PostgreSQL 12+ can materialize with MATERIALIZED keyword)

재귀 CTE

재귀 CTE는 자신을 참조하여 트리/그래프 순회와 시퀀스 생성을 가능하게. 구조: 기본 케이스 UNION ALL 재귀 케이스. 재귀 케이스는 CTE를 참조하고 종료해야 함(무한 루프 방지를 위해 WHERE 추가). 일반 용도: 조직도, 카테고리 트리, 의존성 그래프, 날짜 시퀀스. 각 데이터베이스는 약간 다른 구문 — DBMS 문서를 확인하세요.

sql
-- hierarchical data: org chart
WITH RECURSIVE org_tree AS (
  -- base case: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive case: direct reports
  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 level, name FROM org_tree ORDER BY level, name;

-- generate a series of dates
WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS d
  UNION ALL
  SELECT d + 1 FROM dates WHERE d < '2024-01-31'
)
SELECT d FROM dates;

-- factorial
WITH RECURSIVE fact(n, result) AS (
  SELECT 1, 1
  UNION ALL
  SELECT n + 1, result * (n + 1) FROM fact WHERE n < 10
)
SELECT * FROM fact;

서브쿼리 연산자: ANY, ALL

ANY와 ALL은 값을 서브쿼리 결과 집합과 비교. > ANY는 '적어도 하나보다 큼'을 의미. > ALL은 '모든 것보다 큼'을 의미. = ANY는 IN과 동일. <> ALL은 NOT IN과 동일하지만 NULL을 더 안전하게 처리. 이 연산자들은 IN/EXISTS보다 덜 일반적으로 사용되지만 특정 쿼리를 더 자연스럽게 표현할 수 있음.

sql
-- ANY: greater than ANY of the values (= at least one)
SELECT * FROM products
WHERE price > ANY (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds at least one tech product's price

-- ALL: greater than ALL values (= every one)
SELECT * FROM products
WHERE price > ALL (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds every tech product's price

-- = ANY is equivalent to IN
SELECT * FROM users
WHERE id = ANY (SELECT user_id FROM orders);

-- <> ALL is equivalent to NOT IN (but NULL-safe)
SELECT * FROM users
WHERE id <> ALL (SELECT user_id FROM orders WHERE total < 0);
05

윈도우 함수

ROW_NUMBER, RANK, DENSE_RANK

ROW_NUMBER는 고유한 순차 번호(1, 2, 3...)를 할당. RANK는 동점에 같은 순위를 주지만 후속 번호를 건너뜀(1, 1, 3). DENSE_RANK는 동점에 같은 순위를 주되 건너뛰지 않음(1, 1, 2). PARTITION BY는 행을 그룹으로 나눔; 함수는 파티션별로 재설정. '그룹당 상위 N' 패턴(ROW_NUMBER + WHERE rn <= N)이 분석에서 극히 일반적.

sql
SELECT
  name,
  salary,
  dept,
  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;

-- top 3 earners per department
SELECT * FROM (
  SELECT
    name,
    dept,
    salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) ranked
WHERE rn <= 3;

LAG & LEAD

LAG는 이전 행의 값에 접근; LEAD는 다음 행의 값에 접근. 둘 다 선택적 오프셋(기본 1)과 기본값(기본 NULL)을 받음. 시계열 분석에 필수: 일별 변화, 이동 비교, 갭 감지. 백분율 계산에서 NULLIF가 0으로 나누기를 방지. 결정적 결과를 위해 OVER 절에 항상 ORDER BY를 지정.

sql
-- compare each row to previous/next
SELECT
  date,
  revenue,
  LAG(revenue) OVER (ORDER BY date) AS prev_day,
  LEAD(revenue) OVER (ORDER BY date) AS next_day,
  revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
  ROUND(
    (revenue - LAG(revenue) OVER (ORDER BY date)) * 100.0
    / NULLIF(LAG(revenue) OVER (ORDER BY date), 0),
    2
  ) AS pct_change
FROM daily_sales
ORDER BY date;

-- LAG with offset and default
SELECT
  date,
  revenue,
  LAG(revenue, 7) OVER (ORDER BY date) AS revenue_7_days_ago
FROM daily_sales;

누적 합 & 이동 평균

윈도우 프레임은 함수가 작동할 행을 정의. ROWS BETWEEN은 물리적 행 오프셋을 사용; RANGE는 논리적 값 범위를 사용(날짜 갭에 더 나음). UNBOUNDED PRECEDING은 '처음부터'를 의미. 누적 합(누적 SUM)과 이동 평균이 가장 일반적인 분석 패턴. 프레임 없이, 집계 윈도우 함수는 기본값을 사용: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

sql
-- cumulative sum (running total)
SELECT
  date,
  revenue,
  SUM(revenue) OVER (ORDER BY date) AS running_total,
  SUM(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS last_7_days_sum,
  AVG(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales
ORDER BY date;

-- window frame options:
-- ROWS BETWEEN n PRECEDING AND n FOLLOWING  -- physical rows
-- RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW -- logical range
-- ROWS UNBOUNDED PRECEDING = from start to current
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rows

NTILE & PERCENT_RANK

NTILE(n)은 정렬된 행을 n개의 대략 동일한 그룹으로 나눔(사분위수, 십분위수, 백분위수). PERCENT_RANK는 상대 순위(0에서 1)를 제공. CUME_DIST는 누적 분포를 제공. FIRST_VALUE/LAST_VALUE는 프레임의 첫/마지막 행에서 값을 반환 — LAST_VALUE는 기본 프레임이 현재 행에서 끝나므로 명시적 프레임(UNBOUNDED FOLLOWING)이 필요.

sql
-- divide into quartiles
SELECT
  name,
  salary,
  NTILE(4) OVER (ORDER BY salary DESC) AS quartile,
  PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
  CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;

-- first/last value in a partition
SELECT
  dept,
  name,
  salary,
  FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
  LAST_VALUE(name) OVER (
    PARTITION BY dept ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS lowest_earner
FROM employees;

윈도우 집계 함수

윈도우 집계(SUM, AVG, COUNT 등 with OVER)는 행을 붕괴시키지 않고 집계 값을 계산 — 각 행이 집계를 첨부받음. 이것이 GROUP BY와의 주요 차이: 요약을 보면서 모든 상세 행을 유지. 개별 값을 그룹 평균과 비교, 백분율 계산, 상세 보고서에 컨텍스트 열 추가에 완벽.

sql
-- aggregates over windows (no row collapse!)
SELECT
  name,
  dept,
  salary,
  -- compare to department average
  AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
  salary - AVG(salary) OVER (PARTITION BY dept) AS diff_from_avg,
  -- percentage of department total
  salary * 100.0 / SUM(salary) OVER (PARTITION BY dept) AS pct_of_dept,
  -- count per department
  COUNT(*) OVER (PARTITION BY dept) AS dept_size
FROM employees
ORDER BY dept, salary DESC;

-- key advantage: aggregates without GROUP BY
-- every row is preserved, with the aggregate value attached
06

DDL: 테이블 & 스키마

CREATE TABLE & 데이터 타입

CREATE TABLE은 스키마를 정의. SERIAL(PostgreSQL) / AUTO_INCREMENT(MySQL)가 ID를 자동 생성. VARCHAR(n)은 제한이 있고; TEXT는 무제한. DECIMAL(p,s)은 정확(돈에 사용!), FLOAT은 근사. CHECK 제약은 비즈니스 규칙을 강제. DEFAULT는 지정되지 않을 때 값을 제공. JSONB(PostgreSQL)는 인덱스된 JSON 쿼리를 가능하게. 시간대를 가로지르는 타임스탬프에는 항상 TIMESTAMP WITH TIME ZONE을 사용.

sql
CREATE TABLE users (
  id          SERIAL PRIMARY KEY,          -- auto-increment (PostgreSQL)
  -- MySQL: id INT AUTO_INCREMENT PRIMARY KEY
  username    VARCHAR(50) UNIQUE NOT NULL,
  email       VARCHAR(255) UNIQUE NOT NULL,
  password    VARCHAR(255) NOT NULL,
  age         INT CHECK (age >= 0 AND age <= 150),
  salary      DECIMAL(10, 2) DEFAULT 0.00, -- precision, scale
  bio         TEXT,                        -- unlimited length
  avatar      BYTEA,                       -- binary (PostgreSQL)
  metadata    JSONB,                       -- JSON (PostgreSQL)
  status      VARCHAR(20) DEFAULT 'active',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- common types:
-- INT, BIGINT, SMALLINT, DECIMAL(p,s), NUMERIC
-- VARCHAR(n), CHAR(n), TEXT
-- DATE, TIME, TIMESTAMP, INTERVAL
-- BOOLEAN, UUID, JSON/JSONB, BYTEA/BLOB

제약: PRIMARY, FOREIGN, UNIQUE, CHECK

제약은 데이터베이스 수준에서 데이터 무결성을 강제. PRIMARY KEY는 행을 고유하게 식별(NOT NULL + UNIQUE 암시). FOREIGN KEY는 참조 무결성을 유지 — ON DELETE CASCADE는 부모가 삭제되면 자식을 제거. UNIQUE는 중복을 방지. CHECK는 커스텀 규칙을 강제. 데이터베이스(앱 코드뿐만 아닌)에 제약을 정의하면 데이터 접근 방법과 무관하게 무결성을 보장.

sql
CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total       DECIMAL(10,2) NOT NULL CHECK (total >= 0),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  -- table-level constraints
  CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped','cancelled')),
  CONSTRAINT unique_user_order UNIQUE (user_id, created_at)
);

-- foreign key actions:
-- ON DELETE CASCADE  : delete child rows when parent deleted
-- ON DELETE SET NULL : set FK to NULL (column must be nullable)
-- ON DELETE RESTRICT : prevent parent deletion (default)
-- ON UPDATE CASCADE  : update FK when parent PK changes

-- add constraint to existing table
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);

ALTER TABLE

ALTER TABLE은 기존 테이블 구조를 수정. 기본값이 있는 열 추가는 보통 빠름(PostgreSQL 11+는 테이블을 재작성하지 않음). 열 삭제는 테이블을 잠글 수 있음. 열 타입 변경은 전체 테이블 재작성이 필요할 수 있고 데이터가 변환되지 않으면 실패할 수 있음. 항상 사본에서 스키마 마이그레이션을 먼저 테스트. 버전 제어 스키마 변경을 위해 마이그레이션 도구(Flyway, Alembic, Rails migrations)를 사용.

sql
-- add column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT false;

-- drop column
ALTER TABLE users DROP COLUMN avatar;

-- rename column/table
ALTER TABLE users RENAME COLUMN username TO login;
ALTER TABLE users RENAME TO accounts;

-- change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
-- MySQL: ALTER TABLE users MODIFY COLUMN age BIGINT;

-- add/drop constraints
ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email);
ALTER TABLE users DROP CONSTRAINT email_unique;

-- set default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';

DROP, TRUNCATE & 인덱스

DROP TABLE은 테이블을 완전히 제거; TRUNCATE는 비우지만 구조를 유지(DELETE보다 훨씬 빠르고, 자동 증분 재설정). 인덱스는 쿼리를 빠르게 하지만 쓰기를 느리게 — 전략적으로 인덱스. 복합 인덱스는 왼쪽에서 오른쪽으로 작동: idx(a,b,c)는 WHERE a=?, WHERE a=? AND b=?에 도움, 하지만 WHERE b=?에는 안 됨. GIN 인덱스는 전문 검색을 가능하게. 부분 인덱스는 일치하는 행만 인덱스하여 공간을 절약.

sql
-- DROP: permanently remove table (structure + data)
DROP TABLE IF EXISTS old_logs CASCADE;
-- CASCADE drops dependent objects (views, FKs)

-- TRUNCATE: remove all data, keep structure (faster than DELETE)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY;  -- reset SERIAL counter
TRUNCATE TABLE orders, order_items CASCADE;  -- multiple tables

-- CREATE INDEX for query performance
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_products_name ON products USING gin(to_tsvector('english', name));

-- partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(last_login)
WHERE status = 'active';

-- DROP INDEX
DROP INDEX IF EXISTS idx_users_email;

뷰 & 구체화된 뷰

뷰는 가상 테이블처럼 작동하는 저장된 쿼리 — 매번 기본 쿼리를 실행. 복잡한 쿼리를 단순화, 보안 강제(열 수준 접근), 안정적인 API 제공을 위해 뷰를 사용. 구체화된 뷰는 실제 결과를 저장 — 쿼리는 더 빠르지만 새로고침 필요. 실시간 데이터가 필요 없는 비싼 집계에 구체화된 뷰를 사용. CONCURRENTLY는 잠금 없이 새로고침(PostgreSQL).

sql
-- VIEW: stored query (virtual table, runs on access)
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';

SELECT * FROM active_users WHERE name LIKE 'A%';

-- updatable view (simple views can be INSERTed/UPDATEd)
CREATE VIEW user_summary AS
SELECT id, name, email, age FROM users;

-- MATERIALIZED VIEW: stored result (must refresh)
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY month;

REFRESH MATERIALIZED VIEW monthly_stats;
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; -- no lock

-- drop
DROP VIEW IF EXISTS active_users;
DROP MATERIALIZED VIEW IF EXISTS monthly_stats;
07

DML: Insert, Update, Delete

INSERT

INSERT는 행을 추가. 하나의 문에 여러 VALUES가 별도 insert보다 효율적. INSERT...SELECT는 테이블 간 데이터 복사. RETURNING(PostgreSQL/Oracle)은 한 번에 자동 생성 값(SERIAL id 같은)을 검색 — 애플리케이션 코드에 필수. 모든 기본값으로 행을 삽입하려면 DEFAULT VALUES 사용. 스키마 변경에 대한 복원력을 위해 항상 열 이름을 지정.

sql
-- single row
INSERT INTO users (name, email, age)
VALUES ('Alice', '[email protected]', 30);

-- multiple rows
INSERT INTO users (name, email) VALUES
  ('Bob', '[email protected]'),
  ('Carol', '[email protected]'),
  ('Dave', '[email protected]');

-- INSERT ... SELECT (copy data between tables)
INSERT INTO archive_users (name, email, deleted_at)
SELECT name, email, NOW()
FROM users
WHERE status = 'deleted';

-- INSERT with RETURNING (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Eve', '[email protected]')
RETURNING id, created_at;  -- returns the generated id

-- DEFAULT values
INSERT INTO users DEFAULT VALUES;

UPDATE

UPDATE는 기존 행을 수정. 모든 행을 업데이트하려는 게 아니라면 항상 WHERE 절을 포함. FROM 절(PostgreSQL)은 업데이트에 조인을 허용. RETURNING은 수정된 행을 표시. 문제가 생기면 ROLLBACK할 수 있도록 다단계 업데이트에 트랜잭션을 사용. 일반적 실수는 WHERE를 잊는 것 — 영향받는 행을 확인하기 위해 먼저 같은 WHERE로 SELECT를 실행을 고려.

sql
-- basic update
UPDATE users
SET age = 31, status = 'verified', updated_at = NOW()
WHERE id = 1;

-- update based on another table
UPDATE products p
SET price = p.price * 1.1
FROM categories c
WHERE p.category_id = c.id AND c.name = 'electronics';

-- update with subquery
UPDATE users
SET status = 'premium'
WHERE id IN (
  SELECT user_id FROM orders
  GROUP BY user_id HAVING SUM(total) > 1000
);

-- UPDATE with RETURNING (PostgreSQL)
UPDATE users SET status = 'inactive'
WHERE last_login < '2023-01-01'
RETURNING id, name;

-- WARNING: UPDATE without WHERE affects ALL rows!

DELETE & TRUNCATE

DELETE는 한 번에 하나씩 행을 제거(로깅됨, 롤백 가능, 느림). TRUNCATE는 한 번에 모든 행을 제거(최소 로깅, 훨씬 빠름, 자동 증분 재설정, 일부 DB에서 롤백 불가). 감사 추적에는 하드 삭제 대신 소프트 삭제(deleted_at 타임스탬프)를 사용. DELETE에는 항상 WHERE를 사용. 외래 키 제약을 고려 — ON DELETE CASCADE가 자식 행을 자동으로 처리.

sql
-- delete specific rows
DELETE FROM users WHERE status = 'inactive';

-- delete with subquery
DELETE FROM orders
WHERE user_id IN (
  SELECT id FROM users WHERE status = 'deleted'
);

-- delete with RETURNING (PostgreSQL)
DELETE FROM users
WHERE last_login < '2020-01-01'
RETURNING id, name;

-- delete all rows (slow, logged, can be rolled back)
DELETE FROM logs;

-- TRUNCATE (fast, minimal logging, resets identity)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;

-- soft delete pattern (preferred for audit)
UPDATE users SET deleted_at = NOW() WHERE id = 1;
SELECT * FROM users WHERE deleted_at IS NULL; -- active users

UPSERT (INSERT ... ON CONFLICT)

UPSERT(업데이트 또는 삽입)는 중복 키 충돌을 원자적으로 처리. PostgreSQL은 ON CONFLICT (column) DO UPDATE/DO NOTHING을 사용. MySQL은 ON DUPLICATE KEY UPDATE를 사용. EXCLUDED(PostgreSQL) / VALUES()(MySQL)는 제안된 삽입 값을 참조. 이것이 멱등 연산과 레이스 조건 회피에 필수. upsert 없이는 레이스 조건에 취약한 SELECT-then-INSERT/UPDATE가 필요.

sql
-- PostgreSQL: ON CONFLICT (upsert)
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
RETURNING *;

-- DO NOTHING on conflict
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id) DO NOTHING;

-- MySQL: ON DUPLICATE KEY UPDATE
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON DUPLICATE KEY UPDATE email = VALUES(email);

-- SQLite: ON CONFLICT
INSERT INTO users (id, name)
VALUES (1, 'Alice')
ON CONFLICT(id) DO UPDATE SET name = excluded.name;

MERGE 문

MERGE(aka 업그레이드된 UPSERT)는 행이 일치하는지에 따라 단일 원자적 문에서 INSERT, UPDATE, DELETE를 결합. 소스 간 데이터 동기화의 가장 효율적 방법. WHEN MATCHED는 기존 행에 UPDATE/DELETE를; WHEN NOT MATCHED는 새 행에 INSERT를 트리거. SQL Server, Oracle, PostgreSQL 15+, DB2에서 사용 가능. MySQL은 MERGE를 지원하지 않음 — INSERT...ON DUPLICATE KEY를 사용.

sql
-- MERGE: conditional insert/update/delete in one statement
-- (SQL Server, Oracle, PostgreSQL 15+)
MERGE INTO products AS target
USING (VALUES
  (1, 'Widget', 9.99),
  (2, 'Gadget', 19.99),
  (3, 'Gizmo', 29.99)
) AS source (id, name, price)
ON target.id = source.id
WHEN MATCHED THEN
  UPDATE SET name = source.name, price = source.price
WHEN NOT MATCHED THEN
  INSERT (id, name, price) VALUES (source.id, source.name, source.price)
WHEN MATCHED AND source.price < 0 THEN
  DELETE;

-- useful for:
-- - syncing data from external sources
-- - bulk upsert with conditional logic
-- - ETL operations
08

트랜잭션 & ACID

BEGIN, COMMIT, ROLLBACK

트랜잭션은 연산을 원자적 단위로 그룹화 — 모두 성공(COMMIT)하거나 모두 실패(ROLLBACK). 이것이 ACID의 'A'. BEGIN/START TRANSACTION이 트랜잭션을 시작. SAVEPOINT는 트랜잭션 내에서 명명된 롤백 지점을 생성 — 전체 트랜잭션을 중단하지 않고 롤백 가능. 항상 커밋하거나 롤백 — 트랜잭션을 열어두면 잠금을 유지하고 교착 상태를 유발할 수 있음.

sql
-- basic transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- both updates succeed or both fail (atomicity)

-- rollback on error
BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 50.00);
  -- oops, something went wrong
  ROLLBACK;
-- the insert is undone

-- transaction with savepoints
BEGIN;
  INSERT INTO logs (msg) VALUES ('step 1');
  SAVEPOINT my_savepoint;
  INSERT INTO logs (msg) VALUES ('step 2');
  ROLLBACK TO my_savepoint;  -- undo step 2, keep step 1
  INSERT INTO logs (msg) VALUES ('step 3');
COMMIT;  -- commits step 1 and step 3

격리 수준

격리 수준은 일관성과 동시성의 균형. READ COMMITTED(PostgreSQL/Oracle 기본)는 더러운 읽기를 방지하지만 반복 불가능한 읽기를 허용. REPEATABLE READ는 반복 불가능한 읽기를 방지하지만 팬텀 읽기를 허용. SERIALIZABLE은 모든 이상 현상을 방지하지만 동시성을 감소. 더 높은 격리 = 더 많은 잠금 = 더 적은 동시성. 정확성 요구를 충족하는 가장 낮은 수준을 선택.

sql
-- set isolation level for transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
  -- can see committed data from other transactions
  SELECT balance FROM accounts WHERE id = 1;
COMMIT;

-- isolation levels (from weakest to strongest):
-- READ UNCOMMITTED: can read uncommitted (dirty) data
-- READ COMMITTED: only committed data (PostgreSQL default)
-- REPEATABLE READ: same query returns same results within txn
-- SERIALIZABLE: transactions appear to run sequentially

-- PostgreSQL: set per transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- MySQL: set per session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- check current level
SHOW TRANSACTION ISOLATION LEVEL;

잠금 & SELECT FOR UPDATE

SELECT FOR UPDATE는 커밋할 때까지 다른 트랜잭션이 행을 수정할 수 없게 잠금. 이것이 비관적 동시성 제어를 구현. SKIP LOCKED는 작업 큐에 필수 — 여러 작업자가 서로 차단 없이 작업을 가져올 수 있음. NOWAIT는 대기 대신 빠르게 실패. 잠금은 동시성을 감소시키고 교착 상태를 유발할 수 있으므로 드물게 사용. 대부분의 사용 사례에 낙관적 동시성(버전 열)을 선호.

sql
-- pessimistic locking: lock rows for update
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- row is locked; other transactions must wait
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- lock released

-- NOWAIT: don't wait if locked, error immediately
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: skip locked rows (useful for job queues)
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

-- SHARE LOCK: allow reads but prevent updates
SELECT * FROM products WHERE id = 1 FOR SHARE;

교착 상태 & 에러 처리

교착 상태는 두 트랜잭션이 서로 필요한 잠금을 가질 때 발생. 데이터베이스는 교착 상태를 감지하고 한 트랜잭션(희생자)을 중단. 모든 트랜잭션에서 일관된 순서로 잠금을 획득하여 교착 상태를 방지. 교착 상태나 직렬화 실패로 실패한 트랜잭션을 재시도할 준비를 항상 하세요. 잠금 경합을 줄이기 위해 트랜잭션을 짧게 유지. 애플리케이션 코드는 SQLSTATE 40P01(교착 상태)를 잡고 재시도해야 함.

sql
-- deadlock example:
-- Transaction A:
BEGIN;
  UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- locks row 1
  UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- waits for row 2

-- Transaction B (concurrent):
BEGIN;
  UPDATE accounts SET balance = balance - 30 WHERE id = 2; -- locks row 2
  UPDATE accounts SET balance = balance + 30 WHERE id = 1; -- waits for row 1
-- DEADLOCK! Database detects and kills one transaction

-- prevention: always lock in consistent order
-- Transaction A and B both lock id=1 first, then id=2

-- PostgreSQL: error codes for handling
-- 40P01: deadlock_detected
-- 40001: serialization_failure
-- 40P02: transaction_integrity_constraint_violation

-- retry pattern (pseudocode):
-- for attempt in range(3):
--     try:
--         BEGIN; ... COMMIT; break
--     except deadlock:
--         ROLLBACK; continue

ACID 속성

ACID는 신뢰할 수 있는 데이터베이스 트랜잭션의 기반. 원자성: 트랜잭션의 모든 연산이 함께 성공하거나 실패. 일관성: 트랜잭션이 데이터베이스를 한 유효 상태에서 다른 유효 상태로 이동(제약이 강제됨). 격리: 동시 트랜잭션이 간섭하지 않음(격리 수준으로 제어). 지속성: 일단 커밋되면 데이터가 충돌에서 생존(쓰기-앞 로깅으로 달성). NoSQL 데이터베이스는 확장성을 위해 일부 ACID 속성을 희생하는 경우가 많음.

sql
-- ACID guarantees for transactions:

-- A: Atomicity (all or nothing)
BEGIN;
  INSERT INTO orders (id, total) VALUES (1, 100);
  INSERT INTO order_items (order_id, product_id) VALUES (1, 5);
  -- if either fails, both are rolled back
COMMIT;

-- C: Consistency (valid state to valid state)
-- constraints are checked at commit
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
  CHECK (balance >= 0);
-- a transaction that would make balance negative fails

-- I: Isolation (concurrent transactions don't interfere)
-- controlled by isolation level (see previous section)

-- D: Durability (committed data survives crashes)
-- achieved via WAL (Write-Ahead Logging) + fsync
-- synchronous_commit = on (default) ensures durability
09

인덱스, 뷰 & 저장 프로시저

인덱스 타입 & 전략

B-tree 인덱스(기본)는 동등(=)과 범위(<, >, BETWEEN) 쿼리를 처리. 복합 인덱스는 최좌측 접두사 규칙을 따름 — (a,b,c) 인덱스는 WHERE a=?, WHERE a=? AND b=?에 도움, 하지만 WHERE b=?에는 안 됨. 부분 인덱스는 부분 집합만 인덱스하여 공간 절약. 표현식 인덱스는 함수에 대한 인덱스 쿼리를 가능하게(LOWER, 계산 열). 인덱스가 사용되는지 확인하려면 EXPLAIN ANALYZE를 사용 — 사용되지 않는 인덱스는 공간을 낭비하고 쓰기를 느리게.

sql
-- B-tree index (default): equality and range queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_date ON orders(created_at);

-- composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- helps: WHERE user_id = 1
-- helps: WHERE user_id = 1 AND status = 'paid'
-- does NOT help: WHERE status = 'paid' (leftmost prefix rule)

-- partial index: smaller, faster for common filters
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';

-- expression index
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- unique index
CREATE UNIQUE INDEX idx_unique_email ON users(email);

-- EXPLAIN: see if index is used
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

저장 프로시저 & 함수

함수는 값을 반환하고 SELECT에 사용 가능; 프로시저는 동작을 수행하고 CALL로 호출. 저장 프로시저는 비즈니스 로직을 데이터베이스에 캡슐화 — 네트워크 왕복을 줄이고 로직을 중앙화. 하지만, 확장을 어렵게 만들 수 있고(로직이 앱과 DB에 분할) 데이터베이스별. 데이터에 가까워서 이득이 있는 데이터 집약적 연산에 사용. PostgreSQL은 PL/pgSQL을; MySQL은 자체 절차적 SQL을 사용.

sql
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id INT)
RETURNS TABLE(order_id INT, total DECIMAL) AS $$
BEGIN
  RETURN QUERY
  SELECT id, total FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;

-- call function
SELECT * FROM get_user_orders(1);

-- PostgreSQL procedure (can manage transactions, PostgreSQL 11+)
CREATE PROCEDURE transfer_money(
  from_id INT, to_id INT, amount DECIMAL
) LANGUAGE plpgsql AS $$
BEGIN
  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;
  COMMIT;
END;
$$;

CALL transfer_money(1, 2, 100.00);

-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE GetActiveUsers()
BEGIN
  SELECT * FROM users WHERE status = 'active';
END //
DELIMITER ;
CALL GetActiveUsers();

트리거

트리거는 데이터 변경 시 자동으로 실행. BEFORE 트리거는 들어오는 데이터를 수정 가능(예: 타임스탬프 설정, 검증). AFTER 트리거는 부작용을 수행(예: 감사 로깅, 비정규화). 트리거는 애플리케이션 코드에서 숨겨져 있어 디버깅을 어렵게 만들므로 드물게 사용. 일반적 사용 사례: 감사 추적, 계산 열, 복잡한 제약 강제, 비정규화된 데이터 동기화. 트리거를 항상 명확히 문서화.

sql
-- PostgreSQL trigger: audit log on update
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO user_audit (user_id, old_name, new_name, changed_at)
  VALUES (OLD.id, OLD.name, NEW.name, NOW());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_audit
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_change();

-- trigger timing: BEFORE / AFTER / INSTEAD OF
-- trigger events: INSERT / UPDATE / DELETE / TRUNCATE
-- granularity: FOR EACH ROW / FOR EACH STATEMENT

-- MySQL trigger
CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
SET NEW.created_at = NOW();

-- drop trigger
DROP TRIGGER IF EXISTS trg_user_audit ON users;

JSON 연산 (PostgreSQL)

JSONB(PostgreSQL)는 JSON을 바이너리 형식으로 저장하여, 인덱싱과 효율적인 쿼리를 가능하게. ->는 JSON을, ->>는 텍스트를 반환. @>는 포함을 검사(JSON이 이것을 포함하는가?). GIN 인덱스가 JSON 쿼리를 빠르게. 유연한/반구조화된 데이터(이벤트 로그, API 응답, 설정)를 위해 JSON 열을 사용하면서 관계형 데이터는 일반 열에 유지. JSONB가 JSON보다 선호됨(더 빠르고, 인덱스 가능, 중복 키 없음).

sql
-- JSONB columns (PostgreSQL)
CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  data JSONB NOT NULL
);

INSERT INTO events (data) VALUES
  ('{"type": "click", "user": {"id": 1, "name": "Alice"}, "tags": ["web", "mobile"]}');

-- extract fields (-> for JSON, ->> for text)
SELECT data->'type' AS type,           -- "click" (JSON)
       data->'user'->>'name' AS name,  -- Alice (text)
       data->'tags'->0 AS first_tag    -- "web"
FROM events;

-- filter by JSON field
SELECT * FROM events WHERE data->>'type' = 'click';
SELECT * FROM events WHERE data @> '{"type": "click"}';  -- containment

-- GIN index for JSON queries
CREATE INDEX idx_events_data ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"user": {"id": 1}}';

-- modify JSON
UPDATE events SET data = jsonb_set(data, '{user,name}', '"Bob"');

전문 검색

전문 검색은 자연어 쿼리(어간 추출, 순위, 불용어)를 가능하게. to_tsvector는 텍스트를 검색 가능한 토큰으로 변환; to_tsquery는 검색 쿼리를 생성; @@가 일치. ts_rank가 결과 순위; ts_headline이 일치를 강조. GIN 인덱스가 이것을 빠르게. 대규모 검색을 위해 전용 엔진(Elasticsearch, Solr)을 고려, 하지만 PostgreSQL FTS는 중간 규모 데이터셋에 훌륭하고 인프라 복잡성을 피함.

sql
-- PostgreSQL full-text search
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200),
  body TEXT
);

-- create a full-text search index
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));

-- search with ranking
SELECT
  title,
  ts_rank(to_tsvector('english', body), query) AS rank,
  ts_headline('english', body, query) AS snippet
FROM articles, to_tsquery('english', 'database & performance') query
WHERE to_tsvector('english', title || ' ' || body) @@ query
ORDER BY rank DESC
LIMIT 10;

-- simplified with generated column
ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin(search_vector);

SELECT * FROM articles WHERE search_vector @@ to_tsquery('database');
10

성능 & 쿼리 최적화

EXPLAIN & 쿼리 계획

EXPLAIN은 쿼리 계획을 표시 — 데이터베이스가 쿼리를 어떻게 실행할지. EXPLAIN ANALYZE는 실제로 실행하고 실제 타이밍을 표시. 대형 테이블의 순차 스캔(인덱스 추가), 비싼 정렬(인덱스 추가), 행 추정 불일치(통계 업데이트를 위해 ANALYZE 실행)을 찾으세요. 비용 숫자는 절대적이 아닌 상대적. 쿼리 계획을 이해하는 것이 SQL 성능 튜닝의 #1 기술.

sql
-- EXPLAIN: show query plan without running
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE: run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE with buffers (I/O stats)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users
JOIN orders ON users.id = orders.user_id;

-- key things to look for in the plan:
-- Seq Scan: full table scan (bad for large tables — add index)
-- Index Scan: using an index (good)
-- Bitmap Index Scan: index + heap lookup (good for many rows)
-- Hash Join: builds hash table (good for large joins)
-- Nested Loop: good for small result sets
-- Sort: explicit sort (consider index to avoid)
-- cost: estimated cost (first row, all rows)
-- rows: estimated vs actual rows (big mismatch = stale stats)

일반적 성능 함정

Sargability(Search Argument Able)는 데이터베이스가 인덱스를 사용할 수 있음을 의미. 열의 함수(DATE(col), UPPER(col))는 인덱스 사용을 방지 — 범위 쿼리로 재작성하거나 표현식 인덱스 사용. SELECT *는 I/O를 낭비하고 커버링 인덱스를 방지. OFFSET 페이지 매김은 O(n) — O(1)을 위해 keyset 페이지 매김(WHERE id > last_id)을 사용. 대형 배치 작업은 긴 잠금과 복제 지연을 피하기 위해 청크화해야.

sql
-- 1. Sargability: avoid functions on indexed columns
-- BAD: function prevents index usage
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- GOOD: range query uses index
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

-- 2. Avoid SELECT * (more I/O, prevents covering indexes)
-- BAD
SELECT * FROM users WHERE status = 'active';
-- GOOD
SELECT id, name, email FROM users WHERE status = 'active';

-- 3. Use LIMIT with ORDER BY for pagination
-- BAD: loads all rows
SELECT * FROM products ORDER BY id;
-- GOOD: keyset pagination
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;

-- 4. Batch large updates
-- BAD: one giant transaction
DELETE FROM logs WHERE date < '2023-01-01';
-- GOOD: batch in chunks
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 10000;
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 20000;

UNION, INTERSECT & EXCEPT

집합 연산은 결과 집합을 결합. UNION은 중복을 제거(비싼 정렬); UNION ALL은 유지(더 빠름 — 중복이 없거나 원할 때 선호). INTERSECT는 둘 다에 있는 행을 반환. EXCEPT는 첫 번째에는 있지만 두 번째에는 없는 행을 반환. 모두 호환되는 열 타입이 필요. UNION ALL은 복잡한 OR 조건을 대체할 수 있고 각 브랜치에 다른 인덱스를 사용할 수 있어 종종 더 나은 성능.

sql
-- UNION: combine results, remove duplicates
SELECT name FROM customers
UNION
SELECT name FROM suppliers;
-- UNION ALL: faster, keeps duplicates
SELECT name FROM customers
UNION ALL
SELECT name FROM suppliers;

-- INTERSECT: rows in BOTH results
SELECT product_id FROM sales_2023
INTERSECT
SELECT product_id FROM sales_2024;

-- EXCEPT (MINUS in Oracle): rows in first but not second
SELECT product_id FROM all_products
EXCEPT
SELECT product_id FROM discontinued_products;

-- rules:
-- - same number of columns
-- - compatible types
-- - column names come from first query
-- - UNION is often faster than OR conditions

데이터베이스별 팁

VACUUM(PostgreSQL)은 삭제된 행에서 공간을 회수(MVCC가 '죽은 튜플'을 남김). ANALYZE는 쿼리 플래너를 위해 테이블 통계를 업데이트 — 대량 로드 후 실행. OPTIMIZE TABLE(MySQL)은 테이블을 조각 모음. 외래 키를 명시적으로 인덱스(PostgreSQL은 자동 인덱스하지 않음). pg_stat_user_indexes로 인덱스 사용을 모니터링하고 사용하지 않는 것을 삭제. 연결 풀링(PgBouncer, ProxySQL)은 고트래픽 앱에 필수 — 연결을 여는 것은 비쌈.

sql
-- PostgreSQL: VACUUM to reclaim space
VACUUM ANALYZE users;  -- update stats, reclaim dead rows
VACUUM FULL users;     -- rewrites table (locks, but reclaims all space)

-- PostgreSQL: ANALYZE to update statistics
ANALYZE users;  -- helps query planner make better decisions

-- MySQL: OPTIMIZE TABLE
OPTIMIZE TABLE users;

-- Common indexing rules across databases:
-- 1. Index foreign keys (not automatic in all DBs)
-- 2. Index columns used in WHERE, JOIN, ORDER BY, GROUP BY
-- 3. Composite indexes: high selectivity column first
-- 4. Don't over-index (slows writes, uses disk)
-- 5. Drop unused indexes (check with pg_stat_user_indexes)

-- Connection pooling (application level):
-- - Use PgBouncer (PostgreSQL) or ProxySQL (MySQL)
-- - Reuse connections instead of reconnecting per request
-- - Set appropriate pool size (not too high!)

데이터 타입 & NULL 처리

NULL은 0이나 빈이 아닌 알 수 없는/누락된 데이터. NULL 비교는 항상 NULL(unknown)을 산출, WHERE에서 거짓. 검사하려면 IS NULL / IS NOT NULL 사용. COALESCE는 대체를 제공. NULLIF는 특정 값을 NULL로 변환(0으로 나누기에 유용). 집계는 NULL을 건너뜀 — COUNT(col)는 비-NULL을, COUNT(*)는 모든 행을 셈. LEFT JOIN에서, 비일치 행에 0을 얻으려면 COUNT(right_table.col)을 사용.

sql
-- NULL is not zero or empty string — it's "unknown"
SELECT NULL = NULL;   -- NULL (not true!)
SELECT NULL IS NULL;  -- true
SELECT NULL <> 1;     -- NULL (unknown)

-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, first_name, 'Anonymous') FROM users;

-- NULLIF: return NULL if two values are equal
SELECT NULLIF(score, 0) FROM tests;  -- NULL instead of 0
-- useful for avoiding division by zero:
SELECT total / NULLIF(count, 0) FROM stats;

-- aggregate functions ignore NULL
SELECT AVG(score) FROM tests;  -- avg of non-NULL scores only
SELECT COUNT(score) FROM tests;  -- count of non-NULL
SELECT COUNT(*) FROM tests;  -- count of all rows

-- use LEFT JOIN + COUNT carefully
SELECT u.name, COUNT(o.id) AS orders  -- COUNT(o.id) = 0 for no orders
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
11

재귀 CTE

기본 재귀 CTE 구조

재귀 CTE는 자신을 참조하여 계층적 또는 순차적 데이터를 생성. UNION ALL로 결합된 두 부분: 앵커 쿼리(기본 케이스/시작점)와 재귀 쿼리(CTE를 참조하고 결과에 추가). 재귀는 재귀 쿼리가 행을 반환하지 않을 때까지 계속. 트리 순회(조직도, 파일 시스템), 시퀀스 생성, 그래프 경로 찾기에 재귀 CTE를 사용. 무한 루프를 방지하기 위해 WHERE 절에 항상 종료 조건을 포함.

sql
-- Recursive CTE: a CTE that references itself
-- Three parts: anchor, UNION ALL, recursive member
WITH RECURSIVE countdown(n) AS (
    -- Anchor: starting point
    SELECT 1 AS n
    UNION ALL
    -- Recursive member: references the CTE itself
    SELECT n + 1 FROM countdown WHERE n < 10
)
SELECT n FROM countdown;
-- Result: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

-- PostgreSQL uses WITH RECURSIVE
-- SQL Server/MySQL 8+: WITH (RECURSIVE optional in MySQL)
-- SQLite: WITH RECURSIVE

계층적 데이터 (조직도)

재귀 CTE는 조직도, 카테고리 트리, 파일 시스템 같은 계층적 데이터 순회에 탁월. 앵커는 루트 노드를 선택; 재귀 멤버는 부모-자식 관계로 테이블을 CTE에 조인(manager_id = id). depth 열을 추가하면 각 행이 몇 수준 깊은지 추적하고, path 열(문자열 연결)은 전체 조상 체인을 표시. 이것이 여러 self-join이나 애플리케이션 측 재귀의 필요를 대체. 재귀 중 타입 에러를 방지하기 위해 path에 CAST.

sql
-- Employee hierarchy with manager relationships
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    manager_id INT REFERENCES employees(id)
);

-- Find all direct and indirect reports of employee 1 (CEO)
WITH RECURSIVE org_chain AS (
    -- Anchor: the starting employee
    SELECT id, name, manager_id, 0 AS depth, CAST(name AS VARCHAR(500)) AS path
    FROM employees WHERE id = 1
    UNION ALL
    -- Recursive: find employees whose manager is in the chain
    SELECT e.id, e.name, e.manager_id, oc.depth + 1,
           CAST(oc.path || ' > ' || e.name AS VARCHAR(500))
    FROM employees e
    JOIN org_chain oc ON e.manager_id = oc.id
)
SELECT id, name, depth, path FROM org_chain
ORDER BY depth, name;

-- depth shows hierarchy level, path shows the management chain

시퀀스 & 날짜 생성

재귀 CTE는 시퀀스와 날짜 범위를 생성할 수 있음 — 시계열 보고서의 갭을 채우는 데 유용. 범위의 모든 날짜를 생성하고 데이터에 LEFT JOIN하면, 기록이 없어도 출력에 모든 날짜가 나타남. 이것이 대시보드와 차트의 일반적 패턴. PostgreSQL은 더 간단한 대안으로 generate_series()도 제공. 무한 재귀를 방지하기 위해 항상 종료 조건(WHERE n < 100)을 설정. 일부 데이터베이스는 재귀 깊이를 제한(예: MySQL에서 cte_max_recursion_depth를 통해 기본 100).

sql
-- Generate a series of numbers
WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;

-- Generate a date range (all days in January 2024)
WITH RECURSIVE dates(d) AS (
    SELECT DATE '2024-01-01'
    UNION ALL
    SELECT d + INTERVAL '1 day' FROM dates
    WHERE d < DATE '2024-01-31'
)
SELECT d FROM dates;

-- Generate a time series for gap-filling in reports
-- (ensures every date appears even if no data exists)
SELECT d.date, COALESCE(SUM(o.amount), 0) AS daily_total
FROM dates d
LEFT JOIN orders o ON o.order_date = d.date
GROUP BY d.date ORDER BY d.date;

그래프 경로 찾기 (BFS)

재귀 CTE는 그래프 구조에서 너비 우선 탐색(BFS)을 수행할 수 있음. 앵커는 시작 노드에서 에지를 찾고; 재귀 멤버는 현재 경로의 끝점에 에지를 조인하여 경로를 확장. 순환 그래프에서 사이클 방지가 중요 — 목적지 노드가 이미 경로에 없는지 검사(LIKE나 문자열 검색 사용). hops 제한은 무한 재귀에 대한 안전망. 이 접근은 경로 찾기, 의존성 해결, 네트워크 분석에 작동. 가중 최단 경로에는 애플리케이션 코드에서 Dijkstra 알고리즘을 고려.

sql
-- Find all paths in a directed graph
CREATE TABLE edges (src VARCHAR(10), dst VARCHAR(10));

-- Find all reachable nodes from 'A' with the path taken
WITH RECURSIVE paths AS (
    -- Anchor: start from node A
    SELECT src, dst, CAST(src || '->' || dst AS VARCHAR(1000)) AS path,
           1 AS hops
    FROM edges WHERE src = 'A'
    UNION ALL
    -- Recursive: extend the path
    SELECT p.src, e.dst,
           CAST(p.path || '->' || e.dst AS VARCHAR(1000)),
           p.hops + 1
    FROM paths p
    JOIN edges e ON p.dst = e.src
    WHERE p.hops < 10  -- prevent infinite loops in cyclic graphs
      AND p.path NOT LIKE '%' || e.dst || '%'
)
SELECT DISTINCT path, hops FROM paths ORDER BY hops;

-- Cycle prevention: check the path doesn't already contain the node

팩토리얼 & 재귀로 집계

재귀 CTE는 각 반복마다 상태(n, fact)를 운반하여 팩토리얼 같은 수학적 계산을 수행할 수 있음. 앵커는 기본 케이스(0! = 1)를 설정하고, 재귀 멤버는 이전에서 다음 값을 계산. 누적 합도 이 방식으로 계산 가능, 하지만 누적 집계에는 윈도우 함수(SUM(amount) OVER (ORDER BY id))가 더 효율적이고 관용적. 계산을 위한 재귀 CTE는 주로 교육용 — 윈도우 함수나 절차적 코드가 논리를 표현할 수 없을 때 사용. 각 재귀 수준이 행을 추가하므로, 결과 집합은 깊이와 함께 성장.

sql
-- Compute factorial using recursive CTE
WITH RECURSIVE factorial(n, fact) AS (
    -- Anchor: 0! = 1
    SELECT 0, 1
    UNION ALL
    -- Recursive: n! = n * (n-1)!
    SELECT n + 1, fact * (n + 1) FROM factorial WHERE n < 10
)
SELECT n, fact FROM factorial;

-- Running accumulation: cumulative sum
WITH RECURSIVE running_total AS (
    SELECT id, amount, amount AS cumulative
    FROM transactions WHERE id = 1
    UNION ALL
    SELECT t.id, t.amount, rt.cumulative + t.amount
    FROM transactions t
    JOIN running_total rt ON t.id = rt.id + 1
)
SELECT * FROM running_total ORDER BY id;

-- Note: window functions (SUM OVER) are usually better for this
12

PIVOT & UNPIVOT

PIVOT (행을 열로)

PIVOT은 행을 열로 변환 — 카테고리를 열 헤더로 원하는 교차 탭 보고서에 완벽. IN 목록은 어떤 값이 열이 될지 지정. SQL Server와 Oracle은 네이티브 PIVOT 구문을 가짐. 내부 쿼리는 소스 데이터를 제공하고, PIVOT은 각 열 그룹에 대해 집계(SUM, AVG, COUNT)를 적용. 이것은 조건부 집계와 동일하지만 넓은 pivot에 대해 더 읽기 쉬움. pivot할 고정된, 알려진 값 집합이 있을 때 PIVOT을 사용.

sql
-- Convert rows to columns (cross-tabulation)
-- Source: sales data with rows per quarter
CREATE TABLE sales (quarter VARCHAR(10), region VARCHAR(50), amount DECIMAL(10,2));

-- SQL Server PIVOT syntax:
SELECT region, [Q1], [Q2], [Q3], [Q4]
FROM (
    SELECT quarter, region, amount FROM sales
) AS src
PIVOT (
    SUM(amount) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS pvt;

-- Result:
-- region  | Q1    | Q2    | Q3    | Q4
-- North   | 1000  | 1500  | 1200  | 1800
-- South   | 800   | 1100  | 900   | 1300

조건부 집계 (범용 PIVOT)

조건부 집계(SUM + CASE)는 모든 SQL 데이터베이스에서 작동하는 범용 pivot 기술. 각 CASE 표현식은 하나의 카테고리를 필터링하고, SUM이 일치하는 값을 집계. 이것은 종종 PIVOT보다 빠르고 더 유연. ELSE 0은 비일치 행이 0을 기여하게. PostgreSQL의 crosstab() 함수(tablefunc 확장에서)는 더 간결하지만 고정된 출력 열이 필요. 교차 데이터베이스 호환성이 필요하거나 PIVOT 구문이 없을 때 조건부 집계를 사용.

sql
-- Works in ALL databases (MySQL, PostgreSQL, SQLite, etc.)
SELECT
    region,
    SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END) AS Q1,
    SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END) AS Q2,
    SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END) AS Q3,
    SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) AS Q4,
    SUM(amount) AS total
FROM sales
GROUP BY region
ORDER BY region;

-- PostgreSQL-specific: crosstab() from tablefunc extension
-- SELECT * FROM crosstab('SELECT region, quarter, amount FROM sales ORDER BY 1,2')
-- AS ct(region VARCHAR, Q1 DECIMAL, Q2 DECIMAL, Q3 DECIMAL, Q4 DECIMAL);

동적 PIVOT (동적 SQL)

동적 SQL은 pivot 열이 사전에 알려지지 않았을 때(예: 월이 다양할 때 월별 pivot) 런타임에 쿼리 문자열을 구축. 과정: 고유 값을 쿼리, 열 목록을 빌드, PIVOT 문을 구성, sp_executesql(SQL Server) 또는 PREPARE/EXECUTE(MySQL)로 실행. SQL 인젝션을 방지하기 위해 항상 QUOTENAME() 또는 quote_ident()로 위생 처리. 동적 SQL은 강력하지만 복잡성과 보안 위험을 추가 — 드물게 사용하고 가능할 때 고정 pivot을 선호. 애플리케이션 측 pivot이 종종 더 안전한 대안.

sql
-- When pivot columns are unknown at write time, use dynamic SQL
-- SQL Server example:
DECLARE @cols AS NVARCHAR(MAX),
        @query AS NVARCHAR(MAX);

-- Build the column list dynamically
SELECT @cols = STUFF((
    SELECT DISTINCT ',' + QUOTENAME(quarter)
    FROM sales FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 1, '');

-- Build and execute the pivot query
SET @query = 'SELECT region, ' + @cols + '
FROM (SELECT quarter, region, amount FROM sales) x
PIVOT (SUM(amount) FOR quarter IN (' + @cols + ')) p';

EXEC sp_executesql @query;

-- WARNING: dynamic SQL is vulnerable to SQL injection
-- — always use QUOTENAME() to sanitize column names

UNPIVOT (열을 행으로)

UNPIVOT은 PIVOT을 역전 — 열을 행으로 변환. 이것은 비정규화된 데이터를 정규화, 넓은 임포트 파일을 긴 형식으로 변환, 차트를 위한 데이터 준비에 유용. SQL Server는 네이티브 UNPIVOT 구문을 가짐. UNION ALL 접근은 어디서나 작동: 각 SELECT는 하나의 열을 추출하고 고정 값으로 레이블. UNION ALL(NOT UNION)은 중복을 보존하고 더 빠름. UNPIVOT은 소스 데이터가 스프레드시트 형식(넓음)으로 도착하지만 정규화(길게)로 저장해야 할 때 ETL 파이프라인에서 일반적.

sql
-- Convert columns back to rows
-- Source: wide table with Q1-Q4 columns
-- Target: narrow table with quarter/amount rows

-- SQL Server UNPIVOT:
SELECT region, quarter, amount
FROM quarterly_sales
UNPIVOT (
    amount FOR quarter IN (Q1, Q2, Q3, Q4)
) AS unpvt;

-- Universal UNION ALL approach (all databases):
SELECT region, 'Q1' AS quarter, Q1 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q2' AS quarter, Q2 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q3' AS quarter, Q3 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q4' AS quarter, Q4 AS amount FROM quarterly_sales;

-- Result: one row per region/quarter combination

실용적 pivot 보고서 예제

이 실제 pivot 보고서는 단일 쿼리에서 월별 분석과 연간 비교를 결합. 조건부 집계(SUM + CASE)가 월별 열과 연간 총계를 모두 생성. yoy_change 열은 인라인으로 차이를 계산. HAVING은 판매가 없는 제품을 필터링. 이 패턴은 BI 대시보드와 재무 보고서에서 일반적. EXTRACT 함수는 대부분의 데이터베이스에서 작동(SQL Server에서 DATEPART, SQLite에서 strftime). 진정으로 동적 열을 위해, 동적 SQL과 결합하거나 애플리케이션 계층에서 pivot을 처리.

sql
-- Monthly sales pivot with year-over-year comparison
SELECT
    product_name,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 1  THEN amount ELSE 0 END) AS jan,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 2  THEN amount ELSE 0 END) AS feb,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 3  THEN amount ELSE 0 END) AS mar,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 4  THEN amount ELSE 0 END) AS apr,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 5  THEN amount ELSE 0 END) AS may,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 6  THEN amount ELSE 0 END) AS jun,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) AS total_2024,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS total_2023,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) -
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS yoy_change
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date BETWEEN '2023-01-01' AND '2024-12-31'
GROUP BY product_name
HAVING SUM(amount) > 0
ORDER BY total_2024 DESC;
14

트리거

트리거 기본 (AFTER/BEFORE)

트리거는 데이터 변경 시 자동으로 실행되는 데이터베이스 수준의 코드입니다. AFTER 트리거는 변경사항을 로깅하거나 전파합니다(NEW를 수정할 수 없음). BEFORE 트리거는 데이터 쓰기 전에 검증하거나 변환합니다(NEW를 수정할 수 있음). FOR EACH ROW는 영향받은 각 행마다 한 번 실행되고, FOR EACH STATEMENT는 문장당 한 번 실행됩니다. 감사 로깅, 복잡한 제약조건 강제, 파생 컬럼 자동 업데이트에 트리거를 사용하세요. 비즈니스 로직에는 트리거를 피하세요 - 숨겨져 있고, 디버깅하기 어려우며, 연쇄 효과를 일으킬 수 있습니다. 각 데이터베이스마다 트리거 문법이 다릅니다. PostgreSQL은 함수를 트리거 본문으로 사용합니다.

sql
-- A trigger fires automatically on INSERT/UPDATE/DELETE
-- MySQL syntax:
DELIMITER //
CREATE TRIGGER audit_log
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
    INSERT INTO audit_table (table_name, action, row_id, changed_at)
    VALUES ('employees', 'INSERT', NEW.id, NOW());
END //
DELIMITER ;

-- BEFORE triggers can modify the NEW values:
CREATE TRIGGER validate_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
    IF NEW.email NOT LIKE '%@%.%' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid email';
    END IF;
END //

-- PostgreSQL uses CREATE FUNCTION + CREATE TRIGGER:
-- CREATE TRIGGER audit AFTER INSERT ON employees
-- FOR EACH ROW EXECUTE FUNCTION audit_func();

감사 로깅 트리거

감사 트리거는 컴플라이언스와 디버깅을 위해 모든 데이터 변경을 캡처합니다. 감사 테이블은 작업 유형, 이전 값과 새 값, 변경자(CURRENT_USER), 시간(CURRENT_TIMESTAMP)을 저장합니다. INSERT, UPDATE, DELETE에 대해 별도의 트리거가 필요합니다. OLD는 변경 전 값을 참조(UPDATE/DELETE에서 사용 가능)하고, NEW는 변경 후 값을 참조(INSERT/UPDATE에서 사용 가능)합니다. 감사 테이블은 무한정 증가합니다 - 날짜별로 파티셔닝하거나 오래된 데이터를 보관하세요. 이 패턴은 데이터 변경 추적에 대한 SOX, HIPAA, GDPR 요구사항을 충족합니다.

sql
-- Track all changes to a critical table
CREATE TABLE employee_audit (
    audit_id INT AUTO_INCREMENT PRIMARY KEY,
    action VARCHAR(10),       -- INSERT, UPDATE, DELETE
    employee_id INT,
    old_name VARCHAR(100),
    new_name VARCHAR(100),
    old_salary DECIMAL(10,2),
    new_salary DECIMAL(10,2),
    changed_by VARCHAR(50) DEFAULT CURRENT_USER,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TRIGGER trg_audit_insert
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, new_name, new_salary)
VALUES ('INSERT', NEW.id, NEW.name, NEW.salary);

CREATE TRIGGER trg_audit_update
AFTER UPDATE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, new_name, old_salary, new_salary)
VALUES ('UPDATE', NEW.id, OLD.name, NEW.name, OLD.salary, NEW.salary);

CREATE TRIGGER trg_audit_delete
AFTER DELETE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, old_salary)
VALUES ('DELETE', OLD.id, OLD.name, OLD.salary);

계산/파생 컬럼 트리거

트리거는 파생 컬럼을 자동 계산하여 애플리케이션 코드 없이 일관성을 보장할 수 있습니다. BEFORE INSERT/UPDATE 트리거가 다른 컬럼을 기반으로 NEW.final_price를 설정합니다. 하지만 최신 데이터베이스는 GENERATED(계산) 컬럼을 네이티브로 지원합니다 - 항상 정확하고, 수동으로 재정의할 수 없으며, 인덱스를 생성할 수 있습니다. 계산 값에는 GENERATED 컬럼을 트리거보다 선호하세요. 계산이 외부 데이터, 조건부 로직, 또는 GENERATED 컬럼이 처리할 수 없는 크로스 테이블 의존성을 포함할 때만 트리거를 사용하세요. 트리거는 모든 쓰기 작업에 오버헤드를 추가한다는 점을 기억하세요.

sql
-- Auto-update a derived column when source data changes
CREATE TABLE products (
    id INT PRIMARY KEY,
    price DECIMAL(10,2),
    discount_percent DECIMAL(5,2),
    final_price DECIMAL(10,2)  -- computed: price * (1 - discount/100)
);

-- BEFORE INSERT: compute final_price
CREATE TRIGGER calc_final_price_insert
BEFORE INSERT ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- BEFORE UPDATE: recompute if price or discount changes
CREATE TRIGGER calc_final_price_update
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- Alternative: use GENERATED columns (MySQL 5.7+, PostgreSQL 12+)
-- final_price DECIMAL(10,2) GENERATED ALWAYS AS
--     (price * (1 - discount_percent / 100)) STORED

트리거로 삭제 방지

트리거는 CHECK 제약조건이 표현할 수 없는 데이터 보호 규칙을 강제할 수 있습니다. BEFORE DELETE 트리거는 삭제를 완전히 차단하거나(SIGNAL/RAISE 사용) 소프트 삭제(레코드를 제거하는 대신 삭제됨으로 표시)를 구현할 수 있습니다. SIGNAL SQLSTATE '45000'은 사용자 정의 에러를 발생시키는 MySQL 방식입니다. PostgreSQL은 RAISE EXCEPTION을 사용합니다. 이는 참조 데이터 보호, 자식이 있는 부모 레코드 삭제 방지, 또는 변경 불가능한 감사 추적 구현에 유용합니다. 주의: 작업을 방지하는 트리거는 개발자를 놀라게 할 수 있습니다 - 명확히 문서화하고 애플리케이션 수준 검사를 대신 고려하세요.

sql
-- Prevent deletion of critical records
CREATE TRIGGER prevent_delete_admin
BEFORE DELETE ON users
FOR EACH ROW
BEGIN
    IF OLD.role = 'admin' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Cannot delete admin users';
    END IF;
END //

-- Soft delete instead of hard delete
CREATE TRIGGER soft_delete
BEFORE DELETE ON articles
FOR EACH ROW
BEGIN
    -- Prevent actual deletion, mark as deleted instead
    INSERT INTO articles (id, title, body, deleted_at)
    VALUES (OLD.id, OLD.title, OLD.body, NOW())
    ON DUPLICATE KEY UPDATE deleted_at = NOW();
    -- Still need to prevent the DELETE — use SIGNAL or a flag
END //

-- PostgreSQL: raise exception in trigger function
-- IF OLD.role = 'admin' THEN
--     RAISE EXCEPTION 'Cannot delete admin users';
-- END IF;

트리거 관리 & 디버깅

트리거 관리는 유지보수에 필수적입니다. SHOW TRIGGERS(MySQL)와 information_schema 뷰가 모든 트리거를 나열합니다. DROP TRIGGER IF EXISTS로 트리거를 삭제하세요. 대량 데이터 로드 시 트리거를 일시적으로 비활성화하면 유용합니다(모든 행에 대해 비용이 많이 드는 감사/로깅이 트리거되기 때문입니다). PostgreSQL은 ALTER TABLE ... DISABLE/ENABLE TRIGGER를 사용하고, SQL Server는 DISABLE/ENABLE TRIGGER를 사용합니다. 유지보수 후 항상 트리거를 다시 활성화하세요. 트리거 디버깅은 어렵습니다 - 조용히 실행됩니다. 디버그 테이블에 로깅을 추가하거나 트리거 로직을 먼저 격리해서 테스트하세요. 과도한 트리거는 숨겨진 복잡성과 성능 문제를 만듭니다.

sql
-- View existing triggers
-- MySQL:
SHOW TRIGGERS;
SELECT * FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'mydb';

-- PostgreSQL:
SELECT tgname, tgrelid::regclass, tgtype FROM pg_trigger;

-- SQL Server:
SELECT name, type_desc FROM sys.triggers WHERE parent_id = OBJECT_ID('employees');

-- Drop a trigger
DROP TRIGGER IF EXISTS audit_log;  -- MySQL
DROP TRIGGER IF EXISTS audit_log ON employees;  -- PostgreSQL

-- Disable/enable triggers (PostgreSQL)
ALTER TABLE employees DISABLE TRIGGER ALL;
ALTER TABLE employees ENABLE TRIGGER ALL;

-- Disable for bulk operations (SQL Server)
DISABLE TRIGGER trg_audit ON employees;
-- ... bulk operations ...
ENABLE TRIGGER trg_audit ON employees;
15

사용자 정의 함수 (UDF)

스칼라 함수 (단일 값 반환)

스칼라 UDF는 단일 값을 반환하며 SELECT, WHERE, 계산 컬럼에서 사용할 수 있습니다. DETERMINIC은 출력이 입력에만 의존함을 의미합니다(캐싱 가능). READS SQL DATA는 함수가 테이블에서 읽음을 선언합니다. UDF는 재사용 가능한 로직(할인, 서식, 계산)을 캡슐화하여 쿼리 전체에 일관성을 제공합니다. 하지만 SQL Server의 스칼라 UDF는 성능 문제를 일으킬 수 있습니다(행별 실행) - 가능하면 인라인 테이블 반환 함수나 계산 컬럼을 대신 사용하세요. MySQL 8.0+은 결정론적 함수를 더 잘 최적화합니다. 항상 함수의 목적과 매개변수를 문서화하세요.

sql
-- MySQL: a function that returns one value
CREATE FUNCTION calculate_discount(
    price DECIMAL(10,2),
    customer_tier VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
READS SQL DATA
BEGIN
    DECLARE discount_rate DECIMAL(5,2);
    SET discount_rate = CASE customer_tier
        WHEN 'gold'   THEN 0.20
        WHEN 'silver' THEN 0.10
        WHEN 'bronze' THEN 0.05
        ELSE 0.00
    END;
    RETURN price * (1 - discount_rate);
END //

-- Usage in queries:
SELECT name, price, calculate_discount(price, tier) AS final_price
FROM orders;

-- DETERMINISTIC: same inputs always give same output (cacheable)
-- READS SQL DATA: function reads but doesn't modify tables

테이블 반환 함수 (행 반환)

테이블 반환 함수(TVF)는 테이블처럼 쿼리할 수 있는 결과 세트(행)를 반환합니다. 인라인 TVF(SQL Server)는 뷰만큼 빠릅니다 - 쿼리 옵티마이저가 인라인 처리합니다. 다중 문장 TVF는 결과를 먼저 임시 테이블에 구체화하므로 느릴 수 있습니다. PostgreSQL에서 TABLE이나 SETOF를 반환하는 함수가 동등합니다. TVF는 매개변수가 있는 뷰입니다 - 매개변수가 필요한 뷰에 사용하세요. 복잡한 JOIN과 필터를 캡슐화하는 데 좋습니다. 성능을 위해 다중 문장 TVF보다 인라인 TVF를 선호하세요. PostgreSQL에서는 WHERE 절이 있는 매개변수화된 뷰도 고려하세요.

sql
-- SQL Server: Inline table-valued function (fast, like a view)
CREATE FUNCTION fn_OrdersByCustomer(@cust_id INT)
RETURNS TABLE
AS
RETURN (
    SELECT o.id, o.order_date, o.total
    FROM orders o
    WHERE o.customer_id = @cust_id
);
-- Usage: SELECT * FROM fn_OrdersByCustomer(42);

-- PostgreSQL: function returning a table
CREATE OR REPLACE FUNCTION get_orders_by_customer(cust_id INT)
RETURNS TABLE(order_id INT, order_date DATE, total DECIMAL) AS $$
    SELECT id, order_date, total
    FROM orders
    WHERE customer_id = cust_id;
$$ LANGUAGE SQL;

-- Usage:
SELECT * FROM get_orders_by_customer(42);

-- Multi-statement TVF (SQL Server) — slower, materializes result
-- CREATE FUNCTION fn_ComplexReport(@date DATE)
-- RETURNS @result TABLE (...)
-- AS BEGIN ... INSERT INTO @result ... RETURN END

문자열 조작 함수

사용자 정의 문자열 함수는 내장 함수가 다루지 않는 텍스트 처리 로직을 캡슐화합니다. get_first_name 함수는 LOCATE와 SUBSTRING을 사용하여 첫 단어를 추출합니다. make_slug 함수는 LOWER, REPLACE, REGEXP_REPLACE를 연결하여 URL 친화적인 슬러그를 만듭니다. 동일한 입력이 항상 동일한 출력을 생성하므로 DETERMINIC으로 표시하세요. SQL의 문자열 함수는 데이터베이스마다 다릅니다 - PostgreSQL에는 split_part()가 있고 MySQL에는 SUBSTRING_INDEX()가 있습니다. UDF를 만들면 애플리케이션 전체에서 동작을 표준화할 수 있습니다. SQL에서 복잡한 문자열 조작은 애플리케이션 코드에서 처리하는 것이 더 깔끔한 경우가 많다는 점을 알아두세요.

sql
-- Create a function to split full name into parts
CREATE FUNCTION get_first_name(full_name VARCHAR(200))
RETURNS VARCHAR(100)
DETERMINISTIC
BEGIN
    DECLARE space_pos INT;
    SET space_pos = LOCATE(' ', full_name);
    IF space_pos > 0 THEN
        RETURN SUBSTRING(full_name, 1, space_pos - 1);
    ELSE
        RETURN full_name;
    END IF;
END //

-- Function to generate slug from a title
CREATE FUNCTION make_slug(title VARCHAR(500))
RETURNS VARCHAR(500)
DETERMINISTIC
BEGIN
    DECLARE slug VARCHAR(500);
    SET slug = LOWER(title);
    SET slug = REPLACE(slug, ' ', '-');
    SET slug = REGEXP_REPLACE(slug, '[^a-z0-9-]', '');
    RETURN slug;
END //

-- Usage:
SELECT get_first_name('John Doe Smith') AS first_name;  -- John
SELECT make_slug('Hello World! 2024') AS slug;           -- hello-world-2024

집계 함수 (사용자 정의)

사용자 정의 집계 함수를 사용하면 SUM, AVG, COUNT 이상의 새로운 집계 로직을 정의할 수 있습니다. PostgreSQL의 CREATE AGGREGATE는 상태 전이 함수(SFUNC, 행마다 호출)와 최종 함수(FINALFUNC, 끝에 한 번 호출)가 필요합니다. 이 예제는 기하 평균(곱의 n제곱근)을 계산합니다. 사용자 정의 집계는 통계, 재무, 또는 도메인 특화 계산에 강력합니다. 상태는 행에 걸쳐 누적되고, 최종 함수가 결과를 계산합니다. MySQL과 SQL Server는 사용자 정의 집계를 직접 지원하지 않습니다 - 저장 프로시저나 애플리케이션 측 계산을 대신 사용하세요.

sql
-- PostgreSQL: custom aggregate function
-- Step 1: Define a state transition function
CREATE OR REPLACE FUNCTION geom_mean_state(state numeric[], val numeric)
RETURNS numeric[] AS $$
BEGIN
    IF val IS NULL THEN RETURN state; END IF;
    IF state IS NULL THEN
        RETURN ARRAY[val];
    ELSE
        RETURN state || val;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Step 2: Define the final function
CREATE OR REPLACE FUNCTION geom_mean_final(vals numeric[])
RETURNS numeric AS $$
BEGIN
    IF vals IS NULL OR array_length(vals, 1) IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN exp(avg(ln(v)) FROM unnest(vals) AS v);
END;
$$ LANGUAGE plpgsql;

-- Step 3: Create the aggregate
CREATE AGGREGATE geometric_mean(numeric) (
    SFUNC = geom_mean_state,
    STYPE = numeric[],
    FINALFUNC = geom_mean_final,
    INITCOND = '{}'
);

-- Usage:
SELECT category, geometric_mean(price) FROM products GROUP BY category;

함수 vs 저장 프로시저

함수와 저장 프로시저는 다른 목적을 제공합니다. 함수는 값을 반환하고 SELECT/WHERE에 포함될 수 있습니다 - 대부분의 데이터베이스에서 결정론적이어야 합니다(부작용 없음). 저장 프로시저는 데이터를 수정하고, 트랜잭션을 관리하며, 여러 결과 세트를 반환할 수 있습니다 - 하지만 쿼리 내부에서 사용할 수 없습니다(CALL/EXEC로 호출). 계산과 데이터 검색에는 함수를, 다단계 작업(이체, 배치 처리, ETL)에는 프로시저를 사용하세요. 함수는 조합 가능하고, 프로시저는 명령형입니다. PostgreSQL에서는 함수가 프로시저가 할 수 있는 거의 모든 것(데이터 수정 포함)을 할 수 있어 구분이 모호합니다.

sql
-- FUNCTIONS: return values, usable in queries, no side effects
CREATE FUNCTION get_full_name(fname VARCHAR, lname VARCHAR)
RETURNS VARCHAR(200)
DETERMINISTIC
RETURN CONCAT(fname, ' ', lname);

-- Can be used in SELECT:
SELECT get_full_name(first, last) FROM users;  -- OK
SELECT * FROM users WHERE get_full_name(first, last) LIKE 'J%';  -- OK

-- STORED PROCEDURES: can modify data, use transactions, return result sets
CREATE PROCEDURE transfer_funds(
    IN from_acct INT, IN to_acct INT, IN amount DECIMAL(10,2)
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;
    START TRANSACTION;
    UPDATE accounts SET balance = balance - amount WHERE id = from_acct;
    UPDATE accounts SET balance = balance + amount WHERE id = to_acct;
    INSERT INTO transfers (from_id, to_id, amount) VALUES (from_acct, to_acct, amount);
    COMMIT;
END //

-- Call procedure (can't use in SELECT):
CALL transfer_funds(1, 2, 100.00);
16

데이터베이스 설계 & 정규화

제1정규형 (1NF)

제1정규형은 원자값을 요구합니다 - 각 셀은 목록이나 배열이 아닌 하나의 데이터를 가집니다. 쉼표로 구분된 값은 개별 항목을 쿼리, 인덱싱, 업데이트할 수 없으므로 1NF를 위반합니다. 해결책: 항목당 한 행(복합 기본 키 사용)을 만들거나 별도의 상세 테이블로 분리하세요. 1NF는 또한 각 행을 고유하게 식별하기 위한 기본 키를 요구합니다. 1NF를 위반하면 '마우스가 포함된 모든 주문 찾기' 같은 쿼리가 문자열 파싱을 필요로 합니다 - 느리고 오류가 발생하기 쉽습니다. 항상 1NF 준수로 시작하세요.

sql
-- 1NF: each column contains atomic (indivisible) values
--       no repeating groups, each row is unique

-- VIOLATION: comma-separated values in one column
CREATE TABLE bad_orders (
    id INT,
    customer_name VARCHAR(100),
    products VARCHAR(500)  -- "laptop, mouse, keyboard" — BAD!
);

-- 1NF COMPLIANT: separate row per product
CREATE TABLE orders (
    order_id INT,
    customer_name VARCHAR(100),
    product_name VARCHAR(100),  -- one product per row
    PRIMARY KEY (order_id, product_name)  -- composite key for uniqueness
);

-- Better: normalize further with separate tables
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_name VARCHAR(100));
CREATE TABLE order_items (
    order_id INT REFERENCES orders(order_id),
    product_name VARCHAR(100),
    quantity INT,
    PRIMARY KEY (order_id, product_name)
);

제2 & 제3정규형 (2NF, 3NF)

2NF는 부분 의존성을 제거합니다 - 모든 비키 컬럼은 기본 키의 일부가 아닌 전체 기본 키에 의존해야 합니다. 이는 복합 키에서만 중요합니다. 3NF는 이행적 의존성을 제거합니다 - 비키 컬럼은 다른 비키 컬럼이 아닌 기본 키에만 의존해야 합니다. 예를 들어, customer_name은 customer_id에 의존하고, customer_id는 order_id에 의존합니다(이행적). 정규화는 데이터 중복을 줄이고(각 사실을 한 번만 저장) 이상을 방지합니다(모든 주문이 아닌 한 곳에서 고객 이름 업데이트). 대부분의 실용적인 데이터베이스는 3NF 또는 BCNF를 목표로 합니다.

sql
-- 2NF: 1NF + no partial dependencies (non-key attrs depend on FULL key)
-- Problem: order_items has composite key (order_id, product_id)
-- but product_name depends only on product_id (partial dependency)

-- VIOLATION of 2NF:
CREATE TABLE bad_order_items (
    order_id INT,
    product_id INT,
    product_name VARCHAR(100),  -- depends on product_id only!
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 2NF COMPLIANT: move product_name to products table
CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100)
);
CREATE TABLE order_items (
    order_id INT,
    product_id INT REFERENCES products(product_id),
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 3NF: 2NF + no transitive dependencies
-- (non-key attrs don't depend on other non-key attrs)
-- Problem: orders table has customer_name that depends on customer_id
-- Solution: separate customers table (see above)

반정규화 (규칙을 깨야 할 때)

반정규화는 쓰기 복잡성과 저장 공간을 희생하고 읽기 성능을 향상시키기 위해 의도적으로 정규형을 위반합니다. 정규화된 데이터베이스에서 완전한 주문을 검색하려면 4개의 JOIN이 필요합니다 - 트래픽이 많은 대시보드에 비용이 많이 듭니다. 반정규화된 테이블은 빠른 읽기를 위해 데이터를 미리 조인하고 미리 계산합니다. 절충안: 쓰기는 여러 곳을 업데이트해야 합니다(불일치 위험), 저장 공간이 증가합니다. 읽기 중심 시스템(분석, 보고, 데이터 웨어하우스)에 반정규화를 사용하세요. 구체화된 뷰는 관리되는 반정규화를 제공합니다 - 데이터베이스가 갱신을 처리합니다. OLTP 시스템은 정규화를 유지해야 하고, OLAP 시스템은 일반적으로 반정규화됩니다(스타/스노우플레이크 스키마).

sql
-- Denormalization: intentionally adding redundancy for performance
-- Normalized (3NF): requires JOINs to get full order info
SELECT o.order_id, c.name, p.product_name, oi.quantity, p.price
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- Denormalized: store redundant data for read speed
CREATE TABLE order_summary (
    order_id INT PRIMARY KEY,
    customer_id INT,
    customer_name VARCHAR(100),    -- redundant (also in customers)
    customer_email VARCHAR(200),   -- redundant
    total_amount DECIMAL(10,2),    -- pre-calculated
    item_count INT,                -- pre-calculated
    order_date TIMESTAMP
);

-- Trade-off: faster reads, slower writes, risk of inconsistency
-- Use for: reporting tables, read-heavy dashboards, data warehouses

-- Materialized views are a managed form of denormalization:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, region, SUM(amount) AS total FROM sales GROUP BY date, region;
REFRESH MATERIALIZED VIEW sales_summary;

기본 키, 외래 키 & 제약조건

제약조건은 데이터베이스 수준에서 데이터 무결성을 강제합니다. PRIMARY KEY는 행을 고유하게 식별하고 클러스터형 인덱스를 생성합니다. UNIQUE는 중복을 방지합니다(대부분의 데이터베이스에서 다수의 NULL 허용). CHECK는 사용자 정의 규칙을 강제합니다(salary > 0). FOREIGN KEY는 참조 무결성을 유지합니다 - ON DELETE SET NULL/CASCADE/RESTRICT가 부모 행이 삭제될 때 어떤 일이 일어나는지 제어합니다. ON UPDATE CASCADE는 PK 변경을 FK에 전파합니다. 제약조건은 잘못된 데이터에 대한 마지막 방어선입니다 - 애플리케이션 코드에 버그가 있어도 데이터베이스는 잘못된 데이터를 거부합니다. 항상 제약조건을 정의하세요. 문서이자 강제 수단입니다.

sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY AUTO_INCREMENT,
    dept_name VARCHAR(100) NOT NULL UNIQUE,
    budget DECIMAL(12,2) CHECK (budget >= 0),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    emp_name VARCHAR(100) NOT NULL,
    email VARCHAR(200) UNIQUE,
    dept_id INT,
    salary DECIMAL(10,2) CHECK (salary > 0 AND salary < 1000000),
    hire_date DATE NOT NULL,
    manager_id INT REFERENCES employees(emp_id),  -- self-reference
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
        ON DELETE SET NULL      -- don't delete dept if employees exist
        ON UPDATE CASCADE,      -- update FK if dept_id changes
    INDEX idx_dept (dept_id),
    INDEX idx_email (email)
);

-- Constraint types:
-- PRIMARY KEY: unique + not null (clustered index)
-- UNIQUE: no duplicates (allows NULLs)
-- NOT NULL: required field
-- CHECK: custom condition
-- FOREIGN KEY: referential integrity
-- DEFAULT: value when not specified

인덱싱 전략

인덱스는 읽기를 극적으로 빠르게 하지만 쓰기를 느리게 합니다(각 인덱스는 INSERT/UPDATE/DELETE 시 업데이트되어야 함). B-tree 인덱스는 동등, 범위, 정렬을 지원합니다. 복합 인덱스는 최좌측 접두어 규칙을 따릅니다 - a 또는 a+b 쿼리에 (a, b)를 사용할 수 있지만, b 단독으로는 사용할 수 없습니다. 커버링 인덱스(INCLUDE 절)는 추가 컬럼을 저장하여 쿼리가 테이블을 건드리지 않도록 합니다 - 매우 빠릅니다. 부분 인덱스는 행의 하위 집합만 인덱싱하여 공간을 절약합니다. 인덱스 사용을 모니터링하고(PostgreSQL의 pg_stat_user_indexes) 미사용 인덱스를 삭제하세요. 좋은 규칙: 외래 키와 WHERE/JOIN 절의 컬럼을 인덱싱하세요. 과도한 인덱싱은 쓰기 성능을 해치고 저장 공간을 낭비합니다.

sql
-- B-tree index (default): good for =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_last_name ON employees(last_name);

-- Composite index: order matters! (leftmost prefix rule)
CREATE INDEX idx_dept_salary ON employees(dept_id, salary);
-- Usable for: WHERE dept_id = 5
-- Usable for: WHERE dept_id = 5 AND salary > 50000
-- NOT usable for: WHERE salary > 50000 (skips dept_id)

-- Covering index: includes all columns a query needs
CREATE INDEX idx_covering ON orders(customer_id, order_date)
    INCLUDE (total_amount, status);
-- Query can be satisfied from index alone (no table lookup)

-- Partial/partial index: index only matching rows
CREATE INDEX idx_active_users ON users(last_login)
    WHERE active = true;  -- PostgreSQL

-- Don't over-index: every index slows writes
-- Index columns used in: WHERE, JOIN, ORDER BY, GROUP BY
-- Drop unused indexes: SELECT * FROM pg_stat_user_indexes;
17

EXPLAIN & 쿼리 최적화

EXPLAIN 출력 읽기

EXPLAIN은 데이터베이스가 쿼리를 어떻게 실행하는지 보여줍니다 - 어떤 인덱스가 사용되는지, 테이블이 어떻게 조인되는지, 몇 행이 검사되는지. EXPLAIN ANALYZE(PostgreSQL) 또는 실행과 함께 EXPLAIN(MySQL 8.0+)은 실제로 쿼리를 실행하고 실제 타이밍을 보여줍니다. 찾아볼 것: Seq Scan / ALL(전체 테이블 스캔 - 큰 테이블에 나쁨), Index Scan(좋음), 행 추정치(높음 = 비용 많이 듦). MySQL에서 'Using filesort' 또는 'Using temporary'는 추가 작업을 나타냅니다. EXPLAIN이 큰 테이블에서 전체 테이블 스캔을 보여주면, 인덱스가 필요합니다. 최적화 전에 항상 EXPLAIN을 실행하세요 - 추측하지 마세요.

sql
-- EXPLAIN shows the query execution plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- EXPLAIN ANALYZE actually runs the query (PostgreSQL)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- PostgreSQL output:
-- Index Scan using idx_customer on orders  (cost=0.29..8.31 rows=1 width=74)
--   Index Cond: (customer_id = 42)
--   Execution Time: 0.042 ms

-- Key columns to check:
-- - type: scan method (const > eq_ref > ref > range > index > ALL)
-- - rows: estimated rows examined (lower is better)
-- - key: which index is used (NULL = no index, bad!)
-- - Extra: "Using filesort" or "Using temporary" = warning signs

-- MySQL: EXPLAIN FORMAT=JSON for detailed output
-- SQL Server: SET SHOWPLAN_TEXT ON; or Actual Execution Plan in SSMS

일반적인 성능 문제

몇 가지 일반적인 패턴이 인덱스 사용을 방해하고 전체 테이블 스캔을 유발합니다. 인덱스된 컬럼의 함수(YEAR(date), UPPER(name))는 인덱스 사용을 방해합니다 - 범위 조건으로 다시 작성하세요. LIKE의 선행 와일드카드('%pattern')는 B-tree 인덱스를 사용할 수 없습니다 - 전체 텍스트 검색을 대신 사용하세요. SELECT *는 대역폭을 낭비하고 커버링 인덱스 최적화를 방해합니다. 암시적 타입 변환(문자열 컬럼을 정수와 비교)은 인덱스를 비활성화할 수 있습니다. OR 조건은 때때� IN보다 덜 효율적입니다. 항상 EXPLAIN으로 인덱스가 실제로 사용되는지 확인하세요 - 사용되지 않는 인덱스는 낭비된 저장 공간이자 쓰기 오버헤드입니다.

sql
-- 1. Missing index → full table scan
-- Bad: SELECT * FROM orders WHERE customer_id = 42; (no index)
-- Fix: CREATE INDEX idx_customer ON orders(customer_id);

-- 2. Index not used due to function on column
-- Bad: WHERE YEAR(order_date) = 2024  (function prevents index use)
-- Good: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

-- 3. SELECT * instead of specific columns
-- Bad: SELECT * FROM large_table WHERE id = 1;
-- Good: SELECT id, name, email FROM large_table WHERE id = 1;

-- 4. OR conditions preventing index use
-- Bad: WHERE dept = 'A' OR dept = 'B' OR dept = 'C'
-- Good: WHERE dept IN ('A', 'B', 'C')

-- 5. LIKE with leading wildcard
-- Bad: WHERE name LIKE '%son'  (can't use index)
-- OK:  WHERE name LIKE 'John%'  (can use index)

-- 6. Implicit type conversion
-- Bad: WHERE string_column = 123  (converts to string, skips index)
-- Good: WHERE string_column = '123'

JOIN 최적화

JOIN 최적화는 다중 테이블 쿼리에 중요합니다. 조인 컬럼(일반적으로 외래 키)이 인덱싱되어 있는지 확인하세요 - 인덱싱되지 않은 조인은 중첩 루프 스캔(O(n*m))을 유발합니다. 쿼리 옵티마이저가 일반적으로 최적의 조인 순서를 선택하지만, 일찍 필터링하여 도움을 줄 수 있습니다(개념적으로 JOIN 전에 WHERE). 일치하지 않는 행이 필요 없을 때 INNER JOIN이 OUTER JOIN보다 빠릅니다. 상관 서브쿼리의 경우 EXISTS가 첫 번째 일치에서 단락되므로 IN보다 더 효율적입니다. 필요 없는 테이블 조인을 피하세요 - 각 조인은 작업을 곱합니다. 복잡한 보고서의 경우 구체화된 뷰나 미리 집계된 요약 테이블을 고려하세요.

sql
-- Join order matters: smallest table first (optimizer usually handles this)
-- Ensure join columns are indexed (usually foreign keys)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);

-- Use INNER JOIN when you don't need unmatched rows (faster than OUTER)
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

-- Avoid joining unnecessary tables — fetch details lazily if needed
-- Bad: join 5 tables when you only need 2 columns
-- Good: split into simpler queries or use a covering index

-- EXISTS vs IN for subqueries
-- EXISTS is often faster for large subquery results:
SELECT name FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN is better for small lists:
SELECT * FROM orders WHERE customer_id IN (1, 2, 3);

페이지네이션 최적화

OFFSET 기반 페이지네이션(LIMIT 10 OFFSET 10000)은 O(n)입니다 - 데이터베이스가 건너뛴 모든 행을 스캔하고 버려야 하므로 깊은 페이지가 매우 느립니다. 키셋(커서) 페이지네이션은 WHERE last_value < cursor로 직접 탐색합니다 - 페이지 깊이와 관계없이 O(1)입니다. 이는 정렬 컬럼에 인덱스가 필요합니다. 동점(동일한 타임스탬프)의 경우 복합 커서(created_at, id)를 사용하세요. 큰 테이블의 총 개수에 COUNT(*)를 피하세요 - 전체 테이블을 스캔합니다. 근사 개수(PostgreSQL의 pg_class.reltuples)를 사용하거나 총 개수를 전혀 표시하지 마세요(무한 스크롤). 키셋 페이지네이션은 고성능 API의 표준입니다.

sql
-- Bad: OFFSET pagination (slow for large offsets)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
-- Must scan and discard 10000 rows — gets slower as you page deeper

-- Good: Keyset (cursor) pagination using WHERE
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:30:00'  -- last seen value
ORDER BY created_at DESC
LIMIT 10;
-- Uses index efficiently — constant time regardless of page depth

-- For composite keyset pagination (handles ties):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Count total (expensive on large tables — avoid if possible)
-- Instead of COUNT(*), use an approximate count:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

쿼리 재작성 & 최적화 체크리스트

쿼리 최적화는 반복적 과정입니다: EXPLAIN, 병목 식별, 재작성, 반복. 주요 기술: IN 서브쿼리를 JOIN으로 교체(종종 더 빠름), UNION 대신 UNION ALL 사용(중복 제거 정렬 건너뜀), 일괄 INSERT(1000개 대신 1개 쿼리), prepared statement 사용(쿼리 계획 캐싱). CTE는 가독성을 향상하지만 이전 PostgreSQL 버전에서는 구체화되어(최적화 불가) - PostgreSQL 12+은 인라인 처리합니다. 플래너가 좋은 결정을 내리도록 테이블 통계를 최신으로 유지(ANALYZE). 황금률: EXPLAIN ANALYZE로 측정하세요, 추측하지 마세요. 하나의 데이터베이스/버전에서 빠른 것이 다른 곳에서는 느릴 수 있습니다.

sql
-- 1. Replace subqueries with JOINs when possible
-- Slow: SELECT * FROM orders WHERE customer_id IN
--       (SELECT id FROM customers WHERE active = true);
-- Fast: SELECT o.* FROM orders o
--       JOIN customers c ON o.customer_id = c.id WHERE c.active = true;

-- 2. Use UNION ALL instead of UNION (avoids dedup sort)
SELECT 'A' UNION ALL SELECT 'B';  -- fast
SELECT 'A' UNION SELECT 'B';       -- sorts to remove duplicates

-- 3. Batch operations instead of row-by-row
-- Slow: 1000 individual INSERTs
-- Fast: INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;

-- 4. Use CTEs for readability, but know they may not optimize well
-- (PostgreSQL 12+ inlines CTEs; older versions materialize them)

-- 5. Avoid SELECT DISTINCT when you can use GROUP BY or EXISTS
-- 6. Use prepared statements for repeated queries (plan caching)
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(42);

-- 7. Analyze tables for up-to-date statistics
ANALYZE orders;  -- PostgreSQL: updates planner statistics
18

NoSQL vs SQL 비교

SQL vs NoSQL: 언제 무엇을 사용할까

SQL vs NoSQL 선택은 데이터 모델, 일관성 요구사항, 규모에 따라 달라집니다. SQL 데이터베이스는 스키마를 강제하고, ACID 트랜잭션을 지원하며, JOIN이 있는 복잡한 쿼리에 뛰어납니다 - 금융 시스템과 데이터 무결성이 중요한 앱에 이상적입니다. NoSQL 데이터베이스는 일관성을 확장성과 유연성으로 교환합니다: 진화하는 스키마를 위한 문서 저장소(MongoDB), 캐싱을 위한 키-값 저장소(Redis), 대량 쓰기 처리량을 위한 컬럼 패밀리(Cassandra), 관계 중심 데이터를 위한 그래프 데이터베이스(Neo4j). 최신 SQL 데이터베이스는 이제 JSON, 전체 텍스트 검색, 확장을 지원하여 많은 경우 NoSQL의 필요성을 줄입니다.

sql
-- SQL (Relational): structured, consistent, queryable
-- Best for: financial systems, e-commerce, CRM, any ACID-requiring app
-- Examples: PostgreSQL, MySQL, SQL Server, Oracle

-- NoSQL: flexible schema, horizontal scaling, specific data models
-- Document: MongoDB, CouchDB — JSON-like documents, flexible schema
-- Key-Value: Redis, DynamoDB — fast lookups, caching, sessions
-- Column-Family: Cassandra, HBase — wide-column, time-series, write-heavy
-- Graph: Neo4j, ArangoDB — relationships, social networks, recommendations

-- Decision factors:
-- 1. Data structure: fixed → SQL, evolving/varied → NoSQL
-- 2. Consistency: strict ACID → SQL, eventual consistency OK → NoSQL
-- 3. Scale: vertical (bigger server) → SQL, horizontal (more servers) → NoSQL
-- 4. Queries: complex JOINs → SQL, simple lookups → NoSQL
-- 5. Team expertise: SQL is universal, NoSQL varies

-- Many modern databases blur the line:
-- PostgreSQL: JSON columns, full-text search, pub/sub
-- MongoDB: transactions (multi-document ACID since 4.0)

문서 저장소 패턴 (MongoDB 스타일)

문서 저장소는 테이블 전체에 정규화하는 대신 관련 데이터를 단일 문서에 포함합니다. 이는 읽기 중심 액세스 패턴에 대한 JOIN을 제거하지만 데이터를 중복합니다(모든 주문의 고객 정보). 데이터가 함께 액세스되고 크기가 제한된 경우 포함이 작동합니다. 무제한 관계(수천 건의 주문이 있는 고객)의 경우 참조를 사용하세요(customer_id 저장, 별도로 검색). PostgreSQL의 JSONB 컬럼은 관계형 데이터베이스 내에서 문서 저장소 유연성을 제공합니다 - ACID 트랜잭션, 인덱싱(GIN), JSON에 대한 SQL 쿼리를 얻습니다. 이 하이브리드 접근 방식은 점점 인기가 많아져 별도의 NoSQL 데이터베이스의 필요성을 줄입니다.

sql
-- In SQL, you'd normalize this into 3 tables:
-- customers, orders, order_items

-- In a document store (MongoDB), you might embed everything:
-- (pseudo-code, not SQL)
// db.orders.insertOne({
//   _id: 1,
//   customer: { name: "Alice", email: "[email protected]" },
//   items: [
//     { product: "Laptop", price: 999, qty: 1 },
//     { product: "Mouse", price: 25, qty: 2 }
//   ],
//   total: 1049,
//   status: "shipped",
//   created_at: ISODate("2024-01-15")
// })

-- SQL equivalent with JSON column (PostgreSQL):
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    data JSONB NOT NULL  -- stores the entire document
);
INSERT INTO orders (data) VALUES ('{
    "customer": {"name": "Alice", "email": "[email protected]"},
    "items": [{"product": "Laptop", "price": 999, "qty": 1}],
    "total": 999
}');

-- Query JSON data:
SELECT data->'customer'->>'name' AS name FROM orders
WHERE data->'total' > 500;

키-값 저장소 패턴 (Redis 스타일)

Redis 같은 키-값 저장소는 데이터가 메모리에 있기 때문에 초고속 조회(밀리초 미만)에 뛰어납니다. 일반적인 사용 사례: 비용이 많이 드는 쿼리 결과 캐싱, 세션 저장(TTL 만료 포함), 실시간 카운터(원자적 INCR), 리더보드(정렬된 집합). Redis 데이터 구조(리스트, 집합, 정렬된 집합, 해시)는 단순한 키-값 이상입니다. 절충안: 데이터가 인메모리(RAM 제한)이고 영속성은 선택 사항입니다. SQL 앞에 캐시 계층으로 Redis를 사용하세요 - write-through 또는 cache-aside 패턴. 세션 데이터의 경우 Redis의 자동 만료(TTL)가 이상적입니다. SQL 데이터베이스는 캐시 테이블로 캐싱을 흉내 낼 수 있지만, 핫 데이터에 대한 Redis의 속도를 따라갈 수 없습니다.

sql
-- Redis is an in-memory key-value store — not SQL
-- Common patterns (Redis commands, not SQL):

-- Caching: store expensive query results
// SET user:42:profile '{"name":"Alice","age":30}' EX 3600
// GET user:42:profile  -- returns cached data, expires in 1 hour

-- Session storage: fast, ephemeral
// SET session:abc123 '{"user_id":42}' EX 1800  -- 30 min TTL

-- Counters & leaderboards:
// INCR page:home:views           -- atomic counter
// ZADD leaderboard 1500 "alice"  -- sorted set for rankings
// ZREVRANGE leaderboard 0 9      -- top 10 players

-- SQL equivalent for caching (materialized/precomputed):
CREATE TABLE cache (
    cache_key VARCHAR(200) PRIMARY KEY,
    cache_value TEXT,
    expires_at TIMESTAMP,
    INDEX idx_expires (expires_at)
);
-- Periodically: DELETE FROM cache WHERE expires_at < NOW();

-- When to use Redis vs SQL cache:
-- Redis: sub-millisecond reads, data structures (sets, sorted sets)
-- SQL: when you need ACID or already have a database connection

폴리글랏 영속성 (데이터베이스 혼합)

폴리글랏 영속성은 하나의 애플리케이션 내에서 다른 데이터 요구사항에 대해 다른 데이터베이스를 사용합니다. PostgreSQL은 트랜잭션, Redis는 캐싱, Elasticsearch는 검색, S3은 파일을 처리합니다. 과제는 저장소 간에 데이터 일관성을 유지하는 것입니다 - 해결책은 이벤트 기반 아키텍처입니다: 기본 데이터베이스(진실의 원천)에 쓰고, Change Data Capture(CDC)나 메시지 큐(Kafka, RabbitMQ)를 통해 다른 저장소에 비동기적으로 변경사항을 전파합니다. 이는 최종 일관성을 제공합니다 - 보조 저장소에서의 읽기가 약간 지연될 수 있습니다. 이점: 각 저장소가 자체 작업부에 최적화됩니다. 비용: 운영 복잡성. 단일 SQL 데이터베이스로 시작하세요. 명확한 성능 한계에 도달할 때만 특화된 저장소를 추가하세요.

sql
-- Modern applications often use MULTIPLE database types:
-- Each data store handles what it does best

-- Typical architecture:
-- 1. PostgreSQL: core transactional data (users, orders, payments)
--    ACID guarantees, complex queries, foreign keys
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR UNIQUE);
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));

-- 2. Redis: session storage, caching, rate limiting
--    Fast in-memory access, auto-expiring keys

-- 3. Elasticsearch: full-text search, log analytics
--    Inverted index, faceted search, aggregations

-- 4. S3/Object storage: files, images, backups
--    Cheap, unlimited, HTTP-accessible

-- 5. TimescaleDB/InfluxDB: time-series metrics
--    Optimized for timestamped data, downsampling

-- Challenge: data consistency across stores
-- Solution: event-driven architecture (CDC, message queues)
--   1. Write to PostgreSQL (source of truth)
--   2. Publish event to Kafka
--   3. Consumers update Redis cache, Elasticsearch index, etc.
--   4. eventual consistency — reads may be slightly stale

ACID vs BASE 일관성 모델

ACID(원자성, 일관성, 격리, 지속성)는 엄격한 일관성을 보장합니다 - 트랜잭션은 전부 아니면 전무이며, 데이터는 항상 제약조건을 만족합니다. 이는 부분 업데이트가 오류를 일으키는 금융 시스템에 필수적입니다. BASE(기본적으로 사용 가능, 연성 상태, 최종 일관성)는 즉각적인 일관성을 가용성과 파티션 내성으로 교환합니다 - 데이터가 일시적으로 일치하지 않을 수 있지만 시간이 지나면 수렴합니다. CAP 정리는 네트워크 분할 중에 세 가지(일관성, 가용성, 파티션 내성)를 동시에 가질 수 없다고 말합니다. SQL 데이터베이스는 C+A(단일 노드) 또는 C+P(분산)를 우선시합니다. 많은 NoSQL 데이터베이스는 A+P(Cassandra, DynamoDB)를 우선시합니다. 정확성이 중요할 때 ACID를, 가용성과 규모가 더 중요할 때 BASE를 선택하세요.

sql
-- ACID (SQL databases):
-- Atomicity: all operations in a transaction succeed or fail together
-- Consistency: data always satisfies constraints (FK, CHECK, etc.)
-- Isolation: concurrent transactions don't interfere
-- Durability: committed data survives crashes

-- SQL transaction example:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If either fails, ROLLBACK undoes both
COMMIT;  -- both updates are permanent

-- BASE (NoSQL databases):
-- Basically Available: system responds (may be stale)
-- Soft state: state changes without input (eventual consistency)
-- Eventually consistent: data converges over time

-- NoSQL trade-off: higher availability & partition tolerance
-- at the cost of immediate consistency

-- CAP Theorem: in a network partition, choose:
-- - CP (consistency): refuse writes (some NoSQL: HBase, MongoDB)
-- - AP (availability): accept writes, reconcile later (Cassandra, DynamoDB)

-- SQL databases are typically CA (consistent + available, no partition tolerance
-- in single-node setups; distributed SQL like CockroachDB adds P)
19

CTE & 재귀 CTE

기본 CTE

CTE(Common Table Expression)는 임시로 명명된 결과 세트입니다. 복잡한 쿼리를 분해하여 가독성을 향상합니다. 여러 CTE를 쉼표로 연결할 수 있습니다. CTE는 단일 문장에만 유효합니다.

sql
WITH high_earners AS (
  SELECT * FROM employees WHERE salary > 80000
), by_dept AS (
  SELECT department, COUNT(*) AS cnt FROM high_earners GROUP BY department
)
SELECT * FROM by_dept ORDER BY cnt DESC;

재귀 CTE

재귀 CTE는 자신을 참조합니다. 앵커는 기본 케이스입니다. UNION ALL이 재귀 부분에 연결됩니다. 계층적 데이터에 사용됩니다: 조직도, 파일 시스템, 그래프 순회. 종료 조건이 있어야 합니다.

sql
WITH RECURSIVE org_chart AS (
  -- Anchor: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  -- Recursive: subordinates
  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 * FROM org_chart;

CTE로 피보나치

재귀 CTE는 시퀀스를 생성할 수 있습니다. 앵커가 첫 값을 제공합니다. 각 반복이 다음 값을 계산합니다. WHERE 절이 무한 재귀를 방지합니다. 수학적 시퀀스에 유용합니다.

sql
WITH RECURSIVE fib(n, a, b) AS (
  SELECT 1, 0, 1
  UNION ALL
  SELECT n + 1, b, a + b FROM fib WHERE n < 10
)
SELECT n, a FROM fib;
-- Result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

트리 순회

각 재귀에서 이름을 연결하여 경로를 구축합니다. CAST는 path 컬럼이 충분히 넓어지도록 보장합니다. breadcrumbs, 파일 경로, 카테고리 계층에 유용합니다. ORDER BY path는 계층적으로 정렬합니다.

sql
WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, CAST(name AS VARCHAR(1000)) AS path
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name
  FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, path FROM category_tree ORDER BY path;

CTE vs 서브쿼리

CTE는 가독성을 향상하고 여러 번 참조될 수 있습니다. 서브쿼리는 인라인이며 재사용할 수 없습니다. CTE는 항상 구체화되지 않습니다; 옵티마이저가 인라인 처리할 수 있습니다. 명확성을 위해 CTE를 사용하세요.

sql
-- CTE: more readable, can be referenced multiple times
WITH active_users AS (SELECT * FROM users WHERE active = 1)
SELECT * FROM active_users WHERE age > 18
UNION ALL
SELECT * FROM active_users WHERE age <= 18;
-- Subquery: inline, cannot be reused
SELECT * FROM (SELECT * FROM users WHERE active = 1) au WHERE au.age > 18;
20

인덱스 심층

B-Tree 인덱스

B-Tree는 기본 인덱스 유형입니다. 복합 인덱스는 최좌측 접두어 규칙을 따릅니다: 선행 컬럼으로 필터링하면 쿼리가 인덱스를 사용할 수 있습니다. 선택도와 쿼리 패턴에 따라 컬럼 순서를 정하세요.

sql
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- Composite index: useful for
-- WHERE last_name = 'Smith' AND first_name = 'John'
-- WHERE last_name = 'Smith' (leftmost prefix)

부분 인덱스

부분 인덱스는 WHERE 절과 일치하는 행만 포함합니다. 전체 인덱스보다 작고 빠릅니다. 항상 조건으로 필터링하는 쿼리에 이상적입니다. 쓰기 오버헤드를 줄입니다.

sql
CREATE INDEX idx_active_users ON users(last_login) WHERE active = 1;
-- Only indexes active users, saving space
-- Useful when queries always filter on active = 1

커버링 인덱스

커버링 인덱스는 쿼리에 필요한 모든 컬럼을 포함하여 인덱스 전용 스캔을 가능하게 합니다. PostgreSQL은 비키 컬럼에 INCLUDE를 사용합니다. 테이블 조회를 피하여 SELECT 쿼리를 극적으로 가속합니다.

sql
-- PostgreSQL: INCLUDE clause
CREATE INDEX idx_users_covering ON users(last_name) INCLUDE (first_name, email);
-- The query is "covered" if all columns are in the index:
SELECT first_name, email FROM users WHERE last_name = 'Smith';
-- No table lookup needed (index-only scan)

인덱스 유형

다른 인덱스 유형이 다른 요구사항을 충족합니다. 일반 용도로 B-Tree. 동등 비교만으로 Hash. 전체 텍스트와 JSON으로 GIN. 기하학적 데이터로 GiST. 쿼리 패턴에 따라 선택하세요.

sql
-- B-Tree: default, good for equality and range
CREATE INDEX idx_btree ON users(email);
-- Hash: equality only (PostgreSQL)
CREATE INDEX idx_hash ON users(email) USING HASH;
-- GIN: full-text search, arrays, JSON
CREATE INDEX idx_gin ON docs USING GIN (tsv);
-- GiST: geometric, range types
CREATE INDEX idx_gist ON places USING GIST (location);

인덱스 유지보수

인덱스 사용을 모니터링하여 쓰기를 늦추는 미사용 인덱스를 제거하세요. REINDEX는 조각화된 인덱스를 재구축합니다. ANALYZE는 쿼리 플래너를 위한 통계를 업데이트합니다. 정기적인 유지보수가 최적의 성능을 유지합니다.

sql
-- Check index usage (PostgreSQL)
SELECT * FROM pg_stat_user_indexes;
-- Find unused indexes
SELECT relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Rebuild fragmented index
REINDEX INDEX idx_users_email;
-- Analyze for query planner
ANALYZE users;
21

트랜잭션

ACID 속성

ACID: 원자성(전부 아니면 전무), 일관성(유효한 상태), 격리(동시 트랜잭션이 간섭하지 않음), 지속성(커밋된 데이터 유지). BEGIN이 시작, COMMIT이 저장, ROLLBACK이 실행 취소.

sql
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Or: ROLLBACK to undo

세이브포인트

세이브포인트는 트랜잭션 내에 부분 롤백 지점을 만듭니다. ROLLBACK TO는 트랜잭션을 종료하지 않고 세이브포인트로 실행 취소합니다. 다단계 작업에서 재시작 없이 오류를 처리하는 데 유용합니다.

sql
BEGIN;
INSERT INTO orders VALUES (1);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2);
-- Oops, rollback to savepoint
ROLLBACK TO sp1;
-- Only order 1 is inserted
INSERT INTO orders VALUES (3);
COMMIT;

격리 수준

격리 수준은 일관성과 성능의 균형을 맞춥니다. READ COMMITTED(기본값)는 더티 읽기를 방지합니다. REPEATABLE READ는 반복 불가능한 읽기를 방지합니다. SERIALIZABLE은 팬텀 읽기를 방지하지만 가장 느립니다.

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Levels (increasing isolation):
-- READ UNCOMMITTED: dirty reads
-- READ COMMITTED: no dirty reads (default)
-- REPEATABLE READ: no non-repeatable reads
-- SERIALIZABLE: full isolation

교착 상태

교착 상태는 트랜잭션이 서로 필요한 잠금을 보유할 때 발생합니다. 데이터베이스는 교착 상태를 감지하고 하나의 트랜잭션을 중단합니다. 일관된 순서로 테이블에 액세스하여 방지하세요. 트랜잭션을 짧게 유지하세요.

sql
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
UPDATE accounts SET balance = 0 WHERE id = 2;  -- Waits
-- Transaction 2
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 2;
UPDATE accounts SET balance = 0 WHERE id = 1;  -- Waits
-- Deadlock! Database aborts one transaction

낙관적 잠금

낙관적 잠금은 충돌이 드물다고 가정합니다. version 컬럼이 변경사항을 추적합니다. UPDATE가 0행에 영향을 주면, 데이터가 다른 트랜잭션에 의해 수정된 것입니다. 재시도하거나 사용자에게 알리세요. 긴 잠금 유지를 방지합니다.

sql
-- Add version column
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- Update with version check
UPDATE products SET price = 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If 0 rows affected, someone else updated first
22

SQL에서 JSON

PostgreSQL JSONB

JSONB는 JSON을 바이너리 형식으로 저장하여 인덱싱과 빠른 쿼리를 가능하게 합니다. ->>는 텍스트로 추출, ->는 JSON으로 추출합니다. JSONB가 쿼리에는 JSON보다 바람직합니다. JSONB 컬럼에 GIN 인덱스를 사용하세요.

sql
CREATE TABLE events (id SERIAL, data JSONB);
INSERT INTO events (data) VALUES ('{"user": "alice", "action": "login"}');
SELECT data->>'user' AS user_name FROM events;
SELECT * FROM events WHERE data->>'action' = 'login';

JSON 쿼리

->는 JSON을 탐색, ->>는 텍스트를 반환합니다. @>는 포함을 확인합니다. jsonb_set은 중첩 값을 업데이트합니다. jsonb_object_keys는 최상위 키를 반환합니다. 이 연산자들은 강력한 JSON 쿼리를 가능하게 합니다.

sql
SELECT data->'address'->'city' AS city FROM users;
SELECT * FROM users WHERE data @> '{"role": "admin"}';
SELECT jsonb_object_keys(data) FROM users;
-- Update JSON
UPDATE users SET data = jsonb_set(data, '{last_login}', '"2024-01-01"');

JSON 집계

json_agg는 행을 JSON 배열로 집계합니다. json_build_object는 컬럼에서 JSON 객체를 구성합니다. SQL에서 직접 API 응답을 생성하는 데 유용합니다. 관계형 데이터와 문서 데이터를 결합합니다.

sql
SELECT department,
  json_agg(json_build_object('name', name, 'salary', salary)) AS employees
FROM employees
GROUP BY department;
-- Result: {"department": "Eng", "employees": [{"name": "Alice", "salary": 90000}, ...]}

MySQL JSON

MySQL은 JSON에 $.path 구문을 사용합니다. JSON_EXTRACT가 값을 가져오고, JSON_SET이 업데이트합니다. ->>는 텍스트 결과와 함께 JSON_EXTRACT의 약어입니다. MySQL JSON은 삽입 시 검증됩니다.

sql
CREATE TABLE config (id INT, settings JSON);
INSERT INTO config VALUES (1, '{"theme": "dark", "lang": "en"}');
SELECT settings->>'$.theme' FROM config;
SELECT * FROM config WHERE JSON_EXTRACT(settings, '$.lang') = 'en';
-- Update
UPDATE config SET settings = JSON_SET(settings, '$.theme', 'light');

JSON 인덱스

JSONB의 GIN 인덱스는 모든 키의 빠른 쿼리를 가능하게 합니다. 특정 경로의 표현식 인덱스는 더 작고 대상 쿼리에 더 빠릅니다. 성능을 위해 자주 쿼리되는 JSON 경로를 인덱싱하세요.

sql
-- PostgreSQL GIN index on JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Index specific path
CREATE INDEX idx_events_user ON events ((data->>'user'));
-- MySQL functional index
CREATE INDEX idx_theme ON config ((CAST(settings->>'$.theme' AS CHAR(50))));
23

성능 튜닝

EXPLAIN ANALYZE

EXPLAIN은 쿼리 계획을 보여주고, ANALYZE는 타이밍과 함께 실행합니다. Seq Scan은 인덱스 누락을 나타냅니다. Index Scan이 이상적입니다. 높은 비용 숫자와 느린 작업을 찾으세요. 최적화 전에 항상 EXPLAIN을 실행하세요.

sql
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';
-- Shows: scan type, cost, rows, actual time
-- Seq Scan: full table scan (slow)
-- Index Scan: uses index (fast)
-- Bitmap Heap Scan: index + table lookup

쿼리 최적화

I/O를 줄이기 위해 필요한 컬럼만 선택하세요. 인덱스된 컬럼의 함수를 피하세요(non-sargable). Sargable(Search Argument Able) 쿼리는 인덱스를 사용할 수 있습니다. 함수 대신 범위 조건을 사용하세요.

sql
-- BAD: SELECT * fetches all columns
SELECT * FROM users;
-- GOOD: select only needed columns
SELECT id, name FROM users;
-- BAD: function on indexed column
WHERE YEAR(created_at) = 2024
-- GOOD: sargable
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

JOIN 최적화

모든 조인 컬럼을 인덱싱하세요. 옵티마이저는 통계를 기반으로 조인 순서를 선택합니다. INNER JOIN이 일반적으로 가장 빠릅니다. 표현식으로 조인을 피하세요. 대규모 데이터셋의 경우 반정규화나 구체화된 뷰를 고려하세요.

sql
-- Use INNER JOIN for required relationships
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Index join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- For large joins, ensure both columns are indexed

페이지네이션

OFFSET 페이지네이션은 O(n)입니다 - 건너뛴 모든 행을 스캔합니다. 키셋(커서) 페이지네이션은 O(1)입니다 - 인덱스를 사용합니다. 안정적인 정렬을 위해 튜플 비교를 사용하세요. 깊은 페이지네이션에 훨씬 빠릅니다.

sql
-- BAD: OFFSET scans all skipped rows
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- GOOD: keyset pagination
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- Stable pagination with cursor
SELECT * FROM users WHERE (created_at, id) > ('2024-01-01', 100) ORDER BY created_at, id LIMIT 10;

구체화된 뷰

구체화된 뷰는 쿼리 결과를 물리적으로 저장합니다. 비용이 많이 드는 집계에 대해 뷰보다 빠릅니다. REFRESH가 데이터를 업데이트합니다(CONCURRENTLY 옵션으로 동시에). 빠른 쿼리를 위해 인덱싱하세요.

sql
CREATE MATERIALIZED VIEW sales_summary AS
SELECT product_id, SUM(quantity) AS total, AVG(price) AS avg_price
FROM sales GROUP BY product_id;
-- Refresh periodically
REFRESH MATERIALIZED VIEW sales_summary;
-- Create index on materialized view
CREATE INDEX ON sales_summary (total);
24

고급 JOIN

셀프 조인

셀프 조인은 테이블을 자체적으로 쿼리합니다. 별칭으로 구분하세요. 계층적 데이터(직원-관리자)와 쌍 찾기에 일반적입니다. a.id < b.id 트릭은 중복 쌍을 방지합니다.

sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs in same department
SELECT a.name, b.name FROM employees a, employees b
WHERE a.department = b.department AND a.id < b.id;

크로스 조인

CROSS JOIN은 카티전 곱을 생성합니다: A의 모든 행과 B의 모든 행의 조합. 조합 생성에 유용합니다. 주의: 거대한 결과 세트를 생성할 수 있습니다. 쉼표 구문으로 암시적으로 자주 사용됩니다.

sql
-- Cartesian product: every combination
SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
-- Generate all size/color combinations
-- Useful for generating test data or matrices

FULL OUTER JOIN

FULL OUTER JOIN은 양쪽 테이블의 모든 행을 반환합니다. 일치하지 않는 쪽은 NULL로 채워집니다. 양방향으로 일치하지 않는 레코드를 찾는 데 유용합니다. MySQL에서는 지원되지 않음(LEFT와 RIGHT 조인의 UNION으로 흉내 냄).

sql
SELECT u.name, o.order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Returns all users and all orders
-- NULLs where there is no match

안티 조인

안티 조인은 B와 일치하지 않는 A의 행을 찾습니다. NOT EXISTS가 일반적으로 가장 명확하고 종종 가장 빠릅니다. LEFT JOIN with IS NULL이 대안입니다. 누락된 관계를 찾는 데 사용하세요.

sql
-- Users who have never ordered
SELECT u.* FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: LEFT JOIN ... WHERE IS NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

세미 조인

세미 조인은 B의 최소 한 행과 일치하는 A의 행을 반환합니다. EXISTS는 첫 번째 일치에서 중단되므로 효율적입니다. IN이 동등하지만 성능이 다를 수 있습니다. 상관 서브쿼리에 EXISTS를 사용하세요.

sql
-- Users who have at least one order
SELECT u.* FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: IN
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
25

일반적인 함정

NULL 비교

NULL은 값이 아닌 알 수 없음입니다. = NULL은 항상 NULL을 반환합니다(false로 취급). IS NULL과 IS NOT NULL을 사용하세요. NULL은 산술을 통해 전파됩니다. 기본값을 제공하려면 COALESCE를 사용하세요.

sql
-- NULL = NULL is NULL (not true!)
SELECT * FROM users WHERE phone = NULL;  -- Returns nothing
SELECT * FROM users WHERE phone IS NULL;  -- Correct
SELECT * FROM users WHERE phone IS NOT NULL;
-- NULL in arithmetic yields NULL
SELECT 5 + NULL;  -- NULL

SQL 인젝션

SQL 인젝션은 공격자가 임의의 SQL을 실행할 수 있게 합니다. 사용자 입력을 쿼리에 연결하지 마세요. 항상 매개변수화된 쿼리/prepared statement를 사용하세요. 모든 입력을 검증하고 살균하세요. ORM 매개변수 바인딩을 사용하세요.

sql
-- BAD: string concatenation
query = "SELECT * FROM users WHERE name = '" + input + "'"
-- GOOD: parameterized queries
SELECT * FROM users WHERE name = ?;
-- PostgreSQL: $1
-- MySQL: ?
-- Always use parameters, never concatenate

GROUP BY 함정

GROUP BY를 사용할 때, SELECT의 모든 비집계 컬럼은 GROUP BY에 있어야 합니다. 그렇지 않으면 결과가 모호합니다. MySQL은 이를 허용하지만(임의의 값 반환) 잘못되었습니다. 항상 표준을 따르세요.

sql
-- BAD: non-aggregated column not in GROUP BY
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- Error: which name to show?
-- GOOD: aggregate or include in GROUP BY
SELECT department, COUNT(*) FROM employees GROUP BY department;
SELECT department, MAX(name) FROM employees GROUP BY department;

부동 소수점

FLOAT와 DOUBLE은 근사 유형입니다. 정확한 정밀도(돈, 측정)에는 DECIMAL/NUMERIC을 사용하세요. DECIMAL(10,2)은 소수점 이하 2자리로 10자리를 허용합니다. 재무 데이터에 FLOAT를 절대 사용하지 마세요.

sql
-- Floating point precision issues
SELECT 0.1 + 0.2;  -- 0.30000000000000004
-- Use DECIMAL for money
CREATE TABLE accounts (balance DECIMAL(10, 2));
SELECT 0.10 + 0.20;  -- 0.30 (exact)

암시적 타입 변환

암시적 타입 변환은 인덱스를 비활성화하고 전체 테이블 스캔을 유발할 수 있습니다. 항상 일치하는 타입을 비교하세요. 필요한 경우 명시적으로 캐스트하세요. 컬럼 타입을 확인하고 쿼리 매개변수가 일치하는지 확인하세요.

sql
-- BAD: comparing string to number
SELECT * FROM users WHERE phone = 1234567890;
-- May cause full table scan due to type conversion
-- GOOD: compare same types
SELECT * FROM users WHERE phone = '1234567890';
-- Or cast explicitly
SELECT * FROM users WHERE CAST(phone AS BIGINT) = 1234567890;

Was this helpful?