데이터베이스 수준 캐싱과 Materialized Views 및 Summary Tables: 사전 계산된 진리의 예술
Source: Dev.to
위에 제공된 Source 라인 외에 번역할 본문이 포함되어 있지 않습니다. 번역을 원하는 텍스트(본문)를 제공해 주시면 한국어로 번역해 드리겠습니다.
배경
제가 잊고 싶은 화요일 오후를 되돌려 보겠습니다.
우리는 5년 동안 점점 커지고 풍성해진 Rails 모놀리스를 가지고 있었습니다. 대시보드—아름답고 차트가 가득한 거대한 화면—은 CEO가 **“새로 고침”**을 클릭할 때마다 12초짜리 쿼리를 실행했습니다. 백만 행 규모의 events 테이블을 가로지르는 GROUP BY, COUNT(DISTINCT), LEFT JOIN 지옥이 12초나 걸렸습니다.
CEO는 소리치지 않았습니다. 그는 회전하는 커서를 바라보며 “예전엔 빨랐었는데.” 라고 말했습니다. 그 침묵이 더 끔찍했습니다.
저는 이미 모든 방법을 시도해 보았습니다.
- Redis 캐시? 첫 로드 시 오래된 데이터가 나옵니다.
- 카운터 캐시? 카운트에는 좋지만 복잡한 롤업에는 무용지물입니다.
- 페이지네이션? 대시보드에는 전체 합계가 필요합니다.
새벽 2시에 천장을 바라보며 저는 속삭였습니다: “그냥… 답을 미리 계산하면 어떨까?”
물리화된 뷰와 요약 테이블은 새로운 것이 아닙니다—데이터 웨어하우스만큼 오래된 개념이죠. 하지만 Rails에서 ActiveRecord의 객체‑관계 매핑이 우리의 모든 생각을 지배할 때, 우리는 데이터베이스 자체가 캐시가 될 수 있다는 사실을 잊어버립니다: 거짓말을 하지 않는, 스마트하고 트랜잭션을 지원하며 ACID‑준수를 보장하는 캐시 말이죠.
이것은 객체가 아니라 집합으로 사고하는 법을 배우는 여정입니다. N+1 쿼리를 최적화해 온 시니어 Rails 개발자 여러분: 여러분의 다음 경계는 미리 계산된 컬럼입니다.
우리가 스스로에게 하는 거짓말: “인덱스만 있으면 충분해”
우리는 모든 것에 인덱스를 던집니다—복합, 파셜, 표현식 기반 인덱스까지. 그리고 인덱스는 마법과 같습니다—그렇지 않을 때까지. 쿼리가 수백만 행을 집계할 때, 데이터베이스는 여전히 그 행들을 읽어야 합니다. 커버링 인덱스가 있더라도, 행당, 요청당 작업을 수행하게 됩니다.
그 CEO 대시보드에서 EXPLAIN ANALYZE를 실행했던 기억이 납니다:
Aggregate (cost=12483.67..12483.68 rows=1 width=32)
-> Seq Scan on events (cost=0.00..10483.33 rows=400068 width=32)
Filter: (created_at > '2024-01-01')
Seq scan. 사십만 행. 매. 한. 요청마다.
인덱스 덕분에 스캔이 인덱스 스캔으로 줄어들었지만, 집계는 여전히 수십만 개의 인덱스 엔트리를 순회했습니다. 데이터베이스는 같은 작업을 반복해서 수행하고 있었습니다—마치 요리사가 매번 오믈렛을 만들 때마다 치즈 블록을 갈아 넣는 것처럼, 아침에 미리 갈아 둔 그릇 대신에 말이죠.
그때 나는 materialized views를 발견했습니다: 미리 갈아 둔 치즈 그릇.
첫 번째 단계: 물리화된 뷰를 무거운 짐꾼으로 활용하기
물리화된 뷰는 결과가 디스크에 물리적으로 저장되는 쿼리입니다. 일정에 따라 또는 관련 데이터가 변경된 후에 새로 고칩니다. 읽기는 즉시—초가 아니라 밀리초 단위입니다.
다음은 우리 CEO 대시보드를 구해준 예시입니다:
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT
date(created_at) AS day,
product_id,
COUNT(*) AS units_sold,
SUM(amount_cents) AS revenue_cents,
COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
WHERE status = 'completed'
GROUP BY day, product_id;
Rails에서:
class DailySalesSummary { where(day: 30.days.ago..Date.today) }
end
# Dashboard query becomes:
revenue = DailySalesSummary.recent.sum(:revenue_cents)
12초에서 42 ms로 감소했습니다. CEO의 커서가 멈췄고, 나는 마법사 같은 기분이었습니다.
하지만 물리화된 뷰에는 저주가 있습니다: 데이터 신선도 저하. 데이터는 마지막 REFRESH 시점만큼만 최신입니다. 우리는 매시간 실행되는 크론 작업으로 시작했는데—대시보드에는 괜찮지만 실시간 리더보드에는 적합하지 않았습니다.
그때 나는 증분 새로 고침(REFRESH MATERIALIZED VIEW CONCURRENTLY in PostgreSQL 14+)과 요약 테이블의 기술에 대해 알게 되었습니다.
요약 테이블의 예술: 듣고, 그 다음 업데이트
요약 테이블(또는 집계 테이블)은 트리거나 ActiveRecord 콜백으로 유지 관리하는 일반 PostgreSQL 테이블입니다. 증분 방식으로 업데이트되는 물리화된 뷰이며, 변경된 행만 업데이트합니다.
우리는 게임화 기능을 위해 하나를 만들었습니다: 수십 가지 행동(댓글, 좋아요, 공유)에서 발생하는 사용자 포인트. 원시 user_actions 테이블은 하루에 50 천 행씩 증가했습니다. 실시간 리더보드 쿼리가 우리를 괴롭히고 있었습니다.
마이그레이션
# db/migrate/create_user_points_summaries.rb
create_table :user_points_summaries, id: false do |t|
t.integer :user_id, null: false
t.integer :total_points, default: 0
t.integer :daily_points, default: 0
t.integer :weekly_points, default: 0
t.datetime :last_calculated_at
t.timestamps
end
add_index :user_points_summaries, :user_id, unique: true
증분 업데이트
class UserAction < ApplicationRecord
after_create_commit :update_summary
private
def update_summary
summary = UserPointsSummary.find_or_initialize_by(user_id: user_id)
summary.lock!
summary.total_points += point_value
summary.daily_points = calculate_daily_points
summary.weekly_points = calculate_weekly_points
summary.last_calculated_at = Time.current
summary.save!
end
end
lock이 보이시나요? 맞습니다. 같은 사용자의 두 동시 작업은 주의하지 않으면 교착 상태가 발생합니다. 우리는 SELECT … FOR UPDATE를 사용해 사용자별 업데이트를 직렬화했습니다. 단일 사용자의 행동은 드물기 때문에 괜찮지만, 전역 집계의 경우에는 다른 패턴(예: 대기열 작업)이 필요합니다.
요약 테이블의 장점은? 항상 최신이라는 점입니다. 모든 쓰기 작업이 증분 업데이트를 트리거합니다. 읽기는 *O(1)*입니다. 데이터베이스가 물리화된 스트림이 됩니다.
도전 과제: 원자성 유지
요약 테이블을 유지하면 불일치의 위험이 생깁니다. after_create_commit 콜백이 실패하면 어떻게 될까요? 요약 업데이트는 성공했지만 원래 작업이 롤백되면 어떻게 될까요? 해결책은 원본 쓰기 와 요약 업데이트를 같은 트랜잭션에 묶는 것이거나, 실패 시 재시도하는 밴드 외 처리(예: 백그라운드 작업)를 사용하는 것입니다.
실제 예시:
class UserAction < ApplicationRecord
after_commit :queue_summary_update, on: :create
private
def queue_summary_update
SummaryUpdateJob.perform_later(id)
end
end
작업은 별도의 트랜잭션에서 실행되어 UserAction 행이 요약 변형을 시도하기 전에 영구 저장됨을 보장합니다. 작업이 실패하면 Sidekiq(또는 사용 중인 큐)가 재시도하여 부분적인 쓰기 위험 없이 최종 일관성을 유지합니다.
요점
- 인덱스는 무조건적인 해결책이 아니다 무거운 집계에 대해.
- **물리화된 뷰(materialized view)**는 신선도 저하를 대가로 즉시 읽을 수 있게 해준다.
- **요약 테이블(summary table)**은 실시간 신선도가 필요할 때 제공하지만, 트랜잭션 처리를 신중히 해야 한다.
- 집합 기반(set‑based) 접근을 먼저 생각하고, 데이터베이스의 강점을 모두 활용한 뒤에야 객체 수준의 트릭을 사용하라.
다음에 대시보드가 몇 분씩 돌아갈 때는 데이터베이스도 캐시가 될 수 있다는 점을 기억하라. 🚀
class UserAction < ApplicationRecord
after_create_commit :schedule_summary_refresh
def schedule_summary_refresh
# Non‑critical: use a background job with idempotency key
RefreshUserPointsJob.perform_later(user_id, self.id)
end
end
class RefreshUserPointsJob < ApplicationJob
def perform(user_id, action_id = nil)
# Recalculate from scratch for this user using the raw table
# Idempotent and safe, even if called multiple times
totals = UserAction.where(user_id: user_id)
.group("date(created_at)")
.sum(:point_value)
UserPointsSummary.upsert(
{ user_id: user_id, total_points: totals.values.sum, ... },
unique_by: :user_id
)
end
end
이는 실시간성을 포기하고 최종 일관성(eventual consistency)을 얻는 트레이드오프이다. 리더보드에는 괜찮지만, CEO 대시보드에서는 시간 단위 물리화 뷰를 사용했다.
예술은 어떤 전투를 선택해 싸울지 아는 것이다.
실제 마법: 두 세계 결합
2년간 다듬은 끝에, 이제 데이터베이스 내부에 3단계 캐싱 전략을 갖게 되었습니다:
| 계층 | 기법 | 신선도 | 사용 사례 |
|---|---|---|---|
| L1 | In‑memory (Rails cache) | 초 | 사용자별, 핫 |
| L2 | Summary table (trigger‑updated) | 밀리초 수준 | 실시간 카운터 |
| L3 | Materialized view (scheduled refresh) | 시간/일 | 분석 대시보드 |
이 여정을 시작한 대시보드는 이제 다음을 사용합니다:
- 일일 집계를 위한 물리화 뷰,
- “오늘까지”를 위한 요약 테이블, 그리고
- 30초마다 실시간 요약을 폴링하는 작은 JavaScript.
결과: 12초가 80 ms가 되었습니다. CEO는 이제 스피너를 보지도 않으며, 숫자를 그대로 신뢰합니다.
인간 교훈: 캐싱은 시간의 분류학
모든 것을 캐시할 수는 없습니다. 할 수 있는 일은 데이터가 얼마나 신선해야 하는지에 따라 분류하는 것입니다.
| 필요 신선도 | 권장 저장소 |
|---|---|
| 실시간 카운터 | 요약 테이블 |
| 어제의 수치 | 물리화된 뷰 |
| 작년 보고서 | 좋은 인덱스를 가진 일반 테이블 |
저는 이제 기본값으로 Redis를 찾지 않습니다. 때때로 가장 좋은 캐시는 이미 데이터베이스 안에 있는 캐시이며—트랜잭션, 일관성, 그리고 데이터 형태를 이해하는 캐시입니다.
물리화된 뷰와 요약 테이블은 구식처럼 보일 수 있습니다. 반짝거리는 것은 아니지만 신뢰할 수 있습니다. 너무 많은 캐시 레이어가 복잡성 때문에 무너지는 것을 본 시니어 Rails 엔지니어에게 그 신뢰성은 궁극적인 예술입니다.
이제 아름다운 것을 미리 계산해 보세요. 그리고 주니어가 “그냥 인덱스만 추가하면 안 될까요?”라고 물으면, CEO와 회전하는 커서에 대해 이야기해 주세요. 어떤 교훈은 직접 겪어야 합니다.