# Analytics Service 데이터베이스 설계서 ## 데이터설계 요약 ### 설계 개요 - **서비스명**: Analytics Service - **데이터베이스**: PostgreSQL 16.x (시계열 최적화) - **캐시 DB**: Redis 7.x (분석 결과 캐싱) - **아키텍처 패턴**: Layered Architecture - **데이터 특성**: 시계열 분석 데이터, 실시간 집계 데이터 ### 테이블 구성 | 테이블명 | 설명 | Entity 매핑 | 특징 | |---------|------|-------------|------| | event_stats | 이벤트별 통계 | EventStats | 집계 데이터, userId 인덱스 | | channel_stats | 채널별 성과 | ChannelStats | 외부 API 연동 데이터 | | timeline_data | 시계열 분석 | TimelineData | 시간 순서 데이터, 시계열 인덱스 | ### Redis 캐시 구조 | 키 패턴 | 설명 | TTL | |--------|------|-----| | `analytics:dashboard:{eventId}` | 대시보드 데이터 | 1시간 | | `analytics:channel:{eventId}:{channelName}` | 채널별 분석 | 1시간 | | `analytics:roi:{eventId}` | ROI 분석 | 1시간 | | `analytics:timeline:{eventId}:{granularity}` | 타임라인 데이터 | 1시간 | | `analytics:user:{userId}` | 사용자 통합 분석 | 1시간 | | `analytics:processed:{messageId}` | 멱등성 처리 (Set) | 24시간 | ### 데이터 접근 패턴 1. **이벤트별 조회**: eventId 기반 빠른 조회 (B-Tree 인덱스) 2. **사용자별 조회**: userId 기반 다중 이벤트 조회 3. **시계열 조회**: timestamp 범위 검색 (BRIN 인덱스) 4. **채널별 조회**: eventId + channelName 복합 인덱스 --- ## 1. 테이블 상세 설계 ### 1.1 event_stats (이벤트 통계) #### 테이블 설명 - **목적**: 이벤트별 통계 집계 데이터 관리 - **데이터 특성**: 실시간 업데이트, Kafka Consumer를 통한 증분 업데이트 - **조회 패턴**: eventId 단건 조회, userId 기반 목록 조회 #### 컬럼 정의 | 컬럼명 | 데이터 타입 | Null | 기본값 | 설명 | |--------|------------|------|-------|------| | id | BIGSERIAL | NOT NULL | AUTO | 기본 키 | | event_id | VARCHAR(36) | NOT NULL | - | 이벤트 ID (UUID) | | event_title | VARCHAR(255) | NOT NULL | - | 이벤트 제목 | | user_id | VARCHAR(36) | NOT NULL | - | 사용자 ID (UUID) | | total_participants | INTEGER | NOT NULL | 0 | 총 참여자 수 | | total_views | INTEGER | NOT NULL | 0 | 총 조회 수 | | estimated_roi | DECIMAL(10,2) | NOT NULL | 0.00 | 예상 ROI (%) | | target_roi | DECIMAL(10,2) | NOT NULL | 0.00 | 목표 ROI (%) | | sales_growth_rate | DECIMAL(10,2) | NOT NULL | 0.00 | 매출 성장률 (%) | | total_investment | DECIMAL(15,2) | NOT NULL | 0.00 | 총 투자 금액 (원) | | expected_revenue | DECIMAL(15,2) | NOT NULL | 0.00 | 예상 수익 (원) | | status | VARCHAR(20) | NOT NULL | 'ACTIVE' | 이벤트 상태 | | created_at | TIMESTAMP | NOT NULL | NOW() | 생성 시간 | | updated_at | TIMESTAMP | NOT NULL | NOW() | 수정 시간 | #### 인덱스 ```sql PRIMARY KEY (id) UNIQUE INDEX uk_event_stats_event_id (event_id) INDEX idx_event_stats_user_id (user_id) INDEX idx_event_stats_status (status) INDEX idx_event_stats_created_at (created_at DESC) ``` #### 제약 조건 ```sql CHECK (total_participants >= 0) CHECK (total_views >= 0) CHECK (estimated_roi >= 0) CHECK (target_roi >= 0) CHECK (total_investment >= 0) CHECK (expected_revenue >= 0) CHECK (status IN ('ACTIVE', 'ENDED', 'ARCHIVED')) ``` --- ### 1.2 channel_stats (채널 통계) #### 테이블 설명 - **목적**: 채널별 성과 데이터 관리 - **데이터 특성**: 외부 API 연동 데이터, Circuit Breaker 패턴 적용 - **조회 패턴**: eventId 기반 목록 조회, eventId + channelName 단건 조회 #### 컬럼 정의 | 컬럼명 | 데이터 타입 | Null | 기본값 | 설명 | |--------|------------|------|-------|------| | id | BIGSERIAL | NOT NULL | AUTO | 기본 키 | | event_id | VARCHAR(36) | NOT NULL | - | 이벤트 ID (UUID) | | channel_name | VARCHAR(50) | NOT NULL | - | 채널명 (WooriTV, GenieTV 등) | | channel_type | VARCHAR(20) | NOT NULL | - | 채널 타입 (TV, SNS, VOICE) | | impressions | INTEGER | NOT NULL | 0 | 노출 수 | | views | INTEGER | NOT NULL | 0 | 조회 수 | | clicks | INTEGER | NOT NULL | 0 | 클릭 수 | | participants | INTEGER | NOT NULL | 0 | 참여자 수 | | conversions | INTEGER | NOT NULL | 0 | 전환 수 | | distribution_cost | DECIMAL(15,2) | NOT NULL | 0.00 | 배포 비용 (원) | | likes | INTEGER | NOT NULL | 0 | 좋아요 수 (SNS) | | comments | INTEGER | NOT NULL | 0 | 댓글 수 (SNS) | | shares | INTEGER | NOT NULL | 0 | 공유 수 (SNS) | | total_calls | INTEGER | NOT NULL | 0 | 총 통화 수 (VOICE) | | completed_calls | INTEGER | NOT NULL | 0 | 완료 통화 수 (VOICE) | | average_duration | INTEGER | NOT NULL | 0 | 평균 통화 시간 (초, VOICE) | | created_at | TIMESTAMP | NOT NULL | NOW() | 생성 시간 | | updated_at | TIMESTAMP | NOT NULL | NOW() | 수정 시간 | #### 인덱스 ```sql PRIMARY KEY (id) UNIQUE INDEX uk_channel_stats_event_channel (event_id, channel_name) INDEX idx_channel_stats_event_id (event_id) INDEX idx_channel_stats_channel_type (channel_type) INDEX idx_channel_stats_participants (participants DESC) ``` #### 제약 조건 ```sql CHECK (impressions >= 0) CHECK (views >= 0) CHECK (clicks >= 0) CHECK (participants >= 0) CHECK (conversions >= 0) CHECK (distribution_cost >= 0) CHECK (total_calls >= 0) CHECK (completed_calls >= 0 AND completed_calls <= total_calls) CHECK (average_duration >= 0) CHECK (channel_type IN ('TV', 'SNS', 'VOICE')) ``` --- ### 1.3 timeline_data (시계열 데이터) #### 테이블 설명 - **목적**: 시간별 참여 추이 데이터 관리 - **데이터 특성**: 시계열 데이터, 누적 참여자 수 포함 - **조회 패턴**: eventId + timestamp 범위 조회 (시간 순서) #### 컬럼 정의 | 컬럼명 | 데이터 타입 | Null | 기본값 | 설명 | |--------|------------|------|-------|------| | id | BIGSERIAL | NOT NULL | AUTO | 기본 키 | | event_id | VARCHAR(36) | NOT NULL | - | 이벤트 ID (UUID) | | timestamp | TIMESTAMP | NOT NULL | - | 기록 시간 | | participants | INTEGER | NOT NULL | 0 | 해당 시간 참여자 수 | | views | INTEGER | NOT NULL | 0 | 해당 시간 조회 수 | | engagement | INTEGER | NOT NULL | 0 | 참여도 (상호작용 수) | | conversions | INTEGER | NOT NULL | 0 | 해당 시간 전환 수 | | cumulative_participants | INTEGER | NOT NULL | 0 | 누적 참여자 수 | | created_at | TIMESTAMP | NOT NULL | NOW() | 생성 시간 | | updated_at | TIMESTAMP | NOT NULL | NOW() | 수정 시간 | #### 인덱스 ```sql PRIMARY KEY (id) INDEX idx_timeline_event_timestamp (event_id, timestamp ASC) INDEX idx_timeline_timestamp (timestamp ASC) ``` **시계열 최적화**: BRIN 인덱스 사용 권장 (대량 시계열 데이터) ```sql CREATE INDEX idx_timeline_brin_timestamp ON timeline_data USING BRIN (timestamp); ``` #### 제약 조건 ```sql CHECK (participants >= 0) CHECK (views >= 0) CHECK (engagement >= 0) CHECK (conversions >= 0) CHECK (cumulative_participants >= 0) ``` --- ## 2. Redis 캐시 설계 ### 2.1 캐시 구조 #### 대시보드 캐시 ``` Key: analytics:dashboard:{eventId} Type: String (JSON) TTL: 3600초 (1시간) Value: { "eventId": "uuid", "eventTitle": "이벤트 제목", "summary": { ... }, "channelPerformance": [ ... ], "roi": { ... } } ``` #### 채널 분석 캐시 ``` Key: analytics:channel:{eventId}:{channelName} Type: String (JSON) TTL: 3600초 Value: { "channelName": "WooriTV", "metrics": { ... }, "performance": { ... } } ``` #### ROI 분석 캐시 ``` Key: analytics:roi:{eventId} Type: String (JSON) TTL: 3600초 Value: { "currentRoi": 15.5, "targetRoi": 20.0, "investment": { ... } } ``` #### 타임라인 캐시 ``` Key: analytics:timeline:{eventId}:{granularity} Type: String (JSON) TTL: 3600초 Value: { "dataPoints": [ ... ], "trend": { ... } } ``` #### 사용자 통합 분석 캐시 ``` Key: analytics:user:{userId} Type: String (JSON) TTL: 3600초 Value: { "totalEvents": 5, "summary": { ... }, "events": [ ... ] } ``` #### 멱등성 처리 캐시 ``` Key: analytics:processed:{messageId} Type: Set TTL: 86400초 (24시간) Value: "1" (존재 여부만 확인) ``` ### 2.2 캐시 전략 #### Cache-Aside 패턴 1. **조회**: 캐시 먼저 확인 → 없으면 DB 조회 → 캐시 저장 2. **갱신**: DB 업데이트 → 캐시 무효화 (Kafka Consumer) 3. **만료**: TTL 만료 시 자동 삭제 #### 캐시 무효화 조건 - **Kafka 이벤트 수신 시**: 관련 캐시 삭제 - **배치 스케줄러**: 5분마다 전체 갱신 - **수동 갱신 요청**: `refresh=true` 파라미터 --- ## 3. 데이터베이스 설정 ### 3.1 PostgreSQL 설정 #### Connection Pool ```properties spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000 spring.datasource.hikari.max-lifetime=1800000 ``` #### 시계열 최적화 ```sql -- timeline_data 테이블 파티셔닝 (월별) CREATE TABLE timeline_data ( ... ) PARTITION BY RANGE (timestamp); CREATE TABLE timeline_data_2025_01 PARTITION OF timeline_data FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); -- 자동 파티션 생성 (pg_cron 활용) ``` ### 3.2 Redis 설정 ```properties spring.redis.host=${REDIS_HOST:localhost} spring.redis.port=${REDIS_PORT:6379} spring.redis.password=${REDIS_PASSWORD:} spring.redis.database=${REDIS_DATABASE:0} spring.redis.timeout=3000 spring.redis.lettuce.pool.max-active=20 spring.redis.lettuce.pool.max-idle=10 spring.redis.lettuce.pool.min-idle=5 ``` --- ## 4. 데이터 접근 패턴 ### 4.1 이벤트 대시보드 조회 ```sql -- Cache Miss 시 SELECT * FROM event_stats WHERE event_id = ?; SELECT * FROM channel_stats WHERE event_id = ?; ``` ### 4.2 사용자별 이벤트 조회 ```sql SELECT * FROM event_stats WHERE user_id = ? ORDER BY created_at DESC; ``` ### 4.3 타임라인 조회 ```sql SELECT * FROM timeline_data WHERE event_id = ? AND timestamp BETWEEN ? AND ? ORDER BY timestamp ASC; ``` ### 4.4 채널별 성과 조회 ```sql SELECT * FROM channel_stats WHERE event_id = ? AND channel_name IN (?, ?, ...) ORDER BY participants DESC; ``` --- ## 5. 데이터 동기화 ### 5.1 Kafka Consumer를 통한 실시간 업데이트 #### EventCreatedEvent 처리 ```java @KafkaListener(topics = "event-created") public void handleEventCreated(String message) { // 1. event_stats 생성 EventStats stats = new EventStats(...); eventStatsRepository.save(stats); // 2. 캐시 무효화 redisTemplate.delete("analytics:dashboard:" + eventId); } ``` #### ParticipantRegisteredEvent 처리 ```java @KafkaListener(topics = "participant-registered") public void handleParticipantRegistered(String message) { // 1. 멱등성 체크 (Redis Set) if (redisTemplate.opsForSet().isMember("analytics:processed", messageId)) { return; } // 2. event_stats 업데이트 (참여자 증가) eventStats.incrementParticipants(); // 3. timeline_data 업데이트 updateTimelineData(eventId); // 4. 캐시 무효화 redisTemplate.delete("analytics:*:" + eventId); // 5. 멱등성 기록 redisTemplate.opsForSet().add("analytics:processed", messageId); redisTemplate.expire("analytics:processed:" + messageId, 24, TimeUnit.HOURS); } ``` #### DistributionCompletedEvent 처리 ```java @KafkaListener(topics = "distribution-completed") public void handleDistributionCompleted(String message) { // 1. channel_stats 생성 또는 업데이트 channelStatsRepository.save(channelStats); // 2. 캐시 무효화 redisTemplate.delete("analytics:channel:" + eventId + ":" + channelName); } ``` ### 5.2 배치 스케줄러를 통한 주기적 갱신 ```java @Scheduled(cron = "0 */5 * * * *") // 5분마다 public void refreshAnalyticsDashboard() { List activeEvents = eventStatsRepository.findByStatus("ACTIVE"); for (EventStats event : activeEvents) { // 1. 캐시 갱신 AnalyticsDashboardResponse response = analyticsService.getDashboardData( event.getEventId(), null, null, true ); // 2. 외부 API 호출 (Circuit Breaker) externalChannelService.updateChannelStatsFromExternalAPIs( event.getEventId(), channelStatsRepository.findByEventId(event.getEventId()) ); } } ``` --- ## 6. 성능 최적화 ### 6.1 인덱스 전략 1. **B-Tree 인덱스**: eventId, userId 등 정확한 매칭 2. **BRIN 인덱스**: timestamp 시계열 데이터 3. **복합 인덱스**: (event_id, channel_name) 등 자주 함께 조회 ### 6.2 쿼리 최적화 - **N+1 문제 방지**: Fetch Join 사용 (필요 시) - **Projection**: 필요한 컬럼만 조회 - **캐싱**: Redis를 통한 조회 성능 향상 ### 6.3 파티셔닝 - **timeline_data**: 월별 파티셔닝으로 대용량 시계열 데이터 관리 - **자동 파티션 생성**: pg_cron을 통한 자동화 --- ## 7. 데이터 보안 ### 7.1 접근 제어 ```sql -- 읽기 전용 사용자 CREATE USER analytics_readonly WITH PASSWORD 'secure_password'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_readonly; -- 읽기/쓰기 사용자 CREATE USER analytics_readwrite WITH PASSWORD 'secure_password'; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO analytics_readwrite; ``` ### 7.2 데이터 마스킹 - **개인 정보**: userId는 UUID로만 저장 (이름, 연락처 등 제외) - **금액 정보**: 암호화 저장 권장 (필요 시) --- ## 8. 백업 및 복구 ### 8.1 백업 전략 ```bash # 일일 백업 (pg_dump) pg_dump -U postgres -F c -b -v -f /backup/analytics_$(date +%Y%m%d).dump analytics_db # 주간 전체 백업 (pg_basebackup) pg_basebackup -U postgres -D /backup/full -Ft -z -P ``` ### 8.2 복구 전략 ```bash # 데이터베이스 복구 pg_restore -U postgres -d analytics_db -v /backup/analytics_20250129.dump ``` ### 8.3 Redis 백업 ```bash # RDB 스냅샷 (매일 자정) redis-cli --rdb /backup/redis_$(date +%Y%m%d).rdb # AOF 로그 (실시간) redis-cli CONFIG SET appendonly yes ``` --- ## 9. 모니터링 ### 9.1 데이터베이스 모니터링 ```sql -- 테이블 크기 확인 SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size FROM pg_tables WHERE schemaname = 'public' ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; -- 느린 쿼리 확인 SELECT query, calls, total_time, mean_time FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10; ``` ### 9.2 Redis 모니터링 ```bash # 메모리 사용량 redis-cli INFO memory # 캐시 히트율 redis-cli INFO stats | grep keyspace # 느린 명령어 확인 redis-cli SLOWLOG GET 10 ``` --- ## 10. 트러블슈팅 ### 10.1 일반적인 문제 및 해결 #### 문제 1: 캐시 Miss 증가 - **원인**: TTL이 너무 짧거나, 잦은 캐시 무효화 - **해결**: TTL 조정, 캐시 무효화 로직 최적화 #### 문제 2: 시계열 쿼리 느림 - **원인**: BRIN 인덱스 미사용, 파티셔닝 미적용 - **해결**: BRIN 인덱스 생성, 월별 파티셔닝 적용 #### 문제 3: 외부 API 장애 - **원인**: Circuit Breaker 미동작, Timeout 설정 문제 - **해결**: Resilience4j 설정 확인, Timeout 조정 --- ## 부록: Entity 매핑 확인 ### EventStats 클래스 매핑 ```java @Entity @Table(name = "event_stats") public class EventStats extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "event_id", nullable = false, unique = true, length = 36) private String eventId; @Column(name = "event_title", nullable = false) private String eventTitle; @Column(name = "user_id", nullable = false, length = 36) private String userId; @Column(name = "total_participants", nullable = false) private Integer totalParticipants = 0; // ... 기타 필드 } ``` ### ChannelStats 클래스 매핑 ```java @Entity @Table(name = "channel_stats") public class ChannelStats extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "event_id", nullable = false, length = 36) private String eventId; @Column(name = "channel_name", nullable = false, length = 50) private String channelName; // ... 기타 필드 } ``` ### TimelineData 클래스 매핑 ```java @Entity @Table(name = "timeline_data") public class TimelineData extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "event_id", nullable = false, length = 36) private String eventId; @Column(name = "timestamp", nullable = false) private LocalDateTime timestamp; // ... 기타 필드 } ``` --- **문서 버전**: v1.0 **작성자**: Backend Architect (최수연 "아키텍처") **작성일**: 2025-10-29