백엔드 데이터 인프라 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 를 "테이블처럼" 쓸 수 있어요. PG(PostgreSQL)는 내부적으로 "뷰 정의 + 우리 쿼리" 를 합쳐 한 SQL로 실행해요. 저장된 쿼리 + 별명이라고 보면 됩니다.
SELECT * FROM active_users WHERE city = 'Seoul';
왜 뷰가 필요한가 — 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 트리거(뷰에 직접 INSERT/UPDATE 가 왔을 때 가로채는 트리거)를 따로 박아야 합니다.
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 잡 등록) 중 하나로 갱신합니다.
-- 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(Java Persistence API — 자바 ORM 표준)는 뷰를 "읽기 전용 엔티티" 로 매핑합니다.
@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 는 컬럼 추가만 가능. 변경·삭제는 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 무조건
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 11편 — SELECT 데이터 조회 표준 패턴
- 12편 — JOIN 여러 테이블 합치기
- 13편 — UPDATE 데이터 수정 표준 패턴
- 14편 — DELETE 데이터 삭제와 소프트 삭제 패턴
- 15편 — 외래 키 참조 무결성
다음 글: