From ca4f278b14259f20d2c122a68d411870cb8bf6df Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 11 Feb 2026 10:26:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DeadTokenCleanupWorker=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매주 일요일 03:00 KST 스케줄 실행 - is_active=false, updated_at 7일 이전 Device 물리 삭제 - 배치 단위 1000건씩 삭제 (트랜잭션 없음) - 안전장치: 전체의 50% 초과 시 삭제 중단 + 경고 로그 - SystemLog에 정리 완료/안전장치 발동 로그 기록 - DI에 AddHostedService 등록 Closes #150 --- SPMS.Infrastructure/DependencyInjection.cs | 1 + .../Workers/DeadTokenCleanupWorker.cs | 139 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 SPMS.Infrastructure/Workers/DeadTokenCleanupWorker.cs diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 749ad06..ab0ef29 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -83,6 +83,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); // Token Store & Email Service services.AddMemoryCache(); diff --git a/SPMS.Infrastructure/Workers/DeadTokenCleanupWorker.cs b/SPMS.Infrastructure/Workers/DeadTokenCleanupWorker.cs new file mode 100644 index 0000000..6899a74 --- /dev/null +++ b/SPMS.Infrastructure/Workers/DeadTokenCleanupWorker.cs @@ -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 _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 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(); + + var cutoffUtc = DateTime.UtcNow.AddDays(-7); + + var targetCount = await context.Set() + .CountAsync(d => !d.IsActive && d.UpdatedAt < cutoffUtc, stoppingToken); + + var totalCount = await context.Set() + .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().Add(new SystemLog + { + Action = "DeadTokenCleanup", + Details = details, + CreatedAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(stoppingToken); + } +}