리프레시 가능 머티얼라이즈드 뷰

리프레시 가능 머티얼라이즈드 뷰 (Refreshable materialized view)

리프레시 가능 머티얼라이즈드 뷰는 전통적인 OLTP 데이터베이스의 머티얼라이즈드 뷰와 개념적으로 비슷합니다. 지정한 쿼리의 결과를 저장해 빠르게 조회하고, 비용이 큰 쿼리를 반복 실행할 필요를 줄여줍니다.

출처: 문서

본문

리프레시 가능 머티얼라이즈드 뷰는 전통적인 OLTP 데이터베이스의 머티얼라이즈드 뷰와 개념적으로 비슷합니다. 지정된 쿼리의 결과를 저장해 빠르게 꺼내 쓰고, 리소스 집약적인 쿼리를 반복 실행할 필요를 줄입니다. ClickHouse의 증분 머티얼라이즈드 뷰와 달리, 이 방식은 전체 데이터셋에 대해 쿼리를 주기적으로 실행해야 하며 그 결과는 조회용 타깃 테이블에 저장됩니다. 이 결과 집합은 이론적으로 원본 데이터셋보다 작아야 하므로, 이후 쿼리가 더 빠르게 실행될 수 있습니다.

아래 다이어그램은 리프레시 가능 머티얼라이즈드 뷰가 어떻게 동작하는지 설명합니다. 관련 동영상도 볼 수 있습니다.

리프레시 가능 머티얼라이즈드 뷰는 언제 사용해야 하나요?

ClickHouse 증분 머티얼라이즈드 뷰는 매우 강력하며, 특히 단일 테이블에 대한 집계가 필요할 때 리프레시 가능 머티얼라이즈드 뷰가 쓰는 방식보다 훨씬 잘 확장됩니다. 삽입되는 각 데이터 블록에 대해서만 집계를 계산하고 최종 테이블에서 증분 상태를 병합하므로, 쿼리는 항상 데이터의 부분집합에 대해서만 실행됩니다. 이 방식은 잠재적으로 페타바이트 단위의 데이터까지 확장할 수 있고 보통 선호되는 방법입니다.

하지만 이 증분 과정이 필요 없거나 적용할 수 없는 사용 사례도 있습니다. 어떤 문제는 증분 방식과 맞지 않거나 실시간 업데이트를 요구하지 않아 주기적 재구축이 더 적합합니다. 예를 들어 복잡한 조인을 쓰는 뷰라면 전체 데이터셋에 대해 정기적으로 완전 재계산을 수행하고 싶을 수 있는데, 복잡한 조인은 증분 방식과 맞지 않습니다.

리프레시 가능 머티얼라이즈드 뷰는 역정규화(denormalization) 같은 작업을 수행하는 배치 프로세스를 실행할 수 있습니다. 리프레시 가능 머티얼라이즈드 뷰 사이에 의존성을 만들어 한 뷰가 다른 뷰의 결과에 의존하고 완료된 뒤에만 실행되게 할 수 있습니다. 이는 dbt 잡 같은 예약 워크플로나 단순 DAG를 대체할 수 있습니다. 리프레시 가능 머티얼라이즈드 뷰 사이의 의존성 설정 방법에 대해 더 알아보려면 CREATE VIEW 문서의 Dependencies 섹션을 참고하세요.

리프레시 가능 머티얼라이즈드 뷰를 어떻게 리프레시하나요?

리프레시 가능 머티얼라이즈드 뷰는 생성 시 정의된 간격으로 자동 리프레시됩니다. 예를 들어 다음 머티얼라이즈드 뷰는 매분 리프레시됩니다:

CREATE MATERIALIZED VIEW table_name_mv
REFRESH EVERY 1 MINUTE TO table_name AS
...

머티얼라이즈드 뷰를 강제로 리프레시하려면 SYSTEM REFRESH VIEW 절을 사용할 수 있습니다:

SYSTEM REFRESH VIEW table_name_mv;

뷰를 취소(cancel), 중지(stop), 시작(start)할 수도 있습니다. 자세한 내용은 "리프레시 가능 머티얼라이즈드 뷰 관리" 문서를 참고하세요.

