icon

안동민 개발노트

5장 : SQL 심화 — 조인과 서브쿼리

뷰와 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되는 것을 방지합니다.

WITH CHECK OPTION
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)

일반 뷰와 달리 쿼리 결과를 물리적으로 저장합니다. 조회 성능이 향상되지만 데이터 동기화가 필요합니다.

Materialized View (Oracle)
-- 생성
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;
Materialized View (PostgreSQL)
-- 생성
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;
일반 뷰 vs 물리화된 뷰
┌─────────────┬──────────────────┬────────────────────┐
│    항목     │     일반 뷰      │ 물리화된 뷰        │
├─────────────┼──────────────────┼────────────────────┤
│ 데이터 저장 │ 없음 (가상)      │ 있음 (물리적)      │
│ 조회 성능   │ 매번 쿼리 실행   │ 사전 계산된 결과   │
│데이터 최신성│ 항상 최신        │ 갱신 시점 기준     │
│ 디스크 사용 │       없음       │ 결과 크기만큼 사용 │
│ DML 가능    │ 조건부 가능      │        불가        │
│ 지원 DBMS   │ 모든 DBMS        │ Oracle, PostgreSQL │
└─────────────┴──────────────────┴────────────────────┘

MySQL은 Materialized View를 직접 지원하지 않음
→ 테이블 + 트리거 또는 이벤트 스케줄러로 유사하게 구현

WITH (CTE)

CTE는 쿼리 내에서 임시로 이름 붙인 결과 집합입니다. 한 쿼리 안에서만 유효하며, 뷰처럼 DB에 저장되지 않습니다.

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의 가독성 이점이 분명합니다.

서브쿼리 vs 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를 참조할 수 있어 복잡한 쿼리를 단계적으로 구성할 수 있습니다.

다중 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 구조

재귀 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)

조직도 예제

재귀 CTE: 조직도
-- 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            김대표 > 정부장 > 한과장

카테고리 트리 예제

재귀 CTE: 카테고리 트리
-- 카테고리 테이블
-- 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 문법을 사용할 수 있습니다.

Oracle CONNECT BY
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 vs 재귀 CTE 비교
┌──────────────┬─────────────────────┬────────────────────┐
│     항목     │ 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 비교

항목VIEWCTE
저장 위치DB에 영구 저장쿼리 내에서만 존재
재사용 범위모든 쿼리에서 사용 가능해당 쿼리 내에서만
권한 관리GRANT/REVOKE 가능쿼리 실행 권한으로 충분
재귀 지원불가가능 (WITH RECURSIVE)
성능내부 쿼리 실행 (물리화 뷰 제외)내부 쿼리 실행
사용 목적공유, 보안, 논리적 독립성복잡한 쿼리 분해, 재귀
DDL 필요CREATE VIEW (DDL)없음 (SELECT 내)
선택 기준
반복 사용 + 여러 쿼리에서 공유 → VIEW
한 번만 사용 + 가독성 목적     → CTE
계층 구조 처리                 → 재귀 CTE
보안/접근 제어                 → VIEW
대량 집계 결과 캐싱            → Materialized VIEW

인라인 뷰 (Inline View)

FROM 절에 직접 작성하는 서브쿼리를 인라인 뷰라 합니다. CTE와 기능은 유사하지만 CTE보다 가독성이 떨어집니다.

인라인 뷰 vs 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로 작성하는 것이 효과적입니다.

다음 장에서는 데이터 분석의 핵심인 집계와 윈도우 함수를 다루겠습니다.

목차