using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SPMS.Domain.Entities; using SPMS.Infrastructure.Persistence; namespace SPMS.Infrastructure.Workers; public class DataRetentionWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private static readonly TimeZoneInfo KstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul"); private const int BatchSize = 10000; private static readonly (string TableName, string DateColumn, int RetentionDays)[] RetentionPolicies = { ("PushSendLog", "SentAt", 90), ("PushOpenLog", "OpenedAt", 90), ("WebhookLog", "SentAt", 30), ("SystemLog", "CreatedAt", 180) }; public DataRetentionWorker( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("DataRetentionWorker 시작"); while (!stoppingToken.IsCancellationRequested) { var delay = CalculateDelayUntilNextRun(); _logger.LogInformation("DataRetentionWorker 다음 실행까지 {Delay} 대기", delay); try { await Task.Delay(delay, stoppingToken); } catch (TaskCanceledException) { break; } try { await PurgeAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "DataRetentionWorker 정리 중 오류 발생"); } } } private TimeSpan CalculateDelayUntilNextRun() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); var nextRun = nowKst.Date.AddHours(4); if (nowKst >= nextRun) nextRun = nextRun.AddDays(1); return nextRun - nowKst; } private async Task PurgeAsync(CancellationToken stoppingToken) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); _logger.LogInformation("DataRetentionWorker 정리 시작"); var totalDeletedAll = 0; foreach (var (tableName, dateColumn, retentionDays) in RetentionPolicies) { if (stoppingToken.IsCancellationRequested) break; var cutoffUtc = DateTime.UtcNow.AddDays(-retentionDays); var totalDeleted = 0; int deletedInBatch; do { var sql = $"DELETE FROM `{tableName}` WHERE `{dateColumn}` < @p0 LIMIT {BatchSize}"; deletedInBatch = await context.Database .ExecuteSqlRawAsync(sql, new[] { cutoffUtc }, stoppingToken); totalDeleted += deletedInBatch; if (deletedInBatch > 0) _logger.LogInformation( "DataRetentionWorker [{Table}] 배치 삭제: {BatchCount}건 (누적: {TotalDeleted}건)", tableName, deletedInBatch, totalDeleted); } while (deletedInBatch > 0 && !stoppingToken.IsCancellationRequested); if (totalDeleted > 0) _logger.LogInformation( "DataRetentionWorker [{Table}] 정리 완료: {TotalDeleted}건 삭제 (보관 기간: {Days}일)", tableName, totalDeleted, retentionDays); totalDeletedAll += totalDeleted; } // SystemLog에 정리 완료 기록 context.Set().Add(new SystemLog { Action = "DataRetention", Details = $"데이터 보관 주기 정리 완료: 총 {totalDeletedAll}건 삭제", CreatedAt = DateTime.UtcNow }); await context.SaveChangesAsync(stoppingToken); _logger.LogInformation("DataRetentionWorker 전체 정리 완료: 총 {TotalDeleted}건 삭제", totalDeletedAll); } }