리프레시 가능 머티얼라이즈드 뷰는 언제 마지막으로 리프레시되었나요?

리프레시 가능 머티얼라이즈드 뷰가 언제 마지막으로 리프레시되었는지 알아보려면 아래처럼 system.view_refreshes 시스템 테이블을 쿼리할 수 있습니다:

SELECT database, view, status,
       last_success_time, last_refresh_time, next_refresh_time,
       read_rows, written_rows
FROM system.view_refreshes;
┌─database─┬─view─────────────┬─status────┬───last_success_time─┬───last_refresh_time─┬───next_refresh_time─┬─read_rows─┬─written_rows─┐
│ database │ table_name_mv    │ Scheduled │ 2024-11-11 12:10:00 │ 2024-11-11 12:10:00 │ 2024-11-11 12:11:00 │   5491132 │       817718 │
└──────────┴──────────────────┴───────────┴─────────────────────┴─────────────────────┴─────────────────────┴───────────┴──────────────┘

리프레시 주기는 어떻게 바꾸나요?

리프레시 가능 머티얼라이즈드 뷰의 리프레시 주기를 바꾸려면 ALTER TABLE...MODIFY REFRESH 문법을 사용합니다.

ALTER TABLE table_name_mv
MODIFY REFRESH EVERY 30 SECONDS;

그런 다음 앞의 "리프레시 가능 머티얼라이즈드 뷰는 언제 마지막으로 리프레시되었나요?" 쿼리로 주기가 갱신되었는지 확인할 수 있습니다:

┌─database─┬─view─────────────┬─status────┬───last_success_time─┬───last_refresh_time─┬───next_refresh_time─┬─read_rows─┬─written_rows─┐
│ database │ table_name_mv    │ Scheduled │ 2024-11-11 12:22:30 │ 2024-11-11 12:22:30 │ 2024-11-11 12:23:00 │   5491132 │       817718 │
└──────────┴──────────────────┴───────────┴─────────────────────┴─────────────────────┴─────────────────────┴───────────┴──────────────┘

APPEND로 새 행 추가하기

APPEND 기능을 사용하면 전체 뷰를 대체하는 대신 테이블의 끝에 새 행을 추가할 수 있습니다.

이 기능의 한 용도는 특정 시점의 값 스냅샷을 캡처하는 것입니다. 예를 들어 Kafka, Redpanda, 또는 다른 스트리밍 데이터 플랫폼의 메시지 스트림으로 채워지는 events 테이블이 있다고 상상해 봅시다.

SELECT *
FROM events
LIMIT 10
Query id: 7662bc39-aaf9-42bd-b6c7-bc94f2881036

┌──────────────────ts─┬─uuid─┬─count─┐
│ 2008-08-06 17:07:19 │ 0eb  │   547 │
│ 2008-08-06 17:07:19 │ 60b  │   148 │
│ 2008-08-06 17:07:19 │ 106  │   750 │
│ 2008-08-06 17:07:19 │ 398  │   875 │
│ 2008-08-06 17:07:19 │ ca0  │   318 │
│ 2008-08-06 17:07:19 │ 6ba  │   105 │
│ 2008-08-06 17:07:19 │ df9  │   422 │
│ 2008-08-06 17:07:19 │ a71  │   991 │
│ 2008-08-06 17:07:19 │ 3a2  │   495 │
│ 2008-08-06 17:07:19 │ 598  │   238 │
└─────────────────────┴──────┴───────┘

이 데이터셋은 uuid 컬럼에 4096개의 값을 가집니다. 총 개수가 가장 높은 값을 찾는 다음 쿼리를 작성할 수 있습니다:

SELECT
    uuid,
    sum(count) AS count
FROM events
GROUP BY ALL
ORDER BY count DESC
LIMIT 10
┌─uuid─┬───count─┐
│ c6f  │ 5676468 │
│ 951  │ 5669731 │
│ 6a6  │ 5664552 │
│ b06  │ 5662036 │
│ 0ca  │ 5658580 │
│ 2cd  │ 5657182 │
│ 32a  │ 5656475 │
│ ffe  │ 5653952 │
│ f33  │ 5653783 │
│ c5b  │ 5649936 │
└──────┴─────────┘

