feat: DeadTokenCleanupWorker 구현 (#150) #151

Merged
seonkyu.kim merged 1 commits from feature/#150-dead-token-cleanup into develop 2026-02-11 01:27:48 +00:00
2 changed files with 140 additions and 0 deletions
Showing only changes of commit ca4f278b14 - Show all commits

View File

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

View File

@ -0,0 +1,139 @@
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 DeadTokenCleanupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DeadTokenCleanupWorker> _logger;
private static readonly TimeZoneInfo KstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
private const int BatchSize = 1000;
private const double SafetyThreshold = 0.5;
public DeadTokenCleanupWorker(
IServiceScopeFactory scopeFactory,
ILogger<DeadTokenCleanupWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("DeadTokenCleanupWorker 시작");
while (!stoppingToken.IsCancellationRequested)
{
var delay = CalculateDelayUntilNextSunday();
_logger.LogInformation("DeadTokenCleanupWorker 다음 실행까지 {Delay} 대기", delay);
try
{
await Task.Delay(delay, stoppingToken);
}
catch (TaskCanceledException)
{
break;
}
try
{
await CleanupAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "DeadTokenCleanupWorker 정리 중 오류 발생");
}
}
}
private TimeSpan CalculateDelayUntilNextSunday()
{
var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone);
var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)nowKst.DayOfWeek + 7) % 7;
var nextRun = nowKst.Date.AddDays(daysUntilSunday).AddHours(3);
if (nextRun <= nowKst)
nextRun = nextRun.AddDays(7);
return nextRun - nowKst;
}
private async Task CleanupAsync(CancellationToken stoppingToken)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoffUtc = DateTime.UtcNow.AddDays(-7);
var targetCount = await context.Set<Device>()
.CountAsync(d => !d.IsActive && d.UpdatedAt < cutoffUtc, stoppingToken);
var totalCount = await context.Set<Device>()
.CountAsync(stoppingToken);
_logger.LogInformation("DeadTokenCleanupWorker 삭제 대상: {TargetCount}개 / 전체: {TotalCount}개",
targetCount, totalCount);
if (targetCount == 0)
{
_logger.LogInformation("DeadTokenCleanupWorker 삭제 대상 없음");
await WriteSystemLogAsync(context, 0, stoppingToken);
return;
}
// 안전장치: 삭제 대상이 전체의 50% 초과 시 중단
if (totalCount > 0 && (double)targetCount / totalCount > SafetyThreshold)
{
_logger.LogWarning(
"DeadTokenCleanupWorker 안전 장치 발동! 삭제 대상({TargetCount})이 전체({TotalCount})의 50% 초과. 삭제를 중단합니다.",
targetCount, totalCount);
await WriteSystemLogAsync(context, 0, stoppingToken,
$"안전 장치 발동: 삭제 대상 {targetCount}개 / 전체 {totalCount}개 (50% 초과)");
return;
}
// 배치 삭제 (1000건씩, 트랜잭션 없음)
var totalDeleted = 0;
int deletedInBatch;
do
{
deletedInBatch = await context.Database.ExecuteSqlRawAsync(
"DELETE FROM Device WHERE is_active = false AND updated_at < {0} LIMIT 1000",
new object[] { cutoffUtc },
stoppingToken);
totalDeleted += deletedInBatch;
if (deletedInBatch > 0)
_logger.LogInformation("DeadTokenCleanupWorker 배치 삭제: {BatchCount}건 (누적: {TotalDeleted}건)",
deletedInBatch, totalDeleted);
} while (deletedInBatch > 0 && !stoppingToken.IsCancellationRequested);
await WriteSystemLogAsync(context, totalDeleted, stoppingToken);
_logger.LogInformation("DeadTokenCleanupWorker 정리 완료: {TotalDeleted}건 삭제", totalDeleted);
}
private static async Task WriteSystemLogAsync(
AppDbContext context, int deletedCount,
CancellationToken stoppingToken, string? extraDetails = null)
{
var details = extraDetails ?? $"비활성 토큰 정리 완료: {deletedCount}건 삭제";
context.Set<SystemLog>().Add(new SystemLog
{
Action = "DeadTokenCleanup",
Details = details,
CreatedAt = DateTime.UtcNow
});
await context.SaveChangesAsync(stoppingToken);
}
}