feat: DataRetentionWorker 구현 (#152)

- 매일 04:00 KST 스케줄 실행
- PushSendLog/PushOpenLog: 90일 보관, WebhookLog: 30일, SystemLog: 180일
- 배치 단위 10000건씩 삭제 (트랜잭션 없음)
- SystemLog에 정리 완료 로그 기록
- DI에 AddHostedService 등록

Closes #152
This commit is contained in:
SEAN 2026-02-11 10:31:50 +09:00
parent ffdc343563
commit feca00e329
2 changed files with 125 additions and 0 deletions

View File

@ -84,6 +84,7 @@ public static class DependencyInjection
services.AddHostedService<ScheduleWorker>(); services.AddHostedService<ScheduleWorker>();
services.AddHostedService<DailyStatWorker>(); services.AddHostedService<DailyStatWorker>();
services.AddHostedService<DeadTokenCleanupWorker>(); services.AddHostedService<DeadTokenCleanupWorker>();
services.AddHostedService<DataRetentionWorker>();
// Token Store & Email Service // Token Store & Email Service
services.AddMemoryCache(); services.AddMemoryCache();

View File

@ -0,0 +1,124 @@
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<DataRetentionWorker> _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<DataRetentionWorker> 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<AppDbContext>();
_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<SystemLog>().Add(new SystemLog
{
Action = "DataRetention",
Details = $"데이터 보관 주기 정리 완료: 총 {totalDeletedAll}건 삭제",
CreatedAt = DateTime.UtcNow
});
await context.SaveChangesAsync(stoppingToken);
_logger.LogInformation("DataRetentionWorker 전체 정리 완료: 총 {TotalDeleted}건 삭제", totalDeletedAll);
}
}