학습 목표
이 포스트를 마치면 다음을 할 수 있습니다:
- 쿼리 성능 병목 지점 식별
- 효과적인 캐싱 전략 수립
- 데이터베이스 인덱스 활용
- 쿼리 최적화 기법 적용
- 대용량 데이터 처리 전략
- 성능 모니터링 및 개선
성능 문제 진단
성능 지표
graph TD
A[Performance Metrics] --> B[Query Time]
A --> C[Load Time]
A --> D[Resource Usage]
B --> B1[< 1s: 빠름]
B --> B2[1-3s: 보통]
B --> B3[> 3s: 느림]
C --> C1[Dashboard 로딩]
C --> C2[Question 렌더링]
D --> D1[CPU]
D --> D2[Memory]
D --> D3[Network]
성능 병목 지점
1. 느린 쿼리
1
2
3
4
5
| -- 문제: 전체 테이블 스캔
SELECT * FROM orders
WHERE YEAR(created_at) = 2025
-- Execution time: 15 seconds (10M rows)
|
2. 대시보드 과부하
1
2
3
| Dashboard with 20 questions
각 Question 2초씩
Total: 40초 로딩 시간
|
3. 캐시 미사용
1
2
3
| Same query 100 times today
매번 데이터베이스 조회
→ 불필요한 부하
|
캐싱 전략
graph LR
A[User Request] --> B{Cache Exists?}
B -->|Yes| C[Return Cached]
B -->|No| D[Query Database]
D --> E[Store in Cache]
E --> F[Return Result]
C --> G[Fast: <100ms]
F --> H[Slow: 1-10s]
캐싱 설정
Global Settings:
1
2
3
4
5
6
7
8
9
10
11
12
13
| Admin > Settings > Caching
Query caching:
☑ Enable caching
Minimum query duration: 1 second
(1초 이상 걸리는 쿼리만 캐싱)
Maximum cache entry age: 24 hours
(24시간 후 자동 삭제)
Cache invalidation:
○ Manual
● Automatic (TTL based)
○ Database triggers
|
Database-level Caching:
1
2
3
4
5
6
7
8
| Admin > Databases > Your Database > Caching
Cache TTL: 1 hour (3600 seconds)
Schedule:
- Heavy queries: 24 hours
- Real-time data: 5 minutes
- Static reference data: 7 days
|
Question-level Caching:
1
2
3
4
5
6
7
8
9
10
11
12
| Question > ... menu > Caching
Cache results for:
○ Use database default (1 hour)
● Custom duration: 15 minutes
○ Don't cache
Use case:
- Real-time dashboard: 1-5 minutes
- Hourly report: 1 hour
- Daily report: 24 hours
- Weekly report: 7 days
|
사전 캐싱 (Preemptive Caching) - v0.53+:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| Admin > Settings > Caching > Preemptive Caching
☑ Enable preemptive caching
작동 방식:
1. 대시보드 사용 패턴 자동 학습
2. 인기 파라미터 조합 상위 10개 식별
3. 사용자가 요청하기 전에 미리 캐시 생성
4. 백그라운드에서 자동 갱신
효과:
- 대시보드 로딩 시간 50-80% 단축
- 데이터베이스 부하 감소
- 사용자 경험 대폭 개선
예시:
Dashboard: Sales Overview
학습된 인기 조합:
1. date_range=last_7_days, region=all
2. date_range=last_30_days, region=all
3. date_range=last_7_days, region=west
...
→ 이 조합들은 자동으로 미리 캐시됨
|
캐싱 전략 패턴
패턴 1: 계층별 캐싱
1
2
3
4
5
6
7
8
9
10
11
| Layer 1: Hot Data (자주 조회)
- Cache TTL: 5 minutes
- Examples: Today's revenue, Active users
Layer 2: Warm Data (정기 조회)
- Cache TTL: 1 hour
- Examples: Weekly trends, Department KPIs
Layer 3: Cold Data (가끔 조회)
- Cache TTL: 24 hours
- Examples: Historical reports, Annual stats
|
패턴 2: 시간대별 캐싱
1
2
3
4
5
6
7
8
9
10
11
| Business hours (9 AM - 6 PM):
- Short TTL: 5-15 minutes
- 자주 업데이트되는 데이터
Off hours (6 PM - 9 AM):
- Long TTL: 1-4 hours
- 변경 적음, 트래픽 적음
Weekends:
- Very long TTL: 12-24 hours
- 거의 변경 없음
|
패턴 3: 데이터 타입별 캐싱
1
2
3
4
5
6
7
8
9
10
11
| Transactional data (주문, 결제):
- TTL: 1-5 minutes
- 실시간성 중요
Analytical data (집계, 트렌드):
- TTL: 15-60 minutes
- 정확도보다 속도
Reference data (카테고리, 지역):
- TTL: 24 hours - 7 days
- 거의 변경 안 됨
|
수동 캐시 관리
캐시 초기화:
1
2
3
4
5
6
7
8
9
10
11
| Admin > Troubleshooting > Tools
Clear cache:
☑ Query results cache
☑ Database schema cache
☑ Field values cache
Reason to clear:
- 데이터 스키마 변경
- 잘못된 결과 캐싱
- 새 데이터 즉시 반영 필요
|
Question별 캐시 무효화:
1
2
3
4
| Question > ... menu > Refresh cache
또는 API:
POST /api/card/:id/refresh
|
쿼리 최적화
1. WHERE 절 최적화
❌ 나쁜 예:
1
2
3
4
5
6
| SELECT * FROM orders
WHERE YEAR(created_at) = 2025
AND MONTH(created_at) = 3
-- 문제: 함수 사용으로 인덱스 사용 불가
-- Execution plan: Full table scan
|
✅ 좋은 예:
1
2
3
4
5
6
| SELECT * FROM orders
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01'
-- 인덱스 사용 가능
-- Execution plan: Index scan
|
2. JOIN 최적화
❌ 나쁜 예:
1
2
3
4
5
6
7
8
9
| SELECT
o.*,
c.*,
p.*
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
LEFT JOIN products p ON o.product_id = p.id
-- 문제: 불필요한 컬럼, LEFT JOIN 남용
|
✅ 좋은 예:
1
2
3
4
5
6
7
8
9
10
11
12
| SELECT
o.id,
o.created_at,
o.total,
c.name as customer_name,
p.name as product_name
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
INNER JOIN products p ON o.product_id = p.id
WHERE o.created_at >= '2025-01-01'
-- 필요한 컬럼만, INNER JOIN 사용, 먼저 필터링
|
3. 집계 최적화
❌ 나쁜 예:
1
2
3
4
5
6
7
8
| SELECT
customer_id,
(SELECT SUM(total) FROM orders o2
WHERE o2.customer_id = o.customer_id) as total_spent
FROM orders o
GROUP BY customer_id
-- 문제: Correlated subquery (N+1 problem)
|
✅ 좋은 예:
1
2
3
4
5
6
7
| SELECT
customer_id,
SUM(total) as total_spent
FROM orders
GROUP BY customer_id
-- 단일 패스로 집계
|
4. DISTINCT 최적화
❌ 나쁜 예:
1
2
3
4
5
6
7
8
9
| SELECT DISTINCT
o.id,
o.created_at,
o.total,
c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
-- DISTINCT는 비용이 높음
|
✅ 좋은 예:
1
2
3
4
5
6
7
8
9
| SELECT
o.id,
o.created_at,
o.total,
c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
-- 중복이 없도록 쿼리 설계
|
5. LIMIT 활용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| -- Development/Testing
SELECT * FROM large_table
LIMIT 1000
-- Dashboard preview
SELECT * FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'
LIMIT 10000
-- Top N 쿼리
SELECT *
FROM products
ORDER BY sales DESC
LIMIT 10
|
데이터베이스 인덱스
인덱스 필요성 판단
1
2
3
4
5
6
7
8
9
10
11
12
| 인덱스가 필요한 컬럼:
✅ WHERE 절에 자주 사용
✅ JOIN 조건에 사용
✅ ORDER BY에 사용
✅ GROUP BY에 사용
✅ Cardinality가 높음 (고유 값 많음)
인덱스가 불필요한 컬럼:
❌ SELECT만 하는 컬럼
❌ Cardinality가 낮음 (true/false 같은)
❌ 자주 UPDATE되는 컬럼
❌ 작은 테이블 (< 1000 rows)
|
인덱스 생성
단일 컬럼 인덱스:
1
2
3
4
5
6
7
| -- 날짜 필터링이 많은 경우
CREATE INDEX idx_orders_created_at
ON orders(created_at);
-- 고객별 조회가 많은 경우
CREATE INDEX idx_orders_customer_id
ON orders(customer_id);
|
복합 인덱스:
1
2
3
4
5
6
7
8
9
| -- 날짜 + 상태 필터링
CREATE INDEX idx_orders_date_status
ON orders(created_at, status);
-- 사용 예:
SELECT * FROM orders
WHERE created_at >= '2025-01-01'
AND status = 'completed'
-- 이 쿼리는 복합 인덱스를 활용
|
인덱스 순서 최적화:
1
2
3
4
5
6
7
8
9
10
11
| -- ✅ 좋은 순서 (카디널리티 높은 것 먼저)
CREATE INDEX idx_orders_customer_date
ON orders(customer_id, created_at);
-- customer_id: 10K unique values
-- created_at: 365 unique values
-- ❌ 나쁜 순서
CREATE INDEX idx_orders_date_customer
ON orders(created_at, customer_id);
-- 덜 효율적
|
인덱스 모니터링
사용되지 않는 인덱스 찾기:
1
2
3
4
5
6
7
8
9
10
11
12
13
| -- PostgreSQL
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY tablename;
-- 사용 안 되는 인덱스는 삭제 고려
|
인덱스 효과 측정:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| -- Before index
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 12345;
-- Seq Scan on orders (cost=0.00..10000.00 rows=100)
-- Execution time: 150 ms
-- After index
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 12345;
-- Index Scan using idx_orders_customer_id (cost=0.43..8.45 rows=100)
-- Execution time: 5 ms
-- 30배 빨라짐!
|
대용량 데이터 처리
1. 샘플링
전체 데이터 대신 샘플 사용:
1
2
3
4
5
6
7
8
9
| -- PostgreSQL: TABLESAMPLE
SELECT * FROM large_table
TABLESAMPLE BERNOULLI (10)
-- 10% 샘플링
-- MySQL: RAND()
SELECT * FROM large_table
WHERE RAND() < 0.1
LIMIT 100000
|
2. 집계 테이블 (Materialized Views)
문제:
1
2
3
4
5
6
7
8
| -- 매번 10M rows 집계
SELECT
DATE(created_at) as date,
SUM(total) as revenue
FROM orders
GROUP BY DATE(created_at)
-- 10초 소요
|
해결: Materialized View:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| -- 매일 1회 집계 (cron job)
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT
DATE(created_at) as date,
SUM(total) as revenue,
COUNT(*) as order_count
FROM orders
GROUP BY DATE(created_at);
CREATE INDEX idx_daily_revenue_date ON daily_revenue(date);
-- 사용
SELECT * FROM daily_revenue
WHERE date >= '2025-01-01';
-- 0.1초 소요 (100배 빠름!)
|
자동 갱신:
1
2
3
4
5
| -- PostgreSQL: Manual refresh
REFRESH MATERIALIZED VIEW daily_revenue;
-- Cron job (매일 2 AM)
0 2 * * * psql -c "REFRESH MATERIALIZED VIEW daily_revenue"
|
3. 파티셔닝
시간 기반 파티셔닝:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| -- PostgreSQL: Declarative partitioning
CREATE TABLE orders (
id BIGINT,
created_at TIMESTAMP,
total DECIMAL
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE orders_2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
-- Query only scans relevant partition
SELECT * FROM orders
WHERE created_at >= '2025-01-01';
-- Only scans orders_2025 partition
|
4. 점진적 로딩
Infinite Scroll 대신 Pagination:
1
2
3
4
5
6
7
8
9
10
| -- Page 1
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 100;
-- Page 2 (cursor-based)
SELECT * FROM orders
WHERE id <
ORDER BY created_at DESC
LIMIT 100;
|
1. Connection Pooling
1
2
3
4
5
6
7
8
9
10
11
12
13
| Admin > Databases > Your Database
Connection pool settings:
Min pool size: 2
Max pool size: 10
Connection timeout: 30 seconds
Idle timeout: 10 minutes
Explanation:
- Min: 항상 유지할 연결 수
- Max: 최대 동시 연결 수
- Connection timeout: 연결 대기 시간
- Idle timeout: 미사용 연결 종료 시간
|
2. Query Timeouts
1
2
3
4
5
6
7
| Admin > Settings > General
Query timeout: 60 seconds
Individual question override:
Question > ... menu > Edit > Advanced
Custom timeout: 120 seconds (for heavy queries)
|
3. Sync Frequency
1
2
3
4
5
6
7
8
9
| Admin > Databases > Your Database > Scheduling
Database syncing:
- Scan for new tables: Daily
- Scan for field values: Hourly
Recommendation:
- Production DB: Daily (새 테이블 드물게 추가)
- Development DB: Hourly (자주 변경됨)
|
4. Resource Limits
1
2
3
4
5
6
7
8
9
| Admin > Settings > General
Max results:
- GUI builder: 2000 rows
- Downloaded results: 1 million rows
Memory limits:
- JVM heap size: 4 GB (Metabase 설정)
- Query result buffer: 1 GB
|
성능 모니터링
1. 슬로우 쿼리 로그
PostgreSQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| -- postgresql.conf
log_min_duration_statement = 1000 # 1초 이상
-- 로그 분석
SELECT
query,
calls,
total_time,
mean_time,
max_time
FROM pg_stat_statements
WHERE mean_time > 1000
ORDER BY mean_time DESC
LIMIT 20;
|
MySQL:
1
2
3
4
5
6
7
8
| -- my.cnf
slow_query_log = 1
long_query_time = 1
-- 슬로우 쿼리 확인
SELECT * FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 20;
|
1
2
3
4
5
6
7
8
9
10
| Admin > Troubleshooting > Logs
Query execution log:
- Question ID
- Execution time
- Row count
- Cache hit/miss
- Error messages
Export to CSV for analysis
|
3. 커스텀 모니터링 대시보드
1
2
3
4
5
6
7
8
9
10
11
| -- Metabase database의 query_execution 테이블
SELECT
DATE(started_at) as date,
AVG(running_time) as avg_execution_time,
MAX(running_time) as max_execution_time,
COUNT(*) as query_count,
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) as error_count
FROM query_execution
WHERE started_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY DATE(started_at)
ORDER BY date DESC;
|
4. Alerts 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| Alert: Slow Query Detected
Question:
SELECT COUNT(*) FROM query_execution
WHERE running_time > 10000 -- 10초 이상
AND started_at >= NOW() - INTERVAL '1 hour'
Condition: > 5 (1시간에 5건 이상)
Recipients: DBA team
Action:
- 해당 쿼리 식별
- EXPLAIN ANALYZE 실행
- 인덱스 또는 쿼리 최적화
|
성능 최적화 체크리스트
쿼리 레벨
- WHERE 절에 함수 사용 제거
- 필요한 컬럼만 SELECT
- INNER JOIN 우선 사용
- Subquery 대신 JOIN 사용
- LIMIT으로 결과 제한
- DISTINCT 대신 정확한 JOIN
데이터베이스 레벨
- 자주 사용하는 컬럼에 인덱스
- 복합 인덱스 순서 최적화
- 미사용 인덱스 제거
- 통계 정보 업데이트
- VACUUM (PostgreSQL)
- ANALYZE (주기적 실행)
- 적절한 캐싱 TTL 설정
- Connection pool 튜닝
- Query timeout 설정
- Sync frequency 조정
- 불필요한 Question/Dashboard 제거
아키텍처 레벨
- Materialized views for 집계
- Partitioning for 대용량 테이블
- Read replicas for 분석
- CDN for static assets
- Load balancer for 트래픽 분산
실전 연습 문제
연습 1: 느린 쿼리 최적화 (초급)
문제:
1
2
3
4
| -- 현재 쿼리 (15초 소요)
SELECT * FROM orders
WHERE MONTH(created_at) = 3
AND YEAR(created_at) = 2025
|
해답 보기
1
2
3
4
5
6
7
8
9
10
11
12
| -- 최적화된 쿼리 (0.5초)
SELECT * FROM orders
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01'
-- 인덱스 생성
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- 성능 개선:
-- Before: Full table scan (15초)
-- After: Index range scan (0.5초)
-- 30배 빨라짐
|
연습 2: 집계 테이블 구축 (중급)
과제: 일일 매출 리포트가 매번 10초 걸림. 최적화하기.
해답 보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| -- 1. Materialized View 생성
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT
DATE(created_at) as sale_date,
COUNT(*) as order_count,
SUM(total) as total_revenue,
AVG(total) as avg_order_value,
COUNT(DISTINCT customer_id) as unique_customers
FROM orders
WHERE status = 'completed'
GROUP BY DATE(created_at);
-- 2. 인덱스 생성
CREATE INDEX idx_daily_sales_date ON daily_sales_summary(sale_date);
-- 3. 자동 갱신 (Cron job: 매일 2 AM)
#!/bin/bash
# refresh_daily_sales.sh
psql -U metabase -d production <<EOF
REFRESH MATERIALIZED VIEW daily_sales_summary;
EOF
-- 4. Metabase Model 생성
-- Model: "Daily Sales Summary"
-- Data source: daily_sales_summary view
-- Update: Daily at 2 AM
-- 5. 캐싱 설정
-- Cache TTL: 24 hours
-- 이유: 하루 1번만 갱신되므로
-- 성능 결과:
-- Before: 10초 (1000만 rows 집계)
-- After: 0.1초 (365 rows 조회)
-- 100배 빨라짐!
|
연습 3: 대시보드 최적화 (고급)
과제: 20개 Question이 있는 대시보드가 40초 걸림. 최적화하기.
해답 보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
| 문제 분석:
- 20 Questions × 2초 = 40초
- 많은 Question이 같은 데이터 조회
- 캐싱 미사용
- 불필요한 Question 포함
최적화 전략:
1. Base Model 생성:
-- Model: "Orders Last 30 Days"
SELECT
id,
created_at,
total,
customer_id,
product_id,
status
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
AND status = 'completed'
-- 캐싱: 1 hour
-- 이유: 여러 Question이 공유
2. Question 재구성:
Before:
- Q1: SELECT ... FROM orders WHERE date ...
- Q2: SELECT ... FROM orders WHERE date ...
- Q3: SELECT ... FROM orders WHERE date ...
(각각 별도 쿼리, 캐시 공유 안 됨)
After:
- Q1: SELECT ... FROM "Orders Last 30 Days"
- Q2: SELECT ... FROM "Orders Last 30 Days"
- Q3: SELECT ... FROM "Orders Last 30 Days"
(모두 같은 Model 사용, 캐시 공유)
3. Question 통합:
Before:
- Total Revenue (Question 1)
- Order Count (Question 2)
- Avg Order Value (Question 3)
After:
- KPI Summary (하나의 Question에 3 metrics)
4. Lazy Loading:
<!-- 보이는 Question만 먼저 로드 -->
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadQuestion(entry.target.dataset.questionId);
}
});
});
</script>
5. 불필요한 Question 제거:
Before: 20 Questions
Analysis:
- 5 Questions: 한 달간 조회 0회 → 삭제
- 3 Questions: 중복 정보 → 통합
- 2 Questions: 너무 느림 → 최적화 또는 제거
After: 10 Questions
6. 캐싱 계층화:
Hot Questions (자주 조회):
- Cache TTL: 5 minutes
- Examples: Today's revenue, Active orders
Warm Questions:
- Cache TTL: 1 hour
- Examples: Weekly trends
Cold Questions:
- Cache TTL: 24 hours
- Examples: Monthly comparisons
최종 결과:
- Before: 40초 (20 questions × 2초)
- After: 3초
• Base Model 조회: 1초 (캐시 사용)
• 10 Questions 렌더링: 2초
- 13배 빨라짐!
추가 개선:
- Paginate long tables
- Limit chart data points (max 100)
- Use PNG instead of SVG for static charts
- Enable dashboard filters (사용자가 조정 가능)
|
다음 단계
성능 최적화를 마스터했습니다. 다음 포스트에서는:
- 멀티 데이터소스: 여러 데이터베이스 통합
- Cross-database joins: 다른 DB 간 조인
- Data federation: 통합 분석 환경
요약
성능 최적화 우선순위
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 1. Quick Wins (즉시 효과):
- 캐싱 활성화
- LIMIT 추가
- 불필요한 컬럼 제거
2. Medium Effort (중간 노력):
- 인덱스 생성
- 쿼리 리팩토링
- Question 통합
3. Long-term (장기 투자):
- Materialized views
- Partitioning
- Read replicas
|
성능 목표
| 메트릭 | 목표 | 우수 | 최적 |
| Query time | < 3s | < 1s | < 0.5s |
| Dashboard load | < 10s | < 5s | < 3s |
| Cache hit rate | > 50% | > 70% | > 90% |
| Error rate | < 1% | < 0.1% | < 0.01% |
다음 포스트에서는 여러 데이터베이스를 통합하여 분석하는 방법을 배웁니다!
📚 시리즈 전체 목차
🚀 기초편 (1-5화)
- Metabase 소개와 핵심 개념
- 설치와 초기 설정
- 샘플 데이터 둘러보기
- 첫 차트 만들기 - 실습 완전정복
- 대시보드 만들기 - 한 화면에 모으기
💪 활용편 (6-10화)
- 필터와 파라미터
- SQL 네이티브 쿼리
- 데이터 모델링
- 자동화와 알림
- 권한과 보안
🎯 고급편 (11-16화)
- 임베딩과 공유
- [성능 최적화] (현재 글)
- 멀티 데이터소스
- 커스터마이징
- 운영과 모니터링
- 실전 프로젝트