뷰와 CTE
복잡한 서브쿼리를 반복 사용하면 코드가 길어지고 유지보수가 어렵습니다. 뷰(View)와 CTE(Common Table Expression)는 서브쿼리에 이름을 붙여 재사용성과 가독성을 높이는 방법입니다. 두 기술 모두 쿼리의 결과 집합에 이름을 부여한다는 공통점이 있지만, 사용 범위와 생명주기가 다릅니다.
VIEW
뷰는 자주 사용하는 쿼리를 가상 테이블처럼 이름 붙여 저장하는 것입니다. 일반 뷰 자체에 데이터가 저장되는 것은 아니며, 조회 시점에 기반 쿼리가 실행되거나 옵티마이저에 의해 기반 쿼리와 함께 최적화됩니다.
뷰 생성과 사용
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, PostgreSQLCREATE OR REPLACE VIEW, DROP VIEW IF EXISTS 같은 DDL 편의 문법은 DBMS마다 지원 범위가 다릅니다. 예를 들어 SQL Server는 CREATE OR ALTER VIEW를 주로 사용하고, Oracle은 버전과 객체 상태에 따라 조건부 삭제 방식이 달라질 수 있습니다.
뷰의 장단점
| 장점 | 설명 |
|---|---|
| 복잡한 쿼리 단순화 | 반복되는 JOIN/집계를 한 번만 작성 |
| 보안 | 민감 컬럼(급여, 주민번호)을 숨긴 뷰만 제공 |
| 논리적 독립성 | 테이블 구조가 변경되어도 뷰를 수정하면 앱 코드 변경 불필요 |
| 재사용성 | DB에 영구 저장되어 여러 쿼리에서 공유 |
| 단점 | 설명 |
|---|---|
| 성능 | 별도 저장은 아니며 병합/물리화 방식 확인 필요 |
| UPDATE 제한 | 집계, JOIN이 있는 뷰는 갱신 불가 |
| 디버깅 어려움 | 뷰 위에 뷰를 쌓으면 실행 계획 추적이 복잡 |
갱신 가능 뷰 (Updatable View)
단순한 뷰는 DBMS 조건을 만족하면 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');
-- → DBMS/테이블 기본값에 따라 is_active가 NULL 또는 기본값으로 들어갈 수 있음 (주의!)갱신 가능 뷰라도 기반 테이블의 NOT NULL, DEFAULT, 외래 키, 생성 컬럼 제약은 그대로 적용됩니다. 뷰에 보이지 않는 컬럼이 필수값이면 INSERT가 실패할 수 있고, 성공하더라도 뷰의 조건을 만족하지 않으면 방금 넣은 행이 뷰에서 보이지 않을 수 있습니다.
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;
-- 기본 테이블에 대한 직접 접근은 차단보안 목적으로 뷰를 사용할 때는 기본 테이블 권한을 직접 주지 않는 것이 핵심입니다. 부서별 뷰처럼 UPDATE까지 허용한다면 WITH CHECK OPTION이나 Row-Level Security를 함께 검토해야, 사용자가 행을 다른 부서로 옮겨 뷰 조건 밖으로 빼내는 상황을 막을 수 있습니다.
물리화된 뷰 (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;PostgreSQL의 CONCURRENTLY 갱신은 이미 데이터가 채워진 물리화된 뷰에만 사용할 수 있고, 전체 행을 식별하는 컬럼 기반 UNIQUE INDEX가 필요합니다. Partial index나 expression index만으로는 조건을 만족하지 못합니다. SQL Server에서는 같은 이름의 Materialized View 문법 대신 인덱싱된 뷰(Indexed View)를 검토하는 식으로 접근합니다.
WITH (CTE)
CTE는 쿼리 내에서 임시로 이름 붙인 결과 집합입니다. 한 쿼리 안에서만 유효하며, 뷰처럼 DB 객체로 저장되지 않습니다.
CTE가 실제로 매번 물리화되는지, 또는 서브쿼리처럼 인라인으로 펼쳐지는지는 DBMS와 버전에 따라 다릅니다. 성능 판단은 CTE 사용 여부만으로 결정하지 말고 실행 계획에서 필터 푸시다운, 중복 실행, 임시 저장 여부를 확인해야 합니다.
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 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;이 예시는 CONCAT, REPEAT, CAST(... AS CHAR(...))를 쓰는 MySQL식 표현에 가깝습니다. PostgreSQL에서는 CAST(name AS text), t.path || ' > ' || e.name, repeat(' ', level_depth - 1)처럼 문자열 표현을 바꿀 수 있고, SQL Server는 WITH RECURSIVE 대신 일반 WITH와 OPTION (MAXRECURSION n)을 사용합니다.
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;재귀 CTE는 보통 UNION ALL을 사용합니다. UNION은 중복 제거 비용이 생기고, 경로 컬럼을 포함한 재귀 결과에서는 의도와 다르게 종료 조건이나 성능이 달라질 수 있습니다.
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; -- 같은 레벨 내 정렬재귀 CTE의 무한 루프 방지
계층 데이터에 순환 참조가 있으면 재귀 CTE가 종료되지 않거나 DBMS의 재귀 제한/오류에 걸릴 수 있습니다. 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;PostgreSQL의 CYCLE 절은 PostgreSQL 14 이상에서 사용할 수 있습니다. MySQL은 세션 재귀 깊이 제한, SQL Server는 MAXRECURSION, Oracle은 NOCYCLE처럼 DBMS마다 순환 방어 장치가 다릅니다.
VIEW vs CTE 비교
| 항목 | VIEW | CTE |
|---|---|---|
| 저장 위치 | DB에 영구 저장 | 쿼리 내에서만 존재 |
| 재사용 범위 | 모든 쿼리에서 사용 가능 | 해당 쿼리 내에서만 |
| 권한 관리 | GRANT/REVOKE 가능 | 쿼리 실행 권한으로 충분 |
| 재귀 지원 | 일반적으로 직접 사용하지 않음 | 가능 (WITH RECURSIVE) |
| 성능 | 최적화 방식은 DBMS마다 다름 | inline/materialize 여부 확인 필요 |
| 사용 목적 | 공유, 보안, 논리적 독립성 | 복잡한 쿼리 분해, 재귀 |
| 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 IN ('PAID', 'SHIPPED', 'DELIVERED')
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) / NULLIF(p.revenue, 0) * 100, 1) AS growth_rate
FROM current_month c
CROSS JOIN previous_month p;DATE_FORMAT과 일부 문자열 함수는 MySQL 문법입니다. PostgreSQL에서는 DATE_TRUNC, TO_CHAR, split_part 등을, Oracle에서는 TRUNC, TO_CHAR, SUBSTR 계열을 쓰는 식으로 바꿔야 합니다.
정리
| 개념 | 용도 | 핵심 특징 |
|---|---|---|
| VIEW | 쿼리 재사용, 보안 | DB에 영구 저장, 가상 테이블 |
| Materialized VIEW | 집계 캐싱, 성능 | 물리 저장, 주기적 갱신 필요 |
| CTE | 복잡 쿼리 분해 | 쿼리 내 임시 결과 집합 |
| 재귀 CTE | 계층 구조 탐색 | Anchor + Recursive 구조 |
| 인라인 뷰 | FROM절 서브쿼리 | 짧은 파생 테이블 표현 |
| WITH CHECK OPTION | 뷰 DML 제한 | 조건 위반 데이터 삽입 방지 |
뷰는 보안과 재사용성을, CTE는 가독성과 계층 처리를 강점으로 합니다. 실무에서는 두 기술을 상호 보완적으로 활용하며, 여러 곳에서 공유되는 복잡한 쿼리는 뷰로, 일회성 분석이나 재귀 처리는 CTE로 작성하는 식으로 선택합니다. 성능이 중요한 경우에는 View/CTE 여부만으로 판단하지 말고 실제 실행 계획을 확인해야 합니다.
다음 장에서는 데이터 분석의 핵심인 집계와 윈도우 함수를 다루겠습니다.