uuid의 개수를 10초마다 캡처해 events_snapshot이라는 새 테이블에 저장하고 싶다고 가정해 봅시다. events_snapshot의 스키마는 다음과 같습니다:

CREATE TABLE events_snapshot (
    ts DateTime32,
    uuid String,
    count UInt64
)
ENGINE = MergeTree
ORDER BY uuid;

그런 다음 이 테이블을 채울 리프레시 가능 머티얼라이즈드 뷰를 만들 수 있습니다:

CREATE MATERIALIZED VIEW events_snapshot_mv
REFRESH EVERY 10 SECOND APPEND TO events_snapshot
AS SELECT
    now() AS ts,
    uuid,
    sum(count) AS count
FROM events
GROUP BY ALL;

그러면 events_snapshot을 쿼리해 특정 uuid에 대한 시간 경과별 개수를 얻을 수 있습니다:

SELECT *
FROM events_snapshot
WHERE uuid = 'fff'
ORDER BY ts ASC
FORMAT PrettyCompactMonoBlock
┌──────────────────ts─┬─uuid─┬───count─┐
│ 2024-10-01 16:12:56 │ fff  │ 5424711 │
│ 2024-10-01 16:13:00 │ fff  │ 5424711 │
│ 2024-10-01 16:13:10 │ fff  │ 5424711 │
│ 2024-10-01 16:13:20 │ fff  │ 5424711 │
│ 2024-10-01 16:13:30 │ fff  │ 5674669 │
│ 2024-10-01 16:13:40 │ fff  │ 5947912 │
│ 2024-10-01 16:13:50 │ fff  │ 6203361 │
│ 2024-10-01 16:14:00 │ fff  │ 6501695 │
└─────────────────────┴──────┴─────────┘

예제

이제 몇 가지 예제 데이터셋으로 리프레시 가능 머티얼라이즈드 뷰를 사용하는 방법을 살펴봅시다.

Stack Overflow

"데이터 역정규화 가이드"는 Stack Overflow 데이터셋을 사용해 데이터를 역정규화하는 여러 기법을 보여 줍니다. 우리는 votes, users, badges, posts, postlinks 테이블에 데이터를 채웁니다.

그 가이드에서 우리는 다음 쿼리로 postlinks 데이터셋을 posts 테이블에 역정규화하는 방법을 보여 줬습니다:

SELECT
    posts.*,
    arrayMap(p -> (p.1, p.2), arrayFilter(p -> p.3 = 'Linked' AND p.2 != 0, Related)) AS LinkedPosts,
    arrayMap(p -> (p.1, p.2), arrayFilter(p -> p.3 = 'Duplicate' AND p.2 != 0, Related)) AS DuplicatePosts
FROM posts
LEFT JOIN (
    SELECT
         PostId,
         groupArray((CreationDate, RelatedPostId, LinkTypeId)) AS Related
    FROM postlinks
    GROUP BY PostId
) AS postlinks ON posts_types_codecs_ordered.Id = postlinks.PostId;

그런 다음 이 데이터를 posts_with_links 테이블로 일회성 삽입하는 방법을 보여 줬지만, 프로덕션 시스템에서는 이 연산을 주기적으로 실행하고 싶을 것입니다.

posts 테이블과 postlinks 테이블 모두 업데이트될 수 있습니다. 따라서 증분 머티얼라이즈드 뷰로 이 조인을 구현하려 하기보다는, 이 쿼리를 예를 들어 매시간 등 일정 간격으로 실행하고 결과를 post_with_links 테이블에 저장하는 것만으로 충분할 수 있습니다.

리프레시 가능 머티얼라이즈드 뷰가 도움이 되는 곳이 바로 여기이며, 다음 쿼리로 만들 수 있습니다:

CREATE MATERIALIZED VIEW posts_with_links_mv
REFRESH EVERY 1 HOUR TO posts_with_links AS
SELECT
    posts.*,
    arrayMap(p -> (p.1, p.2), arrayFilter(p -> p.3 = 'Linked' AND p.2 != 0, Related)) AS LinkedPosts,
    arrayMap(p -> (p.1, p.2), arrayFilter(p -> p.3 = 'Duplicate' AND p.2 != 0, Related)) AS DuplicatePosts
