배치-redis,db조회수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyowon Yang 2025-10-24 14:26:11 +09:00
parent b0b0ba3263
commit 21b8fe5efb
4 changed files with 101 additions and 39 deletions

View File

@ -5,11 +5,11 @@
<map>
<!-- Database Settings -->
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="localhost" />
<entry key="DB_HOST" value="4.230.49.9" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="analytics_db" />
<entry key="DB_USERNAME" value="analytics_user" />
<entry key="DB_PASSWORD" value="analytics_pass" />
<entry key="DB_NAME" value="analyticdb" />
<entry key="DB_USERNAME" value="eventuser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- Redis Settings -->
<entry key="REDIS_HOST" value="20.214.210.71" />

View File

@ -5,6 +5,7 @@ import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.analytics.service.AnalyticsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@ -23,13 +24,14 @@ public class AnalyticsBatchScheduler {
private final AnalyticsService analyticsService;
private final EventStatsRepository eventStatsRepository;
private final RedisTemplate<String, String> redisTemplate;
/**
* 5분 단위 Analytics 데이터 갱신 배치
*
* - 모든 활성 이벤트의 대시보드 데이터를 갱신
* - 외부 API 호출을 통해 최신 데이터 수집
* - Redis 캐시 업데이트
* - 이벤트마다 Redis 캐시 확인
* - 캐시 있음 건너뛰기 (1시간 유효)
* - 캐시 없음 PostgreSQL + 외부 API Redis 저장
*/
@Scheduled(fixedRate = 300000) // 5분 = 300,000ms
public void refreshAnalyticsDashboard() {
@ -40,30 +42,41 @@ public class AnalyticsBatchScheduler {
List<EventStats> activeEvents = eventStatsRepository.findAll();
log.info("활성 이벤트 수: {}", activeEvents.size());
// 2. 이벤트별로 대시보드 데이터 갱신
// 2. 이벤트별로 캐시 확인 갱신
int successCount = 0;
int skipCount = 0;
int failCount = 0;
for (EventStats event : activeEvents) {
String cacheKey = "analytics:dashboard:" + event.getEventId();
try {
log.debug("이벤트 데이터 갱신 시작: eventId={}, title={}",
// 2-1. Redis 캐시 확인
if (redisTemplate.hasKey(cacheKey)) {
log.debug("✅ 캐시 유효, 건너뜀: eventId={}", event.getEventId());
skipCount++;
continue;
}
// 2-2. 캐시 없음 데이터 갱신
log.info("캐시 만료, 갱신 시작: eventId={}, title={}",
event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), null, null, true);
successCount++;
log.debug("이벤트 데이터 갱신 완료: eventId={}", event.getEventId());
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
} catch (Exception e) {
failCount++;
log.error("이벤트 데이터 갱신 실패: eventId={}, error={}",
log.error("❌ 배치 갱신 실패: eventId={}, error={}",
event.getEventId(), e.getMessage(), e);
}
}
log.info("===== Analytics 배치 완료: 성공={}, 실패={}, 종료시각={} =====",
successCount, failCount, LocalDateTime.now());
log.info("===== Analytics 배치 완료: 성공={}, 건너뜀={}, 실패={}, 종료시각={} =====",
successCount, skipCount, failCount, LocalDateTime.now());
} catch (Exception e) {
log.error("Analytics 배치 실행 중 오류 발생: {}", e.getMessage(), e);

View File

@ -6,6 +6,7 @@ import com.kt.event.analytics.entity.TimelineData;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.analytics.repository.TimelineDataRepository;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
@ -14,6 +15,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.PreDestroy;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
@ -23,8 +25,8 @@ import java.util.Random;
/**
* 샘플 데이터 로더
*
* 애플리케이션 시작 대시보드 테스트를 위한 샘플 데이터를 자동으로 적재합니다.
* 모든 프로파일에서 실행되며, 기존 데이터가 있으면 건너뜁니다.
* - 서비스 시작 : PostgreSQL 샘플 데이터 자동 생성
* - 서비스 종료 : PostgreSQL 전체 데이터 삭제
*/
@Slf4j
@Component
@ -34,6 +36,7 @@ public class SampleDataLoader implements ApplicationRunner {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final TimelineDataRepository timelineDataRepository;
private final EntityManager entityManager;
private final Random random = new Random();
@ -41,33 +44,42 @@ public class SampleDataLoader implements ApplicationRunner {
@Transactional
public void run(ApplicationArguments args) {
log.info("========================================");
log.info("샘플 데이터 적재 시작");
log.info("🚀 서비스 시작: PostgreSQL 샘플 데이터 생성");
log.info("========================================");
// 기존 샘플 데이터 확인
if (eventStatsRepository.count() > 0) {
log.info("기존 데이터가 존재하여 샘플 데이터 적재를 건너뜁니다.");
return;
// 항상 기존 데이터 삭제 새로 생성
long existingCount = eventStatsRepository.count();
if (existingCount > 0) {
log.info("기존 데이터 {} 건 삭제 중...", existingCount);
timelineDataRepository.deleteAll();
channelStatsRepository.deleteAll();
eventStatsRepository.deleteAll();
// 삭제 커밋 보장
entityManager.flush();
entityManager.clear();
log.info("✅ 기존 데이터 삭제 완료");
}
try {
// 1. 이벤트 통계 데이터 생성
List<EventStats> eventStatsList = createEventStats();
eventStatsRepository.saveAll(eventStatsList);
log.info("이벤트 통계 데이터 적재 완료: {} 건", eventStatsList.size());
log.info("이벤트 통계 데이터 적재 완료: {} 건", eventStatsList.size());
// 2. 채널별 통계 데이터 생성
List<ChannelStats> channelStatsList = createChannelStats(eventStatsList);
channelStatsRepository.saveAll(channelStatsList);
log.info("채널별 통계 데이터 적재 완료: {} 건", channelStatsList.size());
log.info("채널별 통계 데이터 적재 완료: {} 건", channelStatsList.size());
// 3. 타임라인 데이터 생성
List<TimelineData> timelineDataList = createTimelineData(eventStatsList);
timelineDataRepository.saveAll(timelineDataList);
log.info("타임라인 데이터 적재 완료: {} 건", timelineDataList.size());
log.info("타임라인 데이터 적재 완료: {} 건", timelineDataList.size());
log.info("========================================");
log.info("샘플 데이터 적재 완료!");
log.info("🎉 샘플 데이터 적재 완료!");
log.info("========================================");
log.info("테스트 가능한 이벤트:");
eventStatsList.forEach(event ->
@ -80,6 +92,40 @@ public class SampleDataLoader implements ApplicationRunner {
}
}
/**
* 서비스 종료 전체 데이터 삭제
*/
@PreDestroy
@Transactional
public void onShutdown() {
log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
log.info("========================================");
try {
long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count();
log.info("삭제 대상: 이벤트={}, 채널={}, 타임라인={}",
eventCount, channelCount, timelineCount);
timelineDataRepository.deleteAll();
channelStatsRepository.deleteAll();
eventStatsRepository.deleteAll();
// 삭제 커밋 보장
entityManager.flush();
entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!");
log.info("========================================");
} catch (Exception e) {
log.error("샘플 데이터 삭제 중 오류 발생", e);
}
}
/**
* 이벤트 통계 샘플 데이터 생성
*/

View File

@ -41,7 +41,7 @@ public class AnalyticsService {
private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long CACHE_TTL = 3600; // 1시간
private static final long CACHE_TTL = 3600; // 1시간 (단일 캐시)
/**
* 대시보드 데이터 조회
@ -57,12 +57,12 @@ public class AnalyticsService {
String cacheKey = CACHE_KEY_PREFIX + eventId;
// 캐시 조회 (refresh가 false일 때만)
// 1. Redis 캐시 조회 (refresh가 false일 때만)
if (!refresh) {
String cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
try {
log.debug("캐시 HIT: {}", cacheKey);
log.info("✅ 캐시 HIT: {} (1시간 캐시)", cacheKey);
return objectMapper.readValue(cachedData, AnalyticsDashboardResponse.class);
} catch (JsonProcessingException e) {
log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage());
@ -70,34 +70,37 @@ public class AnalyticsService {
}
}
// 캐시 MISS: 데이터 통합 작업
log.debug("캐시 MISS 또는 refresh=true: 데이터 통합 작업 시작");
// 2. 캐시 MISS: 데이터 통합 작업
log.info("캐시 MISS 또는 refresh=true: PostgreSQL + 외부 API 호출");
// 1. Analytics DB 조회
// 2-1. Analytics DB 조회 (PostgreSQL)
EventStats eventStats = eventStatsRepository.findByEventId(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
log.debug("PostgreSQL 조회 완료: eventId={}, 채널 수={}", eventId, channelStatsList.size());
// 2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용)
// TODO: refresh가 true일 때만 외부 API 호출하도록 개선 필요
// 현재는 샘플 데이터 사용을 위해 주석 처리
// if (refresh) {
// externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList);
// }
// 2-2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용)
try {
externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList);
log.info("외부 API 호출 성공: eventId={}", eventId);
} catch (Exception e) {
log.warn("외부 API 호출 실패, PostgreSQL 샘플 데이터 사용: eventId={}, error={}",
eventId, e.getMessage());
// Fallback: PostgreSQL 샘플 데이터만 사용
}
// 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
// 4. Redis 캐싱 (읽기 전용 오류 무시)
// 4. Redis 캐싱 (1시간 TTL)
try {
String jsonData = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
log.debug("캐시 저장 완료: {}", cacheKey);
log.info("✅ Redis 캐시 저장 완료: {} (TTL: 1시간)", cacheKey);
} catch (JsonProcessingException e) {
log.warn("캐시 데이터 직렬화 실패: {}", e.getMessage());
} catch (Exception e) {
// Redis 읽기 전용 오류 캐시 저장 실패 무시하고 계속 진행
log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage());
}