junyeokk
Blog
Database·2024. 12. 30

MySQL View

게시글 목록을 조회하는 API를 만든다고 생각해보자. 게시글 테이블에는 제목, 본문, 작성일 같은 기본 정보만 있고, 작성자 이름은 사용자 테이블에, 태그 목록은 태그 매핑 테이블에, 좋아요 수는 또 다른 테이블에 있다. 결국 하나의 목록을 보여주려면 여러 테이블을 JOIN하고, 서브쿼리로 집계하고, 별칭을 붙이는 복잡한 쿼리를 작성해야 한다.

문제는 이런 쿼리가 애플리케이션 곳곳에서 반복된다는 것이다. 목록 조회, 검색, 트렌드 정렬, 페이지네이션 — 모두 같은 JOIN + 집계 로직이 필요하다. 쿼리를 복사해서 쓰다 보면 한 곳을 수정할 때 다른 곳을 빼먹거나, 새로운 컬럼이 추가될 때 모든 곳을 고쳐야 하는 상황이 발생한다.

MySQL View는 이 문제를 해결한다. 복잡한 SELECT 쿼리를 하나의 가상 테이블로 저장해놓고, 마치 일반 테이블처럼 조회할 수 있게 해준다.


View란 무엇인가

View는 실제 데이터를 저장하지 않는 가상 테이블이다. SELECT 쿼리의 정의만 저장하고, View를 조회할 때마다 해당 쿼리가 실행되어 결과를 반환한다.

sql
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를 일반 테이블처럼 사용할 수 있다:

sql
-- 마치 테이블처럼 조회
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 임시 테이블

같은 문제를 해결하는 다른 방법들과 비교해보자.

서브쿼리 / 인라인 뷰

sql
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)

sql
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 문법

기본 생성

sql
CREATE VIEW view_name AS
SELECT column1, column2, ...
FROM table_name
WHERE condition;

CREATE OR REPLACE

이미 존재하는 View를 수정할 때 DROP 없이 덮어쓸 수 있다:

sql
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 삭제

sql
DROP VIEW IF EXISTS article_list_view;

IF EXISTS를 붙이면 View가 없어도 에러가 발생하지 않는다.


View의 내부 동작

View를 조회하면 MySQL은 다음과 같은 과정을 거친다:

  1. SELECT * FROM article_list_view WHERE view_count > 100을 실행
  2. MySQL이 View의 정의를 가져와 쿼리를 합친다 (merge)
  3. 최종적으로 실행되는 쿼리는 원본 테이블에 대한 JOIN + WHERE 조건이 된다

MySQL은 View를 처리할 때 두 가지 알고리즘 중 하나를 사용한다:

MERGE 알고리즘

View의 쿼리를 외부 쿼리와 합쳐서 하나의 쿼리로 실행한다:

sql
-- 원본 쿼리
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의 결과를 먼저 임시 테이블에 저장하고, 그 다음 외부 쿼리를 적용한다:

sql
-- 1단계: View 결과를 임시 테이블에 저장
-- 2단계: 임시 테이블에서 WHERE view_count > 100 적용

GROUP BY, DISTINCT, 집계 함수, UNION 등이 포함된 View는 MERGE가 불가능해서 TEMPTABLE을 사용한다. 이 경우 원본 테이블의 인덱스를 활용할 수 없고, 데이터 양이 많으면 성능이 떨어질 수 있다.

알고리즘 명시적 지정

sql
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하면서 서브쿼리로 집계하는 패턴이다:

sql
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에서 특히 유용하다:

sql
-- 태그가 3개인 게시글의 경우
-- JSON_ARRAYAGG 없이: 3개 행 반환
-- JSON_ARRAYAGG 사용: 1개 행에 ["JavaScript", "React", "TypeScript"] 반환

주의할 점은 상관 서브쿼리(correlated subquery)로 JSON_ARRAYAGG를 사용하면 외부 쿼리의 각 행마다 서브쿼리가 실행되므로, 데이터 양이 많으면 성능에 영향을 줄 수 있다.

ROW_NUMBER() 윈도우 함수

sql
ROW_NUMBER() OVER (ORDER BY created_at) AS order_id

ROW_NUMBER()는 정렬 기준에 따라 1부터 시작하는 순번을 자동으로 매겨준다. 커서 기반 페이지네이션에서 order_id를 기준으로 다음 페이지를 가져올 때 유용하다:

sql
SELECT * FROM post_summary_view
WHERE order_id > 100
ORDER BY order_id
LIMIT 20;

View의 제약사항

View가 만능은 아니다. 몇 가지 중요한 제약을 알아두자.

인덱스를 직접 걸 수 없다

View는 데이터를 저장하지 않으므로 자체적으로 인덱스를 가질 수 없다. MERGE 알고리즘을 사용하면 원본 테이블의 인덱스를 활용하지만, TEMPTABLE을 사용하면 인덱스 혜택을 받을 수 없다. 성능이 중요하다면 원본 테이블의 인덱스 전략을 잘 세워야 한다.

업데이트 가능 여부

단순한 View는 INSERT, UPDATE, DELETE가 가능하다:

sql
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의 조건을 벗어나는 데이터가 삽입/수정되는 것을 방지한다:

sql
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를 엔티티처럼 정의할 수 있다:

typescript
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를 조회할 수 있다:

typescript
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();

@ViewEntityexpression 속성에 QueryBuilder로 View의 SELECT 쿼리를 정의한다. TypeORM의 synchronize: true 옵션을 사용하면 애플리케이션 시작 시 자동으로 View가 생성되지만, 프로덕션에서는 Migration을 사용해서 View를 관리하는 것이 안전하다.

Migration으로 View 관리

TypeORM Migration에서 View를 생성/수정하는 방법:

typescript
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으로 실행 계획 확인

sql
EXPLAIN SELECT * FROM post_summary_view WHERE view_count > 100;

EXPLAIN 결과에서 View가 MERGE되었는지, TEMPTABLE로 처리되었는지 확인할 수 있다. select_typeDERIVED이면 TEMPTABLE로 처리된 것이다.

원본 테이블 인덱스 전략

View의 성능은 원본 테이블의 인덱스에 달려있다:

sql
-- 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의 결과를 저장하고 주기적으로 갱신하는 방식으로 흉내낼 수 있다:

sql
-- 물리적 테이블로 결과 저장
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 자체가 아닌 원본 테이블의 인덱스 전략이 성능을 결정한다

관련 문서