FROM posts
LEFT JOIN (
    SELECT
         PostId,
         groupArray((CreationDate, RelatedPostId, LinkTypeId)) AS Related
    FROM postlinks
    GROUP BY PostId
) AS postlinks ON posts_types_codecs_ordered.Id = postlinks.PostId;

뷰는 즉시 실행되고, 소스 테이블의 업데이트가 반영되도록 구성된 대로 이후 매시간 실행됩니다. 중요한 것은 쿼리가 다시 실행될 때 결과 집합이 원자적이고 투명하게 갱신된다는 점입니다.

여기 문법은 증분 머티얼라이즈드 뷰와 동일하지만 REFRESH 절이 포함된다는 점만 다릅니다.

IMDb

"dbt와 ClickHouse 통합 가이드"에서 우리는 actors, directors, genres, movie_directors, movies, roles 테이블로 IMDb 데이터셋을 채웠습니다.

그러면 각 배우의 요약을 영화 출연 횟수 순으로 계산하는 다음 쿼리를 쓸 수 있습니다.

SELECT
  id, any(actor_name) AS name, uniqExact(movie_id) AS movies,
  round(avg(rank), 2) AS avg_rank, uniqExact(genre) AS genres,
  uniqExact(director_name) AS directors, max(created_at) AS updated_at
FROM (
  SELECT
    imdb.actors.id AS id,
    concat(imdb.actors.first_name, ' ', imdb.actors.last_name) AS actor_name,
    imdb.movies.id AS movie_id, imdb.movies.rank AS rank, genre,
    concat(imdb.directors.first_name, ' ', imdb.directors.last_name) AS director_name,
    created_at
  FROM imdb.actors
  INNER JOIN imdb.roles ON imdb.roles.actor_id = imdb.actors.id
  LEFT JOIN imdb.movies ON imdb.movies.id = imdb.roles.movie_id
  LEFT JOIN imdb.genres ON imdb.genres.movie_id = imdb.movies.id
  LEFT JOIN imdb.movie_directors ON imdb.movie_directors.movie_id = imdb.movies.id
  LEFT JOIN imdb.directors ON imdb.directors.id = imdb.movie_directors.director_id
)
GROUP BY id
ORDER BY movies DESC
LIMIT 5;
┌─────id─┬─name─────────┬─num_movies─┬───────────avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
│  45332 │ Mel Blanc    │        909 │ 5.7884792542982515 │            19 │            148 │ 2024-11-11 12:01:35 │
│ 621468 │ Bess Flowers │        672 │  5.540605094212635 │            20 │            301 │ 2024-11-11 12:01:35 │
│ 283127 │ Tom London   │        549 │ 2.8057034230202023 │            18 │            208 │ 2024-11-11 12:01:35 │
│ 356804 │ Bud Osborne  │        544 │ 1.9575342420755093 │            16 │            157 │ 2024-11-11 12:01:35 │
│  41669 │ Adoor Bhasi  │        544 │                  0 │             4 │            121 │ 2024-11-11 12:01:35 │
└────────┴──────────────┴────────────┴────────────────────┴───────────────┴────────────────┴─────────────────────┘

5 rows in set. Elapsed: 0.393 sec. Processed 5.45 million rows, 86.82 MB (13.87 million rows/s., 221.01 MB/s.)
Peak memory usage: 1.38 GiB.

결과를 반환하는 데 그리 오래 걸리지 않지만, 더 빠르고 계산 비용이 더 낮기를 원한다고 가정해 봅시다. 이 데이터셋이 지속적으로 업데이트된다고 상상해 보세요. 영화가 끊임없이 개봉되고 새 배우와 감독도 등장합니다.

리프레시 가능 머티얼라이즈드 뷰를 쓸 때이므로, 먼저 결과용 타깃 테이블을 만듭시다:

CREATE TABLE imdb.actor_summary
(
        `id` UInt32,
        `name` String,
        `num_movies` UInt16,
        `avg_rank` Float32,
        `unique_genres` UInt16,
        `uniq_directors` UInt16,
        `updated_at` DateTime
)
ENGINE = MergeTree
ORDER BY num_movies

이제 뷰를 정의할 수 있습니다:

