- Published on
SQL 완전 정복 2025: 개발자 면접에 나오는 SQL — JOIN부터 Window Function, 쿼리 최적화까지
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 도입: 2025년에도 SQL이 최강인 이유
- 1. JOIN 완전 정복
- 2. 집계와 그룹화 (Aggregation)
- 3. Window Functions
- 4. 서브쿼리 vs CTE vs 파생 테이블
- 5. 재귀 CTE (Recursive CTE)
- 6. 인덱싱 (Indexing)
- 7. EXPLAIN ANALYZE
- 8. 쿼리 최적화
- 9. 트랜잭션 격리 수준
- 10. PostgreSQL 고급 기능
- 11. 실전 문제 30선
- 12. 면접 질문 15선
- 13. 퀴즈 5선
- 14. 참고 자료
도입: 2025년에도 SQL이 최강인 이유
Stack Overflow 2024 Developer Survey에서 SQL은 가장 많이 사용되는 언어 3위를 기록했고, 개발자가 가장 배우고 싶은 기술 중 상위에 올랐습니다. PostgreSQL은 2023-2024 연속 DB-Engines 올해의 DBMS로 선정되었으며, AI/LLM 시대에도 데이터를 다루는 근본 기술은 SQL입니다.
왜 SQL이 면접에서 중요한가:
- 백엔드 포지션 면접의 80% 이상에서 SQL 문제 출제
- 데이터 엔지니어, 풀스택, DevOps까지 SQL 필수
- 실무에서 ORM만 쓰다가 성능 문제를 만나면 결국 SQL로 해결
- Window Function과 CTE는 최근 면접 트렌드의 핵심
┌──────────────────────────────────────────────┐
│ SQL이 여전히 최강인 이유 (2025) │
├──────────────────────────────────────────────┤
│ Stack Overflow → 사용 언어 TOP 3 │
│ PostgreSQL → 2년 연속 올해의 DBMS │
│ AI/LLM → Text-to-SQL 핵심 기술 │
│ 클라우드 → Aurora, AlloyDB, Neon │
│ 면접 빈도 → 백엔드 면접 80%+ 출제 │
└──────────────────────────────────────────────┘
이 글에서는 JOIN의 모든 종류부터 Window Function, 인덱싱, 쿼리 최적화, 트랜잭션까지 면접에 필요한 SQL의 모든 것을 다룹니다.
1. JOIN 완전 정복
1.1 샘플 테이블
-- 직원 테이블
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(50),
dept_id INT
);
INSERT INTO employees VALUES
(1, 'Alice', 10),
(2, 'Bob', 20),
(3, 'Charlie', 10),
(4, 'Diana', 30),
(5, 'Eve', NULL);
-- 부서 테이블
CREATE TABLE departments (
id INT PRIMARY KEY,
name VARCHAR(50)
);
INSERT INTO departments VALUES
(10, 'Engineering'),
(20, 'Marketing'),
(40, 'HR');
1.2 INNER JOIN
양쪽 테이블에 모두 매칭되는 행만 반환합니다.
SELECT e.name AS employee, d.name AS department
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id;
┌──────────┬─────────────┐
│ employee │ department │
├──────────┼─────────────┤
│ Alice │ Engineering │
│ Bob │ Marketing │
│ Charlie │ Engineering │
└──────────┴─────────────┘
-- Diana(dept_id=30)와 Eve(dept_id=NULL)는 매칭 없어 제외
-- HR(id=40)도 매칭 직원 없어 제외
┌─────────┐ ┌─────────┐
│Employees│ │ Depts │
│ ┌────┼───┼────┐ │
│ │████│███│████│ │
│ │████│███│████│ │
│ └────┼───┼────┘ │
└─────────┘ └─────────┘
INNER JOIN = 교집합
1.3 LEFT JOIN (LEFT OUTER JOIN)
왼쪽 테이블의 모든 행 + 매칭되는 오른쪽 행 (없으면 NULL).
SELECT e.name AS employee, d.name AS department
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id;
┌──────────┬─────────────┐
│ employee │ department │
├──────────┼─────────────┤
│ Alice │ Engineering │
│ Bob │ Marketing │
│ Charlie │ Engineering │
│ Diana │ NULL │
│ Eve │ NULL │
└──────────┴─────────────┘
1.4 RIGHT JOIN (RIGHT OUTER JOIN)
오른쪽 테이블의 모든 행 + 매칭되는 왼쪽 행 (없으면 NULL).
SELECT e.name AS employee, d.name AS department
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.id;
┌──────────┬─────────────┐
│ employee │ department │
├──────────┼─────────────┤
│ Alice │ Engineering │
│ Bob │ Marketing │
│ Charlie │ Engineering │
│ NULL │ HR │
└──────────┴─────────────┘
1.5 FULL OUTER JOIN
양쪽 테이블의 모든 행을 반환합니다. 매칭이 없으면 NULL.
SELECT e.name AS employee, d.name AS department
FROM employees e
FULL OUTER JOIN departments d ON e.dept_id = d.id;
┌──────────┬─────────────┐
│ employee │ department │
├──────────┼─────────────┤
│ Alice │ Engineering │
│ Bob │ Marketing │
│ Charlie │ Engineering │
│ Diana │ NULL │
│ Eve │ NULL │
│ NULL │ HR │
└──────────┴─────────────┘
1.6 CROSS JOIN
두 테이블의 모든 조합(카테시안 곱)을 반환합니다.
SELECT e.name, d.name
FROM employees e
CROSS JOIN departments d;
-- 5명 x 3부서 = 15행
1.7 SELF JOIN
같은 테이블을 자기 자신과 조인합니다.
-- 관리자-직원 관계 조회
CREATE TABLE staff (
id INT PRIMARY KEY,
name VARCHAR(50),
manager_id INT
);
INSERT INTO staff VALUES
(1, 'CEO', NULL),
(2, 'CTO', 1),
(3, 'Alice', 2),
(4, 'Bob', 2);
SELECT
s.name AS employee,
m.name AS manager
FROM staff s
LEFT JOIN staff m ON s.manager_id = m.id;
┌──────────┬─────────┐
│ employee │ manager │
├──────────┼─────────┤
│ CEO │ NULL │
│ CTO │ CEO │
│ Alice │ CTO │
│ Bob │ CTO │
└──────────┴─────────┘
1.8 JOIN 성능 최적화 팁
┌──────────────────────────────────────────────────┐
│ JOIN 최적화 체크리스트 │
├──────────────────────────────────────────────────┤
│ 1. JOIN 컬럼에 인덱스가 있는가? │
│ 2. 불필요한 컬럼을 SELECT하고 있지 않은가? │
│ 3. WHERE 조건을 JOIN 전에 적용할 수 있는가? │
│ 4. EXPLAIN으로 실행 계획을 확인했는가? │
│ 5. 데이터 크기에 따라 JOIN 순서를 조정했는가? │
└──────────────────────────────────────────────────┘
2. 집계와 그룹화 (Aggregation)
2.1 GROUP BY와 HAVING
-- 부서별 직원 수와 평균 급여
SELECT
dept_id,
COUNT(*) AS emp_count,
AVG(salary) AS avg_salary,
MAX(salary) AS max_salary,
MIN(salary) AS min_salary
FROM employees
GROUP BY dept_id
HAVING COUNT(*) >= 2
ORDER BY avg_salary DESC;
핵심 규칙: SELECT에 집계 함수가 아닌 컬럼이 있으면 반드시 GROUP BY에 포함해야 합니다.
2.2 ROLLUP
-- 부서별, 전체 합계 포함
SELECT
COALESCE(dept_name, '전체') AS department,
COALESCE(job_title, '소계') AS job,
COUNT(*) AS emp_count,
SUM(salary) AS total_salary
FROM employees e
JOIN departments d ON e.dept_id = d.id
GROUP BY ROLLUP(dept_name, job_title);
┌──────────────┬──────────┬───────────┬──────────────┐
│ department │ job │ emp_count │ total_salary │
├──────────────┼──────────┼───────────┼──────────────┤
│ Engineering │ Senior │ 3 │ 300000 │
│ Engineering │ Junior │ 2 │ 140000 │
│ Engineering │ 소계 │ 5 │ 440000 │
│ Marketing │ Senior │ 1 │ 90000 │
│ Marketing │ Junior │ 2 │ 120000 │
│ Marketing │ 소계 │ 3 │ 210000 │
│ 전체 │ 소계 │ 8 │ 650000 │
└──────────────┴──────────┴───────────┴──────────────┘
2.3 CUBE와 GROUPING SETS
-- CUBE: 모든 조합의 소계
SELECT dept_name, job_title, SUM(salary)
FROM employees e JOIN departments d ON e.dept_id = d.id
GROUP BY CUBE(dept_name, job_title);
-- GROUPING SETS: 원하는 조합만 선택
SELECT dept_name, job_title, SUM(salary)
FROM employees e JOIN departments d ON e.dept_id = d.id
GROUP BY GROUPING SETS (
(dept_name, job_title), -- 부서+직급별
(dept_name), -- 부서별
() -- 전체
);
3. Window Functions
3.1 Window Function이란
집계하면서도 개별 행을 유지하는 함수입니다. GROUP BY와 달리 행을 줄이지 않습니다.
-- GROUP BY: 행 축소 (부서별 1행)
SELECT dept_id, AVG(salary) FROM employees GROUP BY dept_id;
-- Window Function: 행 유지 (모든 직원 + 부서 평균)
SELECT
name,
dept_id,
salary,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg
FROM employees;
3.2 ROW_NUMBER, RANK, DENSE_RANK
SELECT
name,
dept_id,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;
┌─────────┬─────────┬────────┬─────────┬──────┬────────────┐
│ name │ dept_id │ salary │ row_num │ rank │ dense_rank │
├─────────┼─────────┼────────┼─────────┼──────┼────────────┤
│ Alice │ 10 │ 120000 │ 1 │ 1 │ 1 │
│ Charlie │ 10 │ 120000 │ 2 │ 1 │ 1 │
│ Bob │ 20 │ 100000 │ 3 │ 3 │ 2 │
│ Diana │ 30 │ 80000 │ 4 │ 4 │ 3 │
│ Eve │ 20 │ 80000 │ 5 │ 4 │ 3 │
└─────────┴─────────┴────────┴─────────┴──────┴────────────┘
ROW_NUMBER: 항상 고유한 번호 (동점에도 다른 번호)
RANK: 동점은 같은 번호, 다음은 건너뜀 (1, 1, 3)
DENSE_RANK: 동점은 같은 번호, 다음은 연속 (1, 1, 2)
3.3 NTILE
데이터를 N등분합니다.
-- 급여 기준 4분위로 나누기
SELECT
name,
salary,
NTILE(4) OVER (ORDER BY salary) AS quartile
FROM employees;
3.4 LAG와 LEAD
이전/다음 행의 값을 참조합니다.
-- 전월 대비 매출 변화
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue, 1) OVER (ORDER BY month) AS change,
LEAD(revenue, 1) OVER (ORDER BY month) AS next_month
FROM monthly_sales;
┌───────┬─────────┬────────────┬────────┬────────────┐
│ month │ revenue │ prev_month │ change │ next_month │
├───────┼─────────┼────────────┼────────┼────────────┤
│ 01 │ 50000 │ NULL │ NULL │ 60000 │
│ 02 │ 60000 │ 50000 │ 10000 │ 45000 │
│ 03 │ 45000 │ 60000 │ -15000 │ 70000 │
│ 04 │ 70000 │ 45000 │ 25000 │ NULL │
└───────┴─────────┴────────────┴────────┴────────────┘
3.5 Running Total (누적 합계)
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders;
3.6 부서별 TOP N 조회
-- 각 부서에서 급여 TOP 3
WITH ranked AS (
SELECT
name,
dept_id,
salary,
ROW_NUMBER() OVER (
PARTITION BY dept_id
ORDER BY salary DESC
) AS rn
FROM employees
)
SELECT name, dept_id, salary
FROM ranked
WHERE rn <= 3;
4. 서브쿼리 vs CTE vs 파생 테이블
4.1 서브쿼리 (Subquery)
-- 상관 서브쿼리: 자기 부서 평균보다 급여가 높은 직원
SELECT name, salary, dept_id
FROM employees e1
WHERE salary > (
SELECT AVG(salary)
FROM employees e2
WHERE e2.dept_id = e1.dept_id
);
-- 비상관 서브쿼리: 전체 평균보다 급여가 높은 직원
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
4.2 CTE (Common Table Expression)
-- CTE: 가독성 향상
WITH dept_avg AS (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
),
high_earners AS (
SELECT e.name, e.salary, e.dept_id, d.avg_salary
FROM employees e
JOIN dept_avg d ON e.dept_id = d.dept_id
WHERE e.salary > d.avg_salary
)
SELECT * FROM high_earners ORDER BY salary DESC;
4.3 비교
┌──────────────┬───────────────┬───────────────┬────────────────┐
│ 항목 │ 서브쿼리 │ CTE │ 파생 테이블 │
├──────────────┼───────────────┼───────────────┼────────────────┤
│ 가독성 │ 낮음 (중첩) │ 높음 │ 보통 │
│ 재사용 │ 불가 │ 같은 쿼리 내 │ 불가 │
│ 재귀 지원 │ 불가 │ 가능 (WITH REC)│ 불가 │
│ 성능 │ DB마다 다름 │ DB마다 다름 │ 인라인 뷰 │
│ 디버깅 │ 어려움 │ 쉬움 │ 보통 │
└──────────────┴───────────────┴───────────────┴────────────────┘
5. 재귀 CTE (Recursive CTE)
5.1 조직도 트리 탐색
-- 조직도 테이블
CREATE TABLE org (
id INT PRIMARY KEY,
name VARCHAR(50),
manager_id INT REFERENCES org(id)
);
INSERT INTO org VALUES
(1, 'CEO', NULL),
(2, 'VP Eng', 1),
(3, 'VP Sales', 1),
(4, 'Dir Eng', 2),
(5, 'Lead Dev', 4),
(6, 'Dev 1', 5),
(7, 'Dev 2', 5);
-- 재귀 CTE로 CEO부터 전체 조직도 탐색
WITH RECURSIVE org_tree AS (
-- 기저 조건: CEO (manager 없음)
SELECT id, name, manager_id, 0 AS depth, name::TEXT AS path
FROM org
WHERE manager_id IS NULL
UNION ALL
-- 재귀 조건: 부하 직원 탐색
SELECT o.id, o.name, o.manager_id,
t.depth + 1,
t.path || ' > ' || o.name
FROM org o
JOIN org_tree t ON o.manager_id = t.id
)
SELECT
REPEAT(' ', depth) || name AS hierarchy,
depth,
path
FROM org_tree
ORDER BY path;
┌────────────────────┬───────┬──────────────────────────────┐
│ hierarchy │ depth │ path │
├────────────────────┼───────┼──────────────────────────────┤
│ CEO │ 0 │ CEO │
│ VP Eng │ 1 │ CEO > VP Eng │
│ Dir Eng │ 2 │ CEO > VP Eng > Dir Eng │
│ Lead Dev │ 3 │ CEO > VP Eng > Dir Eng > ... │
│ Dev 1 │ 4 │ CEO > VP Eng > Dir Eng > ... │
│ Dev 2 │ 4 │ CEO > VP Eng > Dir Eng > ... │
│ VP Sales │ 1 │ CEO > VP Sales │
└────────────────────┴───────┴──────────────────────────────┘
5.2 수열 생성
-- 1부터 10까지 수열
WITH RECURSIVE nums AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM nums WHERE n < 10
)
SELECT n FROM nums;
-- 날짜 시리즈 생성
WITH RECURSIVE dates AS (
SELECT DATE '2025-01-01' AS dt
UNION ALL
SELECT dt + INTERVAL '1 day' FROM dates WHERE dt < '2025-01-31'
)
SELECT dt FROM dates;
6. 인덱싱 (Indexing)
6.1 B-Tree 인덱스
가장 일반적인 인덱스. 등호(=), 범위 비교, 정렬, LIKE 'prefix%'에 효과적입니다.
CREATE INDEX idx_employees_name ON employees(name);
-- 효과적인 쿼리
SELECT * FROM employees WHERE name = 'Alice'; -- 등호
SELECT * FROM employees WHERE name BETWEEN 'A' AND 'M'; -- 범위
SELECT * FROM employees ORDER BY name; -- 정렬
SELECT * FROM employees WHERE name LIKE 'Al%'; -- 접두사
B-Tree 구조:
[M]
/ \
[D,H] [P,T]
/ | \ / | \
[A-C][E-G][I-L][N-O][Q-S][U-Z]
(리프 노드: 실제 데이터 포인터)
6.2 Hash 인덱스
등호(=) 비교에만 효과적. 범위 검색, 정렬은 불가합니다.
CREATE INDEX idx_employees_email_hash
ON employees USING HASH (email);
-- 효과적
SELECT * FROM employees WHERE email = 'alice@example.com';
-- 비효과적 (Hash로는 범위 검색 불가)
-- SELECT * FROM employees WHERE email > 'a@' AND email < 'b@';
6.3 GIN (Generalized Inverted Index)
배열, JSONB, 전문 검색에 적합합니다.
-- JSONB 필드 인덱싱
CREATE INDEX idx_products_tags ON products USING GIN (tags);
SELECT * FROM products WHERE tags @> '["electronics"]';
-- 전문 검색
CREATE INDEX idx_articles_search ON articles
USING GIN (to_tsvector('english', title || ' ' || body));
6.4 GiST (Generalized Search Tree)
지리 데이터, 범위 타입, 근접 검색에 적합합니다.
-- 지리 데이터 인덱싱
CREATE INDEX idx_locations_geom ON locations USING GiST (geom);
-- 반경 내 검색
SELECT * FROM locations
WHERE ST_DWithin(geom, ST_MakePoint(127.0, 37.5)::geography, 1000);
6.5 BRIN (Block Range Index)
물리적으로 정렬된 대용량 테이블에 적합. 매우 작은 크기로 큰 효과를 냅니다.
-- 시계열 데이터에 최적
CREATE INDEX idx_logs_created ON logs USING BRIN (created_at);
6.6 복합 인덱스 (Composite Index)
-- 컬럼 순서가 중요!
CREATE INDEX idx_emp_dept_salary ON employees(dept_id, salary);
-- 효과적: dept_id만 사용, 또는 dept_id + salary 둘 다 사용
SELECT * FROM employees WHERE dept_id = 10;
SELECT * FROM employees WHERE dept_id = 10 AND salary > 50000;
-- 비효과적: salary만 사용 (첫 번째 컬럼 건너뜀)
-- SELECT * FROM employees WHERE salary > 50000;
복합 인덱스의 순서 규칙 (Leftmost Prefix):
인덱스: (A, B, C)
WHERE A = ? → 사용 O
WHERE A = ? AND B = ? → 사용 O
WHERE A = ? AND B = ? AND C = ? → 사용 O
WHERE B = ? → 사용 X (A 없음)
WHERE B = ? AND C = ? → 사용 X (A 없음)
WHERE A = ? AND C = ? → A만 사용 (B 건너뜀)
7. EXPLAIN ANALYZE
7.1 실행 계획 읽기
EXPLAIN ANALYZE
SELECT e.name, d.name AS dept
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE e.salary > 50000;
Hash Join (cost=1.09..2.24 rows=3 width=36) (actual time=0.05..0.06 rows=3 loops=1)
Hash Cond: (e.dept_id = d.id)
-> Seq Scan on employees e (cost=0.00..1.12 rows=3 width=22) (actual time=0.01..0.02 rows=3 loops=1)
Filter: (salary > 50000)
Rows Removed by Filter: 2
-> Hash (cost=1.03..1.03 rows=3 width=18) (actual time=0.01..0.01 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on departments d (cost=0.00..1.03 rows=3 width=18) (actual time=0.00..0.00 rows=3 loops=1)
Planning Time: 0.15 ms
Execution Time: 0.09 ms
7.2 주요 스캔 타입
┌──────────────────┬──────────────────────────────────────┐
│ 스캔 타입 │ 설명 │
├──────────────────┼──────────────────────────────────────┤
│ Seq Scan │ 전체 테이블 순차 스캔 (풀 스캔) │
│ Index Scan │ 인덱스로 조건 행 찾고 테이블 접근 │
│ Index Only Scan │ 인덱스만으로 결과 반환 (테이블 안 봄) │
│ Bitmap Index Scan│ 인덱스로 비트맵 만들고 테이블 접근 │
│ Bitmap Heap Scan │ 비트맵 기반 테이블 접근 │
└──────────────────┴──────────────────────────────────────┘
7.3 비용 읽기
cost=시작비용..총비용 rows=예상행수 width=행크기(bytes)
- 시작 비용: 첫 번째 행을 반환하기까지의 비용
- 총 비용: 모든 행을 반환하는 데 드는 총 비용
- actual time: 실제 실행 시간 (ms)
- rows: 실제 반환 행 수
- loops: 해당 노드 반복 실행 횟수
7.4 느린 쿼리 진단 체크리스트
┌──────────────────────────────────────────────────┐
│ 쿼리 최적화 진단 플로우 │
├──────────────────────────────────────────────────┤
│ 1. EXPLAIN ANALYZE 실행 │
│ ↓ │
│ 2. Seq Scan이 대형 테이블에 있는가? │
│ → 인덱스 추가 고려 │
│ ↓ │
│ 3. 예상 rows와 실제 rows 차이가 큰가? │
│ → ANALYZE 실행하여 통계 갱신 │
│ ↓ │
│ 4. Nested Loop이 대량 데이터에 사용되는가? │
│ → Hash Join이나 Merge Join이 더 적합할 수 있음 │
│ ↓ │
│ 5. Sort가 디스크에서 실행되는가? │
│ → work_mem 증가 또는 인덱스 추가 │
└──────────────────────────────────────────────────┘
8. 쿼리 최적화
8.1 N+1 문제 in SQL
-- 나쁜 예: 각 주문마다 별도 쿼리 (애플리케이션에서)
-- 1. SELECT * FROM orders WHERE user_id = 1;
-- 2. SELECT * FROM order_items WHERE order_id = 101;
-- 3. SELECT * FROM order_items WHERE order_id = 102;
-- ... (N개 주문이면 N+1번 쿼리)
-- 좋은 예: JOIN으로 한 번에
SELECT o.id, o.total, oi.product_name, oi.quantity
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = 1;
8.2 Covering Index (커버링 인덱스)
인덱스만으로 쿼리를 완료하여 테이블 접근을 피합니다.
-- 인덱스 생성
CREATE INDEX idx_emp_dept_name_salary
ON employees(dept_id, name, salary);
-- Index Only Scan 가능 (테이블 접근 불필요)
SELECT name, salary FROM employees WHERE dept_id = 10;
8.3 Partial Index (부분 인덱스)
조건을 만족하는 행만 인덱싱합니다.
-- 활성 사용자만 인덱싱 (대부분의 쿼리가 활성 사용자 대상)
CREATE INDEX idx_active_users
ON users(email)
WHERE status = 'active';
-- 효과적으로 사용됨
SELECT * FROM users WHERE status = 'active' AND email = 'alice@example.com';
8.4 Expression Index (표현식 인덱스)
-- 대소문자 무시 검색을 위한 인덱스
CREATE INDEX idx_users_lower_email
ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- JSON 필드 인덱싱
CREATE INDEX idx_data_category
ON products((data->>'category'));
8.5 최적화 팁 모음
-- 1. EXISTS vs IN: 대량 데이터에서는 EXISTS가 보통 빠름
-- 느린
SELECT * FROM orders WHERE customer_id IN (
SELECT id FROM customers WHERE country = 'KR'
);
-- 빠른
SELECT * FROM orders o WHERE EXISTS (
SELECT 1 FROM customers c
WHERE c.id = o.customer_id AND c.country = 'KR'
);
-- 2. LIMIT과 함께 사용하면 불필요한 스캔 방지
SELECT * FROM logs ORDER BY created_at DESC LIMIT 100;
-- 3. UNION ALL vs UNION: 중복 제거 불필요 시 ALL 사용
SELECT name FROM employees_2024
UNION ALL
SELECT name FROM employees_2025;
-- 4. 불필요한 ORDER BY 제거 (서브쿼리 내에서)
-- 5. SELECT *보다 필요한 컬럼만 명시
9. 트랜잭션 격리 수준
9.1 4가지 격리 수준
┌──────────────────┬───────────┬──────────────┬──────────────┬─────────────┐
│ 격리 수준 │Dirty Read │Non-Repeatable│Phantom Read │ 성능 │
│ │ │ Read │ │ │
├──────────────────┼───────────┼──────────────┼──────────────┼─────────────┤
│ Read Uncommitted │ 가능 │ 가능 │ 가능 │ 가장 빠름 │
│ Read Committed │ 방지 │ 가능 │ 가능 │ 빠름 │
│ Repeatable Read │ 방지 │ 방지 │ 가능 │ 보통 │
│ Serializable │ 방지 │ 방지 │ 방지 │ 가장 느림 │
└──────────────────┴───────────┴──────────────┴──────────────┴─────────────┘
PostgreSQL 기본값: Read Committed
MySQL InnoDB 기본값: Repeatable Read
9.2 이상 현상 설명
Dirty Read: 커밋되지 않은 데이터를 읽음
-- Transaction A
UPDATE accounts SET balance = 0 WHERE id = 1;
-- (아직 COMMIT 안 함)
-- Transaction B (Read Uncommitted)
SELECT balance FROM accounts WHERE id = 1;
-- balance = 0 (커밋 안 된 값!)
-- Transaction A
ROLLBACK;
-- 실제로는 balance가 원래 값으로 복원됨
-- Transaction B는 잘못된 데이터를 기반으로 결정을 내릴 수 있음
Non-Repeatable Read: 같은 쿼리를 두 번 실행했을 때 결과가 다름
-- Transaction A
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- Transaction B
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- Transaction A (같은 트랜잭션 내에서)
SELECT balance FROM accounts WHERE id = 1; -- 500 (결과가 다름!)
Phantom Read: 같은 조건 쿼리에서 행의 수가 달라짐
-- Transaction A
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 5
-- Transaction B
INSERT INTO orders (status) VALUES ('pending');
COMMIT;
-- Transaction A
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 6 (유령 행!)
9.3 MVCC (Multi-Version Concurrency Control)
PostgreSQL은 MVCC를 사용하여 읽기와 쓰기가 서로 블로킹하지 않습니다.
┌──────────────────────────────────────────────────┐
│ MVCC 원리 │
├──────────────────────────────────────────────────┤
│ │
│ Transaction 100 (Read): │
│ "나는 xmin 100 이하의 버전만 본다" │
│ │
│ Transaction 101 (Write): │
│ "새 버전을 xmin=101로 생성, 기존 행에 xmax=101" │
│ │
│ → Transaction 100은 여전히 이전 버전을 봄 │
│ → 읽기와 쓰기가 서로 블로킹하지 않음 │
│ │
└──────────────────────────────────────────────────┘
10. PostgreSQL 고급 기능
10.1 JSONB 쿼리
-- JSONB 데이터 저장
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
data JSONB
);
INSERT INTO products (name, data) VALUES
('Laptop', '{"brand": "Dell", "specs": {"ram": 16, "cpu": "i7"}, "tags": ["electronics", "computer"]}'),
('Phone', '{"brand": "Apple", "specs": {"ram": 8, "cpu": "A17"}, "tags": ["electronics", "mobile"]}');
-- JSONB 쿼리
SELECT name, data->>'brand' AS brand
FROM products
WHERE data->'specs'->>'ram' = '16';
-- 배열 포함 검색
SELECT * FROM products WHERE data->'tags' @> '"mobile"';
-- JSONB 경로 쿼리 (PostgreSQL 12+)
SELECT * FROM products
WHERE data @? '$.specs ? (@.ram > 10)';
10.2 배열 연산자
-- 배열 타입 사용
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT,
tags TEXT[]
);
INSERT INTO articles (title, tags) VALUES
('SQL Guide', ARRAY['sql', 'database', 'backend']),
('React Tips', ARRAY['react', 'frontend', 'javascript']);
-- 배열 포함 검색
SELECT * FROM articles WHERE tags @> ARRAY['sql'];
SELECT * FROM articles WHERE 'react' = ANY(tags);
SELECT * FROM articles WHERE tags && ARRAY['sql', 'react']; -- 겹침 확인
10.3 LATERAL JOIN
-- 각 부서의 최근 주문 3개씩 조회
SELECT d.name, recent.*
FROM departments d
CROSS JOIN LATERAL (
SELECT o.id, o.amount, o.created_at
FROM orders o
WHERE o.dept_id = d.id
ORDER BY o.created_at DESC
LIMIT 3
) recent;
10.4 Materialized View
-- 복잡한 집계 결과를 미리 계산해 저장
CREATE MATERIALIZED VIEW monthly_sales_summary AS
SELECT
DATE_TRUNC('month', order_date) AS month,
dept_id,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount
FROM orders
GROUP BY DATE_TRUNC('month', order_date), dept_id;
-- 인덱스 추가
CREATE INDEX idx_monthly_sales ON monthly_sales_summary(month, dept_id);
-- 데이터 갱신
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary;
11. 실전 문제 30선
Easy (1-10)
문제 1. 모든 직원의 이름과 소속 부서명을 조회하세요. 부서가 없는 직원도 포함합니다.
정답 보기
SELECT e.name, d.name AS department
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id;
문제 2. 각 부서별 직원 수를 구하세요.
정답 보기
SELECT d.name, COUNT(e.id) AS emp_count
FROM departments d
LEFT JOIN employees e ON d.id = e.dept_id
GROUP BY d.name
ORDER BY emp_count DESC;
문제 3. 급여가 전체 평균보다 높은 직원을 조회하세요.
정답 보기
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
문제 4. 중복된 이메일 주소를 가진 사용자를 찾으세요.
정답 보기
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
문제 5. 주문이 한 건도 없는 고객을 찾으세요.
정답 보기
-- LEFT JOIN 방법
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
-- NOT EXISTS 방법
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
문제 6. 가장 최근 주문 5건을 조회하세요.
정답 보기
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 5;
문제 7. 부서별 평균 급여를 구하되, 평균 급여가 60000 이상인 부서만 표시하세요.
정답 보기
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
HAVING AVG(salary) >= 60000;
문제 8. 두 번째로 높은 급여를 구하세요.
정답 보기
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- 또는
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;
문제 9. NULL 값을 기본값으로 대체하여 조회하세요.
정답 보기
SELECT name, COALESCE(phone, 'N/A') AS phone
FROM employees;
문제 10. 문자열 패턴으로 검색: 이름이 'A'로 시작하는 직원을 찾으세요.
정답 보기
SELECT * FROM employees WHERE name LIKE 'A%';
-- 또는 (대소문자 무시)
SELECT * FROM employees WHERE name ILIKE 'a%';
Medium (11-25)
문제 11. 각 부서에서 급여가 가장 높은 직원을 구하세요.
정답 보기
WITH ranked AS (
SELECT name, dept_id, salary,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rk
FROM employees
)
SELECT name, dept_id, salary FROM ranked WHERE rk = 1;
문제 12. 월별 매출과 전월 대비 증감율을 구하세요.
정답 보기
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))::NUMERIC
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100,
2
) AS growth_pct
FROM monthly_sales;
문제 13. 연속으로 3일 이상 로그인한 사용자를 찾으세요.
정답 보기
WITH login_groups AS (
SELECT
user_id,
login_date,
login_date - ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY login_date
)::INT * INTERVAL '1 day' AS grp
FROM logins
)
SELECT user_id, COUNT(*) AS consecutive_days, MIN(login_date), MAX(login_date)
FROM login_groups
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;
문제 14. 누적 합계 (Running Total)을 구하세요.
정답 보기
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders;
문제 15. 피벗 테이블: 부서별/분기별 매출을 행에서 열로 변환하세요.
정답 보기
SELECT
dept_name,
SUM(CASE WHEN quarter = 1 THEN revenue ELSE 0 END) AS q1,
SUM(CASE WHEN quarter = 2 THEN revenue ELSE 0 END) AS q2,
SUM(CASE WHEN quarter = 3 THEN revenue ELSE 0 END) AS q3,
SUM(CASE WHEN quarter = 4 THEN revenue ELSE 0 END) AS q4
FROM quarterly_sales
GROUP BY dept_name;
문제 16. 재귀 CTE로 1부터 100까지의 피보나치 수열을 생성하세요.
정답 보기
WITH RECURSIVE fib AS (
SELECT 1 AS n, 0::BIGINT AS a, 1::BIGINT AS b
UNION ALL
SELECT n + 1, b, a + b FROM fib WHERE n < 20
)
SELECT n, a AS fibonacci FROM fib;
문제 17. 자기 부서 내에서 급여 순위를 매기세요.
정답 보기
SELECT
name,
dept_id,
salary,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dept_rank
FROM employees;
문제 18. 가장 많이 팔린 상품 TOP 5와 해당 카테고리를 구하세요.
정답 보기
SELECT p.name, p.category, SUM(oi.quantity) AS total_sold
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.name, p.category
ORDER BY total_sold DESC
LIMIT 5;
문제 19. LATERAL JOIN으로 각 카테고리의 최신 상품 3개를 구하세요.
정답 보기
SELECT c.name AS category, p.*
FROM categories c
CROSS JOIN LATERAL (
SELECT id, name, price, created_at
FROM products
WHERE category_id = c.id
ORDER BY created_at DESC
LIMIT 3
) p;
문제 20. 이동 평균 (3개월 이동 평균)을 구하세요.
정답 보기
SELECT
month,
revenue,
AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3m
FROM monthly_sales;
문제 21. JSONB 필드에서 특정 조건으로 필터링하세요.
정답 보기
SELECT id, name, data->>'brand' AS brand
FROM products
WHERE (data->'specs'->>'ram')::INT >= 16
AND data->'tags' @> '"electronics"';
문제 22. 갭 분석: 연속된 ID에서 빠진 번호를 찾으세요.
정답 보기
WITH id_range AS (
SELECT generate_series(MIN(id), MAX(id)) AS expected_id
FROM orders
)
SELECT expected_id AS missing_id
FROM id_range
WHERE expected_id NOT IN (SELECT id FROM orders);
문제 23. 중복 행 제거 (가장 최신만 유지).
정답 보기
DELETE FROM users
WHERE id NOT IN (
SELECT DISTINCT ON (email) id
FROM users
ORDER BY email, created_at DESC
);
문제 24. GROUP BY없이 비율 계산.
정답 보기
SELECT
status,
COUNT(*) AS cnt,
ROUND(
COUNT(*)::NUMERIC / SUM(COUNT(*)) OVER () * 100, 2
) AS percentage
FROM orders
GROUP BY status;
문제 25. 누적 고유 사용자 수 구하기.
정답 보기
WITH daily_new AS (
SELECT
user_id,
MIN(login_date) AS first_date
FROM logins
GROUP BY user_id
)
SELECT
login_date,
COUNT(*) AS new_users,
SUM(COUNT(*)) OVER (ORDER BY login_date) AS cumulative_users
FROM daily_new
GROUP BY login_date
ORDER BY login_date;
Hard (26-30)
문제 26. 최장 연속 증가 매출 기간을 구하세요.
정답 보기
WITH changes AS (
SELECT
month,
revenue,
CASE
WHEN revenue > LAG(revenue) OVER (ORDER BY month)
THEN 0 ELSE 1
END AS is_break
FROM monthly_sales
),
groups AS (
SELECT *,
SUM(is_break) OVER (ORDER BY month) AS grp
FROM changes
)
SELECT
grp,
MIN(month) AS start_month,
MAX(month) AS end_month,
COUNT(*) AS streak_length
FROM groups
GROUP BY grp
ORDER BY streak_length DESC
LIMIT 1;
문제 27. 재귀 CTE로 BOM(Bill of Materials) 전개하기.
정답 보기
WITH RECURSIVE bom AS (
-- 최상위 제품
SELECT
parent_id, child_id, quantity, 1 AS level,
quantity AS total_qty
FROM bill_of_materials
WHERE parent_id = 'PRODUCT_A'
UNION ALL
-- 하위 부품 전개
SELECT
b.parent_id, b.child_id, b.quantity,
bom.level + 1,
bom.total_qty * b.quantity
FROM bill_of_materials b
JOIN bom ON b.parent_id = bom.child_id
WHERE bom.level < 10 -- 무한 재귀 방지
)
SELECT child_id AS component, SUM(total_qty) AS total_needed
FROM bom
GROUP BY child_id
ORDER BY total_needed DESC;
문제 28. 시간 간격 겹침 감지 (스케줄링 충돌).
정답 보기
SELECT
a.id AS meeting_a,
b.id AS meeting_b,
a.start_time,
a.end_time,
b.start_time AS conflict_start,
b.end_time AS conflict_end
FROM meetings a
JOIN meetings b ON a.id < b.id
AND a.room_id = b.room_id
AND a.start_time < b.end_time
AND a.end_time > b.start_time;
문제 29. 메디안 (중앙값) 구하기 (집계 함수 없이).
정답 보기
-- PostgreSQL에는 percentile_cont가 있지만, 순수 SQL로:
WITH ordered AS (
SELECT salary,
ROW_NUMBER() OVER (ORDER BY salary) AS rn,
COUNT(*) OVER () AS total
FROM employees
)
SELECT AVG(salary) AS median
FROM ordered
WHERE rn IN (
FLOOR((total + 1) / 2.0),
CEIL((total + 1) / 2.0)
);
-- PostgreSQL 전용:
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY salary) AS median
FROM employees;
문제 30. 그래프 최단 경로 (재귀 CTE).
정답 보기
CREATE TABLE edges (
src VARCHAR(10),
dst VARCHAR(10),
cost INT
);
WITH RECURSIVE paths AS (
SELECT src, dst, cost, ARRAY[src, dst] AS path, 1 AS hops
FROM edges
WHERE src = 'A'
UNION ALL
SELECT p.src, e.dst, p.cost + e.cost,
p.path || e.dst,
p.hops + 1
FROM paths p
JOIN edges e ON p.dst = e.src
WHERE NOT e.dst = ANY(p.path) -- 사이클 방지
AND p.hops < 10 -- 최대 깊이 제한
)
SELECT dst, MIN(cost) AS min_cost, path
FROM paths
WHERE dst = 'Z'
GROUP BY dst, path
ORDER BY min_cost
LIMIT 1;
12. 면접 질문 15선
기초 (1-5)
Q1. INNER JOIN과 LEFT JOIN의 차이점은?
정답 보기
INNER JOIN은 양쪽 테이블에 모두 매칭되는 행만 반환합니다. LEFT JOIN은 왼쪽 테이블의 모든 행을 반환하고, 오른쪽 테이블에 매칭이 없으면 NULL로 채웁니다. 부서가 없는 직원도 조회하려면 LEFT JOIN이 필요합니다.
Q2. GROUP BY와 HAVING의 차이는?
정답 보기
GROUP BY는 행을 그룹으로 묶습니다. HAVING은 그룹화된 결과에 조건을 적용합니다. WHERE는 그룹화 전에 개별 행을 필터링하고, HAVING은 그룹화 후에 그룹을 필터링합니다. 예: HAVING COUNT(*) > 5는 5건 초과 그룹만 반환합니다.
Q3. WHERE절에서 인덱스가 사용되지 않는 경우는?
정답 보기
(1) 컬럼에 함수를 적용한 경우: WHERE YEAR(created_at) = 2025. (2) 암시적 타입 변환. (3) LIKE '%keyword%'처럼 앞에 와일드카드. (4) OR 조건이 인덱스와 맞지 않는 경우. (5) NOT IN, 부정 조건. (6) 옵티마이저가 풀 스캔이 더 효율적이라 판단한 경우.
Q4. NULL에 대한 비교 연산의 결과는?
정답 보기
NULL과의 모든 비교 연산 결과는 NULL(UNKNOWN)입니다. NULL = NULL은 TRUE가 아닌 NULL입니다. NULL 확인에는 IS NULL 또는 IS NOT NULL을 사용해야 합니다. 집계 함수에서 NULL은 무시됩니다(COUNT(*)만 예외).
Q5. DELETE, TRUNCATE, DROP의 차이는?
정답 보기
DELETE: 행 단위 삭제, WHERE 조건 가능, 롤백 가능, 트리거 실행. TRUNCATE: 전체 행 즉시 삭제, 조건 불가, DDL이라 보통 롤백 불가(PostgreSQL은 가능), 매우 빠름. DROP: 테이블 자체를 삭제(구조+데이터+인덱스 모두).
중급 (6-10)
Q6. Window Function이 GROUP BY와 다른 점은?
정답 보기
GROUP BY는 그룹당 하나의 행으로 축소합니다. Window Function은 집계하면서도 개별 행을 유지합니다. OVER(PARTITION BY ...)로 그룹을 정의하고, 각 행에 그룹 수준의 계산 결과를 추가합니다. 예를 들어 각 직원 행에 부서 평균 급여를 함께 표시할 수 있습니다.
Q7. CTE와 서브쿼리 중 어떤 것을 선택하나요?
정답 보기
CTE는 가독성이 좋고, 같은 쿼리 내에서 재사용 가능하며, 재귀 쿼리를 지원합니다. 서브쿼리는 단순한 경우에 사용합니다. 성능은 DB에 따라 다르지만, PostgreSQL 12+에서 CTE는 기본적으로 인라인화되어 성능 차이가 거의 없습니다.
Q8. 복합 인덱스의 컬럼 순서가 중요한 이유는?
정답 보기
B-Tree 복합 인덱스는 Leftmost Prefix 규칙을 따릅니다. (A, B, C) 인덱스는 A만, A+B, A+B+C 조건에서 사용 가능하지만, B만이나 C만으로는 사용할 수 없습니다. 일반적으로 선택도(cardinality)가 높은 컬럼을 먼저, 등호 조건을 범위 조건보다 앞에 배치합니다.
Q9. EXPLAIN ANALYZE에서 주의해야 할 지표는?
정답 보기
(1) actual rows vs rows: 예상치와 실제치의 큰 차이는 통계 부정확. (2) Seq Scan on 대형 테이블: 인덱스 필요 시사. (3) Sort Method: disk = work_mem 부족. (4) Nested Loop with 대량 데이터: Hash Join 고려. (5) Execution Time: 전체 실행 시간 확인.
Q10. 트랜잭션 격리 수준을 선택하는 기준은?
정답 보기
대부분의 OLTP에서 Read Committed가 적합합니다(PostgreSQL 기본값). 재무/결제 등 정확성이 중요한 경우 Serializable. 보고서/분석처럼 스냅샷 일관성이 필요하면 Repeatable Read. 격리 수준이 높을수록 동시성이 낮아지므로 요구사항에 맞게 선택합니다.
심화 (11-15)
Q11. Covering Index란 무엇이며 왜 중요한가요?
정답 보기
Covering Index는 쿼리에 필요한 모든 컬럼이 인덱스에 포함된 경우로, Index Only Scan이 가능합니다. 테이블 본체에 접근하지 않아 I/O가 크게 감소합니다. PostgreSQL에서는 INCLUDE 절로 인덱스에 추가 컬럼을 포함할 수 있습니다.
Q12. MVCC의 동작 원리를 설명하세요.
정답 보기
MVCC는 각 행의 여러 버전을 유지합니다. 트랜잭션이 시작되면 스냅샷 시점을 기록하고, 그 이후의 변경은 보이지 않습니다. 쓰기는 새 버전을 생성하고, 읽기는 자신의 스냅샷에 맞는 버전을 읽습니다. 이로써 읽기와 쓰기가 서로 블로킹하지 않습니다. 오래된 버전은 VACUUM으로 정리합니다.
Q13. Partial Index와 Expression Index의 활용 사례는?
정답 보기
Partial Index는 자주 검색하는 조건의 부분집합만 인덱싱합니다. 예: 활성 사용자만 WHERE status = 'active'. 인덱스 크기가 작아 성능이 좋습니다. Expression Index는 함수 결과를 인덱싱합니다. 예: LOWER(email) 인덱스로 대소문자 무시 검색 최적화.
Q14. PostgreSQL에서 JSONB가 JSON보다 유리한 이유는?
정답 보기
JSONB는 바이너리 형태로 저장되어 파싱 오버헤드가 없고, GIN 인덱스를 지원하며, 포함(@>), 존재(?), 경로(@?) 연산자 사용이 가능합니다. JSON은 텍스트 그대로 저장하여 삽입이 빠르지만, 매번 파싱이 필요하고 인덱싱이 제한됩니다.
Q15. 대규모 테이블의 마이그레이션 전략은?
정답 보기
(1) 온라인 DDL: ALTER TABLE ... ADD COLUMN은 PostgreSQL에서 기본값이 있으면 즉시 완료. (2) 배치 업데이트: 전체 UPDATE 대신 배치로 나누어 실행. (3) 테이블 파티셔닝으로 작업 범위 축소. (4) pg_repack으로 테이블 재구성. (5) 레플리카에서 먼저 테스트. 무중단 운영이 핵심입니다.
13. 퀴즈 5선
퀴즈 1
다음 쿼리의 결과 행 수는?
SELECT * FROM A CROSS JOIN B;
-- A: 5행, B: 3행
A) 3 B) 5 C) 8 D) 15
정답 보기
D) 15. CROSS JOIN은 카테시안 곱으로, 5 x 3 = 15행을 반환합니다.
퀴즈 2
ROW_NUMBER, RANK, DENSE_RANK에서 동점(동일한 salary)이 있을 때, 다음 순위가 건너뛰어지는 것은?
A) ROW_NUMBER B) RANK C) DENSE_RANK D) 모두 건너뜀
정답 보기
B) RANK. RANK는 동점에 같은 순위를 부여하고, 다음 순위를 건너뜁니다(1, 1, 3). DENSE_RANK는 건너뛰지 않습니다(1, 1, 2). ROW_NUMBER는 항상 고유한 번호를 부여합니다.
퀴즈 3
복합 인덱스 (dept_id, salary, name)이 있을 때, 다음 중 이 인덱스를 사용할 수 없는 쿼리는?
A) WHERE dept_id = 10
B) WHERE dept_id = 10 AND salary > 50000
C) WHERE salary > 50000
D) WHERE dept_id = 10 AND salary > 50000 AND name = 'Alice'
정답 보기
C) Leftmost Prefix 규칙에 의해, 첫 번째 컬럼(dept_id) 없이 두 번째 컬럼(salary)만으로는 인덱스를 사용할 수 없습니다.
퀴즈 4
트랜잭션 격리 수준에서 Phantom Read를 방지하는 최소 수준은?
A) Read Uncommitted B) Read Committed C) Repeatable Read D) Serializable
정답 보기
D) Serializable. 표준 SQL에서는 Serializable만 Phantom Read를 방지합니다. 단, PostgreSQL의 Repeatable Read는 내부적으로 MVCC 스냅샷을 사용하여 Phantom Read도 방지합니다.
퀴즈 5
다음 결과에서 dept_avg 값은?
SELECT name, salary,
AVG(salary) OVER () AS dept_avg
FROM employees;
-- employees: Alice 100, Bob 200, Charlie 300
A) 각 행마다 다른 값 B) 모든 행에서 200 C) NULL D) 에러
정답 보기
B) OVER ()는 파티션 없이 전체 데이터에 대한 집계입니다. (100 + 200 + 300) / 3 = 200이 모든 행에 표시됩니다.
14. 참고 자료
공식 문서
서적
- SQL Performance Explained - Markus Winand
- PostgreSQL 14 Internals - Egor Rogov
- The Art of SQL - Stephane Faroult
- High Performance MySQL - Baron Schwartz et al.
- SQL Antipatterns - Bill Karwin
온라인 자료
- Use The Index, Luke - 인덱스 완벽 가이드
- pgexercises.com - PostgreSQL 실습
- SQLZoo - SQL 기초 실습
- LeetCode Database Problems - SQL 면접 문제
- Mode Analytics SQL Tutorial
도구
- explain.dalibo.com - PostgreSQL EXPLAIN 시각화
- pgMustard - 실행 계획 분석 도구
- DBeaver - 범용 DB 클라이언트
- pgAdmin - PostgreSQL 전용 관리 도구