feat: Redis 토큰 캐시 관리 구현 (#154)
- ITokenCacheService 인터페이스 및 Redis 기반 TokenCacheService 구현
- Key: device:token:{serviceId}:{deviceId}, TTL: 1시간
- PushWorker single 발송 시 캐시 우선 조회, 미스 시 DB 조회 후 캐시 저장
- DeviceService 등록/수정/삭제/수신동의 변경 시 캐시 무효화
- RedisConnection에 GetServer() 메서드 추가 (서비스별 전체 무효화용)
Closes #154
This commit is contained in:
parent
fec1bf289f
commit
1b6a87588c
11
SPMS.Application/Interfaces/ITokenCacheService.cs
Normal file
11
SPMS.Application/Interfaces/ITokenCacheService.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace SPMS.Application.Interfaces;
|
||||
|
||||
public interface ITokenCacheService
|
||||
{
|
||||
Task<CachedDeviceInfo?> 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);
|
||||
|
|
@ -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<DeviceRegisterResponseDto> 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<DeviceListResponseDto> 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)
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
|
|
|
|||
102
SPMS.Infrastructure/Caching/TokenCacheService.cs
Normal file
102
SPMS.Infrastructure/Caching/TokenCacheService.cs
Normal file
|
|
@ -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<TokenCacheService> _logger;
|
||||
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1);
|
||||
|
||||
public TokenCacheService(
|
||||
RedisConnection redis,
|
||||
IOptions<RedisSettings> settings,
|
||||
ILogger<TokenCacheService> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CachedDeviceInfo?> 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<CachedDeviceInfo>(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}";
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ public static class DependencyInjection
|
|||
services.AddSingleton<IDuplicateChecker, DuplicateChecker>();
|
||||
services.AddSingleton<IScheduleCancelStore, ScheduleCancelStore>();
|
||||
services.AddSingleton<IBulkJobStore, BulkJobStore>();
|
||||
services.AddSingleton<ITokenCacheService, TokenCacheService>();
|
||||
|
||||
// RabbitMQ
|
||||
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));
|
||||
|
|
|
|||
|
|
@ -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<PushWorker> _logger;
|
||||
|
|
@ -35,6 +36,7 @@ public class PushWorker : BackgroundService
|
|||
IServiceScopeFactory scopeFactory,
|
||||
IDuplicateChecker duplicateChecker,
|
||||
IBulkJobStore bulkJobStore,
|
||||
ITokenCacheService tokenCache,
|
||||
IFcmSender fcmSender,
|
||||
IApnsSender apnsSender,
|
||||
ILogger<PushWorker> 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<Device>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user