CREATE MATERIALIZED VIEW imdb.actor_summary_mv
REFRESH EVERY 1 MINUTE TO imdb.actor_summary AS
SELECT
        id,
        any(actor_name) AS name,
        uniqExact(movie_id) AS num_movies,
        avg(rank) AS avg_rank,
        uniqExact(genre) AS unique_genres,
        uniqExact(director_name) AS uniq_directors,
        max(created_at) AS updated_at
FROM
(
        SELECT
        imdb.actors.id AS id,
        concat(imdb.actors.first_name, ' ', imdb.actors.last_name) AS actor_name,
        imdb.movies.id AS movie_id,
        imdb.movies.rank AS rank,
        genre,
        concat(imdb.directors.first_name, ' ', imdb.directors.last_name) AS director_name,
        created_at
        FROM imdb.actors
    INNER JOIN imdb.roles ON imdb.roles.actor_id = imdb.actors.id
    LEFT JOIN imdb.movies ON imdb.movies.id = imdb.roles.movie_id
    LEFT JOIN imdb.genres ON imdb.genres.movie_id = imdb.movies.id
    LEFT JOIN imdb.movie_directors ON imdb.movie_directors.movie_id = imdb.movies.id
    LEFT JOIN imdb.directors ON imdb.directors.id = imdb.movie_directors.director_id
)
GROUP BY id
ORDER BY num_movies DESC;

뷰는 즉시 실행되고, 소스 테이블의 업데이트가 반영되도록 구성된 대로 이후 매분 실행됩니다. 배우 요약을 얻는 이전 쿼리는 문법적으로 더 단순해지고 훨씬 빨라집니다!

SELECT *
FROM imdb.actor_summary
ORDER BY num_movies DESC
LIMIT 5
┌─────id─┬─name─────────┬─num_movies─┬──avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
│  45332 │ Mel Blanc    │        909 │ 5.7884793 │            19 │            148 │ 2024-11-11 12:01:35 │
│ 621468 │ Bess Flowers │        672 │  5.540605 │            20 │            301 │ 2024-11-11 12:01:35 │
│ 283127 │ Tom London   │        549 │ 2.8057034 │            18 │            208 │ 2024-11-11 12:01:35 │
│ 356804 │ Bud Osborne  │        544 │ 1.9575342 │            16 │            157 │ 2024-11-11 12:01:35 │
│  41669 │ Adoor Bhasi  │        544 │         0 │             4 │            121 │ 2024-11-11 12:01:35 │
└────────┴──────────────┴────────────┴───────────┴───────────────┴────────────────┴─────────────────────┘

5 rows in set. Elapsed: 0.007 sec.

소스 데이터에 영화에 많이 출연한 "Clicky McClickHouse"라는 새 배우를 추가해 봅시다!

INSERT INTO imdb.actors VALUES (845466, 'Clicky', 'McClickHouse', 'M');
INSERT INTO imdb.roles SELECT
        845466 AS actor_id,
        id AS movie_id,
        'Himself' AS role,
        now() AS created_at
FROM imdb.movies
LIMIT 10000, 910;

60초도 안 되어 우리의 타깃 테이블이 Clicky의 왕성한 연기 경력을 반영하도록 업데이트됩니다:

SELECT *
FROM imdb.actor_summary
ORDER BY num_movies DESC
LIMIT 5;
┌─────id─┬─name────────────────┬─num_movies─┬──avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
│ 845466 │ Clicky McClickHouse │        910 │ 1.4687939 │            21 │            662 │ 2024-11-11 12:53:51 │
│  45332 │ Mel Blanc           │        909 │ 5.7884793 │            19 │            148 │ 2024-11-11 12:01:35 │
│ 621468 │ Bess Flowers        │        672 │  5.540605 │            20 │            301 │ 2024-11-11 12:01:35 │
│ 283127 │ Tom London          │        549 │ 2.8057034 │            18 │            208 │ 2024-11-11 12:01:35 │
│  41669 │ Adoor Bhasi         │        544 │         0 │             4 │            121 │ 2024-11-11 12:01:35 │
└────────┴─────────────────────┴────────────┴───────────┴───────────────┴────────────────┴─────────────────────┘

5 rows in set. Elapsed: 0.006 sec.

더 알아보기 (Learn more)