MySQL View
게시글 목록을 조회하는 API를 만든다고 생각해보자. 게시글 테이블에는 제목, 본문, 작성일 같은 기본 정보만 있고, 작성자 이름은 사용자 테이블에, 태그 목록은 태그 매핑 테이블에, 좋아요 수는 또 다른 테이블에 있다. 결국 하나의 목록을 보여주려면 여러 테이블을 JOIN하고, 서브쿼리로 집계하고, 별칭을 붙이는 복잡한 쿼리를 작성해야 한다.
문제는 이런 쿼리가 애플리케이션 곳곳에서 반복된다는 것이다. 목록 조회, 검색, 트렌드 정렬, 페이지네이션 — 모두 같은 JOIN + 집계 로직이 필요하다. 쿼리를 복사해서 쓰다 보면 한 곳을 수정할 때 다른 곳을 빼먹거나, 새로운 컬럼이 추가될 때 모든 곳을 고쳐야 하는 상황이 발생한다.
MySQL View는 이 문제를 해결한다. 복잡한 SELECT 쿼리를 하나의 가상 테이블로 저장해놓고, 마치 일반 테이블처럼 조회할 수 있게 해준다.
View란 무엇인가
View는 실제 데이터를 저장하지 않는 가상 테이블이다. SELECT 쿼리의 정의만 저장하고, View를 조회할 때마다 해당 쿼리가 실행되어 결과를 반환한다.
CREATE VIEW article_list_view AS
SELECT
a.id,
a.title,
a.created_at,
a.view_count,
u.name AS author_name,
(
SELECT COUNT(*)
FROM comments c
WHERE c.article_id = a.id
) AS comment_count
FROM articles a
INNER JOIN users u ON u.id = a.author_id;
이제 이 View를 일반 테이블처럼 사용할 수 있다:
-- 마치 테이블처럼 조회
SELECT * FROM article_list_view WHERE view_count > 100;
-- 정렬, 필터, 페이지네이션 모두 가능
SELECT * FROM article_list_view
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
원래라면 매번 JOIN과 서브쿼리를 작성해야 했던 부분이 단순한 SELECT * FROM view_name으로 줄어든다.
View vs 서브쿼리 vs 임시 테이블
같은 문제를 해결하는 다른 방법들과 비교해보자.
서브쿼리 / 인라인 뷰
SELECT * FROM (
SELECT a.id, a.title, u.name AS author_name
FROM articles a
INNER JOIN users u ON u.id = a.author_id
) AS sub
WHERE sub.author_name = 'Kim';
서브쿼리는 쿼리 안에서 일회성으로 사용된다. 동일한 서브쿼리가 여러 곳에 필요하면 매번 복사해야 하고, 수정 시 모든 곳을 찾아 고쳐야 한다. View는 한 번 정의하면 여러 곳에서 재사용할 수 있다.
임시 테이블 (Temporary Table)
CREATE TEMPORARY TABLE temp_articles AS
SELECT a.id, a.title, u.name AS author_name
FROM articles a
INNER JOIN users u ON u.id = a.author_id;
임시 테이블은 실제로 데이터를 복사해서 저장한다. 세션이 끝나면 사라지고, 데이터가 생성 시점에 고정되므로 원본이 변경되어도 반영되지 않는다. View는 항상 최신 데이터를 반환한다.
비교 정리
| 특성 | View | 서브쿼리 | 임시 테이블 |
|---|---|---|---|
| 재사용 | ✅ | ❌ | 세션 내 한정 |
| 데이터 저장 | ❌ (정의만) | ❌ | ✅ (복사본) |
| 최신 데이터 | ✅ (항상) | ✅ | ❌ (생성 시점) |
| 스키마 영속 | ✅ | ❌ | ❌ |
| 인덱스 가능 | ❌ | ❌ | ✅ |
CREATE VIEW 문법
기본 생성
CREATE VIEW view_name AS
SELECT column1, column2, ...
FROM table_name
WHERE condition;
CREATE OR REPLACE
이미 존재하는 View를 수정할 때 DROP 없이 덮어쓸 수 있다:
CREATE OR REPLACE VIEW article_list_view AS
SELECT
a.id,
a.title,
a.created_at,
a.view_count,
a.like_count, -- 새 컬럼 추가
u.name AS author_name
FROM articles a
INNER JOIN users u ON u.id = a.author_id;
CREATE OR REPLACE는 View가 없으면 생성하고, 있으면 재정의한다. 주의할 점은 기존 View의 컬럼 구조가 바뀌면 해당 View에 의존하는 다른 View나 애플리케이션 코드에 영향을 줄 수 있다는 것이다.
View 삭제
DROP VIEW IF EXISTS article_list_view;
IF EXISTS를 붙이면 View가 없어도 에러가 발생하지 않는다.
View의 내부 동작
View를 조회하면 MySQL은 다음과 같은 과정을 거친다:
SELECT * FROM article_list_view WHERE view_count > 100을 실행- MySQL이 View의 정의를 가져와 쿼리를 합친다 (merge)
- 최종적으로 실행되는 쿼리는 원본 테이블에 대한 JOIN + WHERE 조건이 된다
MySQL은 View를 처리할 때 두 가지 알고리즘 중 하나를 사용한다:
MERGE 알고리즘
View의 쿼리를 외부 쿼리와 합쳐서 하나의 쿼리로 실행한다:
-- 원본 쿼리
SELECT * FROM article_list_view WHERE view_count > 100;
-- MERGE 후 실제 실행되는 쿼리
SELECT a.id, a.title, a.created_at, a.view_count, u.name AS author_name
FROM articles a
INNER JOIN users u ON u.id = a.author_id
WHERE a.view_count > 100;
WHERE 조건이 원본 테이블에 직접 적용되므로 인덱스를 활용할 수 있다. 성능이 좋다.
TEMPTABLE 알고리즘
View의 결과를 먼저 임시 테이블에 저장하고, 그 다음 외부 쿼리를 적용한다:
-- 1단계: View 결과를 임시 테이블에 저장
-- 2단계: 임시 테이블에서 WHERE view_count > 100 적용
GROUP BY, DISTINCT, 집계 함수, UNION 등이 포함된 View는 MERGE가 불가능해서 TEMPTABLE을 사용한다. 이 경우 원본 테이블의 인덱스를 활용할 수 없고, 데이터 양이 많으면 성능이 떨어질 수 있다.
알고리즘 명시적 지정
CREATE ALGORITHM = MERGE VIEW simple_view AS
SELECT id, title FROM articles WHERE published = true;
CREATE ALGORITHM = TEMPTABLE VIEW aggregated_view AS
SELECT author_id, COUNT(*) AS article_count
FROM articles
GROUP BY author_id;
기본값은 UNDEFINED로, MySQL이 가능하면 MERGE를, 불가능하면 TEMPTABLE을 자동 선택한다.
복잡한 View 예시
실무에서 자주 만나는 패턴들을 살펴보자.
JOIN + 서브쿼리 조합
여러 테이블을 JOIN하면서 서브쿼리로 집계하는 패턴이다:
CREATE OR REPLACE VIEW post_summary_view AS
SELECT
ROW_NUMBER() OVER (ORDER BY p.created_at) AS order_id,
p.id,
p.title,
p.path,
p.created_at,
p.thumbnail,
p.view_count,
p.summary,
p.like_count,
b.name AS blog_name,
b.platform AS blog_platform,
(
SELECT JSON_ARRAYAGG(t.name)
FROM tag_map tm
INNER JOIN tag t ON t.id = tm.tag_id
WHERE tm.post_id = p.id
) AS tags
FROM posts p
INNER JOIN blogs b ON b.id = p.blog_id
GROUP BY p.id;
이 View는 게시글의 모든 필요한 정보를 한 번에 가져온다. ROW_NUMBER()로 순번을 매기고, JSON_ARRAYAGG()로 태그 목록을 JSON 배열로 묶었다. 이 View를 만들어두면 목록 조회, 검색, 정렬에서 모두 재사용할 수 있다.
JSON_ARRAYAGG 활용
MySQL 5.7.22+에서 지원하는 JSON_ARRAYAGG()는 여러 행의 값을 JSON 배열로 묶어준다. 1:N 관계의 데이터를 하나의 행에 담을 수 있어서 View에서 특히 유용하다:
-- 태그가 3개인 게시글의 경우
-- JSON_ARRAYAGG 없이: 3개 행 반환
-- JSON_ARRAYAGG 사용: 1개 행에 ["JavaScript", "React", "TypeScript"] 반환
주의할 점은 상관 서브쿼리(correlated subquery)로 JSON_ARRAYAGG를 사용하면 외부 쿼리의 각 행마다 서브쿼리가 실행되므로, 데이터 양이 많으면 성능에 영향을 줄 수 있다.
ROW_NUMBER() 윈도우 함수
ROW_NUMBER() OVER (ORDER BY created_at) AS order_id
ROW_NUMBER()는 정렬 기준에 따라 1부터 시작하는 순번을 자동으로 매겨준다. 커서 기반 페이지네이션에서 order_id를 기준으로 다음 페이지를 가져올 때 유용하다:
SELECT * FROM post_summary_view
WHERE order_id > 100
ORDER BY order_id
LIMIT 20;
View의 제약사항
View가 만능은 아니다. 몇 가지 중요한 제약을 알아두자.
인덱스를 직접 걸 수 없다
View는 데이터를 저장하지 않으므로 자체적으로 인덱스를 가질 수 없다. MERGE 알고리즘을 사용하면 원본 테이블의 인덱스를 활용하지만, TEMPTABLE을 사용하면 인덱스 혜택을 받을 수 없다. 성능이 중요하다면 원본 테이블의 인덱스 전략을 잘 세워야 한다.
업데이트 가능 여부
단순한 View는 INSERT, UPDATE, DELETE가 가능하다:
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE is_active = true;
-- 이런 View는 업데이트 가능
UPDATE active_users SET name = 'New Name' WHERE id = 1;
하지만 다음 조건에 해당하면 업데이트가 불가능하다:
- 집계 함수 (COUNT, SUM, AVG 등) 사용
- DISTINCT, GROUP BY, HAVING 포함
- UNION, UNION ALL 사용
- 서브쿼리가 FROM 절에 있음
- JOIN된 경우 (일부 제한적으로 가능)
복잡한 View는 대부분 읽기 전용이라고 생각하면 된다.
WITH CHECK OPTION
업데이트 가능한 View에서 View의 조건을 벗어나는 데이터가 삽입/수정되는 것을 방지한다:
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE is_active = true
WITH CHECK OPTION;
-- is_active = false인 행은 이 View를 통해 삽입 불가
INSERT INTO active_users (name, email) VALUES ('Test', 'test@example.com');
-- 에러: CHECK OPTION failed
ORM에서의 View 활용
ORM을 사용하는 경우 View를 어떻게 통합하는지 살펴보자. TypeORM을 예로 들면, @ViewEntity 데코레이터로 View를 엔티티처럼 정의할 수 있다:
import {
DataSource,
ViewColumn,
ViewEntity,
} from 'typeorm';
import { Post } from './post.entity';
import { Blog } from './blog.entity';
@ViewEntity({
expression: (dataSource: DataSource) =>
dataSource
.createQueryBuilder()
.select('ROW_NUMBER() OVER (ORDER BY p.created_at)', 'order_id')
.addSelect('p.id', 'id')
.addSelect('p.title', 'title')
.addSelect('p.created_at', 'created_at')
.addSelect('p.view_count', 'view_count')
.addSelect('b.name', 'blog_name')
.from(Post, 'p')
.innerJoin(Blog, 'b', 'b.id = p.blog_id')
.groupBy('p.id'),
name: 'post_summary_view',
})
export class PostSummaryView {
@ViewColumn({ name: 'order_id' })
orderId: number;
@ViewColumn({ name: 'id' })
postId: number;
@ViewColumn({ name: 'title' })
title: string;
@ViewColumn({ name: 'created_at' })
createdAt: Date;
@ViewColumn({ name: 'view_count' })
viewCount: number;
@ViewColumn({ name: 'blog_name' })
blogName: string;
}
이렇게 정의하면 Repository 패턴으로 View를 조회할 수 있다:
const repository = dataSource.getRepository(PostSummaryView);
// 일반 엔티티처럼 조회
const posts = await repository.find({
where: { blogName: 'My Blog' },
order: { createdAt: 'DESC' },
take: 20,
});
// QueryBuilder도 사용 가능
const trending = await repository
.createQueryBuilder('view')
.where('view.viewCount > :min', { min: 100 })
.orderBy('view.viewCount', 'DESC')
.getMany();
@ViewEntity의 expression 속성에 QueryBuilder로 View의 SELECT 쿼리를 정의한다. TypeORM의 synchronize: true 옵션을 사용하면 애플리케이션 시작 시 자동으로 View가 생성되지만, 프로덕션에서는 Migration을 사용해서 View를 관리하는 것이 안전하다.
Migration으로 View 관리
TypeORM Migration에서 View를 생성/수정하는 방법:
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreatePostSummaryView1700000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE OR REPLACE VIEW post_summary_view AS
SELECT
ROW_NUMBER() OVER (ORDER BY p.created_at) AS order_id,
p.id,
p.title,
p.created_at,
p.view_count,
b.name AS blog_name
FROM posts p
INNER JOIN blogs b ON b.id = p.blog_id
GROUP BY p.id;`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP VIEW IF EXISTS post_summary_view;');
}
}
Migration의 up()에서 View를 생성하고 down()에서 삭제한다. CREATE OR REPLACE를 사용하면 View 구조를 변경할 때 새 Migration만 추가하면 된다.
View 성능 최적화
View 자체는 성능을 개선하지도, 악화시키지도 않는다. View를 통해 실행되는 최종 쿼리의 성능이 중요하다.
EXPLAIN으로 실행 계획 확인
EXPLAIN SELECT * FROM post_summary_view WHERE view_count > 100;
EXPLAIN 결과에서 View가 MERGE되었는지, TEMPTABLE로 처리되었는지 확인할 수 있다. select_type이 DERIVED이면 TEMPTABLE로 처리된 것이다.
원본 테이블 인덱스 전략
View의 성능은 원본 테이블의 인덱스에 달려있다:
-- View에서 자주 필터링하는 컬럼에 인덱스
CREATE INDEX idx_posts_created_at ON posts (created_at);
CREATE INDEX idx_posts_view_count ON posts (view_count);
-- JOIN에 사용되는 외래 키에 인덱스
CREATE INDEX idx_posts_blog_id ON posts (blog_id);
대안: Materialized View
MySQL은 Materialized View를 네이티브로 지원하지 않는다. 성능이 정말 중요하고 실시간성이 크게 요구되지 않는다면, 별도 테이블에 View의 결과를 저장하고 주기적으로 갱신하는 방식으로 흉내낼 수 있다:
-- 물리적 테이블로 결과 저장
CREATE TABLE post_summary_cache AS
SELECT * FROM post_summary_view;
-- 인덱스 생성 가능
CREATE INDEX idx_cache_view_count ON post_summary_cache (view_count);
-- 주기적으로 갱신
TRUNCATE TABLE post_summary_cache;
INSERT INTO post_summary_cache SELECT * FROM post_summary_view;
PostgreSQL은 CREATE MATERIALIZED VIEW를 네이티브로 지원한다. MySQL에서 이런 패턴이 자주 필요하다면 PostgreSQL로의 전환을 고려해볼 수도 있다.
정리
- View는 복잡한 SELECT 쿼리를 가상 테이블로 추상화하여 재사용성을 높인다
- 실제 데이터를 저장하지 않으므로 항상 최신 데이터를 반환한다
- MySQL은 MERGE와 TEMPTABLE 두 가지 알고리즘으로 View를 처리한다
- MERGE는 성능이 좋고, TEMPTABLE은 집계/그룹핑이 있을 때 사용된다
- ORM에서는 View를 엔티티처럼 매핑하여 타입 안전하게 사용할 수 있다
- View 자체가 아닌 원본 테이블의 인덱스 전략이 성능을 결정한다