백엔드 데이터 인프라 16편. 뷰(VIEW)로 복잡한 쿼리를 테이블처럼 다루는 패턴과 MATERIALIZED VIEW의 캐시 동작 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 16편이에요. 11~15편 까지 SQL 5대 동사와 JOIN·외래 키를 다뤘으니, 이번 16편은 "이 복잡한 쿼리를 매번 다시 쓰기 싫을 때" — 뷰(VIEW).
뷰란 — 쿼리에 이름을 박은 것
CREATE VIEW active_users AS
SELECT id, name, email, created_at
FROM users
WHERE deleted_at IS NULL;
이제 — active_users 를 "테이블처럼" 사용 가능.
SELECT * FROM active_users WHERE city = 'Seoul';
내부적으로는 — PG가 "뷰 정의 + 우리 쿼리" 합쳐 한 SQL로 실행. 저장된 쿼리 + 별명.
왜 뷰가 필요한가 — 4가지
(1) 복잡한 쿼리 추상화
CREATE VIEW order_summary AS
SELECT
u.id AS user_id,
u.name AS user_name,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.name;
이후 매번 — SELECT * FROM order_summary WHERE total_amount > 100000.
(2) 권한 분리
-- 일반 사용자에겐 users 직접 접근 X
REVOKE SELECT ON users FROM analyst_role;
-- 민감 컬럼(이메일·전화) 뺀 뷰만 노출
CREATE VIEW users_public AS
SELECT id, name, city FROM users WHERE deleted_at IS NULL;
GRANT SELECT ON users_public TO analyst_role;
리포팅·분석 부서에 "필요한 정보만" 노출.
(3) Soft Delete 일관성
14편 DELETE 의 Soft Delete 패턴 — 매번 WHERE deleted_at IS NULL 박는 부담.
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;
이후 모든 코드 — FROM active_users. 실수 0.
(4) 호환성·마이그레이션
테이블 구조 변경 시 — 옛 인터페이스 유지 위해 뷰 박기.
-- 기존 코드는 user_name 컬럼 의존
-- 테이블에서 user_name → name 변경 후
CREATE VIEW legacy_users AS
SELECT id, name AS user_name, email FROM users;
옛 코드 변경 없이 동작.
뷰 갱신 — CREATE OR REPLACE VIEW
CREATE OR REPLACE VIEW active_users AS
SELECT id, name, email, city FROM users WHERE deleted_at IS NULL;
기존 뷰 덮어쓰기. 다만 컬럼 순서·이름 추가만 가능 — 기존 컬럼 삭제·이름 변경은 DROP 후 재생성.
뷰 삭제
DROP VIEW IF EXISTS active_users;
-- 의존하는 다른 뷰도 함께
DROP VIEW active_users CASCADE;
뷰로 INSERT·UPDATE·DELETE — 가능한 경우
-- 단순 뷰 (JOIN·GROUP BY 없음)는 INSERT·UPDATE 가능
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE deleted_at IS NULL;
INSERT INTO active_users (name, email) VALUES ('Alice', '...');
UPDATE active_users SET name = 'New' WHERE id = 1;
PG가 자동으로 — 뷰 INSERT → 실제 테이블 INSERT 로 변환.
복잡한 뷰 (JOIN·GROUP BY·집계 함수) 는 INSERT·UPDATE 자동 불가. 트리거 또는 INSTEAD OF 트리거로 가능.
MATERIALIZED VIEW — 캐시된 뷰
CREATE MATERIALIZED VIEW daily_stats AS
SELECT
DATE(created_at) AS day,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM orders
GROUP BY DATE(created_at);
일반 뷰 = 매 조회마다 원본 쿼리 다시 실행. MATERIALIZED VIEW = "쿼리 결과를 실제 테이블처럼 저장". 조회는 매우 빠름, 단 갱신은 수동.
갱신
REFRESH MATERIALIZED VIEW daily_stats;
-- 다른 쿼리 막지 않고 갱신 (UNIQUE 인덱스 필요)
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats;
CONCURRENTLY = 갱신 중에도 SELECT 가능. 운영 환경 표준.
자동 갱신 — cron + REFRESH
PG 자체 cron 기능 없음 — 외부 cron, 자바 백엔드 입문 55편 @Scheduled, 또는 PG 확장 pg_cron.
-- pg_cron 확장 사용 시
SELECT cron.schedule('refresh-daily-stats', '0 3 * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats');
매일 새벽 3시 자동 갱신.
일반 VIEW vs MATERIALIZED VIEW
| VIEW | MATERIALIZED VIEW | |
|---|---|---|
| 저장 | 정의만 | 데이터까지 |
| 조회 속도 | 원본 쿼리 속도 | 매우 빠름 |
| 갱신 | 자동 (실시간) | 수동 (REFRESH) |
| 데이터 신선도 | 실시간 | 갱신 시점 기준 |
| 인덱스 | 불가 | 가능 |
| 사용 | 추상화·권한·일관성 | 무거운 통계·대시보드 |
룰: - 자주 변하는 데이터·실시간성 = VIEW - 무거운 집계·대시보드·로그 분석 = MATERIALIZED VIEW
인덱스 — MATERIALIZED VIEW 만
CREATE INDEX idx_daily_stats_day ON daily_stats(day);
CREATE UNIQUE INDEX idx_daily_stats_day_unique ON daily_stats(day);
CONCURRENTLY REFRESH는 UNIQUE 인덱스 필수.
실전 예 — 대시보드
-- 1. 일별 매출 (MATERIALIZED)
CREATE MATERIALIZED VIEW dashboard_daily_revenue AS
SELECT
DATE(o.created_at) AS day,
p.category AS category,
COUNT(*) AS orders,
SUM(o.amount) AS revenue
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE o.status = 'PAID'
GROUP BY DATE(o.created_at), p.category;
CREATE UNIQUE INDEX idx_dashboard_day_cat
ON dashboard_daily_revenue(day, category);
-- 2. 사용자별 활성 상태 (일반 VIEW)
CREATE VIEW user_activity AS
SELECT
u.id, u.name,
u.last_login_at,
CASE
WHEN u.last_login_at >= NOW() - INTERVAL '7 days' THEN 'ACTIVE'
WHEN u.last_login_at >= NOW() - INTERVAL '30 days' THEN 'IDLE'
ELSE 'DORMANT'
END AS activity_status
FROM users u
WHERE u.deleted_at IS NULL;
-- 3. 매일 새벽 3시 대시보드 갱신
SELECT cron.schedule('refresh-dashboard', '0 3 * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY dashboard_daily_revenue');
Spring JPA 의 뷰
JPA가 뷰를 "읽기 전용 엔티티" 로 매핑.
@Entity
@Immutable
@Table(name = "order_summary")
public class OrderSummary {
@Id
private Long userId;
private String userName;
private Long orderCount;
private BigDecimal totalAmount;
}
@Immutable = JPA가 "이건 수정 안 함" 알림. 1차 캐시·Dirty Checking 없음.
함정 5가지
(1) 복잡한 뷰 위에 뷰 위에 뷰
뷰 안에서 다른 뷰 참조 = 가능. 단 — 3단계+ 중첩 = 디버깅 매우 어려움, 성능 저하.
(2) MATERIALIZED VIEW 갱신 안 됨
REFRESH 누락 = 데이터가 "옛날 그대로". 자동 갱신 필수.
(3) REFRESH 락
기본 REFRESH 는 "독점 락" — 갱신 중 SELECT 불가. CONCURRENTLY 무조건.
(4) 뷰에 INSERT·UPDATE 시도
복잡한 뷰는 자동 불가. 명확한 인터페이스로 사용 — "뷰는 SELECT 전용" 마인드.
(5) 뷰의 컬럼 변경
CREATE OR REPLACE VIEW 는 컬럼 추가만 OK. 변경·삭제 = DROP 후 재생성. 의존 객체 깨질 수 있음.
(1) 복잡한 쿼리 추상화. (2) 권한 분리 (민감 컬럼 가림). (3) Soft Delete 일관성. (4) 무거운 통계 = MATERIALIZED + 매일 REFRESH. 4가지가 한국 회사 백엔드 90% 시나리오.
한 줄 정리 — VIEW = 쿼리에 이름 박은 것. 추상화·권한 분리·일관성 효과. MATERIALIZED VIEW = 캐시된 뷰 (REFRESH 필요). CONCURRENTLY 갱신 + UNIQUE 인덱스 표준. JPA = @Immutable 매핑.
시험 직전 한 번 더 — VIEW 입문자가 매번 헷갈리는 것
- VIEW = 저장된 쿼리 + 이름
CREATE VIEW name AS SELECT ...- 실시간 — 매 조회 시 원본 쿼리 실행
- CREATE OR REPLACE VIEW = 갱신 (컬럼 추가만)
- DROP VIEW [CASCADE]
- 단순 뷰 = INSERT·UPDATE 자동 가능
- 복잡한 뷰 = SELECT 전용
- INSTEAD OF 트리거 = 복잡 뷰에 쓰기 가능
- MATERIALIZED VIEW = 캐시된 뷰
- 조회 매우 빠름
- REFRESH MATERIALIZED VIEW = 수동 갱신
- CONCURRENTLY = 갱신 중 SELECT 가능 (UNIQUE 인덱스 필요)
- 자동 갱신 = pg_cron 또는 외부 스케줄러
- MV 인덱스 가능 (일반 VIEW 불가)
- 활용 1 = 복잡한 쿼리 추상화
- 활용 2 = 권한 분리 (민감 컬럼)
- 활용 3 = Soft Delete 일관성
- 활용 4 = 대시보드·통계 (MATERIALIZED)
- 3단계+ 뷰 중첩 = 안티패턴
- JPA = @Immutable 읽기 전용 엔티티
- REFRESH 락 = CONCURRENTLY 박기
- legacy_table_name 패턴 = 마이그레이션
- 뷰는 인덱스 못 가짐 (MV는 가능)
- 한국 회사 = 대시보드 = MATERIALIZED VIEW 무조건
시리즈 다른 편
- Part 1 PostgreSQL 튜토리얼: 1편 · 9편 CREATE TABLE · 10편 INSERT · 11편 SELECT · 12편 JOIN · 13편 UPDATE · 14편 DELETE · 15편 외래 키 · 16편 (현재 글)
시리즈 다음 글
다음 글(17편)에서는 트랜잭션 — BEGIN·COMMIT·ROLLBACK — ACID의 직접 표현.
공식 문서: PostgreSQL 18 — Tutorial: Views에서 더 자세한 사양을 확인할 수 있어요.