뷰와 CTE
복잡한 서브쿼리를 반복 사용하면 코드가 길어지고 유지보수가 어렵습니다. 뷰(View)와 CTE(Common Table Expression)는 서브쿼리에 이름을 붙여 재사용성과 가독성을 높이는 방법입니다. 두 기술 모두 쿼리의 결과 집합에 이름을 부여한다는 공통점이 있지만, 사용 범위와 생명주기가 다릅니다.
VIEW
뷰는 자주 사용하는 쿼리를 가상 테이블로 저장하는 것입니다. 뷰 자체에 데이터가 저장되는 것이 아니라, 뷰를 조회할 때마다 내부의 SELECT가 실행됩니다.
뷰 생성과 사용
CREATE VIEW order_summary AS
SELECT o.order_id, u.username, o.order_date, o.status,
SUM(oi.quantity * oi.unit_price) AS total_amount
FROM orders o
JOIN users u ON o.user_id = u.user_id
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.order_id, u.username, o.order_date, o.status;-- 테이블처럼 SELECT
SELECT * FROM order_summary WHERE status = 'DELIVERED';
-- 다른 쿼리에서 조인 대상으로도 사용 가능
SELECT os.username, os.total_amount
FROM order_summary os
WHERE os.total_amount > 100000
ORDER BY os.total_amount DESC;-- 뷰 수정 (CREATE OR REPLACE)
CREATE OR REPLACE VIEW order_summary AS
SELECT o.order_id, u.username, o.order_date, o.status,
SUM(oi.quantity * oi.unit_price) AS total_amount,
COUNT(oi.item_id) AS item_count
FROM orders o
JOIN users u ON o.user_id = u.user_id
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.order_id, u.username, o.order_date, o.status;
-- 뷰 삭제
DROP VIEW order_summary;
-- 조건부 삭제
DROP VIEW IF EXISTS order_summary; -- MySQL, PostgreSQL뷰의 장단점
| 장점 | 설명 |
|---|---|
| 복잡한 쿼리 단순화 | 반복되는 JOIN/집계를 한 번만 작성 |
| 보안 | 민감 컬럼(급여, 주민번호)을 숨긴 뷰만 제공 |
| 논리적 독립성 | 테이블 구조가 변경되어도 뷰를 수정하면 앱 코드 변경 불필요 |
| 재사용성 | DB에 영구 저장되어 여러 쿼리에서 공유 |
| 단점 | 설명 |
|---|---|
| 성능 | 조회할 때마다 내부 쿼리 실행 (물리화된 뷰 제외) |
| UPDATE 제한 | 집계, JOIN이 있는 뷰는 갱신 불가 |
| 디버깅 어려움 | 뷰 위에 뷰를 쌓으면 실행 계획 추적이 복잡 |
갱신 가능 뷰 (Updatable View)
단순한 뷰는 INSERT, UPDATE, DELETE가 가능합니다. 갱신이 가능한 조건은 DBMS마다 다르지만, 공통적인 조건은 다음과 같습니다.
✓ 단일 테이블 기반 (JOIN 없음)
✓ 집계 함수 없음 (SUM, COUNT, AVG 등)
✓ GROUP BY 없음
✓ DISTINCT 없음
✓ UNION 없음
✓ 서브쿼리 없음 (FROM 절)
✗ 위 조건 중 하나라도 위반하면 갱신 불가-- 단순 뷰: 갱신 가능
CREATE VIEW active_users AS
SELECT user_id, username, email, grade
FROM users
WHERE is_active = 1;
-- 뷰를 통한 UPDATE
UPDATE active_users SET grade = 'VIP' WHERE user_id = 1;
-- → 실제로 users 테이블이 업데이트됨
-- 뷰를 통한 INSERT
INSERT INTO active_users (user_id, username, email, grade)
VALUES (100, 'newuser', 'new@test.com', 'NORMAL');
-- → users 테이블에 is_active = NULL로 삽입됨 (주의!)WITH CHECK OPTION
뷰의 WHERE 조건을 벗어나는 데이터가 INSERT/UPDATE되는 것을 방지합니다.
CREATE VIEW active_users AS
SELECT user_id, username, email, grade
FROM users
WHERE is_active = 1
WITH CHECK OPTION;
-- 이제 is_active = 0인 행은 이 뷰를 통해 입력/수정 불가
-- CHECK OPTION이 없으면 조건을 벗어나는 데이터도 삽입될 수 있음
-- LOCAL vs CASCADED (뷰 위에 뷰가 있을 때)
-- LOCAL: 현재 뷰의 조건만 검사
-- CASCADED (기본값): 기반 뷰의 조건도 함께 검사뷰를 활용한 보안 관리
-- 직원 테이블에서 급여 정보를 숨긴 뷰
CREATE VIEW employee_public AS
SELECT emp_id, name, department, position, hire_date
FROM employees;
-- 급여(salary), 주민번호(ssn) 등 민감 정보 제외
-- 부서별 뷰 (자기 부서만 조회 가능)
CREATE VIEW dept_10_employees AS
SELECT emp_id, name, position, salary
FROM employees
WHERE dept_id = 10;
-- 권한 부여
GRANT SELECT ON employee_public TO junior_role;
GRANT SELECT, UPDATE ON dept_10_employees TO dept_10_manager;
-- 기본 테이블에 대한 직접 접근은 차단물리화된 뷰 (Materialized View)
일반 뷰와 달리 쿼리 결과를 물리적으로 저장합니다. 조회 성능이 향상되지만 데이터 동기화가 필요합니다.
-- 생성
CREATE MATERIALIZED VIEW mv_monthly_sales
BUILD IMMEDIATE
REFRESH COMPLETE ON DEMAND
AS
SELECT
EXTRACT(YEAR FROM order_date) AS year,
EXTRACT(MONTH FROM order_date) AS month,
SUM(total_amount) AS monthly_total,
COUNT(*) AS order_count
FROM orders
GROUP BY EXTRACT(YEAR FROM order_date), EXTRACT(MONTH FROM order_date);
-- 수동 갱신
BEGIN
DBMS_MVIEW.REFRESH('mv_monthly_sales');
END;
-- 삭제
DROP MATERIALIZED VIEW mv_monthly_sales;-- 생성
CREATE MATERIALIZED VIEW mv_monthly_sales AS
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS monthly_total,
COUNT(*) AS order_count
FROM orders
GROUP BY DATE_TRUNC('month', order_date);
-- 수동 갱신
REFRESH MATERIALIZED VIEW mv_monthly_sales;
-- 동시 조회 가능한 갱신 (UNIQUE INDEX 필요)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;┌─────────────┬──────────────────┬────────────────────┐
│ 항목 │ 일반 뷰 │ 물리화된 뷰 │
├─────────────┼──────────────────┼────────────────────┤
│ 데이터 저장 │ 없음 (가상) │ 있음 (물리적) │
│ 조회 성능 │ 매번 쿼리 실행 │ 사전 계산된 결과 │
│데이터 최신성│ 항상 최신 │ 갱신 시점 기준 │
│ 디스크 사용 │ 없음 │ 결과 크기만큼 사용 │
│ DML 가능 │ 조건부 가능 │ 불가 │
│ 지원 DBMS │ 모든 DBMS │ Oracle, PostgreSQL │
└─────────────┴──────────────────┴────────────────────┘
MySQL은 Materialized View를 직접 지원하지 않음
→ 테이블 + 트리거 또는 이벤트 스케줄러로 유사하게 구현WITH (CTE)
CTE는 쿼리 내에서 임시로 이름 붙인 결과 집합입니다. 한 쿼리 안에서만 유효하며, 뷰처럼 DB에 저장되지 않습니다.
CTE 기본 문법
WITH high_value_orders AS (
SELECT o.order_id, o.user_id,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.order_id, o.user_id
HAVING SUM(oi.quantity * oi.unit_price) > 100000
)
SELECT u.username, h.total
FROM high_value_orders h
JOIN users u ON h.user_id = u.user_id;서브쿼리와 비교했을 때 CTE의 가독성 이점이 분명합니다.
-- 서브쿼리 방식 (가독성 낮음)
SELECT u.username, sub.total
FROM users u
JOIN (
SELECT o.user_id, SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.user_id
HAVING SUM(oi.quantity * oi.unit_price) > 100000
) sub ON u.user_id = sub.user_id;
-- CTE 방식 (가독성 높음)
WITH high_spenders AS (
SELECT o.user_id, SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.user_id
HAVING SUM(oi.quantity * oi.unit_price) > 100000
)
SELECT u.username, hs.total
FROM users u
JOIN high_spenders hs ON u.user_id = hs.user_id;다중 CTE
CTE는 여러 개를 연결할 수도 있습니다. 뒤의 CTE에서 앞의 CTE를 참조할 수 있어 복잡한 쿼리를 단계적으로 구성할 수 있습니다.
WITH
user_orders AS (
SELECT user_id, COUNT(*) AS order_count
FROM orders GROUP BY user_id
),
user_spending AS (
SELECT o.user_id, SUM(oi.quantity * oi.unit_price) AS total_spent
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.user_id
),
user_stats AS (
-- 앞의 두 CTE를 합쳐서 사용
SELECT uo.user_id, uo.order_count, us.total_spent,
us.total_spent / NULLIF(uo.order_count, 0) AS avg_per_order
FROM user_orders uo
JOIN user_spending us ON uo.user_id = us.user_id
)
SELECT u.username, s.order_count, s.total_spent, s.avg_per_order
FROM users u
JOIN user_stats s ON u.user_id = s.user_id
ORDER BY s.total_spent DESC;재귀 CTE
재귀 CTE는 계층 구조 데이터를 처리할 때 유용합니다. 조직도, 카테고리 트리, 댓글의 대댓글 구조, BOM(Bill of Materials) 등입니다.
재귀 CTE 구조
WITH RECURSIVE cte_name AS (
-- 1. Anchor Member (기저 조건): 재귀의 시작점
SELECT ... FROM table WHERE 시작_조건
UNION ALL
-- 2. Recursive Member (재귀 조건): 자기 자신을 참조
SELECT ... FROM table
JOIN cte_name ON 재귀_조건
)
SELECT * FROM cte_name;
실행 순서
1회차: Anchor 실행 → 결과 집합 R0
2회차: Recursive에 R0 대입 → 결과 집합 R1
3회차: Recursive에 R1 대입 → 결과 집합 R2
...
N회차: 빈 결과 반환 → 종료
최종 결과 = R0 ∪ R1 ∪ R2 ∪ ... ∪ R(N-1)조직도 예제
-- MySQL / PostgreSQL
WITH RECURSIVE org_tree AS (
-- 기저 조건: 최상위 (CEO)
SELECT emp_id, name, manager_id, 1 AS level_depth,
CAST(name AS CHAR(500)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 재귀: 부하 직원
SELECT e.emp_id, e.name, e.manager_id, t.level_depth + 1,
CONCAT(t.path, ' > ', e.name)
FROM employees e
JOIN org_tree t ON e.manager_id = t.emp_id
)
SELECT
CONCAT(REPEAT(' ', level_depth - 1), name) AS org_chart,
level_depth,
path
FROM org_tree
ORDER BY path;org_chart level_depth path
───────────────── ─────────── ────────────────────
김대표 1 김대표
이부장 2 김대표 > 이부장
박과장 3 김대표 > 이부장 > 박과장
정대리 4 김대표 > 이부장 > 박과장 > 정대리
최과장 3 김대표 > 이부장 > 최과장
정부장 2 김대표 > 정부장
한과장 3 김대표 > 정부장 > 한과장카테고리 트리 예제
-- 카테고리 테이블
-- id | name | parent_id
-- 1 | 전자제품 | NULL
-- 2 | 컴퓨터 | 1
-- 3 | 노트북 | 2
-- 4 | 데스크탑 | 2
-- 5 | 의류 | NULL
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id,
0 AS depth,
CAST(id AS CHAR(200)) AS sort_path
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id,
ct.depth + 1,
CONCAT(ct.sort_path, '-', LPAD(c.id, 5, '0'))
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT CONCAT(REPEAT(' ', depth), name) AS tree
FROM category_tree
ORDER BY sort_path;Oracle의 CONNECT BY
Oracle에서는 재귀 CTE 외에도 전통적인 CONNECT BY PRIOR 문법을 사용할 수 있습니다.
SELECT
LPAD(' ', (LEVEL-1)*2) || name AS org_chart,
LEVEL,
SYS_CONNECT_BY_PATH(name, '/') AS path
FROM employees
START WITH manager_id IS NULL -- 시작점 (Anchor)
CONNECT BY PRIOR emp_id = manager_id -- 재귀 조건
ORDER SIBLINGS BY name; -- 같은 레벨 내 정렬┌──────────────┬─────────────────────┬────────────────────┐
│ 항목 │ CONNECT BY │ 재귀 CTE │
├──────────────┼─────────────────────┼────────────────────┤
│ DBMS 지원 │ Oracle 전용 │ SQL 표준 (범용) │
│ 가독성 │ 간결 │ 상대적으로 길음 │
│ 기능 │ LEVEL, SYS_CONNECT │ 직접 구현 필요 │
│ 유연성 │ 계층만 가능 │ 그래프 탐색도 가능 │
│ 성능 │ Oracle 최적화 │ DBMS에 따라 다름 │
└──────────────┴─────────────────────┴────────────────────┘재귀 CTE의 무한 루프 방지
계층 데이터에 순환 참조가 있으면 재귀 CTE가 무한히 실행됩니다. DBMS별 방지 방법은 다음과 같습니다.
-- MySQL: 최대 재귀 깊이 설정 (기본 1000)
SET SESSION cte_max_recursion_depth = 100;
-- PostgreSQL: CYCLE 감지 (SQL:2016 표준)
WITH RECURSIVE org AS (
SELECT emp_id, name, manager_id
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.emp_id, e.name, e.manager_id
FROM employees e JOIN org o ON e.manager_id = o.emp_id
) CYCLE emp_id SET is_cycle USING path_array
SELECT * FROM org WHERE NOT is_cycle;
-- Oracle: CONNECT BY NOCYCLE
SELECT * FROM employees
START WITH manager_id IS NULL
CONNECT BY NOCYCLE PRIOR emp_id = manager_id;VIEW vs CTE 비교
| 항목 | VIEW | CTE |
|---|---|---|
| 저장 위치 | DB에 영구 저장 | 쿼리 내에서만 존재 |
| 재사용 범위 | 모든 쿼리에서 사용 가능 | 해당 쿼리 내에서만 |
| 권한 관리 | GRANT/REVOKE 가능 | 쿼리 실행 권한으로 충분 |
| 재귀 지원 | 불가 | 가능 (WITH RECURSIVE) |
| 성능 | 내부 쿼리 실행 (물리화 뷰 제외) | 내부 쿼리 실행 |
| 사용 목적 | 공유, 보안, 논리적 독립성 | 복잡한 쿼리 분해, 재귀 |
| DDL 필요 | CREATE VIEW (DDL) | 없음 (SELECT 내) |
반복 사용 + 여러 쿼리에서 공유 → VIEW
한 번만 사용 + 가독성 목적 → CTE
계층 구조 처리 → 재귀 CTE
보안/접근 제어 → VIEW
대량 집계 결과 캐싱 → Materialized VIEW인라인 뷰 (Inline View)
FROM 절에 직접 작성하는 서브쿼리를 인라인 뷰라 합니다. CTE와 기능은 유사하지만 CTE보다 가독성이 떨어집니다.
-- 인라인 뷰
SELECT a.dept_name, b.avg_salary
FROM departments a
JOIN (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
) b ON a.dept_id = b.dept_id
WHERE b.avg_salary > 5000000;
-- 동일한 로직을 CTE로
WITH dept_avg AS (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
)
SELECT a.dept_name, b.avg_salary
FROM departments a
JOIN dept_avg b ON a.dept_id = b.dept_id
WHERE b.avg_salary > 5000000;실무 활용 패턴
데이터 마스킹 뷰
CREATE VIEW customer_masked AS
SELECT
customer_id,
CONCAT(LEFT(name, 1), '**') AS name,
CONCAT(LEFT(phone, 3), '-****-', RIGHT(phone, 4)) AS phone,
CONCAT(LEFT(email, 2), '***@', SUBSTRING_INDEX(email, '@', -1)) AS email,
grade, join_date
FROM customers;
-- 일반 직원에게는 마스킹된 뷰만 접근 허용
GRANT SELECT ON customer_masked TO staff_role;집계 뷰 + CTE 조합
-- 월별 매출 뷰 (자주 사용되므로 VIEW)
CREATE VIEW v_monthly_sales AS
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
WHERE status != 'CANCELLED'
GROUP BY DATE_FORMAT(order_date, '%Y-%m');
-- 전월 대비 성장률 분석 (일회성이므로 CTE)
WITH current_month AS (
SELECT * FROM v_monthly_sales WHERE month = '2024-01'
),
previous_month AS (
SELECT * FROM v_monthly_sales WHERE month = '2023-12'
)
SELECT
c.month,
c.revenue AS current_revenue,
p.revenue AS prev_revenue,
ROUND((c.revenue - p.revenue) / p.revenue * 100, 1) AS growth_rate
FROM current_month c
CROSS JOIN previous_month p;정리
| 개념 | 용도 | 핵심 특징 |
|---|---|---|
| VIEW | 쿼리 재사용, 보안 | DB에 영구 저장, 가상 테이블 |
| Materialized VIEW | 집계 캐싱, 성능 | 물리 저장, 주기적 갱신 필요 |
| CTE | 복잡 쿼리 분해 | 쿼리 내 임시 결과 집합 |
| 재귀 CTE | 계층 구조 탐색 | Anchor + Recursive 구조 |
| 인라인 뷰 | FROM절 서브쿼리 | CTE 이전 방식, 가독성 낮음 |
| WITH CHECK OPTION | 뷰 DML 제한 | 조건 위반 데이터 삽입 방지 |
뷰는 보안과 재사용성을, CTE는 가독성과 계층 처리를 강점으로 합니다. 실무에서는 두 기술을 상호 보완적으로 활용하며, 자주 사용되는 복잡한 쿼리는 뷰로, 일회성 분석이나 재귀 처리는 CTE로 작성하는 것이 효과적입니다.
다음 장에서는 데이터 분석의 핵심인 집계와 윈도우 함수를 다루겠습니다.