feat: Redis 토큰 캐시 관리 구현 (#154) #155

Merged
seonkyu.kim merged 1 commits from feature/#154-redis-token-cache into develop 2026-02-11 01:45:19 +00:00
6 changed files with 165 additions and 3 deletions
Showing only changes of commit 1b6a87588c - Show all commits

View 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);

View File

@ -13,11 +13,16 @@ public class DeviceService : IDeviceService
{ {
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ITokenCacheService _tokenCache;
public DeviceService(IDeviceRepository deviceRepository, IUnitOfWork unitOfWork) public DeviceService(
IDeviceRepository deviceRepository,
IUnitOfWork unitOfWork,
ITokenCacheService tokenCache)
{ {
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_tokenCache = tokenCache;
} }
public async Task<DeviceRegisterResponseDto> RegisterAsync(long serviceId, DeviceRegisterRequestDto request) public async Task<DeviceRegisterResponseDto> RegisterAsync(long serviceId, DeviceRegisterRequestDto request)
@ -35,6 +40,7 @@ public class DeviceService : IDeviceService
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(existing); _deviceRepository.Update(existing);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, existing.Id);
return new DeviceRegisterResponseDto { DeviceId = existing.Id, IsNew = false }; return new DeviceRegisterResponseDto { DeviceId = existing.Id, IsNew = false };
} }
@ -97,6 +103,7 @@ public class DeviceService : IDeviceService
device.UpdatedAt = DateTime.UtcNow; device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device); _deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, device.Id);
} }
public async Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request) public async Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request)
@ -109,6 +116,7 @@ public class DeviceService : IDeviceService
device.UpdatedAt = DateTime.UtcNow; device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device); _deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, device.Id);
} }
public async Task<DeviceListResponseDto> GetListAsync(long serviceId, DeviceListRequestDto request) public async Task<DeviceListResponseDto> GetListAsync(long serviceId, DeviceListRequestDto request)
@ -169,6 +177,7 @@ public class DeviceService : IDeviceService
device.MktAgreeUpdatedAt = DateTime.UtcNow; device.MktAgreeUpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device); _deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, device.Id);
} }
private static Platform ParsePlatform(string platform) private static Platform ParsePlatform(string platform)

View File

@ -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) private static string MaskConnectionString(string connectionString)
{ {
var parts = connectionString.Split(','); var parts = connectionString.Split(',');

View 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}";
}

View File

@ -58,6 +58,7 @@ public static class DependencyInjection
services.AddSingleton<IDuplicateChecker, DuplicateChecker>(); services.AddSingleton<IDuplicateChecker, DuplicateChecker>();
services.AddSingleton<IScheduleCancelStore, ScheduleCancelStore>(); services.AddSingleton<IScheduleCancelStore, ScheduleCancelStore>();
services.AddSingleton<IBulkJobStore, BulkJobStore>(); services.AddSingleton<IBulkJobStore, BulkJobStore>();
services.AddSingleton<ITokenCacheService, TokenCacheService>();
// RabbitMQ // RabbitMQ
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName)); services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));

View File

@ -25,6 +25,7 @@ public class PushWorker : BackgroundService
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IDuplicateChecker _duplicateChecker; private readonly IDuplicateChecker _duplicateChecker;
private readonly IBulkJobStore _bulkJobStore; private readonly IBulkJobStore _bulkJobStore;
private readonly ITokenCacheService _tokenCache;
private readonly IFcmSender _fcmSender; private readonly IFcmSender _fcmSender;
private readonly IApnsSender _apnsSender; private readonly IApnsSender _apnsSender;
private readonly ILogger<PushWorker> _logger; private readonly ILogger<PushWorker> _logger;
@ -35,6 +36,7 @@ public class PushWorker : BackgroundService
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IDuplicateChecker duplicateChecker, IDuplicateChecker duplicateChecker,
IBulkJobStore bulkJobStore, IBulkJobStore bulkJobStore,
ITokenCacheService tokenCache,
IFcmSender fcmSender, IFcmSender fcmSender,
IApnsSender apnsSender, IApnsSender apnsSender,
ILogger<PushWorker> logger) ILogger<PushWorker> logger)
@ -44,6 +46,7 @@ public class PushWorker : BackgroundService
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_duplicateChecker = duplicateChecker; _duplicateChecker = duplicateChecker;
_bulkJobStore = bulkJobStore; _bulkJobStore = bulkJobStore;
_tokenCache = tokenCache;
_fcmSender = fcmSender; _fcmSender = fcmSender;
_apnsSender = apnsSender; _apnsSender = apnsSender;
_logger = logger; _logger = logger;
@ -289,10 +292,37 @@ public class PushWorker : BackgroundService
var devices = new List<Device>(); var devices = new List<Device>();
foreach (var id in deviceIds) 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); var device = await deviceRepo.GetByIdAndServiceAsync(id, message.ServiceId);
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 }) if (device is { IsActive: true, PushAgreed: true })
devices.Add(device); devices.Add(device);
} }
}
return devices; return devices;
} }