diff --git a/SPMS.Application/Interfaces/ITokenCacheService.cs b/SPMS.Application/Interfaces/ITokenCacheService.cs new file mode 100644 index 0000000..e803dd9 --- /dev/null +++ b/SPMS.Application/Interfaces/ITokenCacheService.cs @@ -0,0 +1,11 @@ +namespace SPMS.Application.Interfaces; + +public interface ITokenCacheService +{ + Task GetDeviceInfoAsync(long serviceId, long deviceId); + Task SetDeviceInfoAsync(long serviceId, long deviceId, CachedDeviceInfo info); + Task InvalidateAsync(long serviceId, long deviceId); + Task InvalidateByServiceAsync(long serviceId); +} + +public record CachedDeviceInfo(string Token, int Platform, bool IsActive, bool PushAgreed); diff --git a/SPMS.Application/Services/DeviceService.cs b/SPMS.Application/Services/DeviceService.cs index 69c232f..51f8b75 100644 --- a/SPMS.Application/Services/DeviceService.cs +++ b/SPMS.Application/Services/DeviceService.cs @@ -13,11 +13,16 @@ public class DeviceService : IDeviceService { private readonly IDeviceRepository _deviceRepository; private readonly IUnitOfWork _unitOfWork; + private readonly ITokenCacheService _tokenCache; - public DeviceService(IDeviceRepository deviceRepository, IUnitOfWork unitOfWork) + public DeviceService( + IDeviceRepository deviceRepository, + IUnitOfWork unitOfWork, + ITokenCacheService tokenCache) { _deviceRepository = deviceRepository; _unitOfWork = unitOfWork; + _tokenCache = tokenCache; } public async Task RegisterAsync(long serviceId, DeviceRegisterRequestDto request) @@ -35,6 +40,7 @@ public class DeviceService : IDeviceService existing.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(existing); await _unitOfWork.SaveChangesAsync(); + await _tokenCache.InvalidateAsync(serviceId, existing.Id); return new DeviceRegisterResponseDto { DeviceId = existing.Id, IsNew = false }; } @@ -97,6 +103,7 @@ public class DeviceService : IDeviceService device.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); + await _tokenCache.InvalidateAsync(serviceId, device.Id); } public async Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request) @@ -109,6 +116,7 @@ public class DeviceService : IDeviceService device.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); + await _tokenCache.InvalidateAsync(serviceId, device.Id); } public async Task GetListAsync(long serviceId, DeviceListRequestDto request) @@ -169,6 +177,7 @@ public class DeviceService : IDeviceService device.MktAgreeUpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); + await _tokenCache.InvalidateAsync(serviceId, device.Id); } private static Platform ParsePlatform(string platform) diff --git a/SPMS.Infrastructure/Caching/RedisConnection.cs b/SPMS.Infrastructure/Caching/RedisConnection.cs index 2ac0f23..e9558ad 100644 --- a/SPMS.Infrastructure/Caching/RedisConnection.cs +++ b/SPMS.Infrastructure/Caching/RedisConnection.cs @@ -50,6 +50,15 @@ public class RedisConnection : IAsyncDisposable } } + public IServer? GetServer() + { + if (_connection is not { IsConnected: true }) + return null; + + var endpoint = _connection.GetEndPoints().FirstOrDefault(); + return endpoint != null ? _connection.GetServer(endpoint) : null; + } + private static string MaskConnectionString(string connectionString) { var parts = connectionString.Split(','); diff --git a/SPMS.Infrastructure/Caching/TokenCacheService.cs b/SPMS.Infrastructure/Caching/TokenCacheService.cs new file mode 100644 index 0000000..af2ce97 --- /dev/null +++ b/SPMS.Infrastructure/Caching/TokenCacheService.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SPMS.Application.Interfaces; +using SPMS.Application.Settings; +using StackExchange.Redis; + +namespace SPMS.Infrastructure.Caching; + +public class TokenCacheService : ITokenCacheService +{ + private readonly RedisConnection _redis; + private readonly RedisSettings _settings; + private readonly ILogger _logger; + + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1); + + public TokenCacheService( + RedisConnection redis, + IOptions settings, + ILogger logger) + { + _redis = redis; + _settings = settings.Value; + _logger = logger; + } + + public async Task GetDeviceInfoAsync(long serviceId, long deviceId) + { + try + { + var db = await _redis.GetDatabaseAsync(); + var value = await db.StringGetAsync(BuildKey(serviceId, deviceId)); + + if (value.IsNullOrEmpty) + return null; + + return JsonSerializer.Deserialize(value!); + } + catch (Exception ex) + { + _logger.LogError(ex, "토큰 캐시 조회 실패: serviceId={ServiceId}, deviceId={DeviceId}", + serviceId, deviceId); + return null; + } + } + + public async Task SetDeviceInfoAsync(long serviceId, long deviceId, CachedDeviceInfo info) + { + try + { + var db = await _redis.GetDatabaseAsync(); + var json = JsonSerializer.Serialize(info); + await db.StringSetAsync(BuildKey(serviceId, deviceId), json, CacheTtl); + } + catch (Exception ex) + { + _logger.LogError(ex, "토큰 캐시 저장 실패: serviceId={ServiceId}, deviceId={DeviceId}", + serviceId, deviceId); + } + } + + public async Task InvalidateAsync(long serviceId, long deviceId) + { + try + { + var db = await _redis.GetDatabaseAsync(); + await db.KeyDeleteAsync(BuildKey(serviceId, deviceId)); + } + catch (Exception ex) + { + _logger.LogError(ex, "토큰 캐시 무효화 실패: serviceId={ServiceId}, deviceId={DeviceId}", + serviceId, deviceId); + } + } + + public async Task InvalidateByServiceAsync(long serviceId) + { + try + { + var db = await _redis.GetDatabaseAsync(); + var server = _redis.GetServer(); + if (server == null) return; + + var pattern = $"{_settings.InstanceName}device:token:{serviceId}:*"; + var keys = server.Keys(pattern: pattern).ToArray(); + + if (keys.Length > 0) + await db.KeyDeleteAsync(keys); + + _logger.LogInformation("서비스 토큰 캐시 전체 무효화: serviceId={ServiceId}, 삭제={Count}건", + serviceId, keys.Length); + } + catch (Exception ex) + { + _logger.LogError(ex, "서비스 토큰 캐시 전체 무효화 실패: serviceId={ServiceId}", serviceId); + } + } + + private string BuildKey(long serviceId, long deviceId) => + $"{_settings.InstanceName}device:token:{serviceId}:{deviceId}"; +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 76ab44c..d05fb29 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -58,6 +58,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // RabbitMQ services.Configure(configuration.GetSection(RabbitMQSettings.SectionName)); diff --git a/SPMS.Infrastructure/Workers/PushWorker.cs b/SPMS.Infrastructure/Workers/PushWorker.cs index 92c7a4a..d4d0de5 100644 --- a/SPMS.Infrastructure/Workers/PushWorker.cs +++ b/SPMS.Infrastructure/Workers/PushWorker.cs @@ -25,6 +25,7 @@ public class PushWorker : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly IDuplicateChecker _duplicateChecker; private readonly IBulkJobStore _bulkJobStore; + private readonly ITokenCacheService _tokenCache; private readonly IFcmSender _fcmSender; private readonly IApnsSender _apnsSender; private readonly ILogger _logger; @@ -35,6 +36,7 @@ public class PushWorker : BackgroundService IServiceScopeFactory scopeFactory, IDuplicateChecker duplicateChecker, IBulkJobStore bulkJobStore, + ITokenCacheService tokenCache, IFcmSender fcmSender, IApnsSender apnsSender, ILogger logger) @@ -44,6 +46,7 @@ public class PushWorker : BackgroundService _scopeFactory = scopeFactory; _duplicateChecker = duplicateChecker; _bulkJobStore = bulkJobStore; + _tokenCache = tokenCache; _fcmSender = fcmSender; _apnsSender = apnsSender; _logger = logger; @@ -289,9 +292,36 @@ public class PushWorker : BackgroundService var devices = new List(); foreach (var id in deviceIds) { + // Redis 캐시 우선 조회 + var cached = await _tokenCache.GetDeviceInfoAsync(message.ServiceId, id); + if (cached != null) + { + if (cached.IsActive && cached.PushAgreed) + { + devices.Add(new Device + { + Id = id, + ServiceId = message.ServiceId, + DeviceToken = cached.Token, + Platform = (Platform)cached.Platform, + IsActive = true, + PushAgreed = true + }); + } + continue; + } + + // 캐시 미스 → DB 조회 후 캐시 저장 var device = await deviceRepo.GetByIdAndServiceAsync(id, message.ServiceId); - if (device is { IsActive: true, PushAgreed: true }) - devices.Add(device); + if (device != null) + { + await _tokenCache.SetDeviceInfoAsync(message.ServiceId, id, + new CachedDeviceInfo(device.DeviceToken, (int)device.Platform, + device.IsActive, device.PushAgreed)); + + if (device is { IsActive: true, PushAgreed: true }) + devices.Add(device); + } } return